React 静态属性注入:在 completeWork 阶段,React 是如何一次性处理所有 props 到原生 DOM 属性映射的?

欢迎来到 React 内部世界的后台,各位前端工程师、架构师,以及那些还在纠结 classNameclass 到底谁是大佬的同学们。

今天我们不聊怎么写业务代码,不聊 Hooks 的那些花活儿,咱们来聊聊 React 最底层、最硬核、也最像“黑魔法”的地方——协调

具体来说,我们要把聚光灯打在那个神奇的函数上:completeWork

你可能会问:“嘿,老哥,这玩意儿听起来就像是编译器后端的事,跟我写个 div 有啥关系?”

关系大了去了!当你把一个 <div className="hello"> 写在 JSX 里,点击那个“Compile”按钮,或者按 F5 刷新页面,React 完成的工作不仅仅是把字符串变成 HTML 标签。它还要把你的 React 风格的 Props(属性),翻译成浏览器听得懂的语言,然后像特工一样,把代码注入到原生 DOM 节点里。

这个过程,就是我们今天要讲的——静态属性注入

一、 舞台背景:草稿纸上的 Fiber

在进入正题前,咱们得先理解一下 completeWork 在哪儿。这就像是一场交响乐,render 阶段是乐器调音,而 completeWork 是最后把音符落实到五线谱上的乐谱编写者。

当 React 经过 reconcile(协调)阶段,构建完 workInProgress 树(也就是正在构建的虚拟 DOM 树)后,它进入 completeWork 阶段。这个时候,React 已经知道:“嘿,父组件想让我变成一个 div,而且它给了我一堆 Props,比如 classNamestyleonClick 什么的。”

现在的任务就是:拿着这个 div 的 Fiber 节点,去创建或者更新对应的真实 DOM 节点。

这个函数叫 updateHostComponent。它是处理 DOM 元素的核心枢纽。所有的 Props,所有的属性注入,都是在这个函数的辅助下完成的。

二、 核心机制:映射表的艺术

如果 React 只是简单粗暴地把 className 设置给 element.className,那它也太逊了。浏览器虽然聪明,但它的属性名有时候挺反人类的。

比如,HTML 标准里 div 的 class 属性叫 class,但在 JavaScript 的 DOM API 里,为了不跟保留字 class 冲突,它被改名叫了 className。同理,html 标签的 for 属性,在 JS 里叫 htmlFor

React 是怎么一次性处理这些映射的呢?靠的是一张巨大的、精心设计的映射表

在 React 的源码深处(主要是 packages/react-dom/src/client/ReactDOMComponent.js 里),有一个对象,我们姑且叫它 DOMAttributeNames。它就像是 React 的翻译官字典。

// 模拟 React 源码中的映射逻辑
const DOMAttributeNames = {
  // 字符串属性映射:JS 属性名 -> DOM 属性名
  accept: 'accept',
  acceptCharset: 'accept-charset',
  accessKey: 'accesskey',
  // ... 无数个映射 ...
  className: 'class',
  // ... 无数个映射 ...
  htmlFor: 'for',
  // ... 无数个映射 ...
  // 注意:onClick 这种事件监听器不在 DOMAttributeNames 里,我们后面单独说
};

// 还有针对布尔值的特殊处理
const DOMProperty = {
  isCustomAttribute: RegExp.prototype.test.bind(/^(data-|aria-)/),
  // ...
};

“一次性处理”的魔法就在这里:

completeWork 遇到一个 HostComponent(比如 div)时,它会进入 updateHostComponent 函数。这个函数会遍历这个 Fiber 节点上的所有 Props。

function updateHostComponent(current, workInProgress, type, newProps) {
  const hostType = workInProgress.type;
  const oldProps = current !== null ? current.memoizedProps : null;
  const newProps = workInProgress.memoizedProps;

  // 核心逻辑:差异比对
  // React 不会把所有属性都重写一遍,那样太慢了!
  // 它只会更新那些发生了变化的属性。

  // 1. 处理 DOM 属性
  updateDOMProperties(
    workInProgress.stateNode,
    hostType,
    oldProps,
    newProps,
    updateDOMAttributes
  );

  // 2. 处理子节点(这里就不展开了,涉及到 diff 算法)
  reconcileChildren(
    workInProgress,
    newProps.children
  );
}

看到了吗?updateDOMProperties 就是那个“一次性处理”的大管家。它拿到了 newProps(最新的属性)和 oldProps(旧的属性),然后开始干活。

