引言
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語法,因此需要將它變為在前端執行時我們自定義的模組依賴語法。
3. 使用babel對原始碼進行分析
3.1. babel相關工具介紹
對babel以及外掛機制不太瞭解的同學,可以先看這一部分做一個簡單的瞭解。
babel是一個強大的javascript compiler,可以將原始碼通過詞法分析與語法分析轉換為AST(抽象語法樹),通過對AST進行轉換,可以修改原始碼,最後再將修改後的AST轉換會目的碼。
由於篇幅限制,本文不會對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的靈活、強大,它給前端帶來的更多的空間與可能性,在許多衍生的領域也都能發現它的身影。希望本文能成為一個引子,為你擴充解決問題的另一條思路。