Skip to content
New issue

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 是如何将 JSX AST 转换为 JS 对象的?》 #21

Open
wangsiyuan0215 opened this issue Feb 2, 2021 · 0 comments
Open

Comments

@wangsiyuan0215
Copy link
Owner

wangsiyuan0215 commented Feb 2, 2021

我很好奇,在 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 的理解也有了新的认知。

@wangsiyuan0215 wangsiyuan0215 changed the title 《React 是如何将 JSX 转换为 JS 对象的?》 《React 是如何将 JSX AST 转换为 JS 对象的?》 Feb 2, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant