JSX是一个具有灵活性、功能较强的“模版语法”,它不仅仅具有常见的模版语法的功能,如Art、Jade中的功能,更支持Javascript语法的扩展,让我们使用Javascript编写模版。在React + webpack +babel的框架体系里,JSX是如何转换到Vdom的那。
本文研究下JSX语法到vdom的基本流程,研究其核心的三个转换流程,流程如下:
- JSX到AST的转换。主要是使用babel-parser完成JSX AST的转换。
- AST的裁剪处理。主要是Preact完成函数的替换,createElement/createFragment的替换。
- createVnode。虚拟dom的生成与创建。
1. JSX AST的转换
基于React/Preact + Babel的技术选型,我们编写的JSX语法,最终都通过babel转换jsx ast tree。JSX的转换工具有多个,如下所示(github.com/facebook/js…):
- babylon (@babel/parser):babel转换工具,将jsx转换jsx ast tree。
- flow-parser:
- typescript
- esprima
- acorn-jsx。
我们最常用的就是@babel/parser, jsx的处理是复杂的,我们简单看下AST的产物。我们看到一个JSX元素有类似的如下结构:
- JSXElement元素,标识当前为JSXElement。
- JSXOpeningElemen, 开始标签元素,关键属性标签名称、attributes。
- children元素,存储当前JSXElement的子元素。
- JSXClosingElement, 闭合标签元素,关键属性闭合标签名称。
JSX既支持丰富的JSX Attributes,又可以与JS代码结合在一起,这构成了强大的功能。理论上,我们可以给React Props传递任意的属性,包括组件、基本类型、引用类型、函数、JSX等,这可以我们通过props传递任意的内容给子组件。另一方面,JSX与JS代码结合在一起,让我们可以通过各种操作去处理各种数据,更新JSX元素,保持了高度的灵活性。
2. AST的处理
Babel得到JSX的AST后,React createElement/createFragment是如何转换的,其主要使用了babel-plugin-transform-react-jsx完成这样的功能。例如,对于我们1中的case,使用babel-plugin-ransform-react-jsx转换成了类似的代码。
"use strict";
React.createElement("div",
{ className: "A" },
React.createElement("div",
{ className: "B" },
"hello world!"
)
);
我们以@bebel/plugin-transform-react-jsx 7.11.3版本为例来分析下React JSX到React Function Call的转换。@bebel/plugin-transform-react-jsx目前存在两种模式:
-
Classic, 经典模式。v6至今一直支持的模式,功能稳定。
-
Automic, 自动模式。v7.9.0添加的模式,默认开启JSX编译的功能。
两者模式的功能是类似,我们分析Classic是如何实现JSX到React Function Call的功能。这里我们可以先看下@bebel/plugin-transform-react-jsx的参数配置:
-
rumtime: 模式选择,默认classic。
-
throwIfNamespace: XML命名时是否报错,默认为true。
-
Pragma: JSX Expression替换名称,默认React.createElement。
-
pragmaFrag:JSX fragments替换名称,默认React.Fragment。
看完@bebel/plugin-transform-react-jsx的基本参数后,我们可以看下它需要处理的功能部分:
- JSX到React.createElement的转换。
- JSX Attributin的转换。
- JSX Children的处理。
2.1 @bebel/plugin-transform-react-jsx的代码结构
babel的插件在于visitor的功能设计,我们看到@bebel/plugin-transform-react-jsx的visitor设计如下:
- JSXNamespacedName/JSXSpreadChild,不支持的功能标识。
- Program,Program预处理。
- JSXAttribute,JSX属性处理。
- JSXElement, JSXElement节点处理。
- JSXFragment,JSXFragment节点处理。
基于visitor的结构,我们按照“初始化”阶段、JSXElement、JSXFragment来介绍分析JSX的转换流程。
2.2 “初始化”阶段
在transform-classic.js可以看到,该babel的插件的功能可以分为4个部分:
- 初始化options及定义Pre/Post。
- Program。
- JSXAttribute。
2.2.1 options的处理及定义Pre/Post
options的初始化,我们只关心pragma/pragmaFrag的处理,在React里是 React.createElement /React.Fragment,在Preact里是Preact. createElement/Preact.Fragment。
const DEFAULT = {
pragma: "React.createElement",
pragmaFrag: "React.Fragment",
};
export default declare((api, options) => {
...
// 设置PRAGMA_DEFAULT/PRAGMA_FRAG_DEFAULT
const PRAGMA_DEFAULT = options.pragma || DEFAULT.pragma;
const PRAGMA_FRAG_DEFAULT = options.pragmaFrag || DEFAULT.pragmaFrag;
...
}
插件还定义了pre/post的方法,用于JSXElement/JSXFragment预处理,pre功能主要用来处理组件、标签。这里我们就可以看到,组件为什么不能小写开头,当我们以小写开头时,react-jsx将其转换为React.createElement('x')。 const visitor = helper({ pre(state) { const tagName = state.tagName; const args = state.args; // 若以小写开头,则标识为标签,创建为string元素 // 若以非小写开头,标识为元素 // github.com/babel/babel… if (t.react.isCompatTag(tagName)) { args.push(t.stringLiteral(tagName)); } else { args.push(state.tagExpr); } }, }); post主要用来处理注释相关,具体可以参见(babeljs.io/docs/en/bab…
post(state, pass) {
state.callee = pass.get("jsxIdentifier")();
state.pure = PURE_ANNOTATION ?? pass.get("pragma") === DEFAULT.pragma;
},
2.2.2 Program
plugin-transform-react-jsx的program用来处理评论和初始化一些公共参数。评论可以参照Babel的Issue看下,我们重点看下参数的初始化。通过state设置了对应如下的key对应:
- jsxIdentifier。创建为React.createElement的标识符。
- jsxFragIdentifier。创建为React.createFragElement的表示。
- usedFragment。是否使用了fragment。
- pragma。React.createElement的设置。
- pragmaSet。pragma是否设置。
- pragmaFragSet。pragmaFrag是否设置。
visitor.Program = {
enter(path, state) {
const { file } = state;
let pragma = PRAGMA_DEFAULT;
let pragmaFrag = PRAGMA_FRAG_DEFAULT;
let pragmaSet = !!options.pragma;
let pragmaFragSet = !!options.pragma;
...
// 初始化相关参数,JSXElement处理
state.set("jsxIdentifier", createIdentifierParser(pragma));
state.set("jsxFragIdentifier", createIdentifierParser(pragmaFrag));
state.set("usedFragment", false);
state.set("pragma", pragma);
state.set("pragmaSet", pragmaSet);
state.set("pragmaFragSet", pragmaFragSet);
},
exit(path, state) {
// usedFragment但pragmaFragSet未设置时
if (
state.get("pragmaSet") &&
state.get("usedFragment") &&
!state.get("pragmaFragSet")
) {
throw new Error(
"transform-react-jsx: pragma has been set but " +
"pragmaFrag has not been set",
);
}
},
};
2.2.3 JSXAttribute
JSXAttribute的处理很简单,将JSXElement的属性节点转换为jsxExpressionContainer。
典型的场景如<A aProps={<B b='b' />} />
visitor.JSXAttribute = function (path) {
if (t.isJSXElement(path.node.value)) {
path.node.value = t.jsxExpressionContainer(path.node.value);
}
};
2.3 JSXElement/JSXFragment
JSXElement/JSXFragment的节点处理时类似的,我们重点看下JSXElement的处理。JSXElement的处理,关键在于三个处理:
- JSXElement到React.createElement(Function call)的转换。
- JSX Attriebute的提取与处理。
- Children的处理与递归处理。
visitor.JSXElement = {
exit(path, file) {
// 调用buildElementCall处理得到Function Call
const callExpr = buildElementCall(path, file);
if (callExpr) {
path.replaceWith(t.inherits(callExpr, path.node));
}
},
};
2.3.1 Children的处理
在JSXElement的处理函数里,使用t提供的react buildChildren完成children节点的处理。
function buildElementCall(path, file) {
if (opts.filter && !opts.filter(path.node, file)) return;
const openingPath = path.get("openingElement");
// build chilren
openingPath.parent.children = t.react.buildChildren(openingPath.parent);
...
}
buildChildren函数处理了三类节点:
- 文本节点,过滤\r\n\t等空占位符后的文本,若存在push到elements中。
- JSXExpressionContainer节点。设置child为JSXExpressionContainer.express, push到elements中。
- 空的表达式节点删除。
export default function buildChildren(node: Object): Array<Object> {
const elements = [];
for (let i = 0; i < node.children.length; i++) {
let child = node.children[i];
if (isJSXText(child)) {
cleanJSXElementLiteralChild(child, elements);
continue;
}
if (isJSXExpressionContainer(child)) child = child.expression;
if (isJSXEmptyExpression(child)) continue;
elements.push(child);
}
return elements;
}
2.3.2 Attribute的处理
attribute的处理相对复杂,结合了useSpread、useBuiltIns属性的设置,感兴趣的,可以深度阅读attribute的相关处理。
function buildElementCall(path, file) {
...
let attribs = openingPath.node.attributes;
if (attribs.length) {
attribs = buildOpeningElementAttributes(attribs, file);
} else {
attribs = t.nullLiteral();
}
args.push(attribs, ...path.node.children);
...
}
2.3.3 JSXElement的处理
JSXElement的主要功能是将JSXElement节点转换为Function Call函数,结合代码,其主要的功能流程如下:
- 初始化Children,如2.3.1所分析。
- 设置ElementState。插件创建了ElementState元素存储相关的信息,包括节点、名称、参数、call函数等,完成Function.Call的初始化准备工作。
- pre处理。
- 属性设置。
- 返回call。设置最终的call,
const call = state.call || t.callExpression(state.callee, args),完成Function Call节点的创建。
1中我们的代码在实际上的业务中是什么样的那,如下图所示,基于Preact 10.4.6 dev模式下得到的JSX的转换代码。我们可以到JSXElement最终转换成了Preact.createElement的嵌套调用。
var _ref3 =
/*#__PURE__*/
Object(preact__WEBPACK_IMPORTED_MODULE_0__["createElement"])("div", {
className: "A",
}, Object(preact__WEBPACK_IMPORTED_MODULE_0__["createElement"])("div", {
className: "B",
}, "hello world!"));
3. VDOM的转换
我们以Preact10.4.6为基准,看下Preact是如何实现从JSX到Vdom tree的转换的。
3.1 createVNode
在createElement,createVNode的定义如下:
export function createVNode(type, props, key, ref, original) {
const vnode = {
type, // 节点,对应标签(div)/组件(Component)
props, // 属性值, props集合
key, // key值, <div key={xx} />
ref, // ref值, <div ref={xx} />
_children: null, // children 节点集合,null/Array
_parent: null, // 父节点,子元素的父节点
_depth: 0, // 节点深度
_dom: null, // 原生dom节点值,
_nextDom: undefined, // 下一个兄弟节点
_component: null, // 实例化的React Component
constructor: undefined, // React Component 构造函数
_original: original // 源节点内容
};
// origin节点默认为当前node
if (original == null) vnode._original = vnode;
// 存在vnode, 完成vnode初始化
if (options.vnode) options.vnode(vnode);
return vnode;
}
preact只保留了最核心的节点值,包括type、props、key、ref等。这些属性值,在后面的diff、html转换中才会使用,后续的分析将会进一步分析相关的属性值。
出去了节点定义外,我们看到Preact的createVNode处理了original、options.vnode,然后就返回了创建的node节点。
export function createVNode(type, props, key, ref, original) {
const vnode = {...}
if (original == null) vnode._original = vnode;
if (options.vnode) options.vnode(vnode);
return vnode;
}
3.2 createElement
一般对于的JSX Element,Webpack +Babel将其转换为createElement(App, null)的形式,createElement完成节点的创建及初始化工作。
Preact10.4.6的源代码如下,其基本的处理流程如下:
- 归一化props。去除key和ref之外的props,得到归一化后的normalizedProps。
- 第三个及之后的参数转化为child数组并设置normalizedProps.children。
- type为函数(对应Component)时,defaultProps合并到normalizedProps。
- 调用createVNode生成虚拟的节点,并返回。
export function createElement(type, props, children) {
let normalizedProps = {},
i;
for (i in props) {
if (i !== 'key' && i !== 'ref') normalizedProps[i] = props[i];
if (arguments.length > 3) {
children = [children];
for (i = 3; i < arguments.length; i++) {
children.push(arguments[i]);
}
if (children != null) {
normalizedProps.children = children;
}
if (typeof type == 'function' && type.defaultProps != null) {
for (i in type.defaultProps) {
if (normalizedProps[i] === undefined) {
normalizedProps[i] = type.defaultProps[i];
}
}
}
let result = createVNode(
type,
normalizedProps,
props && props.key,
props && props.ref,
null
);
return result;
}
分析完Preact createNode/createElement的函数功能,我们看1/2中的demo实际生成的节点结构如下图。
4. 总结
通过对JSX在Preact的转化编译过程,我们可以看到,JSX到Vdom的三个核心节点:
-
Babel AST的转换,完成JSX语法得构建。
-
Babel/plugin-trasnform-react-jsx的转换,将JSXElement/JSXFragElement转换为Function Call,完成编译期的处理。
-
Preact createVnode/createElement的处理,完成vdom tree的构建。
打个小广告,团队持续扩展中,欢迎各位同学投递。大家可以如下的方式进行投递:
-
通过如下的链接投递(job.toutiao.com/s/JMpw6Rr).
-
可以将简历发送我的邮箱(leisureljc@outlook.com),我来给您投递。