Skip to content

《React 是如何将 JSX AST 转换为 JS 对象的?》 #21

@wangsiyuan0215

Description

@wangsiyuan0215

我很好奇,在 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.jsonmain 字段可以看出这个插件的入口文件是 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 的小伙伴会清楚,prepost 是插件基于 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 的理解也有了新的认知。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions