React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(下)

React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(下)

本文为本章下篇,接续《第06章_ReactDOM渲染器与Commit阶段(上)》。上篇覆盖了 6.16.4 的前半(commitRoot 入口、三子阶段、Placement、Deletion、Layout、Ref);本篇从 6.4.15 继续,并收束 6.5 6.13:与其他模块的交互、边界场景与坑、性能影响、最佳实践、源码索引表与 FAQ,以及两段式 detach 与 commitMount 这两个容易被忽略的收尾细节。

阅读前置:第 3 章(Fiber 与 Flags 副作用模型)、第 4 章(Reconciliation 与 markUpdate)、第 6 章上篇。


6.4 关键算法与流程(续)

承接上篇 6.4.1~6.4.14,本篇从 6.4.15 继续。

6.4.15 React 19 的 updateProperties 内联 diff 全解(1518)

6.4.4 给了 updateProperties 的骨架和「为什么去掉 updatePayload」的取舍。本节把七条 tag 分支 全部展开,逐条说明双遍历逻辑的差异,并解释几个「为什么必须最后再调一次 updateWrapper」的时序约束。

① 总入口与默认双遍历(1518~2023)
js 复制代码
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:1518
export function updateProperties(
  domElement: Element,
  tag: string,
  lastProps: Object,
  nextProps: Object,
): void {
  if (__DEV__) {
    validatePropertiesInDevelopment(tag, nextProps);
  }

  switch (tag) {
    case 'div':
    case 'span':
    case 'svg':
    case 'path':
    case 'a':
    case 'g':
    case 'p':
    case 'li': {
      // Fast track the most common tag types
      break;
    }
    // ... input / select / textarea / option / void / custom element 各自 return ...
  }

  for (const propKey in lastProps) {
    const lastProp = lastProps[propKey];
    if (
      lastProps.hasOwnProperty(propKey) &&
      lastProp != null &&
      !nextProps.hasOwnProperty(propKey)
    ) {
      setProp(domElement, tag, propKey, null, nextProps, lastProp);
    }
  }
  for (const propKey in nextProps) {
    const nextProp = nextProps[propKey];
    const lastProp = lastProps[propKey];
    if (
      nextProps.hasOwnProperty(propKey) &&
      nextProp !== lastProp &&
      (nextProp != null || lastProp != null)
    ) {
      setProp(domElement, tag, propKey, nextProp, nextProps, lastProp);
    }
  }
}

注意最后这两段兜底遍历(2002~2022)------所有不 return 的分支都会流到这里。两种遍历的分工:

  • 第一遍(删旧) :条件 lastProp != null && !nextProps.hasOwnProperty(propKey)。只处理「旧 props 里存在、新 props 里这个 key 彻底消失了」的情况,用 setProp(..., null, ...) 把 DOM 属性/样式清掉。用 hasOwnProperty 而不是 nextProps[propKey] == null,是为了区分「属性被移除」和「属性值被改成 null」------后者由第二遍处理。
  • 第二遍(设新) :条件 nextProp !== lastProp && (nextProp != null || lastProp != null)。!== 是引用/原始值比较;两个都为 null/undefined 时跳过(没有可写的值);一新一旧时调用 setProp。
② 七条分支的完整清单

| tag 分支 | 行号 | 与默认路径的差异 | 收尾动作 |
|--------------------------------------------------------------------------------------------------------------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------|-------------------------------------------------------|----------------|
| div/span/svg/path/a/g/p/li | 1529~1539 | 无(break 后落到默认双遍历)。注释 "Fast track the most common tag types"------把最高频标签放在 switch 最前面,V8 会为这种密集 case 生成跳表 | 无 |
| input | 1540~1700 | 第一遍收集 lastDefaultValue,checked/value 跳过;第二遍收集 type/name/checked/value/defaultValue/defaultChecked,每个都带 if (nextProp !== lastProp) trackHostMutation() | updateInput(...)(1689) |
| select | 1701~1777 | 第一遍收集 wasMultiple,value 跳过;第二遍收集 value/defaultValue/multiple | updateSelect(...)(1775) |
| textarea | 1778~1856 | 第一遍只在「key 消失」时删;第二遍收集 value/defaultValue,children 与 dangerouslySetInnerHTML 做非法性检查 | updateTextarea(...)(1854) |
| option | 1857~1904 | selected 直接写 domElement.selected 属性(不用 setProp),并 trackHostMutation() | 无 |
| img/link/area/base/br/col/embed/hr/keygen/meta/param/source/track/wbr/menuitem | 1905~1959 | void 元素版双遍历:children/dangerouslySetInnerHTML 直接 throw(1940~1950) | 无 |
| default(自定义元素) | 1960~1999 | isCustomElement(tag, nextProps) 命中 → 走 setPropOnCustomElement 双遍历,条件是 lastProp !== undefined / `nextProp !== lastProp && (nextProp !== undefined | | lastProp !== undefined),**与默认路径的 != null` 判断不同** | return(1997) |

为什么控件类标签要把 updateWrapper 放到最后?源码注释(1686~1688)说得很清楚:

js 复制代码
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:1686
      // Update the wrapper around inputs *after* updating props. This has to
      // happen after updating the rest of props. Otherwise HTML5 input validations
      // raise warnings and prevent the new value from being assigned.
      updateInput(
        domElement,
        value,
        defaultValue,
        lastDefaultValue,
        checked,
        defaultChecked,
        type,
        name,
      );

updateInput 内部会写 domElement.value / checked(受控同步)。如果先写 value 再设 type/pattern/maxLength 等约束属性,HTML5 校验会在「新 value 遇上旧约束」时报警并拒绝赋值 。select 的注释(1773~1774)是同一逻辑的另一个侧面:"<select> value update needs to occur after <option> children reconciliation"------<option> 是 child fiber,mutation 阶段的递归顺序保证它们先被处理,等 updateSelect 跑时 DOM 里的 option 列表已就绪,选中关系才能正确落位。

自定义元素分支的 undefined 语义差异 (19601999)是个隐藏很深的细节:默认路径用 != null(null 与 undefined 都算「有值变化」),自定义元素路径用 !== undefined。因为 Web Components 的属性可能是任意类型(对象、数组、undefined 表示「属性不存在」),React 用 undefined 而不是 null 作为「删除属性」的信号(setPropOnCustomElement 的删除调用传的就是 undefined,19691976)。

③ 一遍还是两遍?性能视角

双遍历的复杂度是 O(|lastProps| + |nextProps|),每次属性更新都要扫两个对象。React 19 把 diff 从 render 期的 diffProperties(产出 updatePayload 数组)挪到这里,等于把这个成本从 render 阶段转嫁到 commit 阶段(6.4.4/6.5.2 已讨论设计取舍)。对典型组件(props 数以十计)微不足道;但有两类场景值得留意:

  • style 对象每次渲染都是新引用 :第二遍的 nextProp !== lastProp 恒真,setProp 必然被调用------但 setValueForStyles 内部会做逐 CSS 属性的旧值比较(见 6.4.16),真正落地的样式写入通常只有几个。所以「style 换引用」不等于「样式重设」。
  • 超大 props 对象的高频更新 (如把整个 store 透传给 DOM 元素):双遍历会线性扫描。此时应考虑拆分 props 或用 useMemo 稳定子树引用,让 render 阶段直接 bailout,从源头不进 commitUpdate。

6.4.16 setProp 分支树(397)与 sanitizeURL、trackHostMutation 的真相

setProp 是一个约 600 行的 switch (key),6.4.4 只拆了三个点。本节按功能组 给出完整分支树,然后专门纠正两个流传甚广的误解:sanitizeURL 防的到底是什么,trackHostMutation 到底在统计什么。

