为什么 React17-rc.2 要发布新的 jsx 转换逻辑

2,581 阅读7分钟

React 17.0.0-rc.2 不久前发布,并带来了有关 JSX 新的特性:

  • jsx() 函数替换 React.createElement()
  • 自动引入 jsx() 函数

举个例子就是以下代码

// hello.jsx

const Hello = () => <div>hello</div>;

会被转译为

// hello.jsx

import {jsx as _jsx} from 'react/jsx-runtime'; // 由编译器自动引入

const Hello = () => _jsx('div', { children: 'hello' }); // 不是 React.createElement

React 官方提供了如何使用新的转译语法的说明和自动迁移的工具,详见文档

对于这种变动,我举双手赞成。不过,我最感兴趣的还是,为什么要用新的转换语法呢?在React 的 RFC-0000 文档中,我们可以找到详细的原因。

动机

React 最开始的设计是围绕着 class 组件来的,而随着 hooks 的流行,使得函数组件也变得越来越流行了。其中一些主要考虑 class 组件的设计放到函数组件上就变得不那么合适了,必须引入新的概念让开发者理解。

举个栗子🌰,比如 ref 这个特性面对 class 组件显得很正常,我们通过 ref 能够拿到一个 class 组件的实例。对于没有 hooks 前的函数组件来讲,我们传递 ref 是没有意义的,众所周知,函数组件是没有实例的。但是在有了 hooks 之后,函数组件的行为和 class 组件几乎没区别了,并且 react 官方也提供了useImperativeHandle()hook 让函数组件同样能够做到暴露自身方法到父组件的能力。但是,我们并不能很容易做到这点,这个和 React 处理 ref 的机制有关系。

React 关于 ref 的机制是这样的,React 会拦截掉 props 对象中 的 ref 属性,然后由 React 本身来完成相应挂载和卸载操作。但是对于函数组件来讲,这个机制就显得有点不适宜了。因为拦截,你无法从props拿到ref,你必须以某种方式告诉react 我需要ref 才行,因此React 引入 forwardRef() 函数来完成相关的操作。

// 对于函数组件,我们想做到这样

const Input = (props) => <input ref={props.ref} /> // error props.ref 是 undefined

// 但我们现在必须这样写

const Input = React.forwardRef((props, ref) => <input ref={ref} />)

因此,RFC-0000 提议重新审视当初的一些设计,看看能否进行一些简化

React.createElement()的问题

React.createElement() 是 React 当初实现 jsx 方案的一个平衡选择。在那个时候,它可以很好工作运行,而很多备选方案并没有显示出足够的优势替换它

在一个 React 应用中通过React.createElement() 创建 ReactElement 是非常频繁的操作,因为每次重渲染时都要重新创建对应的ReactElement

随着技术的发展 React.createElement() 设计暴露出来了大量问题:

  • 每次执行React.createElement() 时,都要动态的检测一个组件上是否存在.defaultProps 属性,这导致 js 引擎无法对这点进行优化,因为这段逻辑是高度复态的
  • .defaultPropsReact.lazy 不起作用。因为为对组件 props 进行默认赋值的操作发生在React.createElement() 期间,而 lazy 需要等候异步组件 resolved 。这导致了 React 必须要在渲染时对 props 对象进行默认赋值,这使得 lazy 组件的 .defaultProps 的语义与其他组件的不一致
  • Children 是作为通过参数动态传入,因此不能直接确定它的形状,所以必须在React.createElement() 内将其拼合在一起
  • 调用 React.createElement() 是一个动态属性查找的过程,而非局限在模块内部的变量查找,这需要额外的成本来进行查找操作
  • 无法感知传递的 props 对象是不是用户创建的可变对象,所以必须将其重新克隆下
  • keyref 都是从 props 对象中拿到的,如果我们不克隆新的对象,就必须在传递的 props 对象上 deletekeyref 属性,然而这会使得 props 对象变成 map-like ,不利于引擎优化
  • keyref 可以通过 ... 扩展运算符进行传递,这使得如果不经过复杂的语法分析,是无法判断这种 <div {...props} /> 模式,有没有传递 keyref
  • jsx 转译函数依赖变量 React 存在作用域内,所以必须导入模块的默认导出内容

除了性能上的考量之外,RFC-0000 使得在不远的将来可以将 React 的一些概念给简化或剔除掉,比如 forwardRefdefaultProps ,减少开发者的理解上手成本

除此之外,为了未来有一天标准化 jsx 语法,就必须将 jsx 与 React 耦合的地方解耦掉

JSX 转换流程的变化

自动引入(已实装)

不再需要手动引入 React 到作用域,而是由编译器自动引入

function Foo() {
  return <div />;
}

会被转译为

import {jsx} from "react";
function Foo() {
  return jsx('div', ...);
}

将 key 当作参数传入(已实装)

为了理解这点是什么意思,我们需要看看现在新的转换函数 jsxjsxDev 的函数签名:

  • function jsxDEV(type, config, maybeKey, source, self)
  • function jsx(type, config, key)

可以看到,所谓的将 key 当作参数传入的意思和字面意思一样,为了更好的理解,我们再来看个例子🌰

// test.jsx

const props = {
  value: 0,
  key: 'bar'
};

<div key="foo" {...props}>
    <span>hello</span>
    <span>world</span>
</div>;

<div {...props} key="foo">
    <span>hello</span>
    <span>world</span>
</div>;

对于上述代码,传统转换会转译成如下代码

const props = {
    value: 0,
    key: 'bar'
};
React.createElement("div", Object.assign({ key: "foo" }, props),
    React.createElement("span", null, "hello"),
    React.createElement("span", null, "world"));
React.createElement("div", Object.assign({}, props, { key: "foo" }),
    React.createElement("span", null, "hello"),
    React.createElement("span", null, "world"));

而对于新的转译流程,会转译成如下代码

import { createElement as _createElement } from "react";
import { jsx as _jsx } from "react/jsx-runtime";
import { jsxs as _jsxs } from "react/jsx-runtime";
// 注意,上述代码全是自动引入的
const props = {
  value: 0,
  key: 'bar'
};

_jsxs(
  "div",
  {
    ...props,
    children: [_jsx("span", {
      children: "hello"
    }), _jsx("span", {
      children: "world"
    })]
  },
  "foo", // 当作参数
);

_createElement(
  "div",
  {
    ...props,
    key: "foo" // 依然作为 props 的一部分
  }, _jsx("span", {
    children: "hello"
  }), _jsx("span", {
    children: "world"
  })
);

可以看到对于 <div {...props} key="foo"> 这种形式的新旧转换逻辑是一致的。 jsx 函数同时支持这两种传入 key 的方式,这里主要是为了兼容考虑,React 提倡渐进式升级,所以现在暂时处于过渡阶段,最终将只会支持把 key 当作参数的传入方式

其实兼容的代码也很简单,这里我们稍微看一下

function jsxDEV(type, config, maybeKey, source, self) {
  {
    var propName; // Reserved names are extracted

    var props = {};
    var key = null;
    var ref = null; // Currently, key can be spread in as a prop. This causes a potential
    // issue if key is also explicitly declared (ie. <div {...props} key="Hi" />
    // or <div key="Hi" {...props} /> ). We want to deprecate key spread,
    // but as an intermediary step, we will use jsxDEV for everything except
    // <div {...props} key="Hi" />, because we aren't currently able to tell if
    // key is explicitly declared to be undefined or not.

    if (maybeKey !== undefined) {
      key = '' + maybeKey;
    }

    if (hasValidKey(config)) {
      key = '' + config.key;
    }

		// ... codes
  }
}

兼容逻辑很简单,并且在注释中我们可以看到详细的解释,以及我们现在处于一个 "intermediary step" 中

将 Children 当作 props 传递(已实装)

我们先看看例子,然后再说说为什么要这么做

对于下述代码

const a = 1;
const b = 1;

<div>{a}{b}</div>;

传统转换将转译成

const a = 1;
const b = 1;
React.createElement(
		"div",
		null,
    a,
    b,
);

新的转换逻辑

import { jsxs as _jsxs } from "react/jsx-runtime";
const a = 1;
const b = 1;

_jsxs("div", {
  children: [a, b] // 这里
});

传统的转换流程是通过函数参数传递 Children ,但是这样就需要转换函数内部将参数拼合成一个数组

function createElement(type, config, children) {
  // ... codes
  var childrenLength = arguments.length - 2;

  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    var childArray = Array(childrenLength);

    for (var i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }

    {
      if (Object.freeze) {
        Object.freeze(childArray);
      }
    }

    props.children = childArray;
  } // Resolve default props

	// ... codes

  return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
}

这一步操作其实是很耗费性能的,特别是前文我们提到创建 ReactElement 是一个很频繁的操作,这使得其中的性能损失变得更加严重

通过将 Children 作为 props 传递,我们可以提前知道 Children形状 !而不用再经历一次昂贵的拼合操作

DEV 模式下的转换逻辑

为了帮助开发者调试,React 在 DEV 模式下有一些特殊的行为,因此针对 DEV 模式实现了 function jsxDEV(type, config, maybeKey, source, self) 函数,从签名上可以看出来,区别就在于 DEV 模式下会多传入两个参数 sourceself

总是展开(已实装)

传统的转换逻辑其实有一个特殊模式,大部分情况下传统的转译流程都会对 props 做一次克隆,但是对于 <div {...props} /> 模式,传统模式会转译为 React.createElement('div', props) 。因为 createElement 会在内部会对 props 进行克隆,所以这种转译是无伤大雅的

React 官方不想在新的转换函数 jsx 中实现对 props 的克隆逻辑,因此对于 <div {...props} /> 将总是会转译成 jsx('div', {...props})

结束

本文只是对 RFC-0000 有关 jsx 的部分做了说明,除此之外 RFC-0000 也对 refforwardRef.defaultProps 等相关概念的变更作了说明。

即使是最新 jsx 转换逻辑,其实也是处于一个中间态的过程,其实现依然有很多兼容性的代码,而 RFC-0000 的最终目标将 jsx() 函数实现为如下逻辑

function jsx(type, props, key) {
  return {
    $$typeof: ReactElementSymbol,
    type,
    key,
    props,
  };
}

同样的,我们可以看下 production 模式下 jsx() 函数的实现逻辑

function q(c, a, k) {
    var b, d = {}, e = null, l = null;
    void 0 !== k && (e = "" + k);
    void 0 !== a.key && (e = "" + a.key);
    void 0 !== a.ref && (l = a.ref);
    for (b in a) n.call(a, b) && !p.hasOwnProperty(b) && (d[b] = a[b]);
    if (c && c.defaultProps) for (b in a = c.defaultProps, a) void 0 === d[b] && (d[b] = a[b]);
    return { $$typeof: g, type: c, key: e, ref: l, props: d, _owner: m.current }
}

可以看到现在实现已经很接近 RFC-0000 的目标了