Skip to content

第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 属性的白名单——如 opacityzIndexflexGrowlineHeight 等。对于不在白名单中的数值属性,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 阶段就会检查:如果一个节点同时设置了 dangerouslySetInnerHTMLchildren,会抛出错误。这是因为 innerHTML 会清除所有子节点,与 React 管理的子节点树产生冲突。

typescript
function validateDOMNesting(child: string, parent: string) {
  // React 还会验证 HTML 嵌套规则
  // 例如 <p> 里不能嵌套 <div>
  // 这些校验在开发模式下运行,帮助开发者发现潜在问题
}

深度洞察dangerouslySetInnerHTML 的命名策略是一个教科书级的 API 设计案例。React 团队选择让"危险的操作看起来就像是危险的",而不是提供一个简洁但容易误用的 innerHTML prop。这种设计哲学贯穿了 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)}
    />
  );
}

看似简单的代码背后隐藏着一个精妙的同步循环:

  1. 用户在输入框中按下键盘
  2. 浏览器原生地更新了 input.value(DOM 状态变了)
  3. React 的事件系统捕获了 onChange,调用 setValue
  4. React 触发重渲染,计算新的 props(value 属性已更新)
  5. Commit 阶段,React 将 value 设置回 DOM 节点

问题出在步骤 2 和步骤 5 之间:如果 React 的状态更新被阻止了(比如 onChange 处理器没有调用 setValue),React 需要能够将 DOM 的值"回滚"到之前的状态。这就是 value tracking 机制存在的原因。

基于 VitePress 构建