【webpack進(jìn)階】使用babel避免webpack編譯運(yùn)行時模塊依賴

引言

babel是一個非常強(qiáng)大的工具,作用遠(yuǎn)不止我們平時的ES6 -> ES5語法轉(zhuǎn)換這么單一。在前端進(jìn)階的道路上,了解與學(xué)習(xí)babel及其靈活的插件模式將會為前端賦予更多的可能性。

本文就是運(yùn)用babel,通過編寫babel插件解決了一個實(shí)際項(xiàng)目中的問題。

本文相關(guān)代碼已托管至github: babel-plugin-import-customized-require

1. 遇到的問題

最近在項(xiàng)目中遇到這樣一個問題:我們知道,使用webpack作為構(gòu)建工具是會默認(rèn)自動幫我們進(jìn)行依賴構(gòu)建;但是在項(xiàng)目代碼中,有一部分的依賴是運(yùn)行時依賴/非編譯期依賴(可以理解為像requirejs、seajs那樣的純前端模塊化),對于這種依賴不做處理會導(dǎo)致webpack編譯出錯。

為什么需要非編譯期依賴呢?例如,在當(dāng)前的業(yè)務(wù)模塊(一個獨(dú)立的webpack代碼倉庫)里,我依賴了一個公共業(yè)務(wù)模塊的打點(diǎn)代碼

// 這是home業(yè)務(wù)模塊代碼
// 依賴了common業(yè)務(wù)模塊的代碼
import log from 'common:util/log.js'

log('act-1');

然而,可能是由于技術(shù)棧不統(tǒng)一,或是因?yàn)閏ommon業(yè)務(wù)代碼遺留問題無法重構(gòu),或者僅僅是為了業(yè)務(wù)模塊的分治……總之,無法在webpack編譯期解決這部分模塊依賴,而是需要放在前端運(yùn)行時框架解決。

為了解決webpack編譯期無法解析這種模塊依賴的問題,可以給這種非編譯期依賴引入新的語法,例如下面這樣:

// __my_require__是我們自定義的前端require方法
var log = __my_require__('common:util/log.js')

log('act-1');

但這樣就導(dǎo)致了我們代碼形式的分裂,擁抱規(guī)范讓我們希望還是能夠用ESM的標(biāo)準(zhǔn)語法來一視同仁。

我們還是希望能像下面這樣寫代碼:

// 標(biāo)準(zhǔn)的ESM語法
import * as log from 'common:util/log.js';

log('act-1');

此外,也可以考慮使用webpack提供了externals配置來避免某些模塊被webpack打包。然而,一個重要的問題是,在已有的common代碼中有一套前端模塊化語法,要將webpack編譯出來的代碼與已有模式融合存在一些問題。因此該方式也存在不足。

針對上面的描述,總結(jié)來說,我們的目的就是:

  • 能夠在代碼中使用ESM語法,來進(jìn)行非編譯期分析的模塊引用
  • 由于webpack會嘗試打包該依賴,需要不會在編譯期出錯

2. 解決思路

基于上面的目標(biāo),首先,我們需要有一種方式能夠標(biāo)識不需要編譯的運(yùn)行期依賴。例如util/record這個模塊,如果是運(yùn)行時依賴,可以參考標(biāo)準(zhǔn)語法,為模塊名添加標(biāo)識:runtime:util/record。效果如下:

// 下面這兩行是正常的編譯期依賴
import React from 'react';
import Nav from './component/nav';

// 下面這兩個模塊,我們不希望webpack在編譯期進(jìn)行處理
import record from 'runtime:util/record';
import {Banner, List} from 'runtime:ui/layout/component';

其次,雖然標(biāo)識已經(jīng)可以讓開發(fā)人員知道代碼里哪些模塊是webpack需要打包的依賴,哪些是非編譯期依賴;但webpack不知道,它只會拿到模塊源碼,分析import語法拿到依賴,然后嘗試加載依賴模塊。但這時webpack傻眼了,因?yàn)橄?code>runtime:util/record這樣的模塊是運(yùn)行時依賴,編譯期找不到該模塊。那么,就需要通過一種方式,讓webpack“看不見”非編譯期的依賴。

