React 19 内核探秘
第15章 DOM 更新与渲染管线
第15章 DOM 更新与渲染管线
本章要点
- 属性设置的完整链路:className、style、dangerouslySetInnerHTML 的源码级处理机制
- 属性 diff 的演进:React ≤18 的 diffProperties/updatePayload 模型 vs React 19 在 Commit 阶段一次完成 diff + 写入
- 受控组件与非受控组件的 DOM 同步机制:value tracking 的内核实现
- Portal 的实现原理:createPortal 如何突破 DOM 层级而保持事件冒泡
- Hydration 不匹配的检测与恢复:hydrateRoot 的容错策略
- 属性写入的低层机制:17/18 的 setValueForProperty 元数据表与 React 19 的按调用点分派
在前面的章节中,我们深入分析了 Reconciliation 如何生成变更计划,Commit 阶段如何将计划执行为 DOM 操作。但有一个关键环节被我们有意略过了:当 React 决定"更新这个 DOM 节点的属性"时,具体发生了什么?
这个问题看似简单——不就是 element.className = 'new-class' 吗?但当你深入源码,会发现 React 在这一层做了大量的工作:它需要区分 30 多种不同类型的 DOM 属性,处理浏览器的兼容性差异,维护受控组件的值同步,处理 dangerouslySetInnerHTML 的安全语义,甚至要在 SSR Hydration 时检测服务端与客户端的不一致。这些看似琐碎的细节,构成了 React DOM 渲染管线中最复杂也最容易被忽视的一环。
更有趣的是,Portal 和 Hydration 这两个特性从根本上挑战了"DOM 树结构等于组件树结构"这一直觉假设。Portal 让组件可以将子节点渲染到 DOM 树的任意位置,而 Hydration 则要求 React 能够"认领"一棵已经存在的 DOM 树并赋予它交互能力。理解这些机制,是真正掌握 React DOM 层的关键。
15.1 属性设置:从 props 到 DOM
15.1.1 属性 diff:在哪一步计算最小更新集
当一个 DOM 节点需要更新时,React 不会将所有 props 重新设置一遍,而是对新旧 props 做一次差异计算。但"diff 发生在哪个阶段"在 React 19 出现了一次重要的架构调整,这也是旧资料最容易过时的地方:
- React ≤18:diff 发生在 Render 阶段的
completeWork中——diffProperties生成一个"更新负载"(updatePayload)扁平数组,格式为[propKey1, propValue1, propKey2, propValue2, ...],挂到 fiber 上;Commit 阶段再按这份清单逐条写 DOM。 - React 19:Render 阶段的
completeWork只做一件事——发现oldProps !== newProps就打一个 Update flag,不再做属性级 diff;diff 与 DOM 写入合并到 Commit 阶段,由commitUpdate(domElement, type, oldProps, newProps)调用updateProperties一次完成。updatePayload 这个中间结构在 19 里已经不存在了。
先看 React 19 的基线代码:
// React 19: packages/react-reconciler/src/ReactFiberCompleteWork.js
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
if (supportsMutation) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
// props 同引用,直接 bailout
return;
}
markUpdate(workInProgress); // 只标记 Update flag,不做 diff
}
// ...
}
// React 19: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
export function commitUpdate(
domElement: Instance,
type: string,
oldProps: Props,
newProps: Props,
internalInstanceHandle: Object,
): void {
// Diff and update the properties.
updateProperties(domElement, type, oldProps, newProps);
updateFiberProps(domElement, newProps);
}
这样改的收益很直接:省掉了每次更新都要分配的中间数组;Render 阶段被高优先级更新打断丢弃时,不会浪费已经算好的 diff;创建、更新、hydration 的属性处理路径也得以统一到 ReactDOMComponent.js 一处。
理解了 19 的基线,再回头看 ≤18 的 diffProperties——它的两遍遍历逻辑(删除旧属性、写入新属性)在 19 的 updateProperties 里依然存在,只是搬进了 Commit 阶段、边 diff 边写:
// React ≤18(历史对照): react-dom/src/client/ReactDOMComponent.js
function diffProperties(
domElement: Element,
tag: string,
lastRawProps: Record<string, any>,
nextRawProps: Record<string, any>,
): null | Array<mixed> {
let updatePayload: null | Array<mixed> = null;
let lastProps: Record<string, any>;
let nextProps: Record<string, any>;
// 第一步:根据元素类型进行 props 规范化
// 例如 <input> 和 <textarea> 需要特殊处理 defaultValue
switch (tag) {
case 'input':
lastProps = ReactDOMInputGetHostProps(domElement, lastRawProps);
nextProps = ReactDOMInputGetHostProps(domElement, nextRawProps);
break;
case 'select':
lastProps = ReactDOMSelectGetHostProps(domElement, lastRawProps);
nextProps = ReactDOMSelectGetHostProps(domElement, nextRawProps);
break;
case 'textarea':
lastProps = ReactDOMTextareaGetHostProps(domElement, lastRawProps);
nextProps = ReactDOMTextareaGetHostProps(domElement, nextRawProps);
break;
default:
lastProps = lastRawProps;
nextProps = nextRawProps;
break;
}
// 第二步:遍历旧 props,找出被删除的属性
for (const propKey in lastProps) {
if (
nextProps.hasOwnProperty(propKey) ||
!lastProps.hasOwnProperty(propKey) ||
lastProps[propKey] == null
) {
continue;
}
// 这个属性在新 props 中不存在了,需要删除
if (propKey === STYLE) {
// 清除所有 style 属性
const lastStyle = lastProps[propKey];
for (const styleName in lastStyle) {
if (lastStyle.hasOwnProperty(styleName)) {
if (!styleUpdates) styleUpdates = {};
styleUpdates[styleName] = '';
}
}
} else {
// 将该属性标记为需要删除(值为 null)
(updatePayload = updatePayload || []).push(propKey, null);
}
}
// 第三步:遍历新 props,找出新增或变更的属性
for (const propKey in nextProps) {
const nextProp = nextProps[propKey];
const lastProp = lastProps != null ? lastProps[propKey] : undefined;
if (
!nextProps.hasOwnProperty(propKey) ||
nextProp === lastProp ||
(nextProp == null && lastProp == null)
) {
continue;
}
if (propKey === STYLE) {
// style 属性需要逐一对比每个 CSS 属性
if (lastProp) {
for (const styleName in lastProp) {
if (
lastProp.hasOwnProperty(styleName) &&
(!nextProp || !nextProp.hasOwnProperty(styleName))
) {
if (!styleUpdates) styleUpdates = {};
styleUpdates[styleName] = '';
}
}
for (const styleName in nextProp) {
if (
nextProp.hasOwnProperty(styleName) &&
lastProp[styleName] !== nextProp[styleName]
) {
if (!styleUpdates) styleUpdates = {};
styleUpdates[styleName] = nextProp[styleName];
}
}
} else {
styleUpdates = nextProp;
}
} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
const nextHtml = nextProp ? nextProp.__html : undefined;
const lastHtml = lastProp ? lastProp.__html : undefined;
if (nextHtml != null && lastHtml !== nextHtml) {
(updatePayload = updatePayload || []).push(propKey, nextHtml);
}
} else if (propKey === CHILDREN) {
// 文本子节点的快速路径
if (typeof nextProp === 'string' || typeof nextProp === 'number') {
(updatePayload = updatePayload || []).push(propKey, '' + nextProp);
}
} else {
(updatePayload = updatePayload || []).push(propKey, nextProp);
}
}
// 最后处理 style 的聚合更新
if (styleUpdates) {
(updatePayload = updatePayload || []).push(STYLE, styleUpdates);
}
return updatePayload;
}
这段(≤18 的)代码有一个值得深思的地方:为什么 updatePayload 使用扁平数组而不是对象? 原因是性能。数组的创建和遍历比对象更快,而且这个数据结构只有短暂的生命周期——从 completeWork 创建到 commitWork 消费,之后就会被垃圾回收。React 用两个相邻元素表示一个键值对([key, value, key, value, ...]),虽然不够直观,但在热路径上这种微优化是值得的。而 React 19 的答案更彻底——连这个中间数组也不要了,diff 到哪写到哪。
深度洞察:无论 diff 在 Render 阶段(≤18)还是 Commit 阶段(19),有一条原则从未变过——Render 阶段绝不触碰 DOM。可中断渲染的前提是 Render 没有产生任何不可逆的副作用,至于差异"提前算好存清单"还是"提交时现算现写",只是工程权衡:前者让 Commit 更薄,后者让被丢弃的 Render 更便宜。React 19 选了后者。
15.1.2 属性写入的低层:从 setValueForProperty 到按调用点分派
单个属性最终怎么写进 DOM?React 17/18 的答案是一个集中式入口 setValueForProperty——它依赖一张预定义的属性元数据表(PropertyInfo),按属性类型分类处理:
// React 17/18(历史对照): react-dom/src/client/DOMPropertyOperations.js
function setValueForProperty(
node: Element,
propertyInfo: PropertyInfo | null,
name: string,
value: mixed,
) {
if (propertyInfo !== null) {
// 已知属性:通过预定义的 PropertyInfo 来决定设置方式
const { type, attributeName, attributeNamespace } = propertyInfo;
if (value === null) {
// 删除属性
node.removeAttribute(attributeName);
return;
}
switch (type) {
case BOOLEAN:
// 布尔属性:如 disabled, checked, readOnly
// 值为 true 时设置空字符串,false 时移除
if (value) {
node.setAttribute(attributeName, '');
} else {
node.removeAttribute(attributeName);
}
break;
case OVERLOADED_BOOLEAN:
// 重载布尔属性:如 capture, download
// 值为 true 时设置空字符串,字符串时设置该值
if (value === true) {
node.setAttribute(attributeName, '');
} else if (value === false) {
node.removeAttribute(attributeName);
} else {
node.setAttribute(attributeName, (value: any));
}
break;
case NUMERIC:
// 数值属性:如 rowSpan, colSpan
// 需要过滤 NaN
if (!isNaN(value)) {
node.setAttribute(attributeName, (value: any));
} else {
node.removeAttribute(attributeName);
}
break;
case POSITIVE_NUMERIC:
// 正数属性:如 size, span
if (!isNaN(value) && (value: any) >= 1) {
node.setAttribute(attributeName, (value: any));
} else {
node.removeAttribute(attributeName);
}
break;
default:
// STRING 类型
if (attributeNamespace) {
node.setAttributeNS(attributeNamespace, attributeName, (value: any));
} else {
node.setAttribute(attributeName, (value: any));
}
}
} else if (isAttributeNameSafe(name)) {
// 未知但安全的属性名:直接使用 setAttribute
if (value === null) {
node.removeAttribute(name);
} else {
node.setAttribute(name, (value: any));
}
}
}
注意这里的属性类型分类系统。React 17/18 为每个已知的 HTML/SVG 属性维护了一个 PropertyInfo 对象,记录了该属性的类型(布尔、数值、字符串等)、对应的 DOM attribute 名称、以及是否需要命名空间(SVG 属性)。
React 19 移除了这张集中式元数据表。 属性分派改为 ReactDOMComponent.js 里 setProp 的一个大 switch:按属性名直接命中处理分支,再调用 DOMPropertyOperations.js 中少数几个低层 helper——setValueForKnownAttribute(已知安全的属性名,如 class、tabindex)、setValueForAttribute(未知属性,先做名称安全检查)、setValueForNamespacedAttribute(带命名空间的 SVG 属性)等。布尔、数值这些"类型语义"不再查表,而是直接写在各调用点的分支里。换句话说,17/18 是"数据驱动"(一张表 + 一个通用解释器),19 是"代码驱动"(每类属性一段直白的分支)——后者对 JIT 更友好,也让每个属性的行为在源码里一眼可查。
15.1.3 className 的处理
className 是 React 中最常用的 DOM 属性之一。它的处理看似简单,但背后有一个有趣的历史原因:
// 为什么 React 用 className 而不是 class?
// 因为在早期 JavaScript 中,class 是保留字:
// element.class = 'foo'; // 语法错误(在旧版浏览器中)
// element.className = 'foo'; // ✅ 正确
// React 的处理方式:
function setValueForStyles(node: HTMLElement, styles: Record<string, any>) {
const style = node.style;
for (const styleName in styles) {
if (!styles.hasOwnProperty(styleName)) continue;
const value = styles[styleName];
if (styleName.indexOf('--') === 0) {
// CSS 自定义属性(CSS Variables)
style.setProperty(styleName, value);
} else {
const isCustomProperty = styleName.indexOf('--') === 0;
if (value == null || typeof value === 'boolean' || value === '') {
// 清除该样式
if (isCustomProperty) {
style.setProperty(styleName, '');
} else {
style[styleName] = '';
}
} else if (
typeof value === 'number' &&
value !== 0 &&
!isUnitlessNumber(styleName)
) {
// 数值类型的样式属性:自动添加 'px' 单位
// 但有些属性是无单位的(如 opacity, zIndex, flexGrow)
style[styleName] = value + 'px';
} else {
style[styleName] = ('' + value).trim();
}
}
}
}
isUnitlessNumber 的设计值得关注。React 维护了一个无单位 CSS 属性的白名单——如 opacity、zIndex、flexGrow、lineHeight 等。对于不在白名单中的数值属性,React 会自动追加 px 单位。这个便利设计减少了大量样板代码,但也偶尔会让不了解这个规则的开发者感到困惑。
15.1.4 dangerouslySetInnerHTML 的安全语义
dangerouslySetInnerHTML 是 React 中最"吓人"的 API,名字里带着 dangerously 就是为了让开发者三思而后行:
// 使用方式
<div dangerouslySetInnerHTML={{ __html: '<p>来自服务端的 HTML</p>' }} />
// 为什么需要 { __html: ... } 这层包装?
// 答案:这是一个"速度减速带"(speed bump)
// 防止开发者意外地将用户输入直接传入:
// ❌ 如果 API 是 innerHTML={userInput}
// 开发者可能不假思索地这样写,导致 XSS
// ✅ 现在的 API 要求 dangerouslySetInnerHTML={{ __html: userInput }}
// 多层嵌套迫使你停下来思考:这真的安全吗?
在 Commit 阶段,dangerouslySetInnerHTML 的实际处理非常直接:
function setInnerHTML(node: Element, html: string): void {
// 处理特殊标签:<table>, <tr>, <td> 等不能直接设置 innerHTML
// 的元素需要通过创建临时容器来绕过浏览器限制
if (node.namespaceURI === SVG_NAMESPACE) {
// SVG 元素需要特殊处理命名空间
const svgNode = node as SVGElement;
svgNode.innerHTML = html;
return;
}
// 常规 HTML 元素
node.innerHTML = html;
}
但真正的复杂性在于 dangerouslySetInnerHTML 与子节点的互斥关系。React 在 Render 阶段就会检查:如果一个节点同时设置了 dangerouslySetInnerHTML 和 children,会抛出错误。这是因为 innerHTML 会清除所有子节点,与 React 管理的子节点树产生冲突。
function validateDOMNesting(child: string, parent: string) {
// React 还会验证 HTML 嵌套规则
// 例如 <p> 里不能嵌套 <div>
// 这些校验在开发模式下运行,帮助开发者发现潜在问题
}
深度洞察:
dangerouslySetInnerHTML的命名策略是一个教科书级的 API 设计案例。React 团队选择让"危险的操作看起来就像是危险的",而不是提供一个简洁但容易误用的innerHTMLprop。这种设计哲学贯穿了 React 的方方面面——让正确的事情变得容易,让错误的事情变得困难(但仍然可能)。
15.2 受控组件与非受控组件的 DOM 同步机制
15.2.1 受控组件的核心矛盾
受控组件是 React 表单模型的基石,但它引入了一个根本性的矛盾:浏览器的原生表单元素有自己的状态管理机制,而 React 想要完全控制这个状态。
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
看似简单的代码背后隐藏着一个精妙的同步循环:
- 用户在输入框中按下键盘
- 浏览器原生地更新了
input.value(DOM 状态变了) - React 的事件系统捕获了
onChange,调用setValue - React 触发重渲染,计算新的 props(
value属性已更新) - Commit 阶段,React 将
value设置回 DOM 节点
问题出在步骤 2 和步骤 5 之间:如果 React 的状态更新被阻止了(比如 onChange 处理器没有调用 setValue),React 需要能够将 DOM 的值"回滚"到之前的状态。这就是 value tracking 机制存在的原因。
15.2.2 value tracking 的内核实现
React 为每个受控的 <input>、<textarea> 和 <select> 维护了一个内部的值追踪器(value tracker)。这个追踪器记录了 React 上一次设置到 DOM 上的值,用于在 onChange 触发时判断值是否真正发生了变化。
// react-dom-bindings/src/client/inputValueTracking.js
// 每个受控表单元素都有一个 tracker,存储在内部属性上
type ValueTracker = {
getValue(): string;
setValue(value: string): void;
stopTracking(): void;
};
function track(node: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement) {
if (getTracker(node)) {
return; // 已经在追踪了
}
// 关键技巧:React 重写了元素的 value 属性描述符
const valueField = Object.getOwnPropertyDescriptor(
node.constructor.prototype,
'value',
);
let currentValue = '' + node.value;
// 如果原生的 value 属性已经被其他库覆盖了,不做追踪
if (
node.hasOwnProperty('value') ||
typeof valueField === 'undefined' ||
typeof valueField.get !== 'function' ||
typeof valueField.set !== 'function'
) {
return;
}
const { get, set } = valueField;
// 替换 value 的 getter/setter
Object.defineProperty(node, 'value', {
configurable: true,
get() {
return get.call(this);
},
set(newValue) {
// 记录 React 设置的值
currentValue = '' + newValue;
set.call(this, newValue);
},
});
const tracker: ValueTracker = {
getValue() {
return currentValue;
},
setValue(value: string) {
currentValue = '' + value;
},
stopTracking() {
// 恢复原生的 value 属性
delete node.value;
},
};
// 将 tracker 存储在节点的内部属性上
node._valueTracker = tracker;
}
function getValueFromNode(node: HTMLInputElement): string {
let value = '';
if (node) {
if (isCheckable(node)) {
value = node.checked ? 'true' : 'false';
} else {
value = node.value;
}
}
return value;
}
function trackValueOnNode(node: any): ValueTracker | null {
return node._valueTracker;
}
// 判断值是否真的变了
function updateValueIfChanged(node: HTMLInputElement): boolean {
if (!node) return false;
const tracker = trackValueOnNode(node);
if (!tracker) return true;
const lastValue = tracker.getValue();
const nextValue = getValueFromNode(node);
if (nextValue !== lastValue) {
tracker.setValue(nextValue);
return true; // 值确实变了
}
return false; // 值没变,不需要触发 onChange
}
这段代码中最精妙的技巧是通过 Object.defineProperty 劫持 value 的 setter。当 React 在 Commit 阶段设置 input.value = 'new value' 时,这个自定义 setter 会同时更新 currentValue。之后当浏览器的原生事件触发时,React 可以通过比较 tracker.getValue()(React 上次设置的值)和 node.value(DOM 当前的值)来判断用户是否真的修改了输入。
15.2.3 受控 input 的 Commit 流程
当一个受控 <input> 需要更新时,React 在 Commit 阶段会执行以下流程:
// react-dom-bindings/src/client/ReactDOMInput.js
function updateInput(
element: HTMLInputElement,
value: ?string,
defaultValue: ?string,
lastDefaultValue: ?string,
checked: ?boolean,
defaultChecked: ?boolean,
type: ?string,
name: ?string,
) {
// 1. 先更新 type(如果变了的话)
// 重要:type 必须在 value 之前设置,因为
// 从 text 切换到 number 类型时,如果先设置 value
// 浏览器可能会拒绝无效的数值
if (type != null) {
element.type = type;
}
if (value != null) {
if (element.value !== '' + value) {
// 只有当值确实不同时才更新
// 避免不必要的 DOM 操作(这会重置光标位置)
element.value = '' + value;
}
} else if (type === 'submit' || type === 'reset') {
// 对于 submit/reset 类型,移除 value 属性
// 以显示浏览器默认文本
element.removeAttribute('value');
return;
}
// 2. 处理 defaultValue
if (value != null) {
element.defaultValue = '' + value;
} else if (defaultValue != null) {
element.defaultValue = '' + defaultValue;
} else if (lastDefaultValue != null) {
element.removeAttribute('value');
}
// 3. 处理 checked 和 defaultChecked
if (checked != null) {
element.checked = checked;
} else if (defaultChecked != null) {
element.defaultChecked = defaultChecked;
}
}
这里有一个容易被忽视但至关重要的细节:if (element.value !== '' + value) 这个条件判断。React 只在值确实不同时才设置 element.value。原因是:每次设置 input.value 都会重置光标位置到末尾。如果用户正在输入框中间编辑文字,不必要的 value 赋值会将光标跳到最后,造成极差的用户体验。
15.2.4 非受控组件与 defaultValue
非受控组件是 React 对表单元素的"放手"模式——React 只在初始渲染时设置值,之后由 DOM 自行管理:
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<form onSubmit={() => {
// 提交时才读取值
console.log(inputRef.current?.value);
}}>
<input defaultValue="初始值" ref={inputRef} />
</form>
);
}
在源码层面,defaultValue 被映射为 HTML 的 value attribute(注意区分 attribute 和 property)。React 在首次渲染时设置 element.defaultValue,之后即使重渲染也不会覆盖用户的输入:
// 非受控模式的关键区别:
// 受控模式:每次 Commit 都强制设置 element.value
// 非受控模式:只设置 element.defaultValue,不触碰 element.value
// 这意味着在非受控模式下,DOM 是"真相之源"(source of truth)
// 而在受控模式下,React state 才是真相之源
深度洞察:受控与非受控组件的本质区别在于数据的所有权。受控模式下,React 拥有表单值的所有权,DOM 只是一个"哑终端",只负责显示 React 告诉它的值。非受控模式下,DOM 拥有所有权,React 退居为一个初始化器。这与数据库领域的主从复制(master-slave replication)有异曲同工之处——受控模式中 React 是主库,DOM 是从库;非受控模式中 DOM 是主库,React 只提供初始数据。
15.2.5 select 元素的特殊处理
<select> 元素的受控处理比 <input> 更加复杂,因为它涉及到子元素 <option> 的联动:
// react-dom-bindings/src/client/ReactDOMSelect.js
function updateSelect(
element: HTMLSelectElement,
value: ?string | ?Array<string>,
defaultValue: ?string | ?Array<string>,
multiple: ?boolean,
) {
const options = element.options;
if (value != null) {
// 受控模式:遍历所有 <option>,设置 selected 属性
const selectedValues = multiple
? new Set(value as string[])
: new Set(['' + value]);
for (let i = 0; i < options.length; i++) {
const selected = selectedValues.has(options[i].value);
if (options[i].selected !== selected) {
options[i].selected = selected;
}
}
} else if (defaultValue != null) {
// 非受控模式下的初始值设置
const selectedValues = multiple
? new Set(defaultValue as string[])
: new Set(['' + defaultValue]);
for (let i = 0; i < options.length; i++) {
const selected = selectedValues.has(options[i].value);
if (options[i].defaultSelected !== selected) {
options[i].defaultSelected = selected;
}
}
}
}
<select> 的受控实现需要遍历所有 <option> 子元素来同步 selected 状态。这是一个 O(n) 的操作,在选项非常多的下拉框中可能成为性能瓶颈。但在实际应用中,超大的 <select> 通常会被替换为自定义的搜索下拉组件,所以这个开销在绝大多数场景下是可以忽略的。
15.3 Portal 的实现原理
15.3.1 Portal 解决了什么问题
在典型的 React 应用中,组件树的结构直接映射到 DOM 树的结构。但有些 UI 场景打破了这个假设——模态框(Modal)、提示工具(Tooltip)、下拉菜单(Dropdown)等组件,在逻辑上属于某个父组件,但在 DOM 层面上需要渲染到完全不同的位置(通常是 document.body 的直接子节点),以避免 overflow: hidden 和 z-index 层叠上下文的限制。
// Portal 的使用方式
function Modal({ children }: { children: React.ReactNode }) {
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.body // 渲染到 body 而不是当前组件所在的 DOM 位置
);
}
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div className="app" style={{ overflow: 'hidden' }}>
<button onClick={() => setShowModal(true)}>打开模态框</button>
{showModal && (
<Modal>
{}
<button onClick={() => setShowModal(false)}>关闭</button>
</Modal>
)}
</div>
);
}
15.3.2 createPortal 的实现
createPortal 函数本身非常简单——它只是创建了一个特殊类型的 React 元素:
// packages/react-dom/src/shared/ReactDOM.js(v19.0.0;转发到 react-reconciler/ReactPortal)
function createPortal(
children: ReactNodeList,
container: Element | DocumentFragment,
key?: string,
): ReactPortal {
// 参数校验
if (!isValidContainer(container)) {
throw new Error('Target container is not a DOM element.');
}
return createPortalImpl(children, container, null, key ?? null);
}
// react-reconciler/src/ReactPortal.js
function createPortalImpl(
children: ReactNodeList,
containerInfo: Container,
implementation: any,
key: ?string,
): ReactPortal {
return {
// 标识这是一个 Portal
$$typeof: REACT_PORTAL_TYPE,
key: key == null ? null : '' + key,
children,
containerInfo,
implementation,
};
}
Portal 的"魔力"发生在 Reconciler 处理这个特殊元素的时候。当 beginWork 遇到一个 Portal 类型的 Fiber 时,它会创建一个特殊的 Fiber 节点,这个节点的 stateNode 指向 Portal 的目标容器:
// react-reconciler/src/ReactFiberBeginWork.js
function updatePortalComponent(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo);
const nextChildren = workInProgress.pendingProps;
if (current === null) {
// 首次渲染:Portal 的子节点需要渲染到指定容器中
// 但在 Fiber 树中,它们仍然是当前节点的子节点
reconcileChildFibers(workInProgress, null, nextChildren, renderLanes);
} else {
reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
return workInProgress.child;
}
15.3.3 Portal 的 DOM 挂载
Portal 真正特殊的地方在于 Commit 阶段。当 React 需要将 Portal 的子节点插入 DOM 时,它不会像普通节点那样寻找 Fiber 父节点对应的 DOM 元素,而是直接使用 Portal 指定的容器:
// react-reconciler/src/ReactFiberCommitWork.js
function getHostParentFiber(fiber: Fiber): Fiber {
let parent = fiber.return;
while (parent !== null) {
if (isHostParent(parent)) {
return parent;
}
parent = parent.return;
}
throw new Error('Expected to find a host parent.');
}
function commitPlacement(finishedWork: Fiber): void {
const parentFiber = getHostParentFiber(finishedWork);
switch (parentFiber.tag) {
case HostComponent: {
const parent = parentFiber.stateNode;
// 普通 DOM 节点:插入到父节点中
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
insertOrAppendPlacementNode(finishedWork, before, parent);
}
break;
}
case HostRoot:
case HostPortal: {
// Portal 节点:插入到 Portal 的目标容器中
const parent = parentFiber.stateNode.containerInfo;
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
break;
}
}
}
关键在于 HostPortal 分支——当 commitPlacement 发现父级 Fiber 是一个 Portal 时,它从 stateNode.containerInfo 获取目标容器,而不是使用 Portal 的 Fiber 父节点对应的 DOM 元素。这就是 Portal 实现"DOM 位置与 Fiber 树位置分离"的核心机制。
15.3.4 Portal 的事件冒泡保持
Portal 最反直觉的特性是:虽然 DOM 节点被移动到了另一个位置,但 React 的事件仍然按照 Fiber 树(组件树)的结构冒泡。
function Parent() {
return (
// 这个 onClick 可以捕获到 Portal 内部的点击事件
// 即使在 DOM 层面上 Portal 的内容根本不是它的子节点
<div onClick={() => console.log('Parent clicked!')}>
{createPortal(
<button>Click me</button>,
document.body
)}
</div>
);
}
这是如何实现的?答案在于 React 的合成事件系统。React 的事件委托在 root 容器上监听所有事件,当事件触发时,React 不是沿着 DOM 树冒泡,而是沿着 Fiber 树冒泡:
// 事件分发的简化逻辑
function traverseTwoPhase(inst: Fiber, fn: Function, arg: any) {
const path: Fiber[] = [];
// 收集从当前 Fiber 到根节点的路径
// 注意:这是沿着 Fiber 树的 return 指针遍历的
// 不是沿着 DOM 树的 parentNode
let current: Fiber | null = inst;
while (current !== null) {
path.push(current);
current = current.return; // Fiber 的父节点,不是 DOM 的父节点
}
// 捕获阶段:从根到目标
for (let i = path.length - 1; i >= 0; i--) {
fn(path[i], 'captured', arg);
}
// 冒泡阶段:从目标到根
for (let i = 0; i < path.length; i++) {
fn(path[i], 'bubbled', arg);
}
}
由于 Portal 在 Fiber 树中仍然是其声明位置的子节点(return 指针指向声明处的父 Fiber),事件冒泡会沿着 Fiber 树一路向上,经过 Portal 声明处的父组件。这意味着在组件层面,Portal 的行为就好像它的内容仍然在原始位置一样。
深度洞察:Portal 的设计体现了 React 的一个核心哲学——组件树的语义优先于 DOM 树的物理结构。在 React 看来,一个组件渲染的内容在"概念上"属于该组件,无论它在 DOM 中被放置在哪里。事件冒泡应该反映这种概念上的从属关系,而不是物理上的嵌套关系。这就像一家公司的远程员工——虽然人在另一个城市,但汇报关系仍然遵循公司的组织架构。
15.3.5 Portal 的卸载处理
Portal 在卸载时需要从其目标容器(而非 Fiber 父节点对应的 DOM 容器)中移除子节点:
function commitDeletionEffectsOnFiber(
finishedRoot: FiberRoot,
nearestMountedAncestor: Fiber,
deletedFiber: Fiber,
) {
switch (deletedFiber.tag) {
case HostPortal: {
// Portal 的子节点需要从 Portal 容器中移除
// 而不是从 Fiber 父节点的 DOM 容器中
let child = deletedFiber.child;
while (child !== null) {
// 递归移除子节点
commitDeletionEffectsOnFiber(
finishedRoot,
nearestMountedAncestor,
child,
);
child = child.sibling;
}
break;
}
// ... 其他类型
}
}
15.4 Hydration 不匹配的检测与恢复
15.4.1 Hydration 的基本概念
Hydration(水合)是 SSR(服务端渲染)应用中的关键步骤。服务端将 React 组件渲染为 HTML 字符串发送给客户端,客户端收到后需要"激活"这些静态的 HTML——为它们绑定事件处理器,建立 Fiber 树,使其成为一个可交互的 React 应用。
// 客户端入口:使用 hydrateRoot 而不是 createRoot
import { hydrateRoot } from 'react-dom/client';
// 服务端已经渲染了 HTML,现在客户端"认领"它
const root = hydrateRoot(
document.getElementById('root')!,
<App />
);
与 createRoot 不同,hydrateRoot 不会从零创建 DOM 树。它会复用服务端生成的 DOM 节点,只需要做两件事:
- 构建 Fiber 树,将每个 Fiber 节点关联到已有的 DOM 节点上
- 附加事件处理器和其他客户端特有的逻辑
15.4.2 Hydration 的匹配过程
在 Hydration 期间,React 需要逐个将 Fiber 节点与已有的 DOM 节点进行匹配。这个匹配过程发生在 beginWork 和 completeWork 中:
// react-reconciler/src/ReactFiberHydrationContext.js(v19.0.0 语义,简化)
// 当前正在尝试匹配的 DOM 节点
let nextHydratableInstance: HydratableInstance | null = null;
// 当前的 Hydration 父节点
let hydrationParentFiber: Fiber | null = null;
// 是否处于 Hydration 模式
let isHydrating: boolean = false;
// React 19:不匹配不再"兄弟重试 + 删除跳过节点 + 降级插入"
// 而是直接 throwOnHydrationMismatch,交给 Suspense / 客户端重生路径
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) return;
const currentHostContext = getHostContext();
const shouldKeepWarning = validateHydratableInstance(
fiber.type,
fiber.pendingProps,
currentHostContext,
);
const nextInstance = nextHydratableInstance;
if (
!nextInstance ||
!tryHydrateInstance(fiber, nextInstance, currentHostContext)
) {
if (shouldKeepWarning) {
warnNonHydratedInstance(fiber, nextInstance);
}
// 与 §15.4.4 对齐:抛出 HydrationMismatchException
throwOnHydrationMismatch(fiber);
}
}
function tryHydrateInstance(
fiber: Fiber,
nextInstance: HydratableInstance,
hostContext: HostContext,
): boolean {
// canHydrateInstance 在 HostConfig 层:类型匹配则返回 DOM 节点,否则 null
// (root/singleton 场景下可能跳过部分 hoistable 节点,但主路径不匹配即失败)
const instance = canHydrateInstance(
nextInstance,
fiber.type,
fiber.pendingProps,
rootOrSingletonContext,
);
if (instance !== null) {
fiber.stateNode = instance; // 关联 Fiber 和 DOM
hydrationParentFiber = fiber;
nextHydratableInstance = getFirstHydratableChild(instance);
return true;
}
return false;
}
版本对照:React 17/18 曾在不匹配时尝试下一个 DOM 兄弟、删除被跳过节点,或
insertNonHydratedInstance降级为客户端插入。React 19 去掉了这条"尽量修补"路径——结构对不上就抛错,由最近的 Suspense 边界或根级客户端重生恢复(见 §15.4.4)。
15.4.3 不匹配的检测
Hydration 不匹配发生在服务端渲染的 HTML 与客户端 React 生成的虚拟 DOM 不一致时。React 需要检测这些不匹配,并做出相应的处理。
常见的不匹配场景包括:
// 场景 1:条件渲染依赖了客户端状态
function TimeDisplay() {
// 服务端和客户端的 Date.now() 必然不同
return <span>{Date.now()}</span>;
// ⚠️ Hydration mismatch: text content
}
// 场景 2:使用了 typeof window 判断
function ClientOnly() {
if (typeof window === 'undefined') {
return <span>Loading...</span>; // 服务端
}
return <span>Ready!</span>; // 客户端
// ⚠️ Hydration mismatch: text content
}
// 场景 3:HTML 自动修正
function BadNesting() {
return (
<p>
<div>嵌套的 div</div>
</p>
);
// 浏览器会自动修正 <p> 中不合法的 <div>
// 导致 DOM 结构与 React 预期不同
}
React 在 Hydration 期间执行属性级别的比对来检测不匹配(下面以 React 18 的实现为例,19 的 diffHydratedProperties 不再返回 updatePayload 数组、比对逻辑本身一致):
// React 18: react-dom/src/client/ReactDOMComponent.js
function diffHydratedProperties(
domElement: Element,
tag: string,
props: Record<string, any>,
isConcurrentMode: boolean,
shouldWarnDev: boolean,
): null | Array<mixed> {
let updatePayload: null | Array<mixed> = null;
// 遍历 React props,与 DOM 实际属性对比
for (const propKey in props) {
const propValue = props[propKey];
if (propKey === CHILDREN) {
// 文本内容比对
if (typeof propValue === 'string' || typeof propValue === 'number') {
const serverText = domElement.textContent;
if (('' + propValue) !== serverText) {
// 文本不匹配
if (__DEV__ && shouldWarnDev) {
warnForTextDifference(serverText, '' + propValue);
}
if (!isConcurrentMode) {
// Legacy 模式:静默修复
(updatePayload = updatePayload || []).push(propKey, propValue);
}
}
}
} else if (propKey === STYLE) {
// 样式比对
const serverStyle = domElement.getAttribute('style');
const expectedStyle = createDangerousStringForStyles(propValue);
if (serverStyle !== expectedStyle) {
if (__DEV__ && shouldWarnDev) {
warnForPropDifference('style', serverStyle, expectedStyle);
}
}
} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
const serverHTML = domElement.innerHTML;
const expectedHTML = propValue?.__html;
if (expectedHTML != null && serverHTML !== expectedHTML) {
if (__DEV__ && shouldWarnDev) {
warnForPropDifference(propKey, serverHTML, expectedHTML);
}
}
} else if (
propKey !== SUPPRESS_CONTENT_EDITABLE_WARNING &&
propKey !== SUPPRESS_HYDRATION_WARNING
) {
// 通用属性比对
const serverValue = domElement.getAttribute(
getAttributeAlias(propKey)
);
if (propValue !== serverValue) {
// 属性不匹配
(updatePayload = updatePayload || []).push(propKey, propValue);
}
}
}
return updatePayload;
}
15.4.4 不匹配的恢复策略
当 React 检测到 Hydration 不匹配时,它的恢复策略取决于 React 的版本和渲染模式:
// react-reconciler/src/ReactFiberHydrationContext.js
function warnNonHydratedInstance(
returnFiber: Fiber,
fiber: Fiber,
) {
if (__DEV__) {
// 开发模式下发出警告
console.error(
'Expected server HTML to contain a matching <%s> in <%s>.',
fiber.type,
returnFiber.type,
);
}
}
// React 19 的恢复策略(v19.0.0 措辞简化)
function throwOnHydrationMismatch(fiber: Fiber) {
// 先 queue 可恢复错误,再抛出内部用的 HydrationMismatchException
// 外层会走客户端重生;开发环境错误文案含 As a result this tree will be regenerated...
const error = new Error(
"Hydration failed because the server rendered HTML didn't match " +
'the client. As a result this tree will be regenerated on the client.'
);
queueHydrationError(createCapturedValueAtFiber(error, fiber));
throw HydrationMismatchException;
}
React 19 对 Hydration 不匹配的处理策略如下:
// 简化的恢复流程
function recoverFromConcurrentError(
root: FiberRoot,
originallyAttemptedLanes: Lanes,
) {
// 1. 检测到 Hydration 不匹配(抛出了 HydrationMismatchError)
// 2. React 会尝试找到最近的 Suspense 边界
// 如果找到了,只回退该 Suspense 边界内的子树
// 3. 对于该子树,放弃 Hydration,改为客户端渲染
// - 删除服务端生成的 DOM 节点
// - 从零创建新的 DOM 节点
// - 将新节点插入到 Suspense 边界的容器中
// 4. 如果没有 Suspense 边界,整棵树回退为客户端渲染
// 5. 在开发模式下,输出详细的不匹配信息帮助调试
}
这个恢复机制的关键点是回退的粒度。在 React 19 中,Suspense 边界不仅是加载状态的边界,也是 Hydration 恢复的边界。当某个子树的 Hydration 失败时,React 只会丢弃该 Suspense 边界内的 DOM,而不是整棵应用树。这大大提高了 SSR 应用的容错能力。
function App() {
return (
<div>
<Header /> {/* 这部分 Hydration 成功,保留 */}
<Suspense fallback={<Loading />}>
<DynamicContent />
{/* 如果这里不匹配,只有 Suspense 内的部分回退 */}
</Suspense>
<Footer /> {/* 这部分 Hydration 成功,保留 */}
</div>
);
}
15.4.5 suppressHydrationWarning 与预期中的不匹配
有些不匹配是开发者预期的——比如时间戳、随机 ID 等天然在服务端和客户端不同的值。React 提供了 suppressHydrationWarning prop 来抑制这些已知的不匹配警告:
function Timestamp() {
return (
<time suppressHydrationWarning>
{new Date().toLocaleString()}
</time>
);
}
但需要注意,suppressHydrationWarning 只抑制警告,不阻止 React 用客户端的值覆盖服务端的值。它也只作用于当前节点,不会递归地影响子节点。
对于需要在客户端和服务端展示不同内容的场景,更推荐的模式是使用 useEffect 延迟客户端渲染:
function ClientSideContent({ children }: { children: React.ReactNode }) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// useEffect 只在客户端运行
setIsClient(true);
}, []);
if (!isClient) {
// 首次渲染(包括 Hydration):返回与服务端一致的内容
return null;
}
// 客户端完成 Hydration 后的后续渲染:显示客户端专属内容
return <>{children}</>;
}
15.4.6 React 19 的 Hydration 错误报告增强
React 19 大幅增强了 Hydration 不匹配的错误信息。在早期版本中,不匹配的错误信息往往含糊不清,开发者很难定位问题。React 19 现在会输出精确的差异对比:
// React 19 的增强错误信息
// (packages/react-reconciler/src/ReactFiberHydrationDiffs.js)
// 例如:
// "Hydration failed because the server rendered HTML didn't match the client.
//
// <main>
// + <div className="client-class">
// - <div className="server-class">
// ...
// </div>
// </main>
// "
//
// 源码中 added()(+ 前缀)打印的是客户端(React 期望渲染)的内容,
// removed()(- 前缀)打印的是服务端 HTML 的内容
这种改进的错误信息采用了类似 git diff 的格式:把服务端 HTML 当作"旧版本"、客户端期望渲染当作"新版本"——+ 是客户端会渲染的内容,- 是服务端实际发来的内容,使得开发者可以快速定位不匹配的根源。
深度洞察:Hydration 的设计反映了 React 架构中一个深层的张力——确定性与现实之间的妥协。在理想世界中,服务端和客户端应该渲染出完全相同的结果。但现实中,时间、随机数、浏览器 API 的可用性等因素使得完全一致几乎不可能。React 19 的策略是:尽可能检测不一致,提供详细的诊断信息,并在不一致发生时优雅地回退——而不是崩溃或静默出错。这种"防御性设计"贯穿了 React 的整个架构。
15.5 渲染管线的完整视角
flowchart LR
SS["setState()"] --> BW["beginWork\n创建新 Fiber"]
BW --> CW["completeWork\n标记 Update flag"]
CW --> EL["flags 冒泡\n收集 DOM 变更"]
EL --> CM["commitMutationEffects\ncommitUpdate: diff + 写入真实 DOM"]
CM --> BR["浏览器重排/重绘\n像素更新"]
style SS fill:#ef4444,color:#fff,stroke:none
style CW fill:#f59e0b,color:#fff,stroke:none
style CM fill:#3b82f6,color:#fff,stroke:none
style BR fill:#10b981,color:#fff,stroke:none
15.5.1 从 setState 到像素:属性更新的完整链路
让我们将本章讨论的所有机制串联起来,追踪一次属性更新的完整生命周期:
// 假设用户点击了一个按钮,触发了以下代码(React 19 基线)
setState({ className: 'active', style: { color: 'red' } });
// 1. Render 阶段 - beginWork
// React 创建新的 Fiber 节点,pendingProps 中包含新的 className 和 style
// 2. Render 阶段 - completeWork
// 发现 oldProps !== newProps → markUpdate(workInProgress)
// 只在 fiber.flags 上打一个 Update 标记,不做任何属性级 diff
// (React ≤18 在这一步就调用 diffProperties 生成
// updatePayload: ['className', 'active', 'style', {...}] 存到 fiber 上)
// 3. Commit 阶段 - commitMutationEffects
// 调用 commitUpdate(domElement, 'div', oldProps, newProps)
// → updateProperties 遍历新旧 props,边 diff 边写入:
// - 'className' → setValueForKnownAttribute(domElement, 'class', 'active')
// - 'style' → setValueForStyles 逐项写 domElement.style
// (React ≤18 这一步是遍历现成的 updatePayload、逐个 setValueForProperty)
// 4. 浏览器渲染管线
// - Style Recalculation(重新计算样式)
// - Layout(重新计算布局)
// - Paint(重新绘制)
// - Composite(合成层叠加)
graph TD
A["setState 触发更新"] --> B["Render: beginWork 创建新 Fiber"]
B --> C["Render: completeWork"]
C --> D["oldProps !== newProps → 标记 Update flag"]
D --> F["Commit: commitMutationEffects"]
F --> G["commitUpdate → updateProperties<br/>diff 与写入一次完成"]
G --> H["setProp 按属性名分派"]
H --> I["setValueForStyles 处理 style"]
H --> J["setValueForKnownAttribute / setValueForAttribute"]
I --> K["浏览器: Style Recalculation"]
J --> K
K --> L["浏览器: Layout & Paint"]
图 15-1:DOM 属性更新的完整链路
15.5.2 批量更新与 DOM 写入优化
React 的渲染管线天然地对 DOM 写入进行了批量优化。这得益于 Render 阶段和 Commit 阶段的分离:
// 考虑这样的场景:一个列表中的 100 个 item 同时需要更新样式
function List({ items, activeId }: { items: Item[], activeId: string }) {
return (
<ul>
{items.map(item => (
<li
key={item.id}
className={item.id === activeId ? 'active' : 'inactive'}
style={{ opacity: item.id === activeId ? 1 : 0.5 }}
>
{item.name}
</li>
))}
</ul>
);
}
// 当 activeId 变化时:
// 1. Render 阶段:100 个 li 逐个走 beginWork/completeWork,
// props 变化的都被标记上 Update flag
// 这个过程纯粹在内存中,不触碰 DOM
// (React ≤18 还会在这一步为每个 li 算出 updatePayload)
// 2. Commit 阶段:连续执行 100 个 commitUpdate(每个内部 diff + 写入)
// 虽然是 100 次 DOM 写入,但因为浏览器的渲染是异步的
// 只要这些写入在同一个事件循环的同步代码中完成
// 浏览器只会在 Commit 阶段结束后执行一次 Layout + Paint
// 这就是为什么 React 的 Commit 阶段必须同步——
// 它确保所有 DOM 变更在一个"微事务"中完成
// 浏览器只看到最终状态,不会出现中间状态的闪烁
15.5.3 与浏览器渲染管线的协作
React 的更新管线与浏览器的渲染管线有着精密的协作关系:
// React 的调度策略确保了与浏览器渲染的良好协作
// 1. 同步更新(flushSync)
// Render → Commit → 浏览器立即看到变化
// 适用于:输入框、动画等需要即时反馈的场景
// 2. 并发更新(Transition)
// Render(可中断)→ 让出主线程 → 浏览器处理用户输入和绘制
// → 继续 Render → Commit
// 适用于:列表过滤、路由切换等可以延迟的场景
// 3. useLayoutEffect 的时机
// Commit(Mutation) → root.current 切换 → Commit(Layout)
// → useLayoutEffect 执行(此时 DOM 已更新但浏览器还没绘制)
// → 浏览器绘制
// 适用于:需要在绘制前读取 DOM 布局信息的场景
// 4. useEffect 的时机
// Commit 阶段用 Scheduler 以 NormalPriority 调度一个 flushPassiveEffects
// (底层是 MessageChannel 宏任务,React 从不用 requestIdleCallback)
// → 通常在浏览器绘制之后执行,但这不是保证——
// 离散事件(如 click)触发的更新里,passive effects 可能在
// 下一次同步渲染前被提前同步 flush,赶在绘制之前
// 适用于:数据请求、订阅、日志等不需要阻塞绘制的副作用
深度洞察:React 的渲染管线可以类比为一条工业生产线。Render 阶段是"设计车间",在蓝图上反复修改不影响已经出厂的产品;属性 diff 是"质检环节",比较新旧设计的差异(≤18 提前在设计车间生成变更清单,19 把质检台直接搬进了装配车间、边比对边改装);Commit 阶段是"装配车间",一次性完成所有改装;浏览器的 Layout 和 Paint 则是"喷漆和出厂"。整条流水线的核心约束是:设计可以反复来,但装配必须一气呵成——这就是为什么 Render 阶段可中断而 Commit 阶段不可中断的根本原因。
15.5.4 实测:React 19 DOM 客户端目录体量(v19.0.0)
把 packages/react-dom-bindings/src/client/ 按文件大小排序(tag v19.0.0;目录约 27 个 .js、合计约 10500 行量级。下表节选前 14 个大文件,其余小文件合计约 600+ 行)——
| 文件 | 行(约) | 角色 |
|---|---|---|
ReactFiberConfigDOM.js |
3590 | 本目录最大——Fiber-DOM 适配器(hydrateInstance / commitMount / commitUpdate / detachDeletedInstance 等几十个 Fiber 钩子的 DOM 实现) |
ReactDOMComponent.js |
3162 | DOM 组件主体——本章 §15.1 讲的 setProp / updateProperties / setInitialProperties(以及 hydration 用的 diffHydratedProperties)全在这一文件 |
validateDOMNesting.js |
641 | DOM 嵌套合法性验证(<table> 不能塞 <div> 等)——浏览器会自动修复但 React 会警告 |
ReactDOMInput.js |
443 | <input> 组件——本章 §15.2 受控/非受控 input 的核心实现 |
ReactDOMComponentTree.js |
300 | DOM Node ↔ Fiber 互查的 internalInstanceKey 机制 |
CSSPropertyOperations.js |
256 | style 属性处理(驼峰转 kebab + 单位补全) |
DOMPropertyOperations.js |
232 | 属性 set/get/delete 的低层 helper |
ReactDOMSelection.js |
202 | 文本选择 (Range / Selection API) 适配 |
ReactInputSelection.js |
199 | input 光标位置保留 |
CSSShorthandProperty.js |
192 | shorthand CSS 属性(如 font → font-size/font-family/...)展开表 |
ReactDOMSelect.js |
189 | <select> 组件(§15.2.5) |
inputValueTracking.js |
155 | §15.2.2 "value tracking 内核"完整实现——比章节描述还短 |
ReactDOMTextarea.js |
151 | <textarea> 组件 |
ReactDOMEventHandle.js |
148 | 事件 handle 注册 |
| 以上 14 文件小计 | ~9860 | 加上其余约 13 个小文件(约 630 行),client/ 合计约 10500 |
两条值得记住的物理事实——
ReactFiberConfigDOM.js+ReactDOMComponent.js合计约 6700 行,仍占目录六成以上——两大文件包揽 DOM-Fiber 互通 + DOM 组件主体——印证 §15.5 标题"渲染管线的完整视角"在源码层是两个超大文件配合而不是分散到很多小模块inputValueTracking.js仅 155 行实现整个 value tracking 协议——本章 §15.2.2 用了一节讲它(Object.defineProperty劫持 setter / track 读取 / 双向感知)——155 行就把"让 React 知道 DOM 被外部脚本改了"这件事做完——印证 React 核心 DOM 协议代码量极小、绝大部分体量在 component-specific 的处理上
串联 ch14 §14.9(v19.0.0):events/ 约 3900 + plugins/ 约 1630 + dom-bindings/client/ 约 10500 ≈ 16000 行量级 是 React DOM 的"事件系统 + DOM 操作"生产代码主体。完整 react-dom-bindings 包还包含 server/(约 7700 行)与 shared/(约 17 文件 / 1950 行,ReactDOMFormActions.js 等),总量约 25000+ 行——是 React 19 把 React 协议落地到浏览器 DOM 的全部工程量。行数随 commit 微变,读作量级即可。
15.5.5 源码核对:DOM 层真正暴露给 reconciler 的边界
从 reconciler 的视角看,DOM 渲染器不是一堆零散工具函数,而是一份 HostConfig。在 React 19 里,这份契约中负责更新的入口就是 packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js 的 commitUpdate(domElement, type, oldProps, newProps, internalInstanceHandle)——内部调用 updateProperties 一次完成 diff 与写入。稳定版 v18.3.x 仍走 prepareUpdate + diffProperties;diffInCommitPhase 与 updatePropertiesWithDiff 出现在 18.3 canary 过渡期,是新旧模型并存的痕迹。19 正式版里 prepareUpdate 已经不复存在。也就是说,render 阶段和 commit 阶段之间传递的不是浏览器节点操作,甚至(在 19 里)不再是一份算好的更新清单,而只是"这个节点需要更新"的一个标记。
Hydration 也是同一层边界。v19.0.0 的 ReactFiberConfigDOM.js 中 hydrateInstance(约 L1401)先把已有 DOM 节点和 Fiber 关联起来,再调用 hydration 属性检查;Portal 则通过 preparePortalMount(约 L637)在目标容器上补齐事件监听,确保 Portal 内容虽然挂在另一个 DOM 容器下,仍然能参与 React 的事件委托体系。
受控输入框的特殊性也在这一层落地。packages/react-dom-bindings/src/client/inputValueTracking.js 中 trackValueOnNode(约 L54)选择 value 或 checked 字段并读取原型 descriptor,随后用 Object.defineProperty 包装 setter;track / updateValueIfChanged(约 L121-147)负责保存上一次值并判断浏览器 DOM 是否发生了 React 之外的变化。这解释了为什么受控表单不是单纯的 element.value = props.value:React 还要知道用户输入、脚本赋值、合成事件和 Fiber props 之间是否已经偏离。
因此,DOM binding 的核心职责不是"把 JSX 变成 DOM"这么宽泛,而是把 reconciler 的 HostConfig 回调翻译为浏览器世界的三类动作:创建与关联节点、计算与应用属性差异、在 hydration 和受控组件这些边界上维护一致性。读这一层源码时,最有价值的问题不是某个属性最后调用了哪一个 DOM API,而是这次写入发生在 render 之前、commit 之中,还是 hydration 修复路径里。
这个角度还能帮助排查线上问题。属性不更新时,先看 completeWork 是否给节点打上了 Update flag(oldProps === newProps 同引用 bailout 是最常见的"不更新"原因);打了标记但 DOM 没变,再看 commit 是否走到了 commitUpdate / updateProperties;SSR 不匹配时,重点转向 hydrateInstance 与 diffHydratedProperties;输入框光标异常时,优先检查受控值、浏览器实际值和 value tracker 三者是否同步。把问题映射到 HostConfig 边界,排查范围会从"React 怎么没更新"缩小为"差异计算、提交写入、一致性修复哪一段出了问题"。
15.6 本章小结
DOM 更新是 React 渲染管线中最贴近浏览器的一层。它看似只是简单的属性赋值,实际上涉及了属性分类、差异计算、值追踪、跨容器渲染、服务端一致性检测等多个维度的复杂逻辑。
关键要点:
- 属性 diff 时机的版本演进:React ≤18 在 Render 阶段
completeWork里用diffProperties算出 updatePayload、Commit 阶段应用;React 19 把 diff 也挪进 Commit——completeWork只打 Update flag,commitUpdate里由updateProperties一次完成 diff + 写入。不变的底线是 Render 阶段绝不触碰 DOM,这才是可中断渲染的基础 - updatePayload 的扁平数组设计(≤18 历史):为了热路径上的性能优化,牺牲了可读性;React 19 连这个中间结构一起移除了
- 受控组件的 value tracking:通过
Object.defineProperty劫持valuesetter,实现了 React 状态与 DOM 值的双向感知 - Portal 的双重身份:在 DOM 树中位于目标容器下,在 Fiber 树中位于声明位置,事件冒泡遵循 Fiber 树
- Hydration 的渐进式恢复:以 Suspense 边界为粒度回退,而不是整棵树回退
- 浏览器渲染协作:React 的同步 Commit 确保所有 DOM 变更在一个事件循环中完成,避免中间状态闪烁
物理事实(v19.0.0 量级):react-dom-bindings/src/client/ 约 27 文件 / 10500 行——ReactFiberConfigDOM.js + ReactDOMComponent.js 合计约占六成;inputValueTracking.js 仅 155 行实现整个 value tracking 协议;串联 ch14 §14.9 events/≈3900 + plugins/≈1630 + 本节 client/≈10500 ≈ 16000 行 = React DOM 事件系统 + DOM 操作生产代码主体。
思考题
-
为什么 React 在设置受控
<input>的 value 之前要先比较element.value !== '' + value? 如果去掉这个比较,直接每次都赋值会导致什么具体的用户体验问题?提示:考虑光标位置和 IME(输入法编辑器)的行为。 -
Portal 的事件冒泡遵循 Fiber 树而非 DOM 树。请构造一个具体场景,说明这种设计在实际开发中可能导致的反直觉行为。 例如:一个 Portal 内的元素触发了
stopPropagation,它能阻止事件到达 Portal 声明处的父组件吗?DOM 层面的事件传播又是怎样的? -
在 SSR Hydration 中,如果服务端渲染了
<div class="a b c">,客户端期望的是<div className="c b a">(class 名相同但顺序不同),React 会报不匹配吗? 追踪diffHydratedProperties的源码逻辑来分析。如果会,你认为这是一个 React 的设计缺陷还是有意为之? -
考虑一个嵌套 Portal 的场景:组件 A 通过 Portal 将内容渲染到容器 X,而在这些内容中又有一个组件 B 通过 Portal 将内容渲染到容器 Y。 当组件 A 被卸载时,React 如何确保 B 在容器 Y 中的 DOM 节点也被正确清理?追踪
commitDeletionEffects的递归逻辑。