① setProp 的功能分组
js 复制代码
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:397
function setProp(
  domElement: Element,
  tag: string,
  key: string,
  value: mixed,
  props: any,
  prevValue: mixed,
): void {
  switch (key) {

| 组 | 行号 | 分支 | 行为 |
|--------------------|----------|---------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------|---|---------------------------|---|--------------------------------------------------------------------------|
| 文本 children | 406~433 | children | string/number/bigint → setTextContent;body 与空串 textarea 有 IE11 placeholder 特判(415~417);其他类型 return(431) |
| 高频属性 | 438~451 | className/tabIndex/dir/role/viewBox/width/height | setValueForKnownAttribute |
| 样式 | 453~456 | style | setValueForStyles(domElement, value, prevValue) 后 return |
| URL 类 | 458~546 | data(非 object 标签)、src、href | 空串特判 + sanitizeURL + setAttribute |
| 表单动作 | 547~617 | action/formAction | 函数值装「抛出用 javascript: URL」;prevValue 是函数(脱离 Server Action)时强制补设 name/encType 等 |
| 事件 | 618~649 | onClick/onScroll/onScrollEnd | 特殊监听/trap 后 return |
| innerHTML | 650~674 | dangerouslySetInnerHTML | 校验 __html、与 children 互斥、仅在变化时写 innerHTML |
| 媒体属性 | 677~686 | multiple/muted | 直接写 DOM 属性 |
| 纯 noop 名单 | 687~696 | suppressContentEditableWarning/suppressHydrationWarning/defaultValue/defaultChecked/innerHTML/ref | 什么都不做 |
| 自动聚焦 | 697~703 | autoFocus | noop ,交给 commitMount(见 6.4.18) |
| SVG 命名空间 | 704~941 | xlinkHref/xlink*/xmlBase/xmlLang/xmlSpace | setAttributeNS + sanitizeURL(xlinkHref) |
| Booleanish String | 727~755 | draggable/value/autoReverse/externalResourcesRequired/focusable/preserveAlpha | setAttribute('' + value) 或 remove |
| Boolean | 757~801 | inert(含空串告警)/ allowFullScreen/async/autoPlay/controls/disabled/hidden/readOnly/required 等 | setAttribute(key, '') 或 removeAttribute |
| Overloaded Boolean | 803~825 | capture/download | true → 空串;false → 移除;其他值 → 该值 |
| 正数属性 | 826~846 | cols/rows/size/span | !isNaN && >= 1 才写 |
| 数字属性 | 847~864 | rowSpan/start | !isNaN 才写 |
| popover | 865~869 | popover | 先 listenToNonDelegatedEvent('beforetoggle'/'toggle') 再 setValueForAttribute |
| is | 943~957 | is | DEV 下重复赋值告警(944~950);照常写属性 |
| 忽略名单 | 958~960 | innerText/textContent | return |
| popoverTarget | 961~975 | popoverTarget | DEV 对象告警,穿透 default |
| default | 976~996 | 其他所有 key | onXxx 检测(`key.length > 2 && (key0==='o' | | 'O') && (key1==='n' | | 'N'))→ **return**;否则 getAttributeAlias(key)+setValueForAttribute` |
| 函数尾 | 999 | --- | 走到这里才 trackHostMutation()(998 注释:"To avoid marking things as host mutations we do early returns above.") |

default 分支的 onXxx 检测值得逐字看:

js 复制代码
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:976
    default: {
      if (
        key.length > 2 &&
        (key[0] === 'o' || key[0] === 'O') &&
        (key[1] === 'n' || key[1] === 'N')
      ) {
        if (
          __DEV__ &&
          registrationNameDependencies.hasOwnProperty(key) &&
          value != null &&
          typeof value !== 'function'
        ) {
          warnForInvalidEventListener(key, value);
        }
        // Updating events doesn't affect the visuals.
        return;
      } else {
        const attributeName = getAttributeAlias(key);
        setValueForAttribute(domElement, attributeName, value);
      }
    }

这是一个没有注册表的「语法级」事件判定 ------只要 key 长得像 onXxx(长度 > 2、前两个字符是 on/ON)就当事件处理直接返回。这意味着你写一个叫 once 或 onions 的自定义属性放在普通元素上,不会 被写到 DOM 上(会被当作事件吞掉)。这也是 6.4.4 细节一的完整上下文:handler 的最新版本只活在 updateFiberProps 维护的句柄里,DOM 属性层永远零成本。

② sanitizeURL 到底防什么
js 复制代码
// packages/react-dom-bindings/src/shared/sanitizeURL.js:10
// A javascript: URL can contain leading C0 control or \u0020 SPACE,
// and any newline or tab are filtered out as if they're not part of the URL.
// https://url.spec.whatwg.org/#url-parsing
// Tab or newline are defined as \r\n\t:
// https://infra.spec.whatwg.org/#ascii-tab-or-newline
// A C0 control is a code point in the range \u0000 NULL to \u001F
// INFORMATION SEPARATOR ONE, inclusive:
// https://infra.spec.whatwg.org/#c0-control-or-space

const isJavaScriptProtocol =
  /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i;

function sanitizeURL<T>(url: T): T | string {
  // We should never have symbols here because they get filtered out elsewhere.
  // eslint-disable-next-line react-internal/safe-string-coercion
  if (isJavaScriptProtocol.test('' + (url as any))) {
    // Return a different javascript: url that doesn't cause any side-effects and just
    // throws if ever visited.
    // eslint-disable-next-line no-script-url
    return "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')";
  }
  return url;
}

安全意义在于浏览器与正则的「解析差」 :WHATWG URL 规范规定,URL 解析时会剥掉行首的 C0 控制字符和空格,并忽略 \t/\r/\n 。也就是说 java\tscript:alert(1)、\x01javascript:...、 javascript:... 在浏览器眼里都是合法的 javascript: URL,但一个朴素的 /^javascript:/ 过滤是抓不到它们的。这个正则把所有可被浏览器「抹掉」的字符都显式建模进 pattern([\u0000-\u001F ]* 前缀、[\r\n\t]* 插入在每个字母之间),先按浏览器的归一化规则匹配,再判定协议 。命中后不是简单置空,而是替换成一个「只会 throw」的 javascript: URL------既阻断执行,又保留一个显眼的错误现场。

它的三个调用点(setProp 541 / 612 / 719,分别对应 src/href、action/formAction、xlinkHref)覆盖了所有「用户可控字符串直接进 DOM 属性」的 URL 入口。这是 React 把 XSS 防护做进渲染器的标准动作:不信赖业务代码做转义,在写入 DOM 前统一消毒。

配套的 setValueForAttribute(DOMPropertyOperations.js:106)则负责非 URL 属性的最后一层类型卫生:

js 复制代码
// packages/react-dom-bindings/src/client/DOMPropertyOperations.js:106
export function setValueForAttribute(
  node: Element,
  name: string,
  value: mixed,
) {
  if (isAttributeNameSafe(name)) {
    // If the prop isn't in the special list, treat it as a simple attribute.
    // shouldRemoveAttribute
    if (value === null) {
      node.removeAttribute(name);
      return;
    }
    switch (typeof value) {
      case 'undefined':
      case 'function':
      case 'symbol':
        node.removeAttribute(name);
        return;
      case 'boolean': {
        const prefix = name.toLowerCase().slice(0, 5);
        if (prefix !== 'data-' && prefix !== 'aria-') {
          node.removeAttribute(name);
          return;
        }
      }
    }
    if (__DEV__) {
      checkAttributeStringCoercion(value, name);
    }
    node.setAttribute(
      name,
      enableTrustedTypesIntegration ? (value as any) : '' + (value as any),
    );
  }
}

isAttributeNameSafe 是属性名的白名单(挡掉 style 这种危险名和畸形名);boolean 分支只对 data-*/aria-* 开洞(99~130 行附近的逻辑),其余布尔值一律删除属性;最后统一 '' + value 字符串化(Trusted Types 开启时交给浏览器策略)。

③ trackHostMutation 的真相:它不是「DOM 改动计数器」

6.7.5 说 trackHostMutation 「记录本次 commit 实际修改了多少 DOM,供 Profiler/DevTools 统计」------这个说法需要按源码修正(按本系列规范,与源码冲突时以源码为准并注明)。真实实现短得惊人:

js 复制代码
// packages/react-reconciler/src/ReactFiberMutationTracking.js:45
export function trackHostMutation(): void {
  // This is extremely hot function that must be inlined. Don't add more stuff.
  if (enableViewTransition) {
    viewTransitionMutationContext = true;
  } else if (enableDefaultTransitionIndicator) {
    // We only set this if enableViewTransition is not on. Otherwise we track
    // it on the viewTransitionMutationContext and collect it when we pop
    // to avoid more than a single operation in this hot path.
    rootMutationContext = true;
  }
}

它不计数、不累计,只是把两个模块级布尔量之一置真:

  • viewTransitionMutationContext:本次子树遍历中「宿主树被动过」的位标记 。由 pushMutationContext/popMutationContext(27~43)在进入/离开子树时压栈恢复,popMutationContext 在弹出时如果发现子上下文为真,就把 rootMutationContext 置真------逐层向上汇总。
  • rootMutationContext:整个 commit 期间宿主树是否被动过,供 View Transition / 默认 transition 指示器决策「要不要启动动画」。如果一次 transition 没有产生任何宿主变更(纯 state 变化),就没有必要启动指示器。

所以 setProp 里 style 提前 return 不调 trackHostMutation(6.7.5 观察到的「精确计数」现象)也不是为了计数精确------而是 setValueForStyles 自己 在真正发生样式写入时调了 trackHostMutation(CSSPropertyOperations.js:148,155),无需在外层重复标记。注释 46 "This is extremely hot function that must be inlined. Don't add more stuff." 说明了一切:这是一个每改一个属性都要调一次的热函数,设计目标就是「一个布尔赋值」,任何语义丰富化(计数、栈收集)都被明确拒绝。

④ 自定义元素的对照:setPropOnCustomElement(1002)
js 复制代码
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:1094
    // Fall through
    default: {
      if (registrationNameDependencies.hasOwnProperty(key)) {
        if (__DEV__ && value != null && typeof value !== 'function') {
          warnForInvalidEventListener(key, value);
        }
        return;
      } else {
        setValueForPropertyOnCustomComponent(domElement, key, value);
        // We track mutations inside this call.
        return;
      }
    }

与普通元素的 setProp 对照,差异有三:

  1. style/children/dangerouslySetInnerHTML/onClick/onScroll 有与 setProp 类似的早退 (1011~1082),其余一切属性都落到 setValueForPropertyOnCustomComponent------它内部会**先尝试属性(property)后尝试特性(attribute)**地写入,以兼容 Web Components 的双重语义。
  2. 走 React 事件注册表的 onXxx 直接 return (1095~1099)------自定义元素同样享受根容器事件委托;只有不在注册表里的 onXxx 才会被当作普通特性写上去。
  3. trackHostMutation 在 setValueForPropertyOnCustomComponent 内部调用 (注释 1102),外层不再标记------与 style 分支同一模式。

isCustomElement 的判定(shared/isCustomElement.js:10)只看「标签名含连字符」并排除七个保留的 SVG/MathML 标签(annotation-xml、font-face 等)------这也是 setInitialProperties(1111)的 default 分支(1463)和 updateProperties(1961)识别自定义元素的同一函数。

flowchart TD A[setProp key] --> B{key 分组} B -- children --> C[string/number → setTextContent<br/>其他 return] B -- style --> D[setValueForStyles 对象级 diff<br/>return 不标记] B -- onXxx --> E[特殊监听或 trap<br/>return 不写 DOM] B -- URL 类 src/href/action/xlinkHref --> F[&#34;sanitizeURL 归一化后<br/>setAttribute/setAttributeNS&#34;] B -- Boolean / Booleanish / 数字类 --> G[类型门槛判定后<br/>setAttribute 或 removeAttribute] B -- noop 名单 / autoFocus --> H[什么都不做] B -- default --> I{&#34;key 像 onXxx?&#34;} I -- 是 --> E I -- 否 --> J[getAttributeAlias + setValueForAttribute] J --> K[trackHostMutation<br/>仅置位布尔标记] C --> K F --> K G --> K

6.4.17 refs 的完整生命周期:三种形态、refCleanup 字段与 StrictMode 双调用

6.4.8 讲了 attach/detach 的时机和 cleanup 机制。本节补齐剩下三块:三种 ref 形态在源码里的完整分叉、refCleanup 为什么要在 alternate 上也置 null、以及 StrictMode 下双 attach/detach 的真实实现路径。

① commitAttachRef 的完整分叉(757)
js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:757
function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostHoistable:
      case HostSingleton:
      case HostComponent:
        instanceToUse = getPublicInstance(finishedWork.stateNode);
        break;
      case ViewTransitionComponent: {
        if (enableViewTransition) {
          const instance: ViewTransitionState = finishedWork.stateNode;
          const props: ViewTransitionProps = finishedWork.memoizedProps;
          const name = getViewTransitionName(props, instance);
          if (instance.ref === null || instance.ref.name !== name) {
            instance.ref = createViewTransitionInstance(name);
          }
          instanceToUse = instance.ref;
          break;
        }
        instanceToUse = finishedWork.stateNode;
        break;
      }
      case Fragment:
        if (enableFragmentRefs) {
          const instance: null | FragmentInstanceType = finishedWork.stateNode;
          if (instance === null) {
            finishedWork.stateNode = createFragmentInstance(finishedWork);
          }
          instanceToUse = finishedWork.stateNode;
          break;
        }
      // Fallthrough
      default:
        instanceToUse = finishedWork.stateNode;
    }
    if (typeof ref === 'function') {
      if (shouldProfile(finishedWork)) {
        try {
          startEffectTimer();
          finishedWork.refCleanup = ref(instanceToUse);
        } finally {
          recordEffectDuration(finishedWork);
        }
      } else {
        finishedWork.refCleanup = ref(instanceToUse);
      }
    } else {
      if (__DEV__) {
        // TODO: We should move these warnings to happen during the render
        // phase (markRef).
        if (typeof ref === 'string') {
          console.error('String refs are no longer supported.');
        } else if (!ref.hasOwnProperty('current')) {
          console.error(
            'Unexpected ref object provided for %s. ' +
              'Use either a ref-setter function or React.createRef().',
            getComponentNameFromFiber(finishedWork),
          );
        }
      }

      // $FlowFixMe[incompatible-use] unable to narrow type to the non-function case
      ref.current = instanceToUse;
    }
  }
}

「实例该用哪个」的分派比多数人以为的复杂 :HostComponent 走 getPublicInstance(stateNode)(对普通 DOM 元素是恒等的,ReactFiberConfigDOM.js:420~422,但留给 iframe/video 等特例包装空间);ViewTransitionComponent 会按需创建 ViewTransitionInstance (getViewTransitionName + createViewTransitionInstance),name 变化时还会重建;Fragment 在 enableFragmentRefs 下会惰性创建 fragment 实例并挂到 stateNode 上------这也意味着 <Fragment ref> 的实例不是 DOM 节点,而是一个 React 内部簿记对象。

三种 ref 形态的落点:

形态 attach 时 detach 时
对象 ref({current: ...}) ref.current = instanceToUse(821) ref.current = null(905)
函数 ref 无 cleanup ref(instanceToUse),refCleanup 被赋成 undefined ref(null)(888)
函数 ref 有 cleanup refCleanup = ref(instanceToUse)(803) 调用 refCleanup() 后置 null(858→874)

注意第二行:refCleanup 字段总是会被写入 (无 cleanup 时写入 undefined)。safelyDetachRef 靠 typeof refCleanup === 'function' 区分新旧式函数 ref,没有 cleanup 的回退到 ref(null) 旧协议。

② refCleanup 的双向置空(874~878)
js 复制代码
// packages/react-reconciler/src/ReactFiberCommitEffects.js:870
      } catch (error) {
        captureCommitPhaseError(current, nearestMountedAncestor, error);
      } finally {
        // `refCleanup` has been called. Nullify all references to it to prevent double invocation.
        current.refCleanup = null;
        const finishedWork = current.alternate;
        if (finishedWork != null) {
          finishedWork.refCleanup = null;
        }
      }

这段 finally 是 React 19 ref cleanup 防重入的核心,也是最少被注意到的一行半:detach 之后不仅清 current.refCleanup,还要清 current.alternate.refCleanup 。原因是双缓冲:current 和 alternate 是同一个组件的两个 fiber,refCleanup 在 attach 时只写在其中一个上(attach 用 finishedWork),但树切换后「current」换了边------如果只清一边,下一轮 commit 在另一边读到 stale cleanup 就会二次调用。把两边都置 null 才能保证「detach 一次、cleanup 一次」。

同理,safelyDetachRef 的调用方也严格区分了 fiber:

  • 更新场景:safelyDetachRef(current, current.return)------传的是 alternate(旧 fiber) (ReactFiberCommitWork.js:2135);
  • 删除场景:safilyDetachRef(deletedFiber, nearestMountedAncestor)------传的是被删 fiber 自己(1571、1732)。
③ 为什么 detach 在 mutation、attach 在 layout

把两条时间线合起来看才显出设计的严整:

sequenceDiagram participant M as Mutation 阶段 participant L as Layout 阶段 participant U as 用户代码 Note over M: root.current 仍是旧树 M->>M: 所有旧 ref 的 detach(含 cleanup 调用) Note over M: 此刻 ref.current 全部为 null M->>M: DOM 增删改 Note over L: root.current 已切为新树(4051) L->>L: 所有新 ref 的 attach Note over L: ref.current 指向新实例 L->>U: useLayoutEffect / cDM 可以安全读 ref
  • detach 必须早于 update/insertion (mutation 相位内,先于同节点的属性更新):如果先改 DOM 再拆旧 ref,用户注册在 useLayoutEffect 之外的代码(比如第三方的 MutationObserver 或微任务)可能在新 DOM 上读到「仍指向旧实例的 ref」。
  • attach 必须晚于全部 DOM 变更 (layout 相位):如果插入过程中就 attach,一个父组件的 componentDidMount 通过 ref 访问子 DOM 时,子树可能还没插完。「先全部建好,再全部挂 ref」保证了任何 ref 回调触发的时刻,DOM 形状已最终确定。
  • 「全部 detach 完才开始 attach」还消除了兄弟组件的相互覆盖 :这与 commitHookLayoutUnmountEffects 的注释(ReactFiberCommitEffects.js:119~123:"a destroy function in one component should never override a ref set by a create function in another component during the same commit")是同一原则------副作用按相位批处理,不在组件间交错。
④ StrictMode 的双 attach/detach(DEV only)
js 复制代码
// packages/react-reconciler/src/ReactFiberWorkLoop.js:5327
// Unconditionally disconnects and connects passive and layout effects.
function doubleInvokeEffectsOnFiber(root: FiberRoot, fiber: Fiber) {
  setIsStrictModeForDevtools(true);
  try {
    disappearLayoutEffectsForDEVValidation(fiber);
    disconnectPassiveEffect(fiber);
    reappearLayoutEffectsForDEVValidation(root, fiber.alternate, fiber);
    reconnectPassiveEffects(root, fiber, NoLanes, null, false, 0);
  } finally {
    setIsStrictModeForDevtools(false);
  }
}

doubleInvokeEffectsOnFiber(5328)用** disappear/reappear 一对函数**(而非删除路径)在 DEV 下模拟一次完整卸载-重挂:disappearLayoutEffectsForDEVValidation → detach ref + 跑 layout cleanup(6.4.19 详解 disappear);reappearLayoutEffectsForDEVValidation → 重新 attach ref + 跑 create;passive 侧则是 disconnectPassiveEffect / reconnectPassiveEffects。

触发条件在 doubleInvokeEffectsInDEVIfNecessary(5340~5388):

  • 只有 fiber.flags & PlacementDEV(5351,即本次新插入 的 fiber)且处于 StrictMode 子树(isInStrictMode,由 REACT_STRICT_MODE_TYPE 向上累积,5345~5346)才双调用------所以只有新挂载的组件被双跑,更新不双跑。
  • OffscreenComponent 特殊处理(5365~5387):可见的 Offscreen 且在 StrictMode 下带 Visibility | PlacementDEV → 对它的子树 双调用;否则仅当子树含 PlacementDEV 才继续向下找新插入节点。
  • commitDoubleInvokeEffectsInDEV(5390)的入口守卫:root.tag !== LegacyRoot 或非 StrictLegacyMode/StrictEffectsMode 时 doubleInvokeEffects = false(5398~5403)------legacy root 默认不双调用,除非显式开启 strict 模式。

调用时机有两处:同步 commit 无 passive effect 时(flushSpawnedWork 4244)、passive effect flush 完后(flushPassiveEffectsImpl 4802)。这就是「StrictMode 下 useEffect/useLayoutEffect/ref 在挂载时跑两次」的全部机制------它复用的正是 Offscreen 的 disappear/reappear traversal,而不是另造一套模拟卸载代码。

实践含义 :ref cleanup 与 effect cleanup 必须幂等。正常流程里 refCleanup 调完即置 null(含 alternate,防重入),但 StrictMode 是故意 跑两轮:第一轮 cleanup 之后第二轮会重新 attach 并拿到新的 cleanup 函数。如果你的 cleanup 做了非幂等操作(往全局数组 push、累加计数器、add 一个没 remove 的 listener),DEV 下立刻暴露。


6.4.18 finalizeInitialChildren 与 commitMount:autoFocus 与 img 的 src 重设

初始挂载有一类副作用不能 在 setInitialProperties 里做:它们需要 DOM 节点已经进入文档 (autoFocus)或需要「补一次交互」(img load)。React 的解法分两跳:render 末的 finalizeInitialChildren 决定「这个节点要不要在 layout 阶段被特殊照顾」,commit 时 commitHostMount 落实。

① finalizeInitialChildren:一个布尔值的契约(703)
js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:703
export function finalizeInitialChildren(
  domElement: Instance,
  type: string,
  props: Props,
  hostContext: HostContext,
): boolean {
  setInitialProperties(domElement, type, props);
  switch (type) {
    case 'button':
    case 'input':
    case 'select':
    case 'textarea':
      return !!props.autoFocus;
    case 'img':
      return true;
    default:
      return false;
  }
}

它先完成属性初始化,然后返回「是否需要 commitMount」:表单控件看 props.autoFocus;img 永远返回 true ;其余 false。返回值经 completeWork 传递,最终表现为 HostComponent 挂载时带上 Update flag------commitLayoutEffectOnFiber 的 HostComponent 分支靠这个组合找到目标:

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:689
      // Renderers may schedule work to be done after host components are mounted
      // (eg DOM renderer may schedule auto-focus for inputs and form controls).
      // These effects should only be committed when components are first mounted,
      // aka when there is no current/alternate.
      if (current === null) {
        if (flags & Update) {
          commitHostMount(finishedWork);
        } else if (flags & Hydrate) {
          commitHostHydratedInstance(finishedWork);
        }
      }

693 的 current === null 是「首次挂载」的判据(没有 alternate 就是新 fiber),flags & Update 恰好就是 finalizeInitialChildren 返回 true 的编码。注意这是在 layout 相位 ------节点已在 mutation 阶段插入文档,此时 focus 不会打断其他节点的插入,也不会让 focus 事件早于 React 树就绪。

② commitMount 的两类处理(891)
js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:891
export function commitMount(
  domElement: Instance,
  type: string,
  newProps: Props,
  internalInstanceHandle: Object,
): void {
  // Despite the naming that might imply otherwise, this method only
  // fires if there is an `Update` effect scheduled during mounting.
  // This happens if `finalizeInitialChildren` returns `true` (which it
  // does to implement the `autoFocus` attribute on the client). But
  // there are also other cases when this might happen (such as patching
  // up text content during hydration mismatch). So we'll check this again.
  switch (type) {
    case 'button':
    case 'input':
    case 'select':
    case 'textarea':
      if (newProps.autoFocus) {
        (
          domElement as any as
            | HTMLButtonElement
            | HTMLInputElement
            | HTMLSelectElement
            | HTMLTextAreaElement
        ).focus();
      }
      return;
    case 'img': {
      // The technique here is to assign the src or srcSet property to cause the browser
      // to issue a new load event. If it hasn't loaded yet it'll fire whenever the load actually completes.
      // If it has already loaded we missed it so the second load will still be the first one that executes
      // any associated onLoad props.
      // Even if we have srcSet we prefer to reassign src. The reason is that Firefox does not trigger a new
      // load event when only srcSet is assigned. Chrome will trigger a load event if either is assigned so we
      // only need to assign one. And Safari just never triggers a new load event which means this technique
      // is already a noop regardless of which properties are assigned. We should revisit if browsers update
      // this heuristic in the future.
      if (newProps.src) {
        const src = (newProps as any).src;
        if (enableSrcObject && typeof src === 'object') {
          // ...
          try {
            setSrcObject(domElement, type, src);
            return;
          } catch (x) {
            // If URL.createObjectURL() errors, it was probably some other object type
            // that should be toString:ed instead, so we just fall-through to the normal
            // path.
          }
        }
        (domElement as any as HTMLImageElement).src = src;
      } else if (newProps.srcSet) {
        (domElement as any as HTMLImageElement).srcset = (
          newProps as any
        ).srcSet;
      }
      return;
    }
  }
}

autoFocus 分支 回答「为什么不直接在 setProp 里 focus」:setProp 的 autoFocus 分支(697~703)是刻意 noop,注释解释了动机------「We polyfill it separately on the client during commit. We could have excluded it in the property list instead... but then it wouldn't be emitted on server rendering (but we do want to emit it in SSR)」。autoFocus 属性要照常输出到 SSR HTML(服务端没有 JS 可 focus),但客户端 focus 是个有副作用的动作,必须推迟到 layout 相位同步执行。另一个隐藏收益:在 mutation 相位结束时才 focus,页面此时已最终成形,focus 引起的滚动(scroll-into-view)落在最终布局上,不会出现「聚焦 → 又插入内容 → 滚动位置错乱」。

img 分支 解释「为什么要重设一次 src」------这是 React 为了一个经典的竞态打的补丁:<img onLoad> 的时序。看 setInitialProperties 的 img 分支(1138~1180)就明白问题在哪:

js 复制代码
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:1134
    // img tags previously were implemented as void elements with non delegated events however Safari (and possibly Firefox)
    // begin fetching the image as soon as the `src` or `srcSet` property is set and if we set these before other properties
    // that can modify the request (such as crossorigin) or the resource fetch (such as sizes) then the browser will load
    // the wrong thing or load more than one thing. This implementation ensures src and srcSet are set on the instance last
    case 'img': {
      listenToNonDelegatedEvent('error', domElement);
      listenToNonDelegatedEvent('load', domElement);
      // Mostly a port of Void Element logic with special casing to ensure srcset and src are set last
      let hasSrc = false;
      let hasSrcSet = false;
      for (const propKey in props) {
        // ... 收集其余属性,跳过 src/srcSet ...
      }
      if (hasSrcSet) {
        setProp(domElement, tag, 'srcSet', props.srcSet, props, null);
      }
      if (hasSrc) {
        setProp(domElement, tag, 'src', props.src, props, null);
      }
      return;
    }
  • 顺序问题 :浏览器「一旦 src/srcSet 被赋值就立刻开始请求图片」。如果先写 src 再写 crossorigin/sizes,浏览器可能用错误的请求配置发车,甚至发两次。所以 setInitialProperties 用 hasSrc/hasSrcSet 两个标记把 src/srcSet 留到最后写(1134~1137 的注释即此)。
  • load 事件丢失问题 :listenToNonDelegatedEvent('load', ...)(1139~1140)把 load 挂在节点上,但图片可能在「属性设完 → React 委托系统就绪」之间、甚至在整个 commit 期间就已经加载完成------load 事件已经悄悄发过了,没人收到 。commitMount 的重设 src 就是故意触发第二次 加载:若第一次还没完成,第二次赋值会让 load 在未来某刻正常触发;若第一次已完成(事件被错过),第二次就是 React 能捕获到的「第一次」load,onLoad props 因此总能跑一次。
  • 浏览器差异 (注释 923927):Firefox 对「只重设 srcSet」不触发新 load,所以优先重设 src;Chrome 两者任一都能触发;Safari 永远不触发新 load,这个技巧在 Safari 上是 noop。enableSrcObject 下 src 是 Blob/MediaStream 对象时走 setSrcObject 重新 createObjectURL(930944)------因为旧的 blob URL 可能已被 revoke。
  • ** hydrate 路径的孪生函数**:finalizeHydratedChildren(723~742)对 input/select/textarea/img 返回 true,对应 layout 阶段的 commitHostHydratedInstance(ReactFiberCommitWork.js:696),做的是 hydrate 后的同类修补(如 textarea 文本回填、img load 补放)。两套函数结构对称,是「挂载期特殊处理」的完整拼图。

6.4.19 Offscreen / Activity 在 commit 中的 disappear / reappear

6.6.2 从使用视角描述了 Offscreen 隐藏「不卸载」的现象。本节把 commit 内部为实现这个语义而写的两棵 traversal------disappearLayoutEffects(3101)与 reappearLayoutEffects(3304)------以及 mutation 相位的触发器、layout 相位的入口、hideOrUnhideAllChildren 的宿主隐藏算法,全部按源码走一遍。

① mutation 相位:OffscreenComponent 分支(2567~2707)
js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:2567
    case OffscreenComponent: {
      const newState: OffscreenState | null = finishedWork.memoizedState;
      const isHidden = newState !== null;
      const wasHidden = current !== null && current.memoizedState !== null;

      if (disableLegacyMode || finishedWork.mode & ConcurrentMode) {
        // Before committing the children, track on the stack whether this
        // offscreen subtree was already hidden, so that we don't unmount the
        // effects again.
        const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
        const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
        const prevOffscreenDirectParentIsHidden = offscreenDirectParentIsHidden;
        offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden || isHidden;
        offscreenDirectParentIsHidden =
          prevOffscreenDirectParentIsHidden || isHidden;
        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
        offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
        offscreenDirectParentIsHidden = prevOffscreenDirectParentIsHidden;
        offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
        // ...
      } else {
        recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      }

      commitReconciliationEffects(finishedWork, lanes);

      if (flags & Visibility) {
        const offscreenInstance: OffscreenInstance = finishedWork.stateNode;

        // Track the current state on the Offscreen instance so we can
        // read it during an event
        if (isHidden) {
          offscreenInstance._visibility &= ~OffscreenVisible;
        } else {
          offscreenInstance._visibility |= OffscreenVisible;
        }

        const isUpdate = current !== null;
        if (isHidden) {
          // Only trigger disappear layout effects if:
          //   - This is an update, not first mount.
          //   - This Offscreen was not hidden before.
          //   - Ancestor Offscreen was not hidden in previous commit or in this commit
          if (
            isUpdate &&
            !wasHidden &&
            !offscreenSubtreeIsHidden &&
            !offscreenSubtreeWasHidden
          ) {
            // ...
            const newOffscreenSubtreeIsHidden =
              isHidden || offscreenSubtreeIsHidden;
            const newOffscreenSubtreeWasHidden =
              wasHidden || offscreenSubtreeWasHidden;
            const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
            const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
            offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
            offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;
            recursivelyTraverseDisappearLayoutEffects(
              finishedWork,
              layoutEffectTraversalFlags,
            );
            // ...
            offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
            offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
          }
        }

        // $FlowFixMe[constant-condition]
        if (supportsMutation) {
          // If it's trying to unhide but the parent is still hidden, then we should not unhide.
          if (isHidden || !offscreenDirectParentIsHidden) {
            hideOrUnhideAllChildren(finishedWork, isHidden);
          }
        }
      }
      // ...
      break;
    }

四个动作按顺序:

  1. 维护三个栈变量 (2576~2586):进入子树前把 IsHidden/WasHidden/DirectParentIsHidden 累加上去,退出时恢复------这就是 6.4.14 ③ 说的守卫来源。
  2. _visibility 位记录 (2619~2623):把可见性写进 offscreenInstance,注释 "so we can read it during an event"------事件触发时 React 要能判断「这次点击发生在隐藏子树里吗」(决定要不要 preventDefault/冒泡)。
  3. disappear 触发条件 (26312636,注释逐条列出):isUpdate(首次挂载不 disappear)、!wasHidden(之前没隐藏过)、!offscreenSubtreeIsHidden/!offscreenSubtreeWasHidden(祖先没隐藏)------只有「显隐切换的根节点」才跑 disappear ,嵌套的已隐藏子树不重复跑(disappearLayoutEffects 自己的 OffscreenComponent 分支 32103222 也再挡一次)。
  4. 宿主树显隐 (2686~2691):hideOrUnhideAllChildren(finishedWork, isHidden),带一个 isHidden || !offscreenDirectParentIsHidden 的守卫------父还藏着时不许儿子显示。
