babel是一個很是強大的工具,做用遠不止咱們平時的ES6 -> ES5語法轉換這麼單一。在前端進階的道路上,瞭解與學習babel及其靈活的插件模式將會爲前端賦予更多的可能性。javascript
本文就是運用babel,經過編寫babel插件解決了一個實際項目中的問題。前端
本文相關代碼已託管至github: babel-plugin-import-customized-requirejava
最近在項目中遇到這樣一個問題:咱們知道,使用webpack做爲構建工具是會默認自動幫咱們進行依賴構建;可是在項目代碼中,有一部分的依賴是運行時依賴/非編譯期依賴(能夠理解爲像requirejs、seajs那樣的純前端模塊化),對於這種依賴不作處理會致使webpack編譯出錯。node
爲何須要非編譯期依賴呢?例如,在當前的業務模塊(一個獨立的webpack代碼倉庫)裏,我依賴了一個公共業務模塊的打點代碼react
// 這是home業務模塊代碼
// 依賴了common業務模塊的代碼
import log from 'common:util/log.js'
log('act-1');
複製代碼
然而,多是因爲技術棧不統一,或是由於common業務代碼遺留問題沒法重構,或者僅僅是爲了業務模塊的分治……總之,沒法在webpack編譯期解決這部分模塊依賴,而是須要放在前端運行時框架解決。webpack
爲了解決webpack編譯期沒法解析這種模塊依賴的問題,能夠給這種非編譯期依賴引入新的語法,例以下面這樣:git
// __my_require__是咱們自定義的前端require方法
var log = __my_require__('common:util/log.js')
log('act-1');
複製代碼
但這樣就致使了咱們代碼形式的分裂,擁抱規範讓咱們但願仍是可以用ESM的標準語法來一視同仁。github
咱們仍是但願能像下面這樣寫代碼:web
// 標準的ESM語法
import * as log from 'common:util/log.js';
log('act-1');
複製代碼
此外,也能夠考慮使用webpack提供了externals配置來避免某些模塊被webpack打包。然而,一個重要的問題是,在已有的common代碼中有一套前端模塊化語法,要將webpack編譯出來的代碼與已有模式融合存在一些問題。所以該方式也存在不足。瀏覽器
針對上面的描述,總結來講,咱們的目的就是:
基於上面的目標,首先,咱們須要有一種方式可以標識不須要編譯的運行期依賴。例如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語法,所以須要將它變爲在前端運行時咱們自定義的模塊依賴語法。
對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的編寫便可。
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:
import {util} from 'runtime:util'
,import {util as u} from 'runtime:util';
import util from 'runtime:util'
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插件,咱們就完成了文章最一開始的目標。
細心的讀者確定會發現了,咱們在上面只解決了靜態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);
});
複製代碼
很是方便吧。
本文相關代碼已託管至github: babel-plugin-import-customized-require
本文是從一個關於webpack編譯期的需求出發,應用babel來使代碼中部分模塊依賴不在webpack編譯期進行處理。其實從中能夠看出,babel給咱們賦予了極大的可能性。
文中解決的問題只是一個小需求,也許你會有更不錯的解決方案;然而這裏更多的是展現了babel的靈活、強大,它給前端帶來的更多的空間與可能性,在許多衍生的領域也都能發現它的身影。但願本文能成爲一個引子,爲你拓展解決問題的另外一條思路。