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 引擎无法对这点进行优化,因为这段逻辑是高度复态的 .defaultProps对React.lazy不起作用。因为为对组件 props 进行默认赋值的操作发生在React.createElement()期间,而lazy需要等候异步组件resolved。这导致了 React 必须要在渲染时对 props 对象进行默认赋值,这使得lazy组件的.defaultProps的语义与其他组件的不一致- Children 是作为通过参数动态传入,因此不能直接确定它的形状,所以必须在
React.createElement()内将其拼合在一起 - 调用
React.createElement()是一个动态属性查找的过程,而非局限在模块内部的变量查找,这需要额外的成本来进行查找操作 - 无法感知传递的 props 对象是不是用户创建的可变对象,所以必须将其重新克隆下
key和ref都是从 props 对象中拿到的,如果我们不克隆新的对象,就必须在传递的 props 对象上delete掉key和ref属性,然而这会使得 props 对象变成 map-like ,不利于引擎优化key和ref可以通过...扩展运算符进行传递,这使得如果不经过复杂的语法分析,是无法判断这种<div {...props} />模式,有没有传递key和ref- jsx 转译函数依赖变量
React存在作用域内,所以必须导入模块的默认导出内容
除了性能上的考量之外,RFC-0000 使得在不远的将来可以将 React 的一些概念给简化或剔除掉,比如 forwardRef 和 defaultProps ,减少开发者的理解上手成本
除此之外,为了未来有一天标准化 jsx 语法,就必须将 jsx 与 React 耦合的地方解耦掉
JSX 转换流程的变化
自动引入(已实装)
不再需要手动引入 React 到作用域,而是由编译器自动引入
function Foo() {
return <div />;
}
会被转译为
import {jsx} from "react";
function Foo() {
return jsx('div', ...);
}
将 key 当作参数传入(已实装)
为了理解这点是什么意思,我们需要看看现在新的转换函数 jsx 和 jsxDev 的函数签名:
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 模式下会多传入两个参数 source 和 self
总是展开(已实装)
传统的转换逻辑其实有一个特殊模式,大部分情况下传统的转译流程都会对 props 做一次克隆,但是对于 <div {...props} /> 模式,传统模式会转译为 React.createElement('div', props) 。因为 createElement 会在内部会对 props 进行克隆,所以这种转译是无伤大雅的
React 官方不想在新的转换函数 jsx 中实现对 props 的克隆逻辑,因此对于 <div {...props} /> 将总是会转译成 jsx('div', {...props})
结束
本文只是对 RFC-0000 有关 jsx 的部分做了说明,除此之外 RFC-0000 也对 ref, forwardRef, .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 的目标了