② hideOrUnhideAllChildren:只动「最近的宿主节点」(1202~1306)
js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:1202
function hideOrUnhideAllChildren(parentFiber: Fiber, isHidden: boolean) {
  // $FlowFixMe[constant-condition]
  if (!supportsMutation) {
    return;
  }
  // Finds the nearest host component children and updates their visibility
  // to either hidden or visible.
  let child = parentFiber.child;
  while (child !== null) {
    hideOrUnhideAllChildrenOnFiber(child, isHidden);
    child = child.sibling;
  }
}

hideOrUnhideAllChildrenOnFiber(1216)的 tag 分派:

  • HostComponent/HostHoistable:commitShowHideHostInstance(fiber, isHidden),然后必须继续 hideOrUnhideNearestPortals(1229~1236 注释解释:Portal 不在常规宿主树层级里,父宿主被隐藏掩盖不到它)。
  • HostText:commitShowHideHostTextInstance------DOM 实现是把 nodeValue 清空/恢复(ReactFiberConfigDOM.js:1419~1421, 1447~1452)。
  • DehydratedFragment:commitShowHideSuspenseBoundary(SSR 边界的显隐)。
  • OffscreenComponent/LegacyHiddenComponent:memoizedState !== null(已隐藏)→ 停止下钻,树要保持隐藏;否则继续递归。
  • default:继续下钻。

而「隐藏」的 DOM 实现只有一行(ReactFiberConfigDOM.js:1406):

js 复制代码
export function hideInstance(instance: Instance): void {
  // TODO: Does this work for all element types? What about MathML? Should we
  // pass host context to this method?
  instance = instance as any as HTMLElement;
  const style = instance.style;
  // $FlowFixMe[method-unbinding]
  if (typeof style.setProperty === 'function') {
    style.setProperty('display', 'none', 'important');
  } else {
    style.display = 'none';
  }
}

display: none !important------注意 !important:要压过业务自己写的内联/类样式,否则隐藏可能被 CSS 特异性打败。恢复时 unhideInstance(1429)则读 props.style.display 决定还原成什么(用户没写 display 就还原成空串)。DOM 节点从头到尾没有离开文档,iframe、video、输入框焦点、滚动位置全部保留------这正是「隐藏 ≠ 卸载」的物理基础。

③ disappearLayoutEffects 的逐 tag 遍历(3101~3287)
js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:3101
function disappearLayoutEffects(
  finishedWork: Fiber,
  layoutEffectTraversalFlags: LayoutEffectTraversalFlags,
) {
  // ...
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      // TODO (Offscreen) Check: flags & LayoutStatic
      commitHookLayoutUnmountEffects(
        finishedWork,
        finishedWork.return,
        HookLayout,
      );
      recursivelyTraverseDisappearLayoutEffects(
        finishedWork,
        layoutEffectTraversalFlags,
      );
      break;
    }
    case ClassComponent: {
      // TODO (Offscreen) Check: flags & RefStatic
      safelyDetachRef(finishedWork, finishedWork.return);

      const instance = finishedWork.stateNode;
      if (typeof instance.componentWillUnmount === 'function') {
        safelyCallComponentWillUnmount(
          finishedWork,
          finishedWork.return,
          instance,
        );
      }

      recursivelyTraverseDisappearLayoutEffects(
        finishedWork,
        layoutEffectTraversalFlags,
      );
      break;
    }
    // ... HostSingleton / HostComponent / HostText / HostHoistable ...
    case OffscreenComponent: {
      const isHidden = finishedWork.memoizedState !== null;
      if (isHidden) {
        // Nested Offscreen tree is already hidden. Don't disappear
        // its effects.
      } else {
        recursivelyTraverseDisappearLayoutEffects(
          finishedWork,
          layoutEffectTraversalFlags,
        );
      }
      break;
    }
    // ... ViewTransitionComponent / Fragment / default:递归即可 ...
  }
  // ...
}

与 6.4.14 的删除遍历对照,三个关键差异:

维度 commitDeletionEffectsOnFiber disappearLayoutEffects
宿主节点 删(一次 removeChild) 不动(只 display:none)
ref detach(受 offscreenSubtreeWasHidden 守卫) detach(不受守卫,隐藏本来就要拆)
类卸载 componentWillUnmount(受守卫) componentWillUnmount(照跑)
函数组件 layout cleanup commitHookLayoutUnmountEffects(HookLayout)(1717,受守卫) 同左(3115,照跑;两处 flags 都只用 HookLayout,HookHasEffect 只出现在挂载侧)
passive cleanup 推迟到 passive 相位(6.4.14 ④) 不跑(useEffect 在隐藏期间保持挂载)
Hoistable 资源 releaseResource / unmountHoistable 资源不动(注释 3190~3193:资源跨显隐存续,只在真删除时释放)

layoutEffectTraversalFlags 参数只有两个位:IncludeHostSingletons(2644~2648)和 IncludeWorkInProgressEffects------前者让 traversal 把 <head> 等 singleton 也纳入 disappear/reappear(它们要被重新 acquire),后者标记「这次也包含新完成工作的副作用」。

