快速跳转源码部分plugin-transform-react-jsx->插件结构。
JSX
在react项目中,编写组件的很大一部分工作就是写JSX,如下:
function Title(props) {
return <h1>{props.text}</h1>
}
<Title text='hello world' />
/* 下面是转译后的代码 */
import { jsx as _jsx } from "react/jsx-runtime";
function Title(props) {
return /*#__PURE__*/_jsx("h1", {
children: props.text
});
}
/*#__PURE__*/_jsx(Title, {
text: "hello world"
});
JSX是对JavaScript的扩展,使得用户可以以JavaScript的灵活程度编写UI。
babel-preset-react
JSX到纯JavaScript的转换需要通过babel,多数项目react项目使用了preset-react这个预设,配置通常是这样的:
module.exports = {
presets: [
[
"@babel/preset-react",
{
development: process.env.BABEL_ENV === "development",
},
],
],
};
babel preset与babel plugin的不同是,前者是插件和预设的组合,本身不包含转译逻辑。以preset-react为例,这个预设包含了plugin-transform-react-jsx与plugin-transform-react-display-name,前者是核心。
preset-react的源码非常简单,是这样的:
import { declarePreset } from "@babel/helper-plugin-utils";
import transformReactJSX from "@babel/plugin-transform-react-jsx";
import transformReactJSXDevelopment from "@babel/plugin-transform-react-jsx-development";
import transformReactDisplayName from "@babel/plugin-transform-react-display-name";
import transformReactPure from "@babel/plugin-transform-react-pure-annotations";
import normalizeOptions from "./normalize-options.ts";
// ...类型声明
export default declarePreset((api, opts: Options) => {
// ...配置获取
return {
plugins: [
[
development ? transformReactJSXDevelopment : transformReactJSX,
process.env.BABEL_8_BREAKING
? {
//...options
}
: {
//...options
},
],
transformReactDisplayName,
pure !== false && transformReactPure,
].filter(Boolean),
};
});
可以看到这个preset仅仅返回了两个插件,这里只介绍作为功能核心的plugin-transform-react-jsx。
plugin-transform-react-jsx
babel的工作流程
关于babel的知识,详情可见babel手册(多语种)。
Parse
这个阶段会接收代码,然后生成AST(抽象语法树),包含了两个小阶段:Lexical Analysis与Syntactic Analysis。
Lexical Analysis
Lexical Analysis会将代码转换成token流,也就是一个扁平的语法段数组。对于n * n这样的代码,相应的token流是:
[
{ type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
{ type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
{ type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
...
]
Syntactic Analysis
Syntactic Analysis进一步将扁平化的token流转化为AST(抽象语法树),后者的结构更清晰,更易于处理。
形如
function square(n) {
return n * n;
}
这样的代码,它的抽象语法树是这样的:
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square",
},
params: [
{
type: "Identifier",
name: "n",
},
],
body: {
type: "BlockStatement",
body: [
{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "n",
},
right: {
type: "Identifier",
name: "n",
},
},
},
],
},
};
Transform
在这个阶段,编译器会遍历AST,根据需要插入,修改,移除节点,babel插件就是在这个阶段发挥作用的,在介绍plugin-transform-react-jsx源码的时候会有更详细的说明。
Generate
代码生成阶段会遍历AST,转化生成最终的目标代码。
插件结构
babel插件就是一个导出的函数,就plugin-transform-react-jsx而言是这样的:
import createPlugin from "./create-plugin.ts";
export default createPlugin({
name: "transform-react-jsx",
development: false,
});
/* ./create-plugin.ts */
import jsx from "@babel/plugin-syntax-jsx";
// ...其他导入
// ...类型声明
export default function createPlugin({
name,
development,
}: {
name: string;
development: boolean;
}) {
// ...
return declare((_, options: Options) => {
return {
name,
inherits: jsx,
visitor: { ... };
}
})
}
/* 简化一下就是这样 */
export default function plugin() {
return {
inherits: jsx,
visitor: { ... },
};
}
inherits
plugin-transform-react-jsx本身还依赖另一个插件plugin-syntax-jsx,前者做JSX代码转换,后者识别解析JSX代码,但与转换逻辑无关。
babel插件作为一个函数,返回一个对象,其中inherits属性表明依赖的另一个插件,visitor属性则规定了代码的转换逻辑。
visitor
当遍历AST中的一个节点时,就是在visit它,visitor模式是编译中的一个典型的遍历AST的模式。visitor对象中包含了处理不同类型节点的方法。
插件转换逻辑
这个部分是JSX转换的核心。babel以深度优先遍历的方式处理AST,其逻辑是这样的:
- 对任意已处理的节点。
- 如果有子节点,则继续处理子节点。
- 反之,处理兄弟节点。
visitor对象的结构是这样的:
visitor: {
JSXFragment: {
exit(path, file) {
let callExpr;
if (get(file, "runtime") === "classic") {
callExpr = buildCreateElementFragmentCall(path, file);
} else {
callExpr = buildJSXFragmentCall(path, file);
}
path.replaceWith(t.inherits(callExpr, path.node));
},
},
JSXElement: {
exit(path, file) {
let callExpr;
if (get(file, "runtime") === "classic" || shouldUseCreateElement(path)) {
callExpr = buildCreateElementCall(path, file);
} else {
callExpr = buildJSXElementCall(path, file);
}
path.replaceWith(t.inherits(callExpr, path.node));
},
},
JSXAttribute(path) {
if (t.isJSXElement(path.node.value)) {
path.node.value = t.jsxExpressionContainer(path.node.value);
}
},
};
JSXElement
这里只说明最重要的JSXElement,也就是形如<h1>hello world</h1>以及<App title='foo' />这样的代码。
因为对任何一个节点的遍历,有进入和离开两个阶段,这是深度优先遍历的方式决定的,类似于React处理fiber树的顺序,所以exit方法表明对这个节点的处理是从离开这个节点时展开的(相反情形是enter方法)。
转换逻辑
无论是代码的遍历顺序,还是最终代码的生成,都是由babel完成的,babel插件只需要编写针对特定类型节点的处理方法。
简化后的处理方法是这样的:
const JSXElement = {
exit(path, file) {
const callExpr = buildJSXElementCall(path, file)
path.replaceWith(t.inherits(callExpr, path.node));
},
};
入参path由babel提供,包含了但不限于节点信息,t是babel的type,提供了判断和生成AST节点的方法。
基本逻辑就2条:
- 生成一个函数调用表达式节点(函数调用形如
jsx('h1', { ... }))。 - 替换掉原有的
JSXElement的类型
节点生成
要生成函数调用表达式节点,需要分别知道函数名,实参。
- 函数名由配置决定,不同环境下的配置决定了函数名是
jsx或者createElement。 - 实参包含了element type,props,children arguments三部分。
从path中可以获取JSXElement的tag,attributes,以及children,以此为基础,可以转化生成目标节点的实参。
简化过后的生成方法是这样的:
function buildJSXElementCall(path: NodePath<JSXElement>, file: PluginPass) {
const openingPath = path.get("openingElement");
const args: t.Expression[] = [getTag(openingPath)];
// ...
const children = t.react.buildChildren(path.node);
// ...
attribs = buildJSXOpeningElementAttributes(
attribsArray,
children
);
// ....
args.push(attribs)
return call(file, children.length > 1 ? "jsxs" : "jsx", args);
}
可以看到逻辑包含4条:
- 从
JSXElement获取tag,对于原生组件,生成的tagName会是一个字符串字面量节点(形如'h1'对应的AST节点),对于复合组件,tagName是一个标识符(形如App对应的AST节点)。 - 处理children,一是过滤空表达式节点,二是打开表达式,三是处理字面量child。
- 处理attributes,即将
JSXElement的attributes节点数组转化为对象表达式,同时在对象中加入children。 - 返回生成函数调用表达式。
其中每一步的逻辑都非常简单,直接调api,babel提供了各种节点生成的方法,如:
- 生成字符串字面量可以使用
t.stringLiteral('h1')。 - 生成标识符可以使用
t.identifier('App')。 - 生成函数调用表达式可以使用
t.callExpression(identifier, [args])。 - 等等