【webpack進階】使用babel避免webpack編譯執行時模組依賴

AlienZHOU發表於2018-08-18

引言

babel是一個非常強大的工具,作用遠不止我們平時的ES6 -> ES5語法轉換這麼單一。在前端進階的道路上,瞭解與學習babel及其靈活的外掛模式將會為前端賦予更多的可能性。

本文就是運用babel,通過編寫babel外掛解決了一個實際專案中的問題。

本文相關程式碼已託管至github: babel-plugin-import-customized-require

1. 遇到的問題

最近在專案中遇到這樣一個問題:我們知道,使用webpack作為構建工具是會預設自動幫我們進行依賴構建;但是在專案程式碼中,有一部分的依賴是執行時依賴/非編譯期依賴(可以理解為像requirejs、seajs那樣的純前端模組化),對於這種依賴不做處理會導致webpack編譯出錯。

為什麼需要非編譯期依賴呢?例如,在當前的業務模組(一個獨立的webpack程式碼倉庫)裡,我依賴了一個公共業務模組的打點程式碼

// 這是home業務模組程式碼
// 依賴了common業務模組的程式碼
import log from 'common:util/log.js'

log('act-1');
複製程式碼

然而,可能是由於技術棧不統一,或是因為common業務程式碼遺留問題無法重構,或者僅僅是為了業務模組的分治……總之,無法在webpack編譯期解決這部分模組依賴,而是需要放在前端執行時框架解決。

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

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

log('act-1');
複製程式碼

但這樣就導致了我們程式碼形式的分裂,擁抱規範讓我們希望還是能夠用ESM的標準語法來一視同仁。

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

// 標準的ESM語法
import * as log from 'common:util/log.js';

log('act-1');
複製程式碼

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

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

  • 能夠在程式碼中使用ESM語法,來進行非編譯期分析的模組引用
  • 由於webpack會嘗試打包該依賴,需要不會在編譯期出錯

2. 解決思路

基於上面的目標,首先,我們需要有一種方式能夠標識不需要編譯的執行期依賴。例如util/record這個模組,如果是執行時依賴,可以參考標準語法,為模組名新增標識:runtime:util/record。效果如下:

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

// 下面這兩個模組,我們不希望webpack在編譯期進行處理
import record from 'runtime:util/record';
import {Banner, List} from 'runtime:ui/layout/component';
複製程式碼

其次,雖然標識已經可以讓開發人員知道程式碼裡哪些模組是webpack需要打包的依賴,哪些是非編譯期依賴;但webpack不知道,它只會拿到模組原始碼,分析import語法拿到依賴,然後嘗試載入依賴模組。但這時webpack傻眼了,因為像runtime:util/record這樣的模組是執行時依賴,編譯期找不到該模組。那麼,就需要通過一種方式,讓webpack“看不見”非編譯期的依賴。

最後,拿到非編譯期依賴,由於瀏覽器現在還不支援ESM的import語法,因此需要將它變為在前端執行時我們自定義的模組依賴語法。

【webpack進階】使用babel避免webpack編譯執行時模組依賴

3. 使用babel對原始碼進行分析

3.1. babel相關工具介紹

對babel以及外掛機制不太瞭解的同學,可以先看這一部分做一個簡單的瞭解。

babel是一個強大的javascript compiler,可以將原始碼通過詞法分析與語法分析轉換為AST(抽象語法樹),通過對AST進行轉換,可以修改原始碼,最後再將修改後的AST轉換會目的碼。

【webpack進階】使用babel避免webpack編譯執行時模組依賴

由於篇幅限制,本文不會對compiler或者AST進行過多介紹,但是如果你學過編譯原理,那麼對詞法分析、語法分析、token、AST應該都不會陌生。即使沒了解過也沒有關係,你可以粗略的理解為:babel是一個compiler,它可以將javascript原始碼轉化為一種特殊的資料結構,這種資料結構就是樹,也就是AST,它是一種能夠很好表示原始碼的結構。babel的AST是基於ESTree的。

例如,var alienzhou = 'happy'這條語句,經過babel處理後它的AST大概是下面這樣的

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

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

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

const babel = require('babel-core');
const {ast} = babel.transform(`var alienzhou = 'happy'`);
複製程式碼

然後遍歷AST,找到特定的節點進行修改即可。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)
        // ...
    }
})
複製程式碼

這樣就可以得到所有的字串字面量,當然你也可以替換這個節點的內容:

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

注意,AST是一個mutable物件,所有的節點操作都會在原AST上進行修改。

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

由於大部分的webpack專案都會在loader中使用babel,因此只需要提供一個babel的外掛來處理非編譯期依賴語法即可。而babel外掛其實就是匯出一個方法,該方法會返回我們上面提到的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語法的相關資訊;在exit方法裡,判斷當前ImportDeclaration是否為非編譯期依賴,如果是則進行語法轉換。

收集ImportDeclaration語法相關資訊需要注意,對於不同的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';
複製程式碼

對應了三類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的基礎上,對子節點進行traverse,這裡新建了一個visitor用來訪問Specifier,針對不同語法進行收集:

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進行遍歷:

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)) {
                    // ...
                }
            }
        }
    }
}
複製程式碼

到目前為止,我們在進入ImportDeclaration節點時,收集了import語句相關資訊,在退出節點時,通過判斷可以知道目前節點是否是非編譯期依賴。因此,如果是非編譯期依賴,只需要根據收集到的資訊替換節點語法即可。

生成新節點可以使用babel-types。不過推薦使用babel-template,會令程式碼更簡便與清晰。下面這個方法,會根據不同的import資訊,生成不同的執行時程式碼,其中假定__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外掛修改後變為了var util = require('runtime:util')['default'],該程式碼也會被webpack直接輸出。

這樣,通過babel外掛,我們就完成了文章最一開始的目標。

4. 處理dynamic import

細心的讀者肯定會發現了,我們在上面只解決了靜態import的問題,那麼像下面這樣的動態import不是仍然會有以上的問題麼?

import('runtime:util').then(u => {
    u.record(1);
});
複製程式碼

是的,仍然會有問題。因此,進一步我們還需要處理動態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
                ));
            }
        }
    }
}
複製程式碼

這時,上面的動態import程式碼就會被替換為:

__my_require__.async('runtime:util').then(u => {
    u.record(1);
});
複製程式碼

非常方便吧。

5. 寫在最後

本文相關程式碼已託管至github: babel-plugin-import-customized-require

本文是從一個關於webpack編譯期的需求出發,應用babel來使程式碼中部分模組依賴不在webpack編譯期進行處理。其實從中可以看出,babel給我們賦予了極大的可能性。

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

參考資料

相關文章