我很好奇,在 React 17 以前需要在每个文件(无论是否用到 JSX)都需要 import React from 'react'; 这行语句,而且在 React 17 官方宣布 import React from 'react' 不再是必须的,那么机制到底发生了什么改变呢?
带着好奇和疑问,我打算从 React 17 入手,搜寻答案。
本篇文章需要 AST 和 compiler 的知识点。
正文开始~
React 17 发布在即,尽管我们想对 JSX 的转换进行改进,但我们不想打破现有的配置。于是我们选择与 Babel 合作 ,为想要升级的开发者提供了一个全新的,重构过的 JSX 转换的版本。
升级至全新的转换完全是可选的,但升级它会为你带来一些好处:
- 使用全新的转换,你可以单独使用 JSX 而无需引入 React。
- 根据你的配置,JSX 的编译输出可能会略微改善 bundle 的大小。
- 它将减少你需要学习 React 概念的数量,以备未来之需。
这是 React 官方中文网站上的一段话,总这段话中可以了解到,React 团队与 Babel 团队进行了合作,重构了 JSX 转换器,因此带来了一些优化和便利 —— 针对每个文件无需引入 React、减少 JSX 编译后的输出文件大小。
首先通过 create-react-app CLI 创建一个 React 项目,然后通过 shell npm run eject,将项目的相关配置和脚本暴露出来。

本篇文章自动忽略 JSX 树转换为 AST 树的过程,如有感兴趣额小伙伴请自行搜索~
STEP 1
查看package.json 中的 scripts 字段,可以发现开发、测试和构建命令是通过执行类似 node scripts/*.js 不同的脚本。
STEP 2
通读 start.js 源码,找出与本文的问题有关之处,以下仅展示部分代码以作说明:
const configFactory = require("../config/webpack.config");
// ...
//! 获取 webpack 基本配置
const config = configFactory("development");
// ...
//! 创建 webpack 的 compiler
const compiler = createCompiler({
appName,
//! 应用 webpack 配置
config,
devSocket,
urls,
useYarn,
useTypeScript,
tscCompileOnError,
webpack,
});
从上述代码中可以看出,最有可能执行 JSX 转换的时机就在于 webpack 的执行时,因此确定目标文件:config/webpack.config.js。
STEP 3
追踪到 config/webpack.config.js 文件时发现当前文件共有 700 多行,基本上都是 webpack 的配置。
此时,可以全文试着搜索「JSX」或者小写「jsx」,会发现 hasJsxRuntime 函数,这个函数会引入 react/jsx-runtime 库。
//! 是否存在 JSX 运行时
const hasJsxRuntime = (() => {
//! 如果当前环境变量中 DISABLE_NEW_JSX_TRANSFORM 为 true,则不引用 react/jsx-runtim
if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
return false;
}
try {
require.resolve('react/jsx-runtime');
return true;
} catch (e) {
return false;
}
})();
接着找到调用这个函数所在的位置,在 406 行左右是一个三元运算,同时其所在的块是 babel-preset-react-app 的配置参数,
也就是说,若当前环境变量中 DISABLE_NEW_JSX_TRANSFORM 返回的字符串不是 'true' 的情况下,会先 require('react/jsx-runtime') ,再将当前配置中的 runtime 参数改为 'automatic'。
由此,可以知道的 babel-preset-react-app 这个 babel 的 preset 库一定与我的问题有关系。
STEP 4
在 node_modules 文件夹下找到 babel-preset-react-app,通过 index.js 文件可以知道这个库的核心是 create 函数,而 create 函数则是通过 const create = require('./create'); 这条语句引入的。
OK,直接跳到 ./create.js 文件。
配置过 babel 的小伙伴应该可以看出来这个 js 文件返回了 babel 配置生成函数。
是否还记得在 STEP 3 中,在 babel-preset-react-app 的配置里有 runtime 字段?搜索它!
[
require('@babel/preset-react').default,
{
// ...
//! 关键代码
...(opts.runtime !== 'automatic' ? { useBuiltIns: true } : {}),
runtime: opts.runtime || 'classic',
},
],
根据上述代码可以获知,下一步需要找的目标是 @babel/preset-react。
STEP 5
同样的,在 node_modules/@babel 文件夹下找到 preset-react 库,由于当前库已编译,因此在阅读的时候可能稍微会有点难受,但是好在代码量比较少,其中的关键语句是:
var _pluginTransformReactJsx = _interopRequireDefault(require("@babel/plugin-transform-react-jsx"));
var _pluginTransformReactJsxDevelopment = _interopRequireDefault(require("@babel/plugin-transform-react-jsx-development"));
// ...
const transformReactJSXPlugin = runtime === "automatic" && development
? _pluginTransformReactJsxDevelopment.default
: _pluginTransformReactJsx.default;
上述代码可以发现,@babel/preset-react 库根据当前环境依赖于 @babel/plugin-transform-react-jsx 活 @babel/plugin-transform-react-jsx-development 插件。
STEP 6
在 node_modules/@babel 文件夹下找到 plugin-transform-react-jsx 插件,通过在 package.json 的 main 字段可以看出这个插件的入口文件是 lib/index.js。
lib/index.js 文件虽然也已经进过编译,但是代码量仍然比较少,核心代码:
//! 通过经典的方式手动引用 React 时执行引用的库
var _transformClassic = _interopRequireDefault(require("./transform-classic"));
//! 通过自动的方式引用 React 时执行引用的库
var _transformAutomatic = _interopRequireDefault(require("./transform-automatic"));
//...
var _default = (0, _helperPluginUtils.declare)((api, options) => {
const {
runtime = "classic"
} = options;
if (runtime === "classic") {
return (0, _transformClassic.default)(api, options);
} else {
//! React 17 自动引入方式执行如下代码
return (0, _transformAutomatic.default)(api, options);
}
});
可以看出,_transformAutomatic 就是当前 STEP 所需要定位到的函数。
var _helperPluginUtils = require("@babel/helper-plugin-utils");
var _helperBuilderReactJsxExperimental = require("@babel/helper-builder-react-jsx-experimental");
var _default = (0, _helperPluginUtils.declare)((api, options) => {
const visitor = (0, _helperBuilderReactJsxExperimental.helper)(api, /* options */ Object.assign({
pre(state) {
// ...
},
post(state, pass) {
if (pass.get("@babel/plugin-react-jsx/runtime") === "classic") {
// ...
} else {
state.jsxCallee = pass.get("@babel/plugin-react-jsx/jsxIdentifier")();
state.jsxStaticCallee = pass.get("@babel/plugin-react-jsx/jsxStaticIdentifier")();
state.createElementCallee = pass.get("@babel/plugin-react-jsx/createElementIdentifier")();
state.pure = PURE_ANNOTATION != null ? PURE_ANNOTATION : !pass.get("@babel/plugin-react-jsx/importSourceSet");
}
}
}, options, {
development: false
}));
return {
name: "transform-react-jsx",
inherits: _pluginSyntaxJsx.default,
visitor /* important */
};
});
OK,从上述代码可以看出,当前插件的核心是借助 @babel/helper-plugin-utils 插件工具,基于 @babel/helper-builder-react-jsx-experimental 提供的 helper 创建 visitor 函数(其实就是将 options 注入到 _helperBuilderReactJsxExperimental 执行后返回的对象)。
熟悉 compiler 的小伙伴会清楚,pre 和 post 是插件基于 AST 树遍历时插入的操作函数 hooks,针对 AST 树节点执行额外副作用。post 函数中仅针对 else 语句块可以看出,state 参数插入了 4 个属性,其中 jsxCallee 就是 React 17 自动转换时将 AST树 转换为 对象 的关键。
目前尚有些疑问,@babel/plugin-react-jsx 这个插件我在 node_modules 和 github 上并没有找到,暂时保留这个问题待日后更新……
到这里,真相离我越来越近了~
STEP 7
找到 @babel/helper-builder-react-jsx-experimental,在其 src/index.js 文件中可以找到最终的答案。