三、 属性注入的三个流派

Props 到 DOM 属性的映射,并不是千篇一律的。React 把它们分成了三类:标准属性、布尔属性、特殊属性(Style 和 Event)。completeWork 对待它们的态度完全不同。

1. 标准属性:照本宣科的翻译官

对于大多数属性,比如 idtitlealt,React 的处理非常简单粗暴:翻译 -> 赋值

逻辑是这样的:

function updateDOMAttributes(node, type, oldProps, newProps) {
  for (const prop in newProps) {
    if (!newProps.hasOwnProperty(prop)) {
      continue;
    }

    const nextProp = newProps[prop];
    const oldProp = oldProps ? oldProps[prop] : null;

    // 关键点:如果属性变了,才动 DOM
    if (nextProp !== oldProp) {
      // 查找映射表,看看是不是需要翻译
      const domPropName = DOMAttributeNames[prop] || prop.toLowerCase();

      // 1. 特殊情况:事件监听器
      if (prop.startsWith('on')) {
        // 事件监听器我们在后面讲,这里先跳过
        continue; 
      }

      // 2. 设置属性
      // 比如 prop 是 'className', domPropName 就是 'class'
      // 比如 prop 是 'htmlFor', domPropName 就是 'for'
      if (typeof node[domPropName] === 'boolean' && nextProp === false) {
         // 如果属性是布尔值且被设为 false,某些浏览器需要设置为空字符串
         node[domPropName] = '';
      } else {
         node[domPropName] = nextProp;
      }
    }
  }
}

幽默一下:
这就好比你去餐厅点菜。completeWork 是服务员,newProps 是你的新菜单,oldProps 是之前的菜单。服务员会拿个本子(DOM 节点)记录。如果前一份菜单里写的是“白开水”,现在你改成了“可乐”,服务员就倒掉白开水,倒可乐。如果菜单上写的是“米饭”,新菜单也是“米饭”,服务员就假装没看见,不浪费水。这就是“一次性处理”的精髓:只改必要的地方。

2. 布尔属性:React 的“只要没说不要,那就是要”哲学

这是一个非常容易踩坑的地方。

在 HTML 里,像 disabledcheckedreadOnly 这样的属性,你写了它就是真的,你不写(或者写空)就是假的。

但是,在 JavaScript 里,布尔值是 truefalse

React 的做法是:只要你在 Props 里写了这个属性,不管值是 true 还是 false,React 都会把它注入到 DOM 中。

但是!注意这个 but

如果值是 false,React 不会把它设为 false(因为 DOM 属性里没有 disabled=false 这回事,写了就是有,不写就是没有)。React 会把它设为空字符串 ""

代码示例:

// React 组件
function MyCheckbox() {
  return <input type="checkbox" checked={false} />;
}

// React 内部处理逻辑
// Props: { checked: false }
// DOM 操作: node.setAttribute('checked', '')
// 结果:HTML 中 <input checked>,它是未选中的状态。
// React 组件
function MyCheckbox() {
  return <input type="checkbox" checked={true} />;
}

// React 内部处理逻辑
// Props: { checked: true }
// DOM 操作: node.setAttribute('checked', 'checked')
// 结果:HTML 中 <input checked checked>,它是选中的状态。

这种“注入”策略保证了 React 的状态(checked={false})和 DOM 的表现(无 checked 属性)完美对齐。

3. 样式属性:对象变字符串的炼金术

这是最复杂的部分。

在 React 里,你传给 style 的是一个对象:

<div style={{ color: 'red', fontSize: '14px' }} />

但是在浏览器里,DOM 的 style 属性是一个字符串:

element.style = "color: red; font-size: 14px;"

所以,completeWork 在处理 style 属性时,必须调用一个专门的工具函数——CSSPropertyOperations。它的任务就是把对象“压缩”成字符串。

// 模拟 CSSPropertyOperations.setStyle
function setStyle(domElement, style) {
  if (typeof style === 'string') {
    domElement.style.cssText = style;
    return;
  }

  // 如果是对象,需要遍历并拼接
  const cssText = Object.keys(style).map(prop => {
    // 这里有个细节:React 会自动把驼峰命名(backgroundColor)转成连字符(background-color)
    // 这也是映射的一部分!
    return hyphenate(prop) + ':' + style[prop];
  }).join(';');

  domElement.style.cssText = cssText;
}

