一文读懂react jsx转换——babel插件源码解析

501 阅读5分钟

快速跳转源码部分plugin-transform-react-jsx->插件结构。

JSX

在react项目中,编写组件的很大一部分工作就是写JSX,如下:

function Title(props) {
  return <h1>{props.text}</h1>
}

<Title text='hello world' />

/*   下面是转译后的代码     */
import { jsx as _jsx } from "react/jsx-runtime";
function Title(props) {
  return /*#__PURE__*/_jsx("h1", {
    children: props.text
  });
}
/*#__PURE__*/_jsx(Title, {
  text: "hello world"
});

JSX是对JavaScript的扩展,使得用户可以以JavaScript的灵活程度编写UI。

babel-preset-react

JSX到纯JavaScript的转换需要通过babel,多数项目react项目使用了preset-react这个预设,配置通常是这样的:

module.exports = {
  presets: [
    [
      "@babel/preset-react",
      {
        development: process.env.BABEL_ENV === "development",
      },
    ],
  ],
};

babel preset与babel plugin的不同是,前者是插件和预设的组合,本身不包含转译逻辑。以preset-react为例,这个预设包含了plugin-transform-react-jsxplugin-transform-react-display-name,前者是核心。

preset-react的源码非常简单,是这样的:

import { declarePreset } from "@babel/helper-plugin-utils";
import transformReactJSX from "@babel/plugin-transform-react-jsx";
import transformReactJSXDevelopment from "@babel/plugin-transform-react-jsx-development";
import transformReactDisplayName from "@babel/plugin-transform-react-display-name";
import transformReactPure from "@babel/plugin-transform-react-pure-annotations";
import normalizeOptions from "./normalize-options.ts";

// ...类型声明

export default declarePreset((api, opts: Options) => {

  // ...配置获取

  return {
    plugins: [
      [
        development ? transformReactJSXDevelopment : transformReactJSX,
        process.env.BABEL_8_BREAKING
          ? {
              //...options
            }
          : {
              //...options
            },
      ],
      transformReactDisplayName,
      pure !== false && transformReactPure,
    ].filter(Boolean),
  };
});
  

可以看到这个preset仅仅返回了两个插件,这里只介绍作为功能核心的plugin-transform-react-jsx

plugin-transform-react-jsx

babel的工作流程

关于babel的知识,详情可见babel手册(多语种)

Parse

这个阶段会接收代码,然后生成AST(抽象语法树),包含了两个小阶段:Lexical AnalysisSyntactic Analysis

Lexical Analysis

Lexical Analysis会将代码转换成token流,也就是一个扁平的语法段数组。对于n * n这样的代码,相应的token流是:

[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
  ...
]
Syntactic Analysis

Syntactic Analysis进一步将扁平化的token流转化为AST(抽象语法树),后者的结构更清晰,更易于处理。

形如

function square(n) {
  return n * n;
}

这样的代码,它的抽象语法树是这样的:

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square",
  },
  params: [
    {
      type: "Identifier",
      name: "n",
    },
  ],
  body: {
    type: "BlockStatement",
    body: [
      {
        type: "ReturnStatement",
        argument: {
          type: "BinaryExpression",
          operator: "*",
          left: {
            type: "Identifier",
            name: "n",
          },
          right: {
            type: "Identifier",
            name: "n",
          },
        },
      },
    ],
  },
};

Transform

在这个阶段,编译器会遍历AST,根据需要插入,修改,移除节点,babel插件就是在这个阶段发挥作用的,在介绍plugin-transform-react-jsx源码的时候会有更详细的说明。

Generate

代码生成阶段会遍历AST,转化生成最终的目标代码。

插件结构

babel插件就是一个导出的函数,就plugin-transform-react-jsx而言是这样的:


import createPlugin from "./create-plugin.ts";

export default createPlugin({
  name: "transform-react-jsx",
  development: false,
});

/* ./create-plugin.ts */
import jsx from "@babel/plugin-syntax-jsx";
// ...其他导入

// ...类型声明
export default function createPlugin({
  name,
  development,
}: {
  name: string;
  development: boolean;
}) {
  // ...
  return declare((_, options: Options) => {
    return {
      name,
      inherits: jsx,
      visitor: { ... };
    }
  })
}

/*  简化一下就是这样 */
export default function plugin() {
  return {
    inherits: jsx,
    visitor: { ... },
  };
}