从图中可以看出,代码结构很简单,仅 export helper 函数,这个 helper 函数也就是在 STEP 6 中提及的 _helperBuilderReactJsxExperimental。
export function helper(babel, options) {
// ....
return {
//! AST JSXElement 类型节点的处理
JSXElement: {}
//! AST JSXFragment 类型节点的处理
JSXFragment: {}
//! AST 根节点入口类型
Program: {}
};
}
细看 helper 函数返回得是对象(也就是 visitor 对象),其中的属性是基于 AST 树的节点类型,visitor 会在 traverser 函数中作为参数,在遍历树的同时去匹配当前类型,如果命中则执行其中的方法(exit 和 enter)方法。
JSXElement: {
//! 这里使用的是 exit 函数
exit(path, file) {
let callExpr;
//! 如果运行时标志位返回得是 classic 则基于 createElement 创建虚拟 DOM 树(对象)
if (
file.get("@babel/plugin-react-jsx/runtime") === "classic" ||
shouldUseCreateElement(path)
) {
callExpr = buildCreateElementCall(path, file);
} else {
//! 若为 automatic 或者是 shouldUseCreateElement(path) 为 false,执行 buildJSXElementCall 基于 jsx 函数创建
callExpr = buildJSXElementCall(path, file);
}
// 根据 callExpr 替换 node
path.replaceWith(t.inherits(callExpr, path.node));
},
},
上述代码可以看出,对于 JSXElement 类型的节点,使用的 exit hook 处理节点,这么做的目的是由于 React 遍历 jsx 树时是 DFS(深度优先)。也就是说,遍历的顺序会先找到树的某一个分支的末端(叶子节点)后,才开始处理当前叶子节点。当处理完同级的所有叶子节点之后,才会回到这些叶子节点的父节点进行处理。所以,exit hook 对于叶子节点来说,与 enter hook 没有区别,但是对于父节点来说,区别就在于执行的时机。
这里提一句,React 和 Vue 对于树的遍历均是深度优先,同级比较。
到这里,我就找到了最最关键的,也是这个问题的最核心的答案:buildJSXElementCall 函数。
STEP 8
先来看一下 buildJSXElementCall 函数的实现:
function buildJSXElementCall(path, file) {
const openingPath = path.get("openingElement");
//! 处理 children 节点
openingPath.parent.children = t.react.buildChildren(openingPath.parent);
const tagExpr = convertJSXIdentifier(
openingPath.node.name,
openingPath.node,
);
const args = [];
//! 获取标签名称
let tagName;
if (t.isIdentifier(tagExpr)) {
tagName = tagExpr.name;
} else if (t.isLiteral(tagExpr)) {
tagName = tagExpr.value;
}
const state = {
tagExpr: tagExpr,
tagName: tagName,
args: args,
pure: false,
};
//! 执行 options 的 pre hook(还记得 @babel/helper-builder-react-jsx-experimental 中 transform-automatic 里的 options 嘛?)
if (options.pre) {
options.pre(state, file);
}
let attribs = [];
const extracted = Object.create(null);
// ...
//! 构建 attributes
if (attribs.length || path.node.children.length) {
attribs = buildJSXOpeningElementAttributes(
attribs,
file,
path.node.children,
);
} else {
// attributes should never be null
attribs = t.objectExpression([]);
}
//! 将节点属性放到 jsx 函数参数里
args.push(attribs);
//! 针对 开发环境 和 生产环境 对 key 做不同的处理,如果 key 不存在,在 开发环境 中使用 buildUndefinedNode 方法作为默认值
//! 同时对 开发环境 为每个节点增加 __source 和 __self 字段,但这两个字段不会在 生产环境 中出现
if (!options.development) {
if (extracted.key !== undefined) {
args.push(extracted.key);
}
} else {
args.push(
extracted.key ?? path.scope.buildUndefinedNode(),
t.booleanLiteral(path.node.children.length > 1),
extracted.__source ?? path.scope.buildUndefinedNode(),
extracted.__self ?? t.thisExpression(),
);
}
//! 执行 options.post hook
if (options.post) {
options.post(state, file);
}
const call =
state.call ||
//! 执行表达式,如果当前节点存在 children,执行 jsxStaticCallee;
//! 若不存在 children(叶子节点),执行 jsxCallee;
//! 并将参数传递
t.callExpression(
path.node.children.length > 1 ? state.jsxStaticCallee : state.jsxCallee,
args,
);
if (state.pure) annotateAsPure(call);
return call;
}
不去计较这个函数的每行代码执行的意义,从宏观的角度来分析这段代码所做的事情才是比较重要的。
我已经在代码中明确注释了大致的流程,具体可看上述代码。
总结
至此,React 基于 Babel 将以 .js 或 .jsx 文件中的 JSX 转换为对象的整体流程已经全部梳理完毕,在感叹模块相互依赖关系处理的优雅精细以外,也对这种设计模式(生成函数、hooks 注入)大呼巧妙。同时,在梳理的过程中对 AST 和 compiler 的理解也有了新的认知。
我很好奇,在 React 17 以前需要在每个文件(无论是否用到 JSX)都需要
import React from 'react';这行语句,而且在 React 17 官方宣布import React from 'react'不再是必须的,那么机制到底发生了什么改变呢?带着好奇和疑问,我打算从 React 17 入手,搜寻答案。
本篇文章需要 AST 和 compiler 的知识点。
正文开始~
这是 React 官方中文网站上的一段话,总这段话中可以了解到,React 团队与 Babel 团队进行了合作,重构了 JSX 转换器,因此带来了一些优化和便利 —— 针对每个文件无需引入 React、减少 JSX 编译后的输出文件大小。
首先通过
create-react-appCLI 创建一个 React 项目,然后通过 shellnpm run eject,将项目的相关配置和脚本暴露出来。本篇文章自动忽略 JSX 树转换为 AST 树的过程,如有感兴趣额小伙伴请自行搜索~
STEP 1
查看
package.json中的scripts字段,可以发现开发、测试和构建命令是通过执行类似node scripts/*.js不同的脚本。STEP 2
通读
start.js源码,找出与本文的问题有关之处,以下仅展示部分代码以作说明:从上述代码中可以看出,最有可能执行 JSX 转换的时机就在于 webpack 的执行时,因此确定目标文件:
config/webpack.config.js。STEP 3
追踪到
config/webpack.config.js文件时发现当前文件共有 700 多行,基本上都是 webpack 的配置。此时,可以全文试着搜索「JSX」或者小写「jsx」,会发现
hasJsxRuntime函数,这个函数会引入react/jsx-runtime库。接着找到调用这个函数所在的位置,在 406 行左右是一个三元运算,同时其所在的块是
babel-preset-react-app的配置参数,也就是说,若当前环境变量中
DISABLE_NEW_JSX_TRANSFORM返回的字符串不是'true'的情况下,会先require('react/jsx-runtime'),再将当前配置中的runtime参数改为'automatic'。由此,可以知道的
babel-preset-react-app这个 babel 的 preset 库一定与我的问题有关系。STEP 4
在 node_modules 文件夹下找到
babel-preset-react-app,通过index.js文件可以知道这个库的核心是create函数,而create函数则是通过const create = require('./create');这条语句引入的。OK,直接跳到
./create.js文件。配置过 babel 的小伙伴应该可以看出来这个 js 文件返回了 babel 配置生成函数。
是否还记得在 STEP 3 中,在
babel-preset-react-app的配置里有runtime字段?搜索它!根据上述代码可以获知,下一步需要找的目标是
@babel/preset-react。STEP 5
同样的,在 node_modules/@babel 文件夹下找到
preset-react库,由于当前库已编译,因此在阅读的时候可能稍微会有点难受,但是好在代码量比较少,其中的关键语句是:上述代码可以发现,
@babel/preset-react库根据当前环境依赖于@babel/plugin-transform-react-jsx活@babel/plugin-transform-react-jsx-development插件。STEP 6
在 node_modules/@babel 文件夹下找到
plugin-transform-react-jsx插件,通过在package.json的main字段可以看出这个插件的入口文件是lib/index.js。lib/index.js文件虽然也已经进过编译,但是代码量仍然比较少,核心代码:可以看出,
_transformAutomatic就是当前 STEP 所需要定位到的函数。OK,从上述代码可以看出,当前插件的核心是借助
@babel/helper-plugin-utils插件工具,基于@babel/helper-builder-react-jsx-experimental提供的 helper 创建 visitor 函数(其实就是将 options 注入到_helperBuilderReactJsxExperimental执行后返回的对象)。熟悉 compiler 的小伙伴会清楚,
pre和post是插件基于 AST 树遍历时插入的操作函数 hooks,针对 AST 树节点执行额外副作用。post函数中仅针对else语句块可以看出,state参数插入了 4 个属性,其中jsxCallee就是 React 17 自动转换时将 AST树 转换为 对象 的关键。目前尚有些疑问,
@babel/plugin-react-jsx这个插件我在 node_modules 和 github 上并没有找到,暂时保留这个问题待日后更新……到这里,真相离我越来越近了~
STEP 7
找到
@babel/helper-builder-react-jsx-experimental,在其src/index.js文件中可以找到最终的答案。从图中可以看出,代码结构很简单,仅 export helper 函数,这个 helper 函数也就是在 STEP 6 中提及的
_helperBuilderReactJsxExperimental。细看
helper函数返回得是对象(也就是 visitor 对象),其中的属性是基于 AST 树的节点类型,visitor 会在 traverser 函数中作为参数,在遍历树的同时去匹配当前类型,如果命中则执行其中的方法(exit 和 enter)方法。上述代码可以看出,对于
JSXElement类型的节点,使用的 exit hook 处理节点,这么做的目的是由于 React 遍历 jsx 树时是 DFS(深度优先)。也就是说,遍历的顺序会先找到树的某一个分支的末端(叶子节点)后,才开始处理当前叶子节点。当处理完同级的所有叶子节点之后,才会回到这些叶子节点的父节点进行处理。所以,exit hook 对于叶子节点来说,与 enter hook 没有区别,但是对于父节点来说,区别就在于执行的时机。这里提一句,React 和 Vue 对于树的遍历均是深度优先,同级比较。
到这里,我就找到了最最关键的,也是这个问题的最核心的答案:
buildJSXElementCall函数。STEP 8
先来看一下
buildJSXElementCall函数的实现:不去计较这个函数的每行代码执行的意义,从宏观的角度来分析这段代码所做的事情才是比较重要的。
我已经在代码中明确注释了大致的流程,具体可看上述代码。
总结
至此,React 基于 Babel 将以
.js或.jsx文件中的 JSX 转换为对象的整体流程已经全部梳理完毕,在感叹模块相互依赖关系处理的优雅精细以外,也对这种设计模式(生成函数、hooks 注入)大呼巧妙。同时,在梳理的过程中对 AST 和 compiler 的理解也有了新的认知。