④ layout 相位:OffscreenComponent 的 reappear 入口(782~855)
js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:782
    case OffscreenComponent: {
      const isModernRoot =
        disableLegacyMode || (finishedWork.mode & ConcurrentMode) !== NoMode;
      if (isModernRoot) {
        const isHidden = finishedWork.memoizedState !== null;
        const newOffscreenSubtreeIsHidden =
          isHidden || offscreenSubtreeIsHidden;
        if (newOffscreenSubtreeIsHidden) {
          // The Offscreen tree is hidden. Skip over its layout effects.
        } else {
          // The Offscreen tree is visible.

          const wasHidden = current !== null && current.memoizedState !== null;
          const newOffscreenSubtreeWasHidden =
            wasHidden || offscreenSubtreeWasHidden;
          const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
          const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
          offscreenSubtreeIsHidden = newOffscreenSubtreeIsHidden;
          offscreenSubtreeWasHidden = newOffscreenSubtreeWasHidden;

          if (offscreenSubtreeWasHidden && !prevOffscreenSubtreeWasHidden) {
            // This is the root of a reappearing boundary. As we continue
            // traversing the layout effects, we must also re-mount layout
            // effects that were unmounted when the Offscreen subtree was
            // hidden. So this is a superset of the normal commitLayoutEffects.
            let layoutEffectTraversalFlags: LayoutEffectTraversalFlags;
            // $FlowFixMe[constant-condition]
            if (supportsSingletons) {
              layoutEffectTraversalFlags = IncludeHostSingletons;
            } else {
              layoutEffectTraversalFlags = NoLayoutEffectTraversalFlags;
            }
            if ((finishedWork.subtreeFlags & LayoutMask) !== NoFlags) {
              layoutEffectTraversalFlags |= IncludeWorkInProgressEffects;
            }
            recursivelyTraverseReappearLayoutEffects(
              finishedRoot,
              finishedWork,
              layoutEffectTraversalFlags,
            );
            // ...
          } else {
            recursivelyTraverseLayoutEffects(
              finishedRoot,
              finishedWork,
              committedLanes,
            );
          }
          offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
          offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
        }
      } else {

判据 offscreenSubtreeWasHidden && !prevOffscreenSubtreeWasHidden(802)就是「本节点是 reappear 的根 」的精确表达:进来之前整条路没隐藏(prev...WasHidden === false),进来之后隐藏了(当前 offscreenSubtreeWasHidden === true),说明切换发生在本节点。此时换用 recursivelyTraverseReappearLayoutEffects(3304)这套「超集」遍历;否则走普通 layout 遍历。隐藏中的 Offscreen 整棵跳过 layout effects (789~790)------隐藏树里的 useLayoutEffect 连 create 都不会跑。

⑤ reappearLayoutEffects 与 disappear 的不对称(3304~3598)

reappear 是 disappear 的逆操作,但有几处刻意的不对称,值得逐一标注:

js 复制代码
// packages/react-reconciler/src/ReactFiberCommitWork.js:3322
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      recursivelyTraverseReappearLayoutEffects(
        finishedRoot,
        finishedWork,
        layoutEffectTraversalFlags,
      );
      // TODO: Check flags & LayoutStatic
      commitHookLayoutEffects(finishedWork, HookLayout);
      break;
    }
    case ClassComponent: {
      recursivelyTraverseReappearLayoutEffects(
        finishedRoot,
        finishedWork,
        layoutEffectTraversalFlags,
      );

      commitClassDidMount(finishedWork);

      commitClassHiddenCallbacks(finishedWork);

      // If this is newly finished work, check for setState callbacks
      if (includeWorkInProgressEffects && flags & Callback) {
        commitClassCallbacks(finishedWork);
      }

      // TODO: Check flags & Ref
      safelyAttachRef(finishedWork, finishedWork.return);
      break;
    }
  • 函数组件 :commitHookLayoutEffects(finishedWork, HookLayout)------注意这里没有 HookHasEffect(对比 layout 相位正常路径 623 行的 HookLayout | HookHasEffect)。因为 reappear 遍历同时访问复用的旧节点 (这些节点的 HookHasEffect 未必置位),只用 HookLayout 才能把「隐藏期间被 disappear 掉的所有 layout effect」一个不落地重新挂上。
  • 类组件 :commitClassDidMount(finishedWork)(3342)------类组件重现时会再跑一次 componentDidMount 。这符合语义:reappear 时实例的状态应当「像刚挂载一样」重新参与生命周期;配合 commitClassHiddenCallbacks(3344)把隐藏期间被 deferHiddenCallbacks(ReactFiberCommitWork.js:2143,mutation 相位 ClassComponent 分支把隐藏期间的 setState 回调暂存)攒下的回调补发。
  • HostComponent (33813406):commitHostMount 仅在 includeWorkInProgressEffects && current === null && flags & Update 时跑(3399 3401)------autoFocus 只在真正的新挂载 时触发,重现不抢焦点。然后 safelyAttachRef。
  • OffscreenComponent(3526~3542):仍隐藏 → 跳过;可见 → 继续 reappear 递归,并 attach 自身 ref。
  • 注释 3355~3360 解释了一个结构差异:"Unlike commitLayoutEffectsOnFiber, we don't need to handle HostRoot because this function only visits nodes that are inside an Offscreen fiber."------reappear 永远不会从 root 进来,所以不需要 HostRoot 分支。
⑥ 全流程图
stateDiagram-v2 [*] --> 可见: 挂载 可见 --> 隐藏: Visibility flag + mutation 相位 隐藏 --> 可见: Visibility flag + mutation 相位 可见 --> 删除: ChildDeletion 隐藏 --> 删除: ChildDeletion(offscreenSubtreeWasHidden=true) note right of 可见 layout effects 挂载 ref attach useLayoutEffect/cDM 正常跑 end note note right of 隐藏 mutation: disappearLayoutEffects (ref detach + layout cleanup + cWU) mutation: hideInstance display:none passive effects 保持挂载 end note note left of 删除 删除: offscreenSubtreeWasHidden=true 跳过 ref detach/cWU/layout cleanup (已随 disappear 卸载过) passive 相位: detachFiberAfterEffects end note

一句话总结这套设计 :Activity/Offscreen 把「卸载」拆成了两个可独立使用的原子------disappear(卸副作用、留 DOM)和 reappear(挂回副作用),删除路径则通过 offscreenSubtreeWasHidden 守卫复用了 disappear 的成果。由此也可以精确回答「隐藏再显示会跑什么」:函数组件的 useLayoutEffect 以 HookLayout 重新挂载、类组件的 componentDidMount 会 再跑一次(3342)、被暂存的 setState 回调补发(3344)、ref 重新 attach,而 commitHostMount 的 autoFocus 只在真正的新挂载时触发(3399)。


6.5 与其他模块的交互(衔接第 3/4/10 章 + HostConfig 解耦)

6.5.1 与第 3 章(Flags)的衔接

第 3 章我们讲 completeWork/render 阶段如何给 Fiber 打 Placement/Update/ChildDeletion/Ref/Snapshot/Callback 等 Flag,并把它们冒泡到 subtreeFlags。第 3 章是「写」,本章是「读」:

  • Placement → 本章 6.4.5 commitPlacement。
  • Update → 6.4.4 commitHostUpdate(属性)与 6.4.7 的 useLayoutEffect/componentDidUpdate(布局副作用)。
  • ChildDeletion → 6.4.6 commitDeletionEffects(由 parentFiber.deletions 数组驱动,而非 Flag 本身)。
  • Ref → 6.4.8 的 detach(mutation)/ attach(layout)。
  • Snapshot → 6.4.3 commitClassSnapshot。
  • Callback → 6.4.7 commitClassCallbacks。

可以这样记:render 阶段是把「意图」编码成 Flag,commit 阶段是把「Flag 解码成动作」 。subtreeFlags 上的位让 commit 能短路整棵无关子树(见 6.3.1)。

6.5.2 与第 4 章(markUpdate / 属性 diff 准备)的衔接

第 4 章讲 render 阶段如何识别属性变化(旧版 React 会在这里生成 updatePayload 并存入 Fiber)。在 React 19:

  • updatePayload 已被移除 (见 6.4.4 的「设计取舍」)。属性 diff 不再在 render 期预计算,而是推迟到 commit 期由 commitUpdate → updateProperties 内联 diff lastProps/nextProps。
  • 因此第 4 章里 markUpdate 这类「提前标记属性更新」的钩子,在 React 19 里被显著弱化------真正决定「调不调 commitUpdate」的,是 render 阶段打的 Update Flag(只要 props 引用变了或 fiber 需要更新,Update 位就会被置上,见第 3 章)。
  • 这带来一个行为变化:在旧版,render 期就算出 payload,commit 期无脑应用;现在 commit 期要重新遍历 props 对比。好处是 render 期更轻、payload 不再穿越两个大阶段占用内存;代价是 commit 期多了一次 props 遍历(对绝大多数组件可忽略,但超大属性对象要留意,见 6.7)。

6.5.3 与第 10 章(事件委托)的衔接:fiber↔DOM 双向索引

第 10 章会讲:React 把所有事件委托在根容器上,事件触发时从 event.target 出发,用 getInstanceFromNode 找到对应的 DOM 节点,再找到 Fiber,然后沿 Fiber 树向上收集所有相关 handler。

本章提供了这件事的「地基」:

  1. createInstance(534)末尾的 precacheFiberNode(internalInstanceHandle, domElement) 建立了 DOM → Fiber 的反向索引(getInstanceFromNode 的根基)。
  2. 同一处 updateFiberProps(domElement, props) 把当前 props 句柄 (含最新事件 handler)挂到 DOM 上(getFiberCurrentPropsFromNode 可读)。
  3. commitUpdate 末尾再次 updateFiberProps (1011)------这就是为什么事件 handler 在更新后仍然「新鲜」:每次属性更新,最新的 onClick 等都被刷新到句柄里,第 10 章委托分发时拿到的永远是最新版。
  4. 事件属性在 setProp 里直接 return (6.4.4 细节一)不写 DOM,正是因为它只活在 updateFiberProps 句柄中,由第 10 章在事件分发时取出。

所以「事件委托能跨 DOM 找 fiber」= precacheFiberNode(DOM→fiber)+ updateFiberProps(fiber→最新props)+ 第 10 章的事件分发器,三者缺一不可。

6.5.4 HostConfig 解耦:renderer 不知道 DOM

再强调一次 6.3.4/6.3.5 的核心结论,并给出一个「解耦证据」清单:

  • react-reconciler 全程只调用 createInstance/appendChild/commitUpdate 等抽象方法,源码里搜不到 document. 或 .appendChild 的 DOM 字样 (DOM 字样只存在于 react-dom-bindings)。
  • supportsMutation = true(889)是 DOM 渲染器向协调器「声明」自己是「变更式(mutation-based)」的------即允许直接改真实 DOM,而非像 React Native(persistence 模式)那样维护影子树。
  • supportsMoveBefore(1026)是「能力探测」:运行时检测 Element.prototype.moveBefore 是否可用,决定插入用 moveBefore 还是 insertBefore。这是渲染器把「平台能力」隔离在自己内部的范例,协调器根本不关心。

这种解耦让同一套 react-reconciler 算法能服务 Web、Native、Canvas、终端等无数平台,是 React 架构可扩展性的根本。


6.6 边界场景与坑

6.6.1 StrictMode 双调用

DEV 下 StrictMode 会故意双调用某些生命周期/effect,以暴露不纯代码:

  • componentDidMount/componentDidUpdate、useLayoutEffect、useEffect 的创建函数会跑两次(第二次前先跑第一次的 cleanup/detach)。
  • 对应的副作用机制:在 commit 阶段,React 对 StrictMode 子树会先模拟一次 unmount(detach ref、跑 cleanup),再重新 mount(attach ref、跑 effect) 。这正是 6.4.8 的 safelyDetachRef/safelyAttachRef 被设计为「可重复、带 guard」的原因------refCleanup 调完立即置 null(6.4.8 末尾),防止 cleanup 被调两次。

坑 :如果你的 useLayoutEffect/ref cleanup 里有「非幂等」操作(如往全局数组 push 却不 pop),StrictMode 下会看到两份。这是 React 在用 StrictMode 逼你写纯副作用,不是 React 的 bug。

6.6.2 Offscreen / Activity 隐藏与 offscreenSubtreeWasHidden

这是并发特性引入的复杂度(6.4.8 已提及)。当一个子树被 Offscreen/Activity 隐藏:

  • 隐藏(hide) :React 走的是「disappear」路径(disappearLayoutEffects 等),不触发 ref detach、不跑 componentWillUnmount ,只把 DOM 隐藏(display/visibility 手段,见 hideInstance)。因为子树随时可能「重现」,detach 会丢状态(焦点、滚动、播放进度)。
  • offscreenSubtreeWasHidden 守卫 :在 commitDeletionEffectsOnFiber(1510/1536/1570)和 commitMutationEffectsOnFiber(2134)里反复出现。它的作用是告诉「当前正在删除/更新的子树,整体是被隐藏的」,从而跳过 detach、跳过 unmount 副作用,避免破坏隐藏语义。
  • 重现(reappear) :走「reappear」路径(reappearLayoutEffects),重新 attach ref、跑 layout effect,但不跑DidMount(因为逻辑上是「从隐藏到显示」,不是「重新挂载」)。

坑 :在 Offscreen 子树里用 useLayoutEffect 做「挂载时初始化」,隐藏再显示时它不会重新执行 (因为不是 unmount/mount,而是 hide/show)。如果需要每次显示都执行,应该用 useEffect 配合依赖,或显式监听可见性状态。

6.6.3 Hydration(SSR)边界

hydrate 路径不走 createInstance/commitPlacement 的常规插入,而是复用服务端已有的 DOM。commitHostHydratedContainer(2400)、commitHydratedInstance(956)等钩子在 mutation 阶段处理 hydrate 后的修补(如 autoFocus、img load 事件重放)。

坑 :hydration mismatch(服务端/客户端渲染不一致)时,React 会丢弃服务端 DOM、走客户端重建 ------这会绕过 commitPlacement 的常规插入,直接在 mutation 阶段用客户端生成的节点替换。因此 hydrate 后的首次 commit,其 DOM 操作路径与纯客户端不同,调试时要先确认是否 hydrate mismatch。

6.6.4 并发更新中断与 commit 的原子性

render 阶段可中断、可丢弃,但一旦进入 commit,整棵树的副作用必须一次性完成 。如果 commit 中途抛错(如某组件 useLayoutEffect 抛异常),React 会捕获(见 captureCommitPhaseError)并尽量继续,但已执行的 DOM 变更不可回滚------这正是 commit 必须「短平快、不依赖外部状态」的原因。

坑 :在 useLayoutEffect 里做耗时同步计算会阻塞 paint(因为 layout 在 paint 前、同步执行)。如果确实需要副作用,优先 useEffect(异步、不阻塞 paint),把 useLayoutEffect 留给「必须在 paint 前读/写 DOM 布局」的场景(如测量元素尺寸并即时调整位置)。

6.6.5 删除中的 ref 与卸载顺序

6.4.6 讲过「唯一一次 removeChild」+「先递归卸载副作用」。坑 :如果 componentWillUnmount 里访问了 this.refs 或子组件的 ref,在 React 19 里要注意------因为 detach 是从外到内 递归的,父的 ref 已经 detach 后,子的 ref 可能还在;但 DOM 尚未删除(removeChild 在所有卸载之后)。所以 componentWillUnmount 中仍能访问到真实 DOM(尚未删),但 ref 引用可能已处于「半 detach」状态。业务代码不应依赖这种脆弱时序。

6.6.6 findDOMNode 已废弃

历史版本里 commitReconciliationEffects 清 Placement 位的注释提到 findDOMNode(见 2857 行注释)。React 19 中 findDOMNode 已彻底移除,因此那段注释是「历史遗迹」。不要用 findDOMNode,一律用 ref。


6.7 性能影响

6.7.1 Placement 的指数级回退(核心瓶颈)

6.4.5 详细讲过 getHostSibling 的「指数级搜索」注释。量化一下:

  • 场景:一个 N 个兄弟节点全部带 Placement (如列表整体重排、key 全变、<For> 重渲染)。
  • 插入第 1 个节点:向后扫描 N-1 个 Placement 兄弟(逐个 continue siblings)。
  • 插入第 2 个:扫描 N-2 个。
  • ...... 总计约 N²/2 次扫描,每次扫描还可能伴随 return 向上回退 + child 向下钻取。
  • 叠加多层嵌套,最坏情况接近「指数级」恶化,正是源码注释用 "exponential" 强调的。

规避:

  1. 给列表项稳定且语义正确的 key 。当 key 稳定时,React 在 render 阶段能复用节点(第 4/5 章),大量 Placement 标不会出现,自然逃过 getHostSibling 的回退。
  2. 避免过度「整体替换」列表------优先做「原地更新」而非「销毁重建」。
  3. 对超大列表,考虑虚拟列表(只渲染可视区),从根上减少需要 Placement 的节点数。

但也要冷静:对绝大多数真实应用,单次 commit 的 Placement 数量很小,这个 O(N²) 在实际中几乎不可见。它是「理论最坏情况」,不是「日常瓶颈」------React 官方也只在注释里留了 TODO,没有为它牺牲别的复杂度。

6.7.2 moveBefore 保留状态 vs insertBefore 重建

insertBefore(1135)与 appendChild(1032)在两个分支里优先用 Element.moveBefore(前提是 supportsMoveBefore 为真,即浏览器实现了该 API):

js 复制代码
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:1135
export function insertBefore(parentInstance, child, beforeChild): void {
  if (supportsMoveBefore && child.parentNode !== null) {
    parentInstance.moveBefore(child, beforeChild);  // 保留 child 内部状态
  } else {
    parentInstance.insertBefore(child, beforeChild);  // 传统插入
  }
}

moveBefore 的语义是「把已存在于 DOM 中的节点移动到新位置」,浏览器保留该节点的内部状态 (<video> 继续播、<input> 保留焦点与输入、iframe 不重载)。而传统 insertBefore 在跨父移动时,若先 removeChild 再 append,会丢失状态甚至重载 iframe。

性能/正确性收益 :在列表重排(reorder)场景,React 19 用 moveBefore 把节点「搬」而不是「删了重建」,既省了 DOM 创建开销,又保住了交互状态。这是 React 19 相对早期版本在 DOM 操作层面的显著优化。注意 child.parentNode !== null 的判断------只有「已挂载的节点」才值得 moveBefore;新创建的、尚未插入的节点(parentNode 为 null)走传统 append。

6.7.3 commit 阶段不可中断的代价

因为 commit 是同步、不可中断的,一个巨型 commit(如整页重渲染)会一次性占用主线程,期间用户无法交互、无法 paint。这是 Fiber 架构「可中断」红利的边界------render 可切片,commit 不行。

规避:

  1. 用 React.memo / useMemo / 合理 key 减少每次 commit 的 Fiber 数量。
  2. 用 startTransition 标记非紧急更新,让 React 优先处理用户输入,必要时丢弃进行中的低优先级 render(render 可中断),从而间接缩小最终 commit 的规模。
  3. 把重副作用从 useLayoutEffect 移到 useEffect,避免阻塞 paint。

6.7.4 updateFiberProps 每次更新都刷新句柄

6.4.4 提到 commitUpdate 末尾必调 updateFiberProps。这意味着每次属性更新都会刷新 DOM 上的 props 句柄。对绝大多数组件这是必要的(事件系统依赖),但若某个组件属性对象巨大且频繁更新,这个刷新(一次对象引用更新 + 可能的内存写入)会累积。这属于微优化,通常无需担心,但高频更新的大型应用可做 profiling 确认。

6.7.5 trackHostMutation 与变更计数

commitHostUpdate/setProp/插入函数等很多地方调用 trackHostMutation()(如 ReactFiberCommitHostEffects.js:348)。它用于记录本次 commit 实际修改了多少 DOM ,供 Profiler/DevTools 统计「host mutation 次数」。注意 setProp 里对 style、事件等早返回的分支不调 trackHostMutation(因为它们没真改 DOM 属性),这是有意为之的精确计数------也提醒我们:事件属性变更虽然「看起来是更新」,但对 DOM 属性层零成本。


6.8 用法与最佳实践(结合源码解释「为什么这样写才对」)

6.8.1 ref 的三种写法与 cleanup

jsx 复制代码
// ✅ 推荐:函数式 ref + cleanup(React 19 特性)
function Comp() {
  return <input ref={(node) => {
    if (node === null) return;          // 卸载时 node 为 null
    node.focus();
    return () => node.blur();           // 返回的 cleanup 在 detach 时调用
  }} />;
}

源码依据:6.4.8 commitAttachRef 把返回值存入 refCleanup(803),safelyDetachRef 在 detach 时调用它(842)。函数 ref 的返回值即 cleanup ,等价于 useEffect 返回 cleanup,让「挂载时做 X、卸载时撤销 X」成对出现,避免内存泄漏。

jsx 复制代码
// ✅ 对象式 ref(最常用)
const inputRef = useRef(null);
<input ref={inputRef} />;  // commitLayoutEffects 里 ref.current = DOM 节点
jsx 复制代码
// ❌ 错误:在 render 期间读取 ref.current
function Bad() {
  const r = useRef(null);
  if (r.current) { /* ... */ }  // render 时 ref 还可能是 null(layout 阶段才 attach)
  return <div ref={r} />;
}

为什么错 :ref 在 layout 阶段才 attach(6.4.7/6.4.8),render 阶段(甚至首个 commit 的 before/mutation 阶段)ref.current 仍是 null。要在「DOM 就绪后」读 ref,必须放在 useLayoutEffect/useEffect/事件回调里。

6.8.2 useLayoutEffect vs useEffect 的时序选择

执行时机 是否阻塞 paint 典型用途
useLayoutEffect Layout 阶段,commit 内同步 是 测量 DOM 尺寸、同步调整布局避免闪烁
useEffect Passive 阶段,commit 后异步 否 数据获取、订阅、绝大多数副作用

源码依据:6.4.1 的 scheduleCallback(flushPassiveEffects)(3815)把 useEffect 异步排队;而 useLayoutEffect 在 commitLayoutEffectOnFiber 里同步触发(6.4.7)。结论 :能写 useEffect 就写 useEffect;只有「必须在浏览器绘制前同步读/改 DOM 以防止视觉跳动」才用 useLayoutEffect。

6.8.3 getSnapshotBeforeUpdate 与 componentDidUpdate 的配对

若需在 DOM 变更前读旧布局(如保存滚动位置),用 getSnapshotBeforeUpdate(Before Mutation 阶段,见 6.4.3 commitClassSnapshot),把值存入 getSnapshotBeforeUpdate 的返回值,再在 componentDidUpdate(Layout 阶段,见 6.4.7)的第三参数读到。顺序的铁律 就是 6.4.2 那句 root.current = finishedWork 注释保证的。

6.8.4 列表 key 的正确性

6.7.1 已讲。再强调:key 是 Placement 算法能否逃过指数级回退的关键 。用数组下标作 key、或 key 频繁变化,会让 React 误以为所有节点都是新的,触发全量 Placement + 全量 getHostSibling 回退。稳定的、语义唯一的 key(如数据 id)能让 render 阶段复用节点(第 5 章),从源头消灭 Placement。

6.8.5 不要依赖 componentWillUnmount 访问新 DOM

6.4.2/6.4.6 证明:componentWillUnmount 执行时 root.current 仍是旧树、DOM 尚未被删(removeChild 在所有卸载之后)。所以你能在 unmount 里读旧 DOM,但永远读不到新 DOM 。若需在新旧 DOM 间对比,请用 getSnapshotBeforeUpdate + componentDidUpdate 配对(6.8.3)。

6.8.6 避免在 commit 期触发 setState(除非必要)

commit 阶段可以 setState(会进入「commit 期更新」,由 didIncludeCommitPhaseUpdate 检测,见 6.4.1 复位处)。但 useLayoutEffect 里的 setState 会立即触发一次同步重渲染+重 commit ,可能形成同步循环。React 对此有告警与限制,但最佳实践是:layout effect 里若需基于新 DOM 派生状态,优先用 useMemo/派生值,或把 setState 放到 useEffect(让它在下一次 macrotask 异步、合并处理)。


6.9 源码索引表(本章关键文件 + 函数 + 行号速查)

函数 / 常量 文件 行号 作用
commitRoot react-reconciler/src/ReactFiberWorkLoop.js 3725 Commit 总入口,调度 passive + 驱动三子阶段
三子阶段注释 同上 3848~3850 「分多趟遍历,mutation 全在 layout 前」
Before/Mutation 短路判断 同上 3857~3861 用 subtreeFlags/flags 短路
commitBeforeMutationEffects react-reconciler/src/ReactFiberCommitWork.js 352 Before Mutation 入口
commitBeforeMutationEffectsOnFiber 同上 483 按 tag 处理 Snapshot/clearContainer
commitClassSnapshot 同上 527 getSnapshotBeforeUpdate 等价物
flushMutationEffects react-reconciler/src/ReactFiberWorkLoop.js 4009 Mutation 闸门
root.current = finishedWork 同上 4051 切换时机,注释在 4047~4050
flushLayoutEffects 同上 4055 Layout 闸门
flushAfterMutationEffects 同上 3997 View Transition 专用相位
commitMutationEffects react-reconciler/src/ReactFiberCommitWork.js 2027 Mutation 入口
recursivelyTraverseMutationEffects 同上 2046 先删后递归子
commitMutationEffectsOnFiber 同上 2072 按 tag 处理各类 mutation + ref detach
commitReconciliationEffects 同上 2845 处理 Placement(清 Placement 位)
commitLayoutEffects 同上 3063 Layout 入口
commitLayoutEffectOnFiber 同上 600 按 tag 处理 useLayoutEffect/生命周期/ref attach
recursivelyTraverseLayoutEffects 同上 3080 子先父后遍历
commitDeletionEffects 同上 1386 删除入口,找宿主父 + detachFiberMutation
commitDeletionEffectsOnFiber 同上 1490 按 tag 卸载副作用 + 唯一 removeChild
detachFiberMutation 同上 1474 切断 return 防内存泄漏
commitPlacement react-reconciler/src/ReactFiberCommitHostEffects.js 452 Placement 主算法
isHostParent 同上 257 判断宿主父
getHostSibling 同上 271 找 before 参照(指数级回退注释 273~275)
insertOrAppendPlacementNode 同上 394 递归插入(普通父)
insertOrAppendPlacementNodeIntoContainer 同上 330 递归插入(容器/Portal)
commitHostUpdate react-reconciler/src/ReactFiberCommitHostEffects.js 114 调 commitUpdate + 错误捕获
createInstance react-dom-bindings/src/client/ReactFiberConfigDOM.js 534 创建 DOM(末尾 precacheFiberNode + updateFiberProps)
precacheFiberNode 同上 661 建立 DOM→fiber 反向索引
updateFiberProps 同上 662 刷新 props 句柄(事件系统根基)
finalizeInitialChildren 同上 703 设初始属性,返回是否需 commitMount
appendInitialChild 同上 695 初始 append(不用 moveBefore)
commitUpdate 同上 999 React 19 内联 diff,末尾 updateFiberProps
appendChild 同上 1032 优先 moveBefore
insertBefore 同上 1135 优先 moveBefore
appendChildToContainer / insertInContainerBefore 同上 1082 / 1148 容器版插入
supportsMutation 同上 889 声明变更式渲染器
supportsMoveBefore 同上 1026 能力探测 Element.moveBefore
commitMount 同上 891 autoFocus/img 特殊处理
setInitialProperties react-dom-bindings/src/client/ReactDOMComponent.js 1111 初始属性设置
updateProperties 同上 1518 内联 diff lastProps/nextProps
setProp 同上 397 巨型属性分派 switch
事件属性直接 return 同上 976~991 事件不走 DOM 属性
setValueForStyles 同上 453(style 分支) 样式对象级 diff
setPropOnCustomElement 同上 1002 自定义元素属性处理
safelyAttachRef react-reconciler/src/ReactFiberCommitEffects.js 827 ref attach(layout)
commitAttachRef 同上 757 实际 attach,缓存 refCleanup(803)
safelyDetachRef 同上 842 ref detach(mutation/删除),调 cleanup
offscreenSubtreeWasHidden (多处:2134、1510、1536、1570) --- Offscreen 守卫,跳过重隐藏子树的 detach

6.9.1 补充索引:新增深度小节(6.4.10~6.4.19)涉及的源码位置

上表覆盖 6.4.16.4.8;6.4.106.4.19 新引用的函数与常量按文件汇总如下(行号均已对照本仓库源码核实):

函数 / 常量 文件 行号 作用
commitRoot(passive 调度段) react-reconciler/src/ReactFiberWorkLoop.js 3776~3827 passiveSubtreeMask 选择 + scheduleCallback 排队 + root.callbackNode 清理
pendingEffectsRemainingLanes 赋值 同上 3740 给 passive 阶段的 cache 池释放留数据
commitRoot(VT 分支) 同上 3895~3914 startViewTransition 接管五个 flush 回调
flushAfterMutationEffects 同上 3997~4007 View Transition 专用相位(推进到 PENDING_SPAWNED_WORK)
flushLayoutEffects 同上 4055~4152 LayoutMask 短路(4107)、indicator cleanup(4080)、PENDING_AFTER_MUTATION_PHASE(4151)
flushSpawnedWork 同上 4154~4192 requestPaint()(4189)+ passive 相位决策(4197~4221)
onCommitRootDevTools 同上 4249 DevTools commit 通知
flushPendingEffects(sync 离散刷新) 同上 4324~4329 sync lane 的 passive 同步 flush
嵌套更新死循环检测 同上 4342~4385 nestedUpdateCount / NESTED_UPDATE_SYNC_LANE
flushPassiveEffects 同上 4691~4726 优先级降档 + releaseRootPooledCache(4724)
flushPassiveEffectsImpl 同上 4728~4875 unmount→mount 顺序(4788/4789)、flushSyncWorkOnAllRoots(4818)
flushSyncWorkOnLegacyRootsOnly react-reconciler/src/ReactFiberRootScheduler.js 177~183 legacy root 同步 flush(调用点 1109、1874)
BeforeMutationMask react-reconciler/src/ReactFiberFlags.js 101~113 flag 依赖定义
BeforeAndAfterMutationTransitionMask 同上 117~118 VT 版 before 相位 Mask
MutationMask 同上 120~128
LayoutMask 同上 129 `Update
PassiveMask / PassiveTransitionMask 同上 132 / 136
StaticMask 同上 141~151 克隆时保留的静态位
commitBeforeMutationEffects_begin react-reconciler/src/ReactFiberCommitWork.js 373~463 subtreeMask 逐层短路(376、449)
recursivelyTraverseMutationEffects 同上 2046 `MutationMask
recursivelyTraverseLayoutEffects 同上 3080~3093 subtreeFlags & LayoutMask 单行短路(3085)
hideOrUnhideAllChildren(OnFiber) 同上 1202 / 1216 最近的宿主节点显隐 + Portal 特判
hideOrUnhideNearestPortals(OnFiber) 同上 1265 / 1279 PortalStatic 门控的 Portal 显隐
detachFiberMutation 同上 1308~1330 只切 return
detachFiberAfterEffects 同上 1332~1377 passive 阶段深度清理(调用点 5382)
recursivelyTraverseDeletionEffects 同上 1477~1488 删除的子节点遍历
commitDeletionEffectsOnFiber 各分支 同上 1507~1830 HostHoistable/HostSingleton/HostComponent/HostText/DehydratedFragment/HostPortal/Function/Class/Scope/Offscreen/VT/Fragment/default
disappearLayoutEffects 同上 3101~3275 Offscreen 隐藏的副作用卸载
recursivelyTraverseDisappearLayoutEffects 同上 3277~3287
reappearLayoutEffects 同上 3304~3598 Offscreen 显示的副作用重挂
recursivelyTraverseReappearLayoutEffects 同上 3600~3625 IncludeWorkInProgressEffects 逐层收窄
commitPassiveUnmountEffects 同上 4853~4856 passive unmount 入口
recursivelyTraversePassiveUnmountEffects 同上 5071~5116 ChildDeletion 判断(5076)+ PassiveMask 判断(5109)
commitPassiveUnmountEffectsInsideOfDeletedTree_begin 同上 5347~5369 被删树的 nextEffect 链表遍历(父→子)
commitPassiveUnmountEffectsInsideOfDeletedTree_complete 同上 5371~5396 detachFiberAfterEffects(5382)
commitAfterMutationEffects 同上 2884 View Transition 恢复 view-transition-name
commitHookEffectListMount react-reconciler/src/ReactFiberCommitEffects.js 141~247 effect 环形链表挂载
commitHookEffectListUnmount 同上 249~303 effect 环形链表卸载(inst.destroy 置 undefined)
commitHookLayoutEffects / commitHookLayoutUnmountEffects 同上 97 / 114 注释 101104 / 119123:相位批处理防兄弟干扰
commitClassLayoutLifecycles 同上 340~495 componentDidMount/componentDidUpdate
commitClassSnapshot 同上 635 getSnapshotBeforeUpdate
commitClassHiddenCallbacks 同上 568~590 reappear 时补发隐藏期间回调
commitAttachRef 同上 757~824 refCleanup 写入(798/803)
safelyAttachRef 同上 827~840
safelyDetachRef 同上 842~908 cleanup 调用 + 双向置 null(874~878)
doubleInvokeEffectsOnFiber react-reconciler/src/ReactFiberWorkLoop.js 5328~5338 StrictMode 断开-重连
doubleInvokeEffectsInDEVIfNecessary 同上 5340~5388 PlacementDEV 判定 + Offscreen 特例
commitDoubleInvokeEffectsInDEV 同上 5390~5419 legacy root 守卫
commitPlacement react-reconciler/src/ReactFiberCommitHostEffects.js 452~543 四分支宿主父
insertOrAppendPlacementNode 同上 394~450 递归插入(Instance 版)
insertOrAppendPlacementNodeIntoContainer 同上 330~392 递归插入(Container 版,before=null 差异 368~369)
commitImmutablePlacementNodeToFragmentInstances 同上 545~583 非 mutation 渲染器的 fragment 簿记
commitHostRemoveChild(FromContainer) 同上 620 / 597 唯一一次 removeChild
createInstance react-dom-bindings/src/client/ReactFiberConfigDOM.js 534~664 script/select 特殊创建 + precacheFiberNode
cloneMutableInstance 同上 668~693 cloneNode(View Transition/Gesture 用)
appendInitialChild 同上 695~701 注释:初始 append 不用 moveBefore
finalizeInitialChildren 同上 703~721 autoFocus/img 的 commitMount 契约
finalizeHydratedChildren 同上 723~742 hydrate 版
prepareForCommit 同上 424~436 选中态快照 + 事件系统临时关闭
resetAfterCommit 同上 457~462 恢复选中态与事件开关
commitMount 同上 891~954 autoFocus focus + img src 重设
appendChild / insertBefore 同上 1032 / 1135 supportsMoveBefore 优先
supportsMoveBefore 同上 1026~1030 能力探测
removeChild / removeChildFromContainer 同上 1213 / 1220
hideInstance / unhideInstance 同上 1406 / 1429 display:none !important
hideTextInstance / unhideTextInstance 同上 1419 / 1447 nodeValue 清空/恢复
clearContainer / clearContainerSparingly 同上 3972 / 3990 HEAD/HTML/BODY 逐个删、SCRIPT/STYLE/LINK 保留
setInitialProperties react-dom-bindings/src/client/ReactDOMComponent.js 1111~1496 img 的 src 最后写(1134~1180)、input/select/textarea/option/void/自定义元素分支
updateProperties 同上 1518~2023 七条 tag 分支 + 默认双遍历
setProp 同上 397~1000 巨型 switch(分组见 6.4.16 ①)
setPropOnCustomElement 同上 1002~1109 自定义元素分支
setValueForStyles react-dom-bindings/src/client/CSSPropertyOperations.js 111~166 对象级 diff + trackHostMutation(148、155)
setValueForStyle 同上 68~102 单属性写入(px 后缀、float→cssFloat)
setValueForAttribute react-dom-bindings/src/client/DOMPropertyOperations.js 106~140 类型卫生 + 布尔属性规则
setValueForKnownAttribute 同上 142 已知属性快路径
sanitizeURL react-dom-bindings/src/shared/sanitizeURL.js 10~34 javascript: URL 归一化拦截
isCustomElement react-dom-bindings/src/shared/isCustomElement.js 10~31 连字符 + 七个保留标签排除
trackHostMutation / pushRootMutationContext react-reconciler/src/ReactFiberMutationTracking.js 4555 / 1825 布尔位标记(非计数器,见 6.4.16 ③)

6.10 自测 FAQ(10 个高频疑问与精答)

Q1:为什么 React 要把 Commit 分成 Before Mutation / Mutation / Layout 三趟,而不是一趟搞定? A:因为三类副作用的时序约束互相冲突:①「读旧 DOM」必须在「改 DOM」之前(getSnapshotBeforeUpdate);②「删旧的」必须在「挂新的」之前(componentWillUnmount 不能看到新 DOM);③「挂 ref / 跑生命周期」必须在「root.current 已切换」之后(componentDidMount 必须看到新 DOM)。一趟线性遍历无法同时满足,所以分三趟,各扫一遍整树,顺序由 commitRoot/flushXxxEffects 固定(见 6.4.1~6.4.2)。

Q2:root.current = finishedWork 为什么必须夹在 Mutation 和 Layout 之间? A:源码注释(4047~4051)原文说明------它必须在 mutation 之后 ,使 componentWillUnmount 期间旧树仍是 current;又必须在 layout 之前 ,使 componentDidMount/Update 期间新树已是 current。放错位置会直接破坏所有依赖该约定的生命周期语义。

Q3:React 19 为什么去掉了 updatePayload? A:旧版在 render 期预计算 [key, value, ...] 的 payload 数组存进 Fiber,commit 期无脑应用;React 19 改为 commit 期 commitUpdate → updateProperties 内联 diff lastProps/nextProps(见 6.4.4)。好处:render 期更轻、不再跨阶段传递大数组、最新 props 句柄(updateFiberProps)刷新更内聚。代价:commit 期多一次 props 遍历(多数场景可忽略)。

Q4:为什么 commitUpdate 里事件属性(onClick 等)不写进 DOM? A:React 事件是委托在根容器的(第 10 章),最新 handler 存在 updateFiberProps 维护的 props 句柄里,分发时由事件系统取出。所以 setProp 对 onXxx 直接 return(976~991),真正刷新只发生在 commitUpdate 末尾的 updateFiberProps(1011)。这正是「更新后事件能正确分发」的根基。

Q5:Placement 时怎么知道新节点该插在哪儿? A:render 阶段只打 Placement 标,不记录位置。commit 时 commitPlacement(452)①向上找宿主父(isHostParent)②getHostSibling(271)向后找第一个没有 Placement 标 的已有宿主兄弟作 before 参照,找不到则 append 末尾 ③insertOrAppendPlacementNode 递归插入所有宿主叶子。

Q6:getHostSibling 的「指数级回退」是什么?怎么规避? A:当连续多个兄弟都带 Placement 时,每次插入都要向后跳过所有尚未插入的兄弟,最坏 O(N²) 甚至更糟(源码 273~275 自述 exponential)。规避:给列表稳定且语义正确的 key,让 render 阶段复用节点,从源头减少 Placement 标(见 6.7.1)。

Q7:删除子树时为什么要「只删最外层一个节点」? A:因为浏览器 DOM 是树形结构,删掉父节点其子孙由浏览器级联回收 。commitDeletionEffectsOnFiber 在递归卸载所有副作用(ref detach / cWU / cleanup)之前,把栈上 hostParent 临时置 null(1569 起),使子树内部不再触发 removeChild,递归完只在最外层调一次 commitHostRemoveChild(见 6.4.6)。既省性能又保证 unmount 顺序正确。

Q8:ref 的 attach 和 detach 分别在哪个阶段?函数式 ref 返回的 cleanup 什么时候被调用? A:detach 在 Mutation 阶段 (先于 update,且删除场景在 removeChild 前),attach 在 Layout 阶段 (此时 root.current 已是新树,DOM 已就位)。commitAttachRef(757)把函数 ref 的返回值存入 refCleanup(803);当该 ref 再次 detach 时,safelyDetachRef(842)调用这个 cleanup,然后置 null 防重复(见 6.4.8)。

Q9:useLayoutEffect 和 useEffect 谁先跑?为什么? A:useLayoutEffect(layout 阶段,commit 内同步 )一定先于 useEffect(passive 阶段,commit 后异步 通过 scheduleCallback 排队,见 6.4.1 的 3815)。因为 layout 在 commit 内阻塞完成,passive 被刻意推迟到这次 commit 之后的 macrotask,让浏览器先 paint。

Q10:Offscreen / Activity 隐藏子树时,ref 和生命周期为什么「不卸载」? A:隐藏只是「藏起来」,随时会「重现(reappear)」。若隐藏时 detach ref / 跑 cWU,重现时要重新 attach / mount,会丢失焦点、滚动、播放进度等状态。所以 React 用 offscreenSubtreeWasHidden 守卫在隐藏/更新路径上跳过 detach 与 unmount 副作用 (见 6.6.2、6.4.8)。注意:隐藏再显示不会触发 componentDidMount(逻辑上是 show 不是 mount)。

6.10 续:深度小节自测 FAQ(Q11~Q30)

以下 20 题对应 6.4.10~6.4.19 新增小节,答案均可回到所列源码行号验证。 另附一处以上源为准的更正 :Q10 答案末句「隐藏再显示不会触发 componentDidMount」需要修正------reappearLayoutEffects 的 ClassComponent 分支确实调用了 commitClassDidMount(ReactFiberCommitWork.js:3342)。准确的说法是:函数组件 的 useLayoutEffect 会被 disappear 卸载、reappear 时以 HookLayout 重新挂载(commitHookLayoutEffects 3332,不依赖 HookHasEffect),而类组件 在 reappear 时会再跑一次 componentDidMount,并补发隐藏期间被 deferHiddenCallbacks 暂存的 setState 回调(3344)。另见 Q26。

Q11:commitRoot 里 passive effect 的调度为什么必须「尽早」,还要先清 root.callbackNode? A:3806 的 scheduleCallback 引用 issue #16714:passive 回调必须排在 commit 期间可能产生的其他任务之前 ,否则 useEffect 的相对执行顺序会不稳定。root.callbackNode = null(3804)是先擦掉上一个 Scheduler 任务句柄,让新任务能排进去(注释 "So we can clear these now to allow a new callback to be scheduled")。如果这次 commit 没有 passive 效果,3804 的对称分支(3825)也会把 callbackNode/ callbackPriority 清掉,避免残留调度。

Q12:pendingEffectsStatus 的完整状态机是什么? A:NO_PENDING_EFFECTS →(commitRoot 3894)PENDING_MUTATION_PHASE →(flushMutationEffects 4052,中间 4051 切 root.current)PENDING_LAYOUT_PHASE →(flushLayoutEffects 4151)PENDING_AFTER_MUTATION_PHASE →(flushSpawnedWork 4182 收尾,VT 路径则先经 flushAfterMutationEffects 4006 到 PENDING_SPAWNED_WORK)→ 有 passive 则 PENDING_PASSIVE_PHASE(4209),否则 NO_PENDING_EFFECTS(4211)→(flushPassiveEffectsImpl 4735)NO_PENDING_EFFECTS。注意非 VT 路径「跳过 flushAfterMutationEffects」只是跳过那个函数,flushLayoutEffects 仍然把状态置成 PENDING_AFTER_MUTATION_PHASE,由 flushSpawnedWork 兜底(4155~4162 允许两种状态进入)。

Q13:flushSpawnedWork 里的 requestPaint() 是干什么的? A:4189 行,注释原文 "Tell Scheduler to yield at the end of the frame, so the browser has an opportunity to paint."------这是 React 显式告诉 Scheduler「本帧到此为止,让浏览器去绘制」的位置。Mutation/Layout 改好的 DOM 直到这里之后才可能出现在屏幕上,它是「commit 同步段结束」的物理分界线。

Q14:onCommitRootDevTools 什么时候被调用?和 passive 之后的那个通知有什么区别? A:同步段末尾 flushSpawnedWork 调一次 onCommitRootDevTools(finishedWork.stateNode, renderPriority)(4249),把 FiberRoot 和本次 render 优先级递给 DevTools;passive 阶段全部跑完后,flushPassiveEffectsImpl 末尾再调 onPostCommitRootDevTools(root)(4867)。两者是「 commit 完成」和「含 passive 的完整提交完成」两个通知点。另有 4257 的 onCommitRootTestSelector() 供测试选择器使用。

Q15:为什么有时 useEffect 几乎同步执行,有时要等 paint 之后? A:flushSpawnedWork 4324~4329:includesSyncLane(pendingEffectsLanes) && root.tag !== LegacyRoot 时立即 flushPendingEffects(),注释说明这是为了「让结果立即可观察」;其他情况(transition、普通更新)则等 scheduleCallback 排的 passive 任务在 paint 后跑。所以离散事件(click)触发的 useEffect 通常同步执行,transition 里触发的要等一帧。

Q16:为什么 subtreeFlags & LayoutMask 能整棵跳过?LayoutMask 到底含哪些位? A:LayoutMask = Update | Callback | Ref | Visibility(ReactFiberFlags.js:129)。layout 相位只处理这四类 flag,一个子树的 subtreeFlags 与它按位与得 0,说明子树里没有任何 fiber 需要跑 useLayoutEffect/生命周期/setState 回调/ref attach/显隐------整棵子树可以一次都不访问。判据就在 recursivelyTraverseLayoutEffects 的第一行(3085)。

Q17:Before Mutation 的判断为什么写着 BeforeMutationMask | MutationMask,直接用 BeforeMutationMask 不行吗? A:不行,会漏相位。默认 flag 下 BeforeMutationMask = Snapshot | Update,它的位是 MutationMask 的子集------这个 | 是恒等的,但条件里的 MutationMask 保证「只要有 DOM 变更就跑 before 相位」。原因:开 enableCreateEventHandleAPI 时 BeforeMutationMask 扩到 Update | ChildDeletion | Visibility(要访问被删除/隐藏的树发 beforeblur),而 View Transition 场景下 before 相位还要扫描可能受影响的 ViewTransition 组件(BeforeAndAfterMutationTransitionMask,117~118)。把两个 Mask 或起来是用一个判断覆盖所有「before 相位可能有活」的场景,cost 是多跑一个 Snapshot 极少的便宜相位。

Q18:MutationMask | Cloned 里的 Cloned 是干什么的? A:Cloned(ReactFiberFlags.js:26)标记 bailout 时克隆出来的 fiber。它的 subtreeFlags 是从旧树继承 的,不能仅凭 Mask 判定子树干净,所以 453 行显式把 Cloned 或进去强制遍历。配合的是 StaticMask(141151):LayoutStatic/PassiveStatic 等位克隆时保留,保证「bailout 掉的子树真正卸载时 passive 遍历仍能进去清」(注释 6670)。

Q19:getHostSibling 最坏能有多慢?React 为什么十年不修? A:N 个连续 Placement 兄弟时总计约 N(N-1)/2 次兄弟跳转(平方级;嵌套外壳、Portal、Singleton 分支还会放大每跳成本,源码 273~275 自述 "exponential search" 并留 TODO)。React 接受它的理由:① key 稳定时 render 阶段复用节点,Placement 根本不会连续出现;② 要 O(1) 定位「下一个稳定宿主兄弟」需要在 render 期维护宿主子节点索引或 commit 期维护插入游标,都会给热路径加常驻成本;③ 绝对成本有限,通常和 commit 的 DOM 操作叠加后才可见。规避靠稳定 key、避免整体重建、超大列表虚拟化(见 6.4.12、6.7.1)。

Q20:commitPlacement 时宿主父是 Portal,节点会插到哪里? A:insertOrAppendPlacementNodeIntoContainer(finishedWork, before, hostParentFiber.stateNode.containerInfo, ...)(529534)------插到 Portal 自己的容器 里,不是外层 React 树的宿主父。并且 insertOrAppendPlacementNodeIntoContainer 遇到 tag === HostPortal 会直接 return(350 355):Portal 内容不随 Portal 一起插,每个子节点各自的 Placement 由它们自己的 commitPlacement 处理。

Q21:删除时 hostParent = null 之后,子树里如果藏着 Portal 怎么办? A:Portal 分支(16731705)不受影响:进入 HostPortal 时把 hostParent 临时切成 deletedFiber.stateNode.containerInfo(1679 1680),所以 Portal 内部子树的 removeChild 依然能发生,从 Portal 容器里正确摘除。这正是 1770~1772 TODO 说的「portals... need to be detached no matter how deeply they are nested」------外层 hostParent = null 的技巧对 Portal 严格例外。

Q22:offscreenSubtreeWasHidden 和 offscreenSubtreeIsHidden 有什么区别? A:IsHidden = 这棵树在新树里是隐藏的(进入嵌套隐藏守卫用);WasHidden = 这棵树在本次 commit 之前 就是隐藏的。删除路径上 WasHidden 是「卸载防重入锁」:树被隐藏时 layout 副作用已被 disappearLayoutEffects 卸载,删除时再看到 WasHidden=true 就跳过 detach/cWU/layout cleanup(1731、1716、1570 等守卫),保证「卸载一次,绝不两次」。

Q23:detachFiberMutation 为什么不清 child/sibling? A:源码注释 13181320 直说:"Note that we can't clear child or sibling pointers yet. They're needed for passive effects and for findDOMNode. We defer those fields, and all other cleanup, to the passive phase (see detachFiberAfterEffects)." passive 阶段要遍历被删子树跑 useEffect destroy(commitPassiveUnmountEffectsInsideOfDeletedTree_begin 5347,用 nextEffect 链表、父→子顺序),没有 child/sibling 下不去。深度清理(child/sibling/stateNode/memoizedProps/updateQueue 全清、HostComponent 额外 detachDeletedInstance)发生在 passive 的 _complete 遍历里(5382→1332)。alternate 也要留到 passive 一起清(13221324)。

Q24:React 19 的 updateProperties 为什么要遍历两遍 props? A:第一遍只处理「旧 props 有、新 props 的 key 彻底消失」(!nextProps.hasOwnProperty(propKey))→ setProp(..., null, ...) 清除 DOM 属性/样式;第二遍处理「值发生变化」(nextProp !== lastProp)→ 写入。区分「移除」与「改成 null」必须靠 hasOwnProperty,单遍遍历做不到。这是去掉 updatePayload 后「render 期不再预计算 diff」的直接后果(6.4.4、6.4.15)。

Q25:style={{...}} 每次渲染都是新对象,会不会每帧重设所有 CSS? A:不会。第二遍的 nextProp !== lastProp 确实恒真、setProp 必然被调,但 setValueForStyles(CSSPropertyOperations.js:111)内部对 prevStyles 和 styles 逐属性 比较:旧有新无 → 清空;值不同才 setValueForStyle 写入。所以真正落到 style[xxx] 的写入口等于「实际变化的 CSS 属性数」。这也是为什么 setProp 的 style 分支提前 return 不调 trackHostMutation------setValueForStyles 自己在写入点标记了(148、155)。

Q26:Activity 隐藏后再显示,useLayoutEffect 和 componentDidMount 到底跑不跑? A:函数组件 :隐藏时 disappearLayoutEffects 跑 commitHookLayoutUnmountEffects(HookLayout)(3115)卸载 cleanup;显示时 reappearLayoutEffects 用 commitHookLayoutEffects(finishedWork, HookLayout)(3332)重新挂载------注意没有 HookHasEffect,因为 reappear 要覆盖复用的旧节点。类组件 :commitClassDidMount 在 3342 被显式调用 ,componentDidMount 会再跑一次;隐藏期间暂存的 setState 回调由 commitClassHiddenCallbacks(3344)补发。HostComponent :commitHostMount(autoFocus 等)只在 includeWorkInProgressEffects && current === null && flags & Update 时跑(3399)------重现不抢焦点。这是对 Q10 答案的源码级修正。

Q27:<a href="javascript:alert(1)"> 经 React 渲染会发生什么?sanitizeURL 防的到底是什么? A:setProp 的 href/src/action/xlinkHref 分支写入前都过 sanitizeURL(541、612、719)。它的正则把「浏览器解析 URL 时会剥掉的字符」建模进匹配:^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v...------即 java\tscript:、\x01javascript:、 javascript: 这些朴素过滤漏掉的变体都会被拦截,替换成 javascript:throw new Error('React has blocked a javascript: URL...')。防的是「浏览器与过滤正则的解析差」型 XSS(WHATWG URL 规范会忽略 URL 中的 tab/换行并剥掉行首 C0 控制符与空格)。

Q28:为什么 onClick 变了 DOM 上什么都不会发生?事件系统怎么拿到最新 handler? A:setProp 的 default 分支用语法判定(key.length > 2 && key[0] 是 o/O && key[1] 是 n/N)把一切「长得像事件」的 key 直接 return(976~991),注释 "Updating events doesn't affect the visuals."。handler 的最新版本只活在 updateFiberProps 维护的 DOM→props 句柄里,每次属性更新由 commitUpdate 末尾的 updateFiberProps(domElement, newProps)(1011)刷新;事件委托触发时第 10 章用 getInstanceFromNode 找 fiber、getFiberCurrentPropsFromNode 拿最新 props。副作用:自定义属性如果恰好叫 once、onions 这种 on 开头的形式,在普通元素上不会被写到 DOM。

Q29:ref 的 cleanup 为什么要在 alternate 上也置 null(874~878)? A:current 和 alternate 是同一组件的两个 fiber,refCleanup 只在 attach 时写入其中一个(finishedWork)。树切换后「current」换了边,若只清一边,下一轮 commit 会在另一边读到 stale cleanup 并二次调用 。finally 里把 current.refCleanup 和 current.alternate.refCleanup 都置 null,配合「detach 用旧 fiber、attach 用新 fiber」的调用约定(2135 vs 642),才保证 cleanup 全局只跑一次。

Q30:StrictMode 下 ref/effect 挂载时跑几次?靠什么实现? A:DEV 且 StrictEffectsMode 下,新挂载 的 fiber(flags & PlacementDEV,5351)会走 doubleInvokeEffectsOnFiber(5328):disappearLayoutEffectsForDEVValidation → disconnectPassiveEffect → reappearLayoutEffectsForDEVValidation → reconnectPassiveEffects------一次「模拟卸载再重挂」。它复用的正是 Offscreen 的 disappear/reappear traversal,不是另造一套。入口守卫在 commitDoubleInvokeEffectsInDEV(5390):legacy root 且未开 StrictLegacyMode/StrictEffectsMode 时 doubleInvokeEffects = false。调用时机:同步 commit 无 passive 时(4244)、passive flush 后(4802)。实践含义:ref/effect 的 cleanup 必须幂等。


6.13 本章要点小结

  1. Commit = 把带 Flags 的 Fiber 树翻译成真实副作用的、不可中断的、分三趟遍历的同步执行器。
  2. 三子阶段顺序的铁律 :Before Mutation(读旧值/清容器)→ Mutation(改 DOM:先删、再插、再更新属性、detach ref)→ root.current = finishedWork(4051) → Layout(挂 ref、useLayoutEffect、componentDidMount/Update)→ Passive(useEffect 异步)。
  3. root.current 切换时机是生命周期时序正确性的基石,源码注释(4047~4051)原文保证。
  4. Placement 算法 :向上找宿主父 + getHostSibling 找 before + 递归插入;代价是 getHostSibling 在连续 Placement 下的指数级回退,靠稳定 key 规避。
  5. 删除算法 :先递归卸载所有副作用(ref detach / cWU / cleanup),最后只删最外层节点,靠浏览器级联回收;detachFiberMutation 切断 return 防内存泄漏。
  6. React 19 去掉 updatePayload :commit 期内联 diff lastProps/nextProps;事件属性走 updateFiberProps 句柄(在 setProp 直接 return),这是第 10 章事件委托能跨 DOM 找 fiber 的地基。
  7. ref 时序 :detach 在 mutation(先于 update / 删除),attach 在 layout;函数式 ref 返回值即 cleanup,detach 时调用并置 null 防重入。
  8. HostConfig 解耦 :react-reconciler 全程不知 DOM,只调用 createInstance/appendChild/commitUpdate 等抽象;precacheFiberNode + updateFiberProps 架起 fiber↔DOM 双向索引。
  9. 衔接:第 3 章 Flags「写意图」、本章「读意图执行」;第 4 章 markUpdate 在 React 19 被弱化(diff 推迟到 commit);第 10 章事件委托依赖本章的双向索引。
  10. 性能焦点 :避免连续全量 Placement(稳定 key)、利用 moveBefore 保留状态、把重副作用从 useLayoutEffect 移到 useEffect 以不阻塞 paint。

6.11 删除的两段式 detach:为什么 mutation 只断 return,passive 才断 child/sibling

删除一个 fiber 子树时,React 不是"一把清空",而是分成两步。这是 commit 阶段最容易被忽略、但对内存与语义都很关键的设计。

第一步:mutation 阶段只切断 return

packages/react-reconciler/src/ReactFiberCommitWork.js:1308

js 复制代码
function detachFiberMutation(fiber: Fiber) {
  // Cut off the return pointer to disconnect it from the tree.
  // This enables us to detect and warn against state updates on an unmounted component.
  // It also prevents events from bubbling from within disconnected components.
  //
  // Ideally, we should also clear the child pointer of the parent alternate to let this
  // get GC:ed but we don't know which for sure which parent is the current
  // one so we'll settle for GC:ing the subtree of this child.
  // This child itself will be GC:ed when the parent updates the next time.
  //
  // Note that we can't clear child or sibling pointers yet.
  // They're needed for passive effects and for findDOMNode.
  // We defer those fields, and all other cleanup, to the passive phase (see detachFiberAfterEffects).
  //
  // Don't reset the alternate yet, either. We need that so we can detach the
  // alternate's fields in the passive phase. Clearing the return pointer is
  // sufficient for findDOMNode semantics.
  const alternate = fiber.alternate;
  if (alternate !== null) {
    alternate.return = null;
  }
  fiber.return = null;
}

这段注释解释了三个问题:

  1. 为什么先断 return:断掉向上的指针后,这个 fiber 就从树里"脱落"了。于是 (a) 在已卸载组件上 setState 时 React 能检测到并告警;(b) 事件不会再从已卸载的子树冒泡上来。
  2. 为什么不断 child/sibling :passive effect(useEffect 的清理函数)还没执行 ,它还需要沿着 child/sibling 遍历整棵子树去逐个调用清理函数。同理 findDOMNode 也需要这些指针。
  3. 为什么不立即清 alternate:因为 passive 阶段还要用它去清理**另一侧(旧树)**的对应字段。

第二步:passive 阶段彻底断开环引用

:1332

js 复制代码
function detachFiberAfterEffects(fiber: Fiber) {
  const alternate = fiber.alternate;
  if (alternate !== null) {
    fiber.alternate = null;
    detachFiberAfterEffects(alternate);
  }

  // Clear cyclical Fiber fields. This level alone is designed to roughly
  // approximate the planned Fiber refactor. ...
  fiber.child = null;
  fiber.deletions = null;
  fiber.sibling = null;

  // The `stateNode` is cyclical because on host nodes it points to the host
  ...
}

注意它是递归处理 alternate 的 (detachFiberAfterEffects(alternate))------因为双缓冲意味着同一个逻辑节点在两棵树里各有一份,两边都要清。

这三个字段为什么必须清

fiber 是一个高度环状的结构:fiber.child → child.return → 父;fiber.stateNode(宿主节点)又通过 __reactFiber$xxx 指回 fiber(第 6 章"fiber↔DOM 双向索引")。如果只从树里摘掉而不清内部指针,整棵子树会因为循环引用而无法被 GC 回收 。这就是为什么 detachFiberAfterEffects 要清 child / sibling / deletions / stateNode。

由此得到一条实战结论:不要在已卸载组件里保留对 fiber、DOM 节点或大型对象的长期引用(比如把它塞进模块级 Map)。React 能断开自己的环,但断不开你加的那条引用。


6.12 commitMount:需要"DOM 已入文档"才能做的收尾

finalizeInitialChildren 发生在 mutation 阶段,此时节点可能还没被插入文档。有一类操作必须等 DOM 真正入文档之后才生效,React 为此留了一个专门的回调 commitMount(packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:891)。

典型例子是 autoFocus:ReactDOMComponent.js 为它准备了独立的 case (初始属性阶段 :697、更新阶段 :2716),而不是当成普通属性直接 setAttribute。原因是自动聚焦必须发生在元素已进入文档之后,否则浏览器不会真的聚焦。另一类经典场景是 <img>:为了让 load 事件在 React 挂载监听之前不丢失,需要在 commit 之后再重设 src。

这类"必须在 commit 之后补一刀"的操作共同点是:它们依赖浏览器的文档级副作用 ,而 React 的属性 diff(setProp)只负责写属性,不负责触发这类副作用。理解这一点,就能明白为什么有些属性"写了却没生效"------不是 React 漏了,而是浏览器要求它必须在特定时机做。


下一章预告:第 7 章将深入 Passive 阶段(useEffect 的调度与刷新)、调度器(Scheduler)如何把 passive effect 排进 macrotask,以及 Lane 优先级如何在 commit 之后继续影响工作循环。本章建立的「fiber↔DOM 双向索引」也会在第 10 章事件系统中被反复使用。

相关推荐
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 13 章:React 19 新特性深解
前端·react.js·开源
wflynn1 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
git·开源·go·github·agent·coding-agents
donk13141 小时前
React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南
javascript·分布式·react native·react.js·wpf·harmonyos
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
前端·react.js·开源
小小龙学IT1 小时前
Nginx 开源 Web 服务器与反向代理深度解
前端·nginx·开源
miofly2 小时前
腾讯开源 Hy Memory 2.0 记忆系统,3B 激活参数超越 claude-sonnet-5-5
开源·github
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 2 章 响应式内幕(一):`effect` 与依赖追踪机制
前端·vue.js·开源
鶴哥只手遮天2 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 06 章 编译器内幕(二):`transform` 管线与结构指令
前端·vue.js·开源