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 的目标了