最后,拿到非編譯期依賴,由于瀏覽器現(xiàn)在還不支持ESM的import語法,因此需要將它變?yōu)樵谇岸诉\(yùn)行時我們自定義的模塊依賴語法。

image

3. 使用babel對源碼進(jìn)行分析

3.1. babel相關(guān)工具介紹

對babel以及插件機(jī)制不太了解的同學(xué),可以先看這一部分做一個簡單的了解。

babel是一個強(qiáng)大的javascript compiler,可以將源碼通過詞法分析與語法分析轉(zhuǎn)換為AST(抽象語法樹),通過對AST進(jìn)行轉(zhuǎn)換,可以修改源碼,最后再將修改后的AST轉(zhuǎn)換會目標(biāo)代碼。

image

由于篇幅限制,本文不會對compiler或者AST進(jìn)行過多介紹,但是如果你學(xué)過編譯原理,那么對詞法分析、語法分析、token、AST應(yīng)該都不會陌生。即使沒了解過也沒有關(guān)系,你可以粗略的理解為:babel是一個compiler,它可以將javascript源碼轉(zhuǎn)化為一種特殊的數(shù)據(jù)結(jié)構(gòu),這種數(shù)據(jù)結(jié)構(gòu)就是樹,也就是AST,它是一種能夠很好表示源碼的結(jié)構(gòu)。babel的AST是基于ESTree的。

例如,var alienzhou = 'happy'這條語句,經(jīng)過babel處理后它的AST大概是下面這樣的

{
    type: 'VariableDeclaration',
    kind: 'var',
    // ...其他屬性
    decolarations: [{
        type: 'VariableDeclarator',
        id: {
            type: 'Identifier',
            name: 'alienzhou',
            // ...其他屬性
        },
        init: {
            type: 'StringLiteral',
            value: 'happy',
            // ...其他屬性
        }
    }],
}

這部分AST node表示,這是一條變量聲明的語句,使用var關(guān)鍵字,其中id和init屬性又是兩個AST node,分別是名稱為alienzhou的標(biāo)識符(Identifier)和值為happy的字符串字面量(StringLiteral)。

這里,簡單介紹一些如何使用babel及其提供的一些庫來進(jìn)行AST的分析和修改。生成AST可以通過babel-core里的方法,例如:

const babel = require('babel-core');
const {ast} = babel.transform(`var alienzhou = 'happy'`);

然后遍歷AST,找到特定的節(jié)點(diǎn)進(jìn)行修改即可。babel也為我們提供了traverse方法來遍歷AST:

const traverse = require('babel-traverse').default;

在babel中訪問AST node使用的是vistor模式,可以像下面這樣指定AST node type來訪問所需的AST node:

traverse(ast, {
    StringLiteral(path) {
        console.log(path.node.value)
        // ...
    }
})

這樣就可以得到所有的字符串字面量,當(dāng)然你也可以替換這個節(jié)點(diǎn)的內(nèi)容:

let visitor = {
    StringLiteral(path) {
        console.log(path.node.value)
        path.replaceWith(
            t.stringLiteral('excited');
        )
    }
};
traverse(ast, visitor);

注意,AST是一個mutable對象,所有的節(jié)點(diǎn)操作都會在原AST上進(jìn)行修改。

這篇文章不會詳細(xì)介紹babel-core、babel-traverse的API,而是幫助沒有接觸過的朋友快速理解它們,具體的使用方式可以參考相關(guān)文檔。

由于大部分的webpack項(xiàng)目都會在loader中使用babel,因此只需要提供一個babel的插件來處理非編譯期依賴語法即可。而babel插件其實(shí)就是導(dǎo)出一個方法,該方法會返回我們上面提到的visitor對象。

