Appearance
第15章 DOM 更新与渲染管线
本章要点
- 属性设置的完整链路:className、style、dangerouslySetInnerHTML 的源码级处理机制
- diffProperties 算法:React 如何高效计算 DOM 属性的最小更新集
- 受控组件与非受控组件的 DOM 同步机制:value tracking 的内核实现
- Portal 的实现原理:createPortal 如何突破 DOM 层级而保持事件冒泡
- Hydration 不匹配的检测与恢复:hydrateRoot 的容错策略
- setValueForProperty 的属性分类与特殊处理逻辑
在前面的章节中,我们深入分析了 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 diffProperties:计算最小更新集
当一个 DOM 节点需要更新时,React 不会将所有 props 重新设置一遍。它会调用 diffProperties 来计算新旧 props 之间的差异,生成一个"更新负载"(updatePayload)。这个负载是一个扁平数组,格式为 [propKey1, propValue1, propKey2, propValue2, ...]。
typescript
// react-dom-bindings/src/client/ReactDOMComponent.ts
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;
}这段代码的设计有一个值得深思的地方:为什么 updatePayload 使用扁平数组而不是对象? 原因是性能。数组的创建和遍历比对象更快,而且这个数据结构只有短暂的生命周期——从 completeWork 创建到 commitWork 消费,之后就会被垃圾回收。React 用两个相邻元素表示一个键值对([key, value, key, value, ...]),虽然不够直观,但在热路径上这种微优化是值得的。
深度洞察:
diffProperties的设计体现了 React 的一个重要原则——延迟计算。属性差异的计算发生在 Render 阶段的completeWork中,而实际的 DOM 操作则延迟到 Commit 阶段。这种分离使得 Render 阶段可以被安全地中断和重新开始,因为它没有产生任何不可逆的副作用。
15.1.2 setValueForProperty:属性分类与设置
当 Commit 阶段应用 updatePayload 时,最终会调用 setValueForProperty 来设置单个属性。这个函数是 React DOM 属性系统的核心,它需要处理各种不同类型的属性:
typescript
// react-dom-bindings/src/client/DOMPropertyOperations.ts
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 为每个已知的 HTML/SVG 属性维护了一个 PropertyInfo 对象,记录了该属性的类型(布尔、数值、字符串等)、对应的 DOM attribute 名称、以及是否需要命名空间(SVG 属性)。这个预计算的映射表避免了运行时的重复判断。
15.1.3 className 的处理
className 是 React 中最常用的 DOM 属性之一。它的处理看似简单,但背后有一个有趣的历史原因:
typescript
// 为什么 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 就是为了让开发者三思而后行:
typescript
// 使用方式
<div dangerouslySetInnerHTML={{ __html: '<p>来自服务端的 HTML</p>' }} />
// 为什么需要 { __html: ... } 这层包装?
// 答案:这是一个"速度减速带"(speed bump)
// 防止开发者意外地将用户输入直接传入:
// ❌ 如果 API 是 innerHTML={userInput}
// 开发者可能不假思索地这样写,导致 XSS
// ✅ 现在的 API 要求 dangerouslySetInnerHTML={{ __html: userInput }}
// 多层嵌套迫使你停下来思考:这真的安全吗?在 Commit 阶段,dangerouslySetInnerHTML 的实际处理非常直接:
typescript
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 管理的子节点树产生冲突。
typescript
function validateDOMNesting(child: string, parent: string) {
// React 还会验证 HTML 嵌套规则
// 例如 <p> 里不能嵌套 <div>
// 这些校验在开发模式下运行,帮助开发者发现潜在问题
}深度洞察:
dangerouslySetInnerHTML的命名策略是一个教科书级的 API 设计案例。React 团队选择让"危险的操作看起来就像是危险的",而不是提供一个简洁但容易误用的innerHTMLprop。这种设计哲学贯穿了 React 的方方面面——让正确的事情变得容易,让错误的事情变得困难(但仍然可能)。
15.2 受控组件与非受控组件的 DOM 同步机制
15.2.1 受控组件的核心矛盾
受控组件是 React 表单模型的基石,但它引入了一个根本性的矛盾:浏览器的原生表单元素有自己的状态管理机制,而 React 想要完全控制这个状态。
tsx
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 机制存在的原因。