第 18 章:属性与 DOM 更新——style、事件、ref
第 17 章说 mutation 阶段改 DOM。本章看它具体怎么改一个元素:新旧 props 怎么算出差集、style 怎么处理、事件和 ref 为什么不在这一步直接绑。
先记住这一句:DOM 属性更新就一条原则:只改变化的,不碰没变的。style 拆到 CSS 属性级去比;事件根本不在这里绑(走第 19 章委托);ref 不在这里赋值(在 layout 阶段)。
一图流:一次属性更新的分派
updateProperties:只算差异
mutation 阶段对 HostComponent 调用 commitUpdate,最后落到 updateProperties(ReactDOMComponent.js:1433)。它拿 lastProps(上次的)和 nextProps(这次的),逐个属性算出要增删改什么,再对 DOM 调用对应的 set/remove 操作。
源码里对标签做了快路径(1443):div、span、svg、path、a、g、p、li 这些最常见标签直接走通用逻辑,省掉一次分支判断。而 input 这种有状态标签单独处理:value、checked 这些受控属性不走通用 setAttribute,而是通过 updateWrapper 统一写(保证输入框的行为符合 React 的受控语义)。
style:diff 到 CSS 属性级
style 是特殊 props,React 不会整个 style.cssText 覆盖,而是逐条 CSS 属性对比:lastProps.style 和 nextProps.style 里每个键,只对变化的调用 style.setProperty,对消失的调用 style.removeProperty。这样你在渲染里写 style={{marginLeft: 10}},React 只动 margin-left 一条,其他样式原封不动。这也是 React 的 style 性能优于手动 style.cssText 覆盖的原因之一。
事件:根本不在这里绑
onClick 这类 props 在 updateProperties 里不会 addEventListener。React 19 的事件处理是全局委托:所有支持的事件在 createRoot 时就统一绑定到了根容器(第 19 章的主角),元素上只有"这个 fiber 想监听什么"的记录。所以你给元素换 onClick 时,不需要解绑再绑,只是改一下内部记录。这就是第 19 章要展开的机制。
ref:在 layout 阶段赋值
ref 的赋值也不在 mutation。commitLayoutEffectOnFiber(ReactFiberCommitWork.js:592)在 layout 阶段做:DOM 改完了、实例就位了,才把 DOM 节点挂进 ref.current(或调用 ref 回调)。所以 ref 回调里能安全读 DOM,是因为它保证跑在 DOM 变更之后、paint 之前。
动手实验
- 看差异计算:在
updateProperties(ReactDOMComponent.js:1433)打断点,更新一个元素的className和style.margin,看它是只对变化的两处调用写操作。 - 验证 style 粒度:第一次渲染
style={{width: 10}},第二次style={{width: 10, height: 5}},断点观察height被 set,width没被碰。 - 验证 ref 时机:ref 回调里
console.log('ref', el.offsetWidth),对比 useEffect 里的offsetWidth,确认 ref 在 layout 阶段(paint 前)拿到 DOM。
小结
- 属性更新原则:只改变化的,
updateProperties(1433)逐属性算差集。 - 常用标签走快路径,
input的value/checked特殊处理。 - style 拆到 CSS 属性级 diff,只动变化的。
- 事件不在这里绑,走全局委托(第 19 章)。
- ref 在 layout 阶段赋值(commitLayoutEffectOnFiber:592),保证能安全读 DOM。
- 下一步:事件委托到底怎么工作,第 19 章。