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的灵活、强大,它给前端带来的更多的空间与可能性,在许多衍生的领域也都能发现它的身影。但愿本文能成为一个引子,为你拓展解决问题的另外一条思路。