inherits

plugin-transform-react-jsx本身还依赖另一个插件plugin-syntax-jsx,前者做JSX代码转换,后者识别解析JSX代码,但与转换逻辑无关。

babel插件作为一个函数,返回一个对象,其中inherits属性表明依赖的另一个插件,visitor属性则规定了代码的转换逻辑。

visitor

当遍历AST中的一个节点时,就是在visit它,visitor模式是编译中的一个典型的遍历AST的模式。visitor对象中包含了处理不同类型节点的方法。

插件转换逻辑

这个部分是JSX转换的核心。babel以深度优先遍历的方式处理AST,其逻辑是这样的:

  1. 对任意已处理的节点。
  2. 如果有子节点,则继续处理子节点。
  3. 反之,处理兄弟节点。

visitor对象的结构是这样的:

visitor: {
  JSXFragment: {
    exit(path, file) {
      let callExpr;
      if (get(file, "runtime") === "classic") {
        callExpr = buildCreateElementFragmentCall(path, file);
      } else {
        callExpr = buildJSXFragmentCall(path, file);
      }

      path.replaceWith(t.inherits(callExpr, path.node));
    },
  },

  JSXElement: {
    exit(path, file) {
      let callExpr;
      if (get(file, "runtime") === "classic" || shouldUseCreateElement(path)) {
        callExpr = buildCreateElementCall(path, file);
      } else {
        callExpr = buildJSXElementCall(path, file);
      }

      path.replaceWith(t.inherits(callExpr, path.node));
    },
  },

  JSXAttribute(path) {
    if (t.isJSXElement(path.node.value)) {
      path.node.value = t.jsxExpressionContainer(path.node.value);
    }
  },
};

JSXElement

这里只说明最重要的JSXElement,也就是形如<h1>hello world</h1>以及<App title='foo' />这样的代码。

因为对任何一个节点的遍历,有进入和离开两个阶段,这是深度优先遍历的方式决定的,类似于React处理fiber树的顺序,所以exit方法表明对这个节点的处理是从离开这个节点时展开的(相反情形是enter方法)。

转换逻辑

无论是代码的遍历顺序,还是最终代码的生成,都是由babel完成的,babel插件只需要编写针对特定类型节点的处理方法。

简化后的处理方法是这样的:

const JSXElement = {
  exit(path, file) {
    const callExpr = buildJSXElementCall(path, file)
    path.replaceWith(t.inherits(callExpr, path.node));
  },
};

入参path由babel提供,包含了但不限于节点信息,t是babel的type,提供了判断和生成AST节点的方法。

基本逻辑就2条:

  1. 生成一个函数调用表达式节点(函数调用形如jsx('h1', { ... }))。
  2. 替换掉原有的JSXElement的类型

节点生成

要生成函数调用表达式节点,需要分别知道函数名,实参。

  • 函数名由配置决定,不同环境下的配置决定了函数名是jsx或者createElement
  • 实参包含了element type,props,children arguments三部分。

path中可以获取JSXElement的tag,attributes,以及children,以此为基础,可以转化生成目标节点的实参。

简化过后的生成方法是这样的:

function buildJSXElementCall(path: NodePath<JSXElement>, file: PluginPass) {
  const openingPath = path.get("openingElement");
  const args: t.Expression[] = [getTag(openingPath)];

  // ...
  const children = t.react.buildChildren(path.node);

  // ...
    attribs = buildJSXOpeningElementAttributes(
        attribsArray,
        children
    );
  // ....

  args.push(attribs)

  return call(file, children.length > 1 ? "jsxs" : "jsx", args);
}

可以看到逻辑包含4条:

  • JSXElement获取tag,对于原生组件,生成的tagName会是一个字符串字面量节点(形如'h1'对应的AST节点),对于复合组件,tagName是一个标识符(形如App对应的AST节点)。
  • 处理children,一是过滤空表达式节点,二是打开表达式,三是处理字面量child。
  • 处理attributes,即将JSXElement的attributes节点数组转化为对象表达式,同时在对象中加入children。
  • 返回生成函数调用表达式。

其中每一步的逻辑都非常简单,直接调api,babel提供了各种节点生成的方法,如:

  • 生成字符串字面量可以使用t.stringLiteral('h1')
  • 生成标识符可以使用t.identifier('App')
  • 生成函数调用表达式可以使用t.callExpression(identifier, [args])
  • 等等