那么接下來我們專注于visitor的編寫即可。

3.2 編寫一個babel插件來解決非編譯期依賴

ESM的import語法在AST node type中是ImportDeclaration

export default function () {
    return {
        ImportDeclaration: {
            enter(path) {
                // ...
            }
            exit(path) {
                let source = path.node.source;
                if (t.isStringLiteral(source) && /^runtime:/.test(source.value)) {
                    // ...
                }
            }
        }
    }
}

在enter方法里,需要收集ImportDeclaration語法的相關(guān)信息;在exit方法里,判斷當(dāng)前ImportDeclaration是否為非編譯期依賴,如果是則進(jìn)行語法轉(zhuǎn)換。

收集ImportDeclaration語法相關(guān)信息需要注意,對于不同的import specifier類型,需要不同的分析方式,下面列舉了這五種import:

import util from 'runtime:util';
import * as util from 'runtime:util';
import {util} from 'runtime:util';
import {util as u} from 'runtime:util';
import 'runtime:util';

對應(yīng)了三類specifier:

  • ImportSpecifier:import {util} from 'runtime:util',import {util as u} from 'runtime:util';
  • ImportDefaultSpecifier:import util from 'runtime:util'
  • ImportNamespaceSpecifier:import * as util from 'runtime:util'

import 'runtime:util'中沒有specifier

可以在ImportDeclaration的基礎(chǔ)上,對子節(jié)點(diǎn)進(jìn)行traverse,這里新建了一個visitor用來訪問Specifier,針對不同語法進(jìn)行收集:

const specifierVisitor = {
    ImportNamespaceSpecifier(_path) {
        let data = {
            type: 'NAMESPACE',
            local: _path.node.local.name
        };

        this.specifiers.push(data);
    },

    ImportSpecifier(_path) {
        let data = {
            type: 'COMMON',
            local: _path.node.local.name,
            imported: _path.node.imported ? _path.node.imported.name : null
        };

        this.specifiers.push(data);
    },

    ImportDefaultSpecifier(_path) {
        let data = {
            type: 'DEFAULT',
            local: _path.node.local.name
        };

        this.specifiers.push(data);
    }
}

在ImportDeclaration中使用specifierVisitor進(jìn)行遍歷:

export default function () {
    // store the specifiers in one importDeclaration
    let specifiers = [];
    return {
        ImportDeclaration: {
            enter(path) {
                path.traverse(specifierVisitor, { specifiers });
            }
            exit(path) {
                let source = path.node.source;
                if (t.isStringLiteral(source) && /^runtime:/.test(source.value)) {
                    // ...
                }
            }
        }
    }
}

到目前為止,我們在進(jìn)入ImportDeclaration節(jié)點(diǎn)時,收集了import語句相關(guān)信息,在退出節(jié)點(diǎn)時,通過判斷可以知道目前節(jié)點(diǎn)是否是非編譯期依賴。因此,如果是非編譯期依賴,只需要根據(jù)收集到的信息替換節(jié)點(diǎn)語法即可。

生成新節(jié)點(diǎn)可以使用babel-types。不過推薦使用babel-template,會令代碼更簡便與清晰。下面這個方法,會根據(jù)不同的import信息,生成不同的運(yùn)行時代碼,其中假定my_require方法就是自定義的前端模塊require方法。

const template = require('babel-template');

function constructRequireModule({
    local,
    type,
    imported,
    moduleName
}) {

    /* using template instead of origin type functions */
    const namespaceTemplate = template(`
        var LOCAL = __my_require__(MODULE_NAME);
    `);

    const commonTemplate = template(`
        var LOCAL = __my_require__(MODULE_NAME)[IMPORTED];
    `);

    const defaultTemplate = template(`
        var LOCAL = __my_require__(MODULE_NAME)['default'];
    `);

    const sideTemplate = template(`
        __my_require__(MODULE_NAME);
    `);
    /* ********************************************** */

    let declaration;
    switch (type) {
        case 'NAMESPACE':
            declaration = namespaceTemplate({
                LOCAL: t.identifier(local),
                MODULE_NAME: t.stringLiteral(moduleName)
            });
            break;

        case 'COMMON':
            imported = imported || local;
            declaration = commonTemplate({
                LOCAL: t.identifier(local),
                MODULE_NAME: t.stringLiteral(moduleName),
                IMPORTED: t.stringLiteral(imported)
            });
            break;

        case 'DEFAULT':
            declaration = defaultTemplate({
                LOCAL: t.identifier(local),
                MODULE_NAME: t.stringLiteral(moduleName)
            });
            break;

        case 'SIDE':
            declaration = sideTemplate({
                MODULE_NAME: t.stringLiteral(moduleName)
            })

        default:
            break;
    }

    return declaration;
}

最后整合到一開始的visitor中:

export default function () {
    // store the specifiers in one importDeclaration
    let specifiers = [];
    return {
        ImportDeclaration: {
            enter(path) {
                path.traverse(specifierVisitor, { specifiers });
            }
            exit(path) {
                let source = path.node.source;
                let moduleName = path.node.source.value;
                if (t.isStringLiteral(source) && /^runtime:/.test(source.value)) {
                    let nodes;
                    if (specifiers.length === 0) {
                        nodes = constructRequireModule({
                            moduleName,
                            type: 'SIDE'
                        });
                        nodes = [nodes]
                    }
                    else {
                        nodes = specifiers.map(constructRequireModule);
                    }
                    path.replaceWithMultiple(nodes);
                }
                specifiers = [];
            }
        }
    }
}

那么,對于一段import util from 'runtime:util'的源碼,在該babel插件修改后變?yōu)榱?code>var util = require('runtime:util')['default'],該代碼也會被webpack直接輸出。

這樣,通過babel插件,我們就完成了文章最一開始的目標(biāo)。

4. 處理dynamic import

細(xì)心的讀者肯定會發(fā)現(xiàn)了,我們在上面只解決了靜態(tài)import的問題,那么像下面這樣的動態(tài)import不是仍然會有以上的問題么?

import('runtime:util').then(u => {
    u.record(1);
});

是的,仍然會有問題。因此,進(jìn)一步我們還需要處理動態(tài)import的語法。要做的就是在visitor中添加一個新的node type:

{
    Import: {
        enter(path) {
            let callNode = path.parentPath.node;
            let nameNode = callNode.arguments && callNode.arguments[0] ? callNode.arguments[0] : null;

            if (t.isCallExpression(callNode)
                && t.isStringLiteral(nameNode)
                && /^runtime:/.test(nameNode.value)
            ) {
                let args = callNode.arguments;
                path.parentPath.replaceWith(
                    t.callExpression(
                        t.memberExpression(
                            t.identifier('__my_require__'), t.identifier('async'), false),
                            args
                ));
            }
        }
    }
}

這時,上面的動態(tài)import代碼就會被替換為:

__my_require__.async('runtime:util').then(u => {
    u.record(1);
});

非常方便吧。

5. 寫在最后

本文相關(guān)代碼已托管至github: babel-plugin-import-customized-require

本文是從一個關(guān)于webpack編譯期的需求出發(fā),應(yīng)用babel來使代碼中部分模塊依賴不在webpack編譯期進(jìn)行處理。其實(shí)從中可以看出,babel給我們賦予了極大的可能性。

文中解決的問題只是一個小需求,也許你會有更不錯的解決方案;然而這里更多的是展示了babel的靈活、強(qiáng)大,它給前端帶來的更多的空間與可能性,在許多衍生的領(lǐng)域也都能發(fā)現(xiàn)它的身影。希望本文能成為一個引子,為你拓展解決問題的另一條思路。

參考資料

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,443評論 6 532
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,530評論 3 416
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,407評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,981評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,759評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,204評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,263評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,415評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,955評論 1 336
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,650評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,892評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,675評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,967評論 2 374