We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
我很好奇,在 React 17 以前需要在每个文件(无论是否用到 JSX)都需要 import React from 'react'; 这行语句,而且在 React 17 官方宣布 import React from 'react' 不再是必须的,那么机制到底发生了什么改变呢?
import React from 'react';
import React from 'react'
带着好奇和疑问,我打算从 React 17 入手,搜寻答案。
本篇文章需要 AST 和 compiler 的知识点。
正文开始~
React 17 发布在即,尽管我们想对 JSX 的转换进行改进,但我们不想打破现有的配置。于是我们选择与 Babel 合作 ,为想要升级的开发者提供了一个全新的,重构过的 JSX 转换的版本。 升级至全新的转换完全是可选的,但升级它会为你带来一些好处: 使用全新的转换,你可以单独使用 JSX 而无需引入 React。 根据你的配置,JSX 的编译输出可能会略微改善 bundle 的大小。 它将减少你需要学习 React 概念的数量,以备未来之需。
React 17 发布在即,尽管我们想对 JSX 的转换进行改进,但我们不想打破现有的配置。于是我们选择与 Babel 合作 ,为想要升级的开发者提供了一个全新的,重构过的 JSX 转换的版本。 升级至全新的转换完全是可选的,但升级它会为你带来一些好处:
这是 React 官方中文网站上的一段话,总这段话中可以了解到,React 团队与 Babel 团队进行了合作,重构了 JSX 转换器,因此带来了一些优化和便利 —— 针对每个文件无需引入 React、减少 JSX 编译后的输出文件大小。
首先通过 create-react-app CLI 创建一个 React 项目,然后通过 shell npm run eject,将项目的相关配置和脚本暴露出来。
create-react-app
npm run eject
本篇文章自动忽略 JSX 树转换为 AST 树的过程,如有感兴趣额小伙伴请自行搜索~
查看package.json 中的 scripts 字段,可以发现开发、测试和构建命令是通过执行类似 node scripts/*.js 不同的脚本。
package.json
scripts
node scripts/*.js
通读 start.js 源码,找出与本文的问题有关之处,以下仅展示部分代码以作说明:
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。
config/webpack.config.js
追踪到 config/webpack.config.js 文件时发现当前文件共有 700 多行,基本上都是 webpack 的配置。 此时,可以全文试着搜索「JSX」或者小写「jsx」,会发现 hasJsxRuntime 函数,这个函数会引入 react/jsx-runtime 库。
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
DISABLE_NEW_JSX_TRANSFORM
'true'
require('react/jsx-runtime')
runtime
'automatic'
由此,可以知道的 babel-preset-react-app 这个 babel 的 preset 库一定与我的问题有关系。
在 node_modules 文件夹下找到 babel-preset-react-app,通过 index.js 文件可以知道这个库的核心是 create 函数,而 create 函数则是通过 const create = require('./create'); 这条语句引入的。
index.js
create
const create = require('./create');
OK,直接跳到 ./create.js 文件。
./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。
@babel/preset-react
同样的,在 node_modules/@babel 文件夹下找到 preset-react 库,由于当前库已编译,因此在阅读的时候可能稍微会有点难受,但是好在代码量比较少,其中的关键语句是:
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 插件。
@babel/plugin-transform-react-jsx
@babel/plugin-transform-react-jsx-development
在 node_modules/@babel 文件夹下找到 plugin-transform-react-jsx 插件,通过在 package.json 的 main 字段可以看出这个插件的入口文件是 lib/index.js。
plugin-transform-react-jsx
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 所需要定位到的函数。
_transformAutomatic
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 执行后返回的对象)。
@babel/helper-plugin-utils
@babel/helper-builder-react-jsx-experimental
_helperBuilderReactJsxExperimental
熟悉 compiler 的小伙伴会清楚,pre 和 post 是插件基于 AST 树遍历时插入的操作函数 hooks,针对 AST 树节点执行额外副作用。post 函数中仅针对 else 语句块可以看出,state 参数插入了 4 个属性,其中 jsxCallee 就是 React 17 自动转换时将 AST树 转换为 对象 的关键。
pre
post
else
state
jsxCallee
目前尚有些疑问,@babel/plugin-react-jsx 这个插件我在 node_modules 和 github 上并没有找到,暂时保留这个问题待日后更新……
@babel/plugin-react-jsx
到这里,真相离我越来越近了~
找到 @babel/helper-builder-react-jsx-experimental,在其 src/index.js 文件中可以找到最终的答案。
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)方法。
helper
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 没有区别,但是对于父节点来说,区别就在于执行的时机。
JSXElement
这里提一句,React 和 Vue 对于树的遍历均是深度优先,同级比较。
到这里,我就找到了最最关键的,也是这个问题的最核心的答案:buildJSXElementCall 函数。
buildJSXElementCall
先来看一下 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 的理解也有了新的认知。
.js
.jsx
The text was updated successfully, but these errors were encountered:
No branches or pull requests
我很好奇,在 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-app
CLI 创建一个 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 的理解也有了新的认知。The text was updated successfully, but these errors were encountered: