在看篇文章之前,希望你有一点ast树和babel 插件基础知识的了解。
起因
由于对 babel-plugin-import 的代码非常感兴趣,研究了源码。代码简化为如下所示:
{
CallExpression(path, state) {
const { node } = path;
const file = (path && path.hub && path.hub.file) || (state && state.file);
const { name } = node.callee;
const { types } = this;
const pluginState = this.getPluginState(state);
// 也就是说,antd其实也做了进一步的tree-shaking ,只有被使用的组件才会被替换
node.arguments = node.arguments.map(arg => {
const { name: argName } = arg;
if (
pluginState.specified[argName] &&
path.scope.hasBinding(argName) &&
path.scope.getBinding(argName).path.type === 'ImportSpecifier'
) {
// 本质上是这里发生了替换
return this.importMethod(pluginState.specified[argName], file, pluginState);
}
return arg;
});
}
}
最核心的逻辑其实很简单的,以antd为例:
- 找到CallExpression语句(比如React.createElement)中 arguements包含Button关键字,进行替换。比如:
React.createElement(Button, null, "xxxx")
变为
import Button from "antd/lib/button"
React.createElement(Button, null, "xxxx")
到这里,发现一个很有意思的问题。在 babel-plugin-import 插件解析之前,jsx语法已经被转化为了 React.createElement 了。有点意思,值得深度研究一下。
- babel 的插件机制
如果希望babel既要支持react 语法,又要支持按需加载,可以这样调用
import { transformFileSync } from '@babel/core';
transformFileSync(actualFile, {
presets: ['@babel/preset-react'],
plugins: ['babel-plugin-import']})
ps: transformFileSync 是babel的一个转化代码的函数
而根据 babel官方文档,plugin和presets的优先级如下所示:
也就是说,plugins会优先执行。那这里不是矛盾了吗? 假如babel-plugin-import优先执行于 @babel/preset-react,此时,jsx语法还没有转化为React.createElement 函数,babel-plugin-import如何发挥作用。我们深入 babel 源码和 @babel/preset-react 源码看看
@babel/preset-react
@babel/preset-react 中,本质上和jsx转化有关系是插件 @babel/babel-helper-builder-react-jsx,它的代码如下所示:
visitor.JSXElement = {
exit(path, file) {
const callExpr = buildElementCall(path, file);
if (callExpr) {
path.replaceWith(t.inherits(callExpr, path.node));
}
},
};
你可以这样理解,上面的代码是将
<div>
111
</div>
转化为
React.createElement("div", null, "111");
也就是说,按照结果,倒推解析顺序,插件执行顺序
再继续分析一下babel的源码
babel
处理plugin和presets顺序的代码中在 @babel/babel-core。的确就是babel文档中所说,plugin的优先级高于preset,有兴趣的小伙伴可以自行看源码了解,这里不贴出来分析了。
按照babel中代码逻辑,将plugins 和presets中fn合并,生成visitors,结构如下所示
{
JSXElement: ['function in @babel/babel-helper-builder-react-jsx'],
CallExpression: ['function in babel-plugin-import']
}
babel中做代码转化相关的是 @babel/babel-traverse 这个包。首先入口函数是这个
export function traverseNode(
node: t.Node,
opts: TraverseOptions,
scope?: Scope,
state?: any,
path?: NodePath,
skipKeys?: string[],
): boolean {
const keys = VISITOR_KEYS[node.type];
if (!keys) return false;
const context = new TraversalContext(scope, opts, state, path);
for (const key of keys) {
if (skipKeys && skipKeys[key]) continue;
if (context.visit(node, key)) {
return true;
}
}
return false;
}
这里需要注意一下:
-
VISITOR_KEYS,这是一个映射,比如当前node.type == 'JSXElement',VISITOR_KEYS['JSXElement']=['openingElement', 'children', 'closingElement'],代表需要访问当前节点的哪些属性。因为这个映射存在,能递归往下遍历节点 -
opts也很重要,里面包含上面得到的visitors对象 -
context.path函数,将代码简化如下所示:
this.queue = queue;
...
for (const path of queue) {
....
if (path.visit()) {
stop = true;
break;
}
}
....
进入到path.visit函数看看,简化部分代码
if (this.call("enter")) {
return this.shouldStop;
}
....
this.shouldStop = traverseNode(
this.node,
this.opts,
this.scope,
this.state,
this,
this.skipKeys,
);
...
this.call("exit");
而 this.call 函数,其实是根据当前节点的类型,顺序调用visitor[node.type]的"enter"或者 "exit"方法,这里有一个小tips:
this.call简化代码如下所示:
export function _call(this: NodePath, fns?: Array<Function>): boolean {
if (!fns) return false;
for (const fn of fns) {
if (!fn) continue;
const node = this.node;
if (!node) return true;
const ret = fn.call(this.state, this, this.state);
// node has been replaced, it will have been requeued
if (this.node !== node) return true;
}
return false;
}
export function call(this: NodePath, key: string): boolean {
const opts = this.opts;
if (this.node) {
return this._call(opts[this.node.type] && opts[this.node.type][key]);
}
return false;
}
根据上面的核心代码,可以总结插件函数的执行顺序。如果我的ast树结构如下所示
- FunctionDeclaration
- Identifier (id)
同时我的visitors如下所示:
{
"FunctionDeclaration": {
"enter": [fn1, fn2]
"exit": [fn3]
},
"Identifier": {
"enter": [fn4]
"exit": [fn5,fn6]
}
}
那么node遍历顺序应该是
enter FunctionDeclaration
enter Identifier
exit Identifier
exit FunctionDeclaration
其中visitors的执行顺序是
fn1
fn2
fn4
fn5
fn6
fn3
按照上面总结流程,假如代码片段是 <Button></Button>执行过程
但是还是没有解释为什么在节点变化为React.createElement("Button", null),也就是 CallExpression类型以后,到底是什么机制,能让变化的新节点执行babel-plugin-import 插件中 CallExpression 对应的方法
{
CallExpression: ['from babel-plugin-import']
}
在这里纠结了很久,一直没有想通是什么改变了babel处理节点的顺序。按说这个节点已经处理过了,不会重新再次处理。到底和什么有关系?难道是context.path 函数中 queue?对,只有这个可能了, 那么是什么改变了queue。我再次重新去看了 @babel/babel-helper-builder-react-jsx的代码
visitor.JSXElement = {
exit(path, file) {
const callExpr = buildElementCall(path, file);
if (callExpr) {
path.replaceWith(t.inherits(callExpr, path.node));
}
},
};
重点是path.replaceWith函数,path是babel的对象,再去翻一下这个函数干了什么
export function replaceWith(this: NodePath, replacement: t.Node | NodePath){
....
// requeue for visiting
this.requeue();
....
}
找到了,这个改变后的节点被重新push到了队列中。所以会被 babel-plugin-import插件进行处理。
到此为止,逻辑完整了,可以总结babel在处理<Button/>的代码流程了
那么针对babel中插件机制顺序的文档,可以补充一点
-
如果插件中调用了
path.replaceWith函数,并且节点发生变更。那么变更后的节点将再次执行一遍,其对应的插件函数。