技术点:
React 在这里不仅做了注入,还做了一个属性名转换backgroundColor -> background-color。这是浏览器 CSS 引擎能识别的标准格式。这个转换逻辑在 CSSProperty.js 里,通过正则表达式 ^(-?[A-Z]+)|(^$) 来判断,如果是驼峰命名,就替换第一个字母为小写并在前面加 -

4. 事件属性:绑定发生在 commit 阶段

好了,我们聊了 classNamestyledisabled。最后还有一个大块头:事件

当你写 <button onClick={handleClick}> 时,React 需要做两件事:

  1. onClick 注入到 DOM 节点(在 completeWork 阶段)。
  2. handleClick 函数绑定上去(在 commit 阶段)。

这里有个误区:completeWork 并不直接绑定事件函数。

completeWork 阶段,React 会处理事件相关的属性(比如 onMouseDown)。它会调用 DOMPropertyOperations.setValueForProperty

// 模拟处理事件属性
if (prop === 'onClick' || prop === 'onMouseDown' || prop === 'onMouseUp') {
  // 1. 设置 DOM 属性
  // 注意:这里设置的是 null 或者空字符串,并不是绑定函数
  // 真正的绑定发生在 commit 阶段的 nativeMountComponent 里
  DOMPropertyOperations.setValueForProperty(node, prop, null);
}

React 把事件监听器的注册推迟到了 commit 阶段。为什么要这么做?为了性能。为了批处理。如果你在一个 setTimeout 里连续调用两次 setState,React 可以把这两个状态合并成一次渲染,只触发一次事件绑定。如果在 completeWork 里就绑定,那批处理就没法做了。

四、 dangerouslySetInnerHTML:那个不安全的特洛伊木马

React 里有一个非常有名的属性叫 dangerouslySetInnerHTML。它的名字里带着“dangerously”,说明它是个坏孩子。

它的作用是允许你直接插入 HTML 字符串。比如:

<div dangerouslySetInnerHTML={{ __html: '<b>Hello</b>' }} />

这东西太危险了,容易导致 XSS(跨站脚本攻击)。React 为什么要提供它?因为有时候你确实需要把后端传来的 HTML 直接塞进去。

completeWork 里处理这个属性非常简单,但也非常关键:

if (prop === 'dangerouslySetInnerHTML') {
  if (nextProp && nextProp.__html) {
    // 直接把 HTML 字符串赋值给 innerHTML
    // 注意:没有转义!没有消毒!
    node.innerHTML = nextProp.__html;
  }
}

这就是“一次性处理”的体现。React 一眼就识别出了这个特殊的 prop,直接绕过了常规的属性注入流程,执行了最原始、最高效(但也最危险)的 DOM 操作。

五、 完整演练:模拟 completeWork

让我们把上面所有的逻辑串起来,模拟一下当 React 渲染一个 <div id="test" className="box" style={{color:'red'}} onClick={fn}> 时,completeWork 到底发生了什么。

步骤 1:找到节点
React 在 workInProgress 树里找到了这个 div 的 Fiber 节点。

步骤 2:进入 updateHostComponent

function completeWork(current, workInProgress) {
  const tag = workInProgress.tag;

  if (tag === HostComponent) {
    // 如果是 DOM 元素,调用这个
    updateHostComponent(current, workInProgress, workInProgress.type, workInProgress.pendingProps);
  }

  // ... 其他 tag 的处理 ...
}

步骤 3:遍历 Props
updateHostComponent 开始遍历 workInProgress.memoizedProps(也就是 { id: "test", className: "box", style: {...}, onClick: fn })。

步骤 4:逐个注入

  1. id:

    • 映射表:id -> id
    • 比较:旧值是 undefined,新值是 "test"
    • 操作:node.id = "test"
  2. className:

    • 映射表:className -> class
    • 比较:旧值是 undefined,新值是 "box"
    • 操作:node.class = "box"
  3. style:

    • 映射表:无(特殊处理)。
    • 比较:旧值是 null,新值是 { color: 'red' }
    • 操作:调用 CSSPropertyOperations.setStyle(node, { color: 'red' })
    • 内部逻辑:遍历对象,转换驼峰为连字符,拼接字符串 "color:red",执行 node.style.cssText = "color:red"
  4. onClick:

    • 映射表:无(特殊处理)。
    • 比较:旧值是 undefined,新值是 fn
    • 操作:调用 DOMPropertyOperations.setValueForProperty(node, 'onClick', fn)
    • 注意:此时 React 并没有调用 node.addEventListener('click', fn)。它只是标记了一下“嘿,这个节点有个点击事件”。
  5. 其他属性:

    • 如果是 children,React 会递归调用 completeWork 去处理子节点。

