引言
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)行時我們自定義的模塊依賴語法。
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)代碼。
由于篇幅限制,本文不會對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)它的身影。希望本文能成為一個引子,為你拓展解決問題的另一條思路。