步骤 5:提交
completeWork 全部跑完,React 进入 commit 阶段。
此时,DOM 节点已经创建好了,属性也设置好了(id, class, style)。
但是,事件监听器还没绑。commit 阶段会遍历所有标记了事件的 Fiber 节点,执行真正的 addEventListener

六、 为什么说这是“静态属性注入”?

你可能会问:“嘿,刚才说的那些 classNamestyle,它们不也是动态的吗?为什么叫静态属性注入?”

这里的“静态”,指的是映射逻辑的确定性

React 不会在运行时去猜测“哦,这个 prop 是 div 的属性,还是 span 的属性”。它有一套静态的规则(映射表)。

  1. 确定性映射:只要你是 divclassName 就永远映射到 class。只要你是 inputvalue 就永远映射到 value。这种映射关系在 React 源码里是写死的,是静态的。
  2. 批量注入completeWork 在遍历 Props 时,是一次性把所有静态属性(非事件、非子节点)都处理完的。它不会处理一个属性就查一次 DOM,而是收集完所有变化,最后统一更新。

这种设计模式非常像依赖注入。React 把“如何将 React 的 Props 转化为 DOM 属性”这个依赖(映射逻辑),注入到了 completeWork 这个函数的执行流程中。

七、 性能优化:别做重复劳动

回到我们最开始的问题:“React 是如何一次性处理所有 props 到原生 DOM 属性映射的?”

答案不仅仅是“它有一个映射表”,更在于它“比较”了属性。

completeWork 的逻辑里,有一行至关重要的代码:
if (nextProp !== oldProp) { ... do update ... }

假设你的组件里有一个 div,里面有 20 个属性。

  • 如果 React 不做比较:每次渲染,它都会把 divclassNameidstyle 全部重置一遍。这意味着浏览器要重新解析 CSS 规则,重新应用样式,重新读取 DOM 属性。这性能是灾难级的。
  • 如果 React 做了比较:它发现 className 还是 "box"id 还是 "test"。于是它跳过这些属性。它只处理 style{color: 'red'} 变成了 {color: 'blue'}

这就是“静态属性注入”的高级玩法:

React 并不是简单地“注入”属性,它是“智能注入”。它利用静态的映射规则,快速判断哪些属性需要注入,哪些不需要。

八、 深入细节:valuechecked 的坑

在处理静态属性映射时,有两个属性特别狡猾:valuechecked。它们属于“受控组件”的核心。

对于 <input value="hello" />

  1. React 的 Props{ value: 'hello' }
  2. DOM 的行为 是:如果你设置了 value,用户输入的内容会被强制覆盖'hello'
  3. 映射注入:React 在 completeWork 里会执行 node.value = 'hello'

对于 <input checked={true} />

  1. React 的 Props{ checked: true }
  2. DOM 的行为checked 属性决定了复选框是否选中。
  3. 映射注入:React 在 completeWork 里会执行 node.checked = true

这种注入方式,让 React 完全掌控了 DOM 的状态。无论用户怎么去点,只要 React 的 Props 没变,DOM 的表现就永远符合 React 的预期。这就是 React 的魔力所在。

九、 总结:幕后英雄

所以,回到我们的讲座主题。

completeWork 阶段,React 并没有搞什么复杂的魔法。它就是一个勤勤恳恳的翻译官和整理员。

  1. 它拿着你的 workInProgress 树(草稿)。
  2. 它对照着 DOMAttributeNames 这本字典(静态规则)。
  3. 它拿着 CSSPropertyOperations 这把手术刀(样式转换)。
  4. 它拿着 DOMPropertyOperations 这把尺子(布尔值和标准属性)。
  5. 它拿着 dangerouslySetInnerHTML 这把锤子(危险操作)。
  6. 一次性遍历了所有的 Props,进行了差异比对,只把变了的属性注入到了 DOM 节点里。

它没有把所有属性都重写一遍,它只注入了必要的部分。它把 React 的声明式代码,翻译成了浏览器听得懂的命令式 DOM 操作。

这就是 React 静态属性注入的真相:在幕后,用最严谨的规则,把最复杂的逻辑,变成了最平滑的交互。

下次当你看到屏幕上的按钮变了颜色,或者输入框的值变了,别忘了,在 React 的深处,有一个函数正在拿着映射表,默默地为你把代码变成现实。

好了,今天的讲座就到这里。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注