React useRef 的宿主绑定:探究在 completeWork 阶段如何将物理 DOM 实例写入 ref.current 指针

各位老铁,大家好!

今天咱们不聊花哨的 Hooks,也不谈那些让你掉头发的面试八股文,咱们来聊聊 React 最底层、最硬核、最接近浏览器那一层的一块基石——DOM 宿主绑定

特别是大家耳熟能详的 useRef,这个看似简单的小钩子,它的背后其实是一场精心编排的“魔术”。

想象一下,你是一个建筑师。你在大脑里(内存中)画好了蓝图(虚拟 DOM),然后你派了一群工人(Fiber 节点)去现场施工。到了最后一步,也就是“完工验收”的时候,也就是 React 的 completeWork 阶段,这些工人需要把图纸上的东西变成真砖头。这时候,如果某个工人的口袋里揣着一张“优先提货单”(ref 属性),他必须在拿到真砖头的那一刻,立刻把砖头的所有权(引用)交给这张提货单。

今天,我就要带大家走进 completeWork 的后台,亲眼看看这个“提货”的过程。


第一幕:React 的建筑工地——Fiber 树与 completeWork

首先,咱们得把背景音乐换一下。React 的渲染流程,本质上是一个递归遍历的过程。我们通常把渲染过程分为两个大阶段:

  1. Render 阶段:计算什么该变,什么不该变,生成新的 Fiber 树。这一阶段是“纯计算”,不涉及 DOM 操作。
  2. Commit 阶段:把 Render 阶段计算好的结果,真正地写到页面上。

但是!注意这个“但是”! 在 Commit 阶段之前,还有一个至关重要的中间环节,那就是 completeWork

completeWork 是什么?它是 React 内部的一个函数(通常位于 workLoopperformUnitOfWork 中调用),它的任务非常明确:将 Fiber 节点转换成真实的 DOM 节点,并将其挂载到父节点上。

它是“宿主绑定”的核心。没有它,你的组件永远只是内存里的幽灵。

第二幕:宿主绑定的大管家——completeWork 的登场

completeWork 开始工作时,它手里拿着一个指针,叫 workInProgress。这个指针指向当前正在处理的 Fiber 节点。

它的核心逻辑通常长这样(简化版):

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

  // 根据不同的类型,走不同的“装修”流程
  switch (tag) {
    case HostComponent: // 比如div, span, p
      return completeHostComponent(current, workInProgress, nextChildren);
    case HostText: // 比如字符串 "Hello"
      return completeHostTextComponent(workInProgress);
    case Fragment:
      // ...
    default:
      return null;
  }
}

咱们今天只看 HostComponent(比如 <div>)。因为这是 DOM 的最基本单元,也是 useRef 最常出现的地方。

第三幕:从幽灵到实体——createInstance 的魔法

completeWork 判定当前节点是 HostComponent 时,它首先得去浏览器里创建一个真实的 <div> 元素。

这一步叫 createInstance(或者类似的命名,取决于 React 版本,这里统称“创建实例”)。

// 简化的逻辑
function createInstance(type, props, rootContainerInstance) {
  // 假设 type 是 'div'
  const domNode = document.createElement(type);

  // 设置一些基本属性,比如 className
  if (props.className) {
    domNode.className = props.className;
  }

  // 设置事件监听器(这部分非常复杂,React 会做很多优化,比如事件委托)
  // ...

  return domNode;
}

此时,内存里有一个 Fiber 节点(逻辑),浏览器里有一个 DOM 节点(物理)。但是,这两个家伙还是陌生人,谁也不认识谁。

第四幕:挂载与递归——appendInitialChild

有了 DOM 实例,接下来要把它放对位置。React 需要把它挂载到父节点的 DOM 实例下面。

completeWork 会先处理完子节点,确保子节点都安顿好了,然后再把自己挂上去。这就像盖房子,先盖完二楼,再把三楼盖上去,最后把屋顶盖上。

这个过程叫 appendInitialChild

function appendInitialChild(parentInstance, child) {
  parentInstance.appendChild(child);
}

关键点来了! 在这个过程中,React 会递归调用 completeWork。也就是说,它会一层层往下深入,把你的组件树从根节点到叶子节点,全部变成真实的 DOM。

第五幕:灵魂时刻——宿主绑定与 ref 的对接

好了,现在我们站在了一个具体的 DOM 节点面前(比如一个 <div>)。它已经被创建出来了,也被插到了正确的位置。

这时候,React 的 completeWork 需要检查一个关键问题:“嘿,这个小家伙,有没有什么特殊要求?”

这个特殊要求,就是 ref

在 React 的 Fiber 节点结构中,props 属性里可能包含一个 ref

// Fiber 节点内部结构示意
{
  type: 'div',
  props: {
    id: 'my-box',
    className: 'box',
    ref: React.createRef(), // 这个 ref 对象
    children: [...]
  },
  // ...
}

completeWorkHostComponent 分支逻辑里,当所有 DOM 操作都做完之后,会有一个专门处理 Ref 的步骤。这通常发生在 finalizeInitialChildren 或者是 commitPlacement 之后,但在 commitRoot 之前。

让我们来一段高仿 React 源码的“揭秘”:

function completeHostComponent(current, workInProgress, renderState) {
  // 1. 确保子节点都处理完了
  // ... 递归处理子节点的代码 ...

  // 2. 获取刚刚创建出来的 DOM 实例
  // 在 React 内部,这个实例通常保存在 workInProgress.stateNode 中
  const domNode = workInProgress.stateNode;

  // 3. 核心步骤:检查是否有 ref
  const props = workInProgress.memoizedProps; // 或者 pendingProps

  if (props.ref !== null) {
    // 噢!有 ref!这可是个大活儿!
    // React 有两种 ref:对象形式 和 回调函数形式
    if (typeof props.ref === 'function') {
      // === 情况 A:回调函数 ===
      // React 会立即执行这个函数,把刚刚创建的 domNode 传进去
      // 类似于:ref.current = domNode
      props.ref(domNode);
    } else {
      // === 情况 B:createRef 返回的对象 ===
      // 这是一个引用传递
      // React 直接把刚刚创建的 domNode 赋值给这个对象的 current 属性
      props.current = domNode;
    }
  }

  // 4. 返回工作节点,准备提交给 Commit 阶段
  return workInProgress;
}

这就是你要找的答案!

completeWork 阶段,当 React 确信 DOM 节点已经创建完毕,并且已经正确地挂载在父节点上了,它才会去处理 ref

为什么这么做?
因为 ref 需要的是一个真实的、存在的 DOM 引用。你不能在 DOM 还没创建的时候就把引用塞给它。completeWork 就是那个“检查员”,只有当货物(DOM)已经上架(挂载),它才会把提货单(ref)交出去。

第六幕:实战演练——代码模拟

为了让大家看得更明白,咱们来写一个极简版的 React 渲染器。不要怕,我会用大白话解释每一行代码。

// 1. 定义一个假的数据结构,模拟 Fiber
class FiberNode {
  constructor(tag, type, props) {
    this.tag = tag; // HostComponent, HostText 等
    this.type = type; // 'div', 'span'
    this.props = props; // { id: 'a', ref: ..., children: ... }
    this.stateNode = null; // 这个节点对应的 DOM 实例
    this.return = null; // 父节点
    this.child = null; // 第一个子节点
    this.sibling = null; // 下一个兄弟节点
    this.memoizedProps = props; // 渲染后的 props
  }
}

// 2. 模拟 createInstance
function createDOM(type, props) {
  const dom = document.createElement(type);
  // 简单设置一些属性
  if (props.className) dom.className = props.className;
  return dom;
}

// 3. 模拟 completeWork 的核心逻辑
function completeWork(workInProgress) {
  const tag = workInProgress.tag;
  const newProps = workInProgress.memoizedProps;

  if (tag === 'HostComponent') {
    // --- 开始处理 HostComponent ---

    // 步骤 A: 创建 DOM 实例
    // React 会把 DOM 实例存在 stateNode 里,方便后续引用
    const domNode = createDOM(workInProgress.type, newProps);
    workInProgress.stateNode = domNode;

    // 步骤 B: 递归处理子节点
    // 这里省略了具体的递归遍历逻辑,假设已经处理完了所有子节点
    // 并且把子节点的 DOM 实例挂载到了 domNode 下面

    // 步骤 C: 宿主绑定——处理 ref
    // 注意:这一步必须发生在 DOM 已经创建之后!
    if (newProps.ref) {
      console.log("🔍 [completeWork] 发现 ref,开始绑定...");

      if (typeof newProps.ref === 'function') {
        // 回调模式
        newProps.ref(domNode);
      } else {
        // 对象模式
        newProps.current = domNode;
      }
      console.log("✅ [completeWork] 绑定完成,ref.current 指向了 DOM 节点。");
    } else {
      console.log("ℹ️ [completeWork] 没有 ref,跳过绑定。");
    }

    return workInProgress;
  }

  return null;
}

// --- 测试场景 ---

// 我们构建一个简单的组件树:<div ref={r => myRef = r}>Hello</div>
const myRef = { current: null }; // 模拟 createRef 的结果

const fiberDiv = new FiberNode('HostComponent', 'div', {
  ref: myRef, // 这里我们直接传对象,模拟 createRef
  className: 'container',
  children: [new FiberNode('HostText', 'Hello', { text: 'Hello' })]
});

// 开始渲染流程
console.log("--- 开始 completeWork ---");
const result = completeWork(fiberDiv);

// 检查结果
console.log("--- 检查结果 ---");
console.log("DOM 节点:", fiberDiv.stateNode);
console.log("ref.current:", myRef.current);

// 输出:
// --- 开始 completeWork ---
// 🔍 [completeWork] 发现 ref,开始绑定...
// ✅ [completeWork] 绑定完成,ref.current 指向了 DOM 节点。
// --- 检查结果 ---
// DOM 节点: <div class="container">Hello</div>
// ref.current: <div class="container">Hello</div>

看到没?这就是 completeWork 的宿主绑定过程。它就像是给 DOM 节点“上户口”,把它的地址(引用)写到了 ref.current 这个本子上。

第七幕:为什么偏偏是 completeWork?

你可能会问:“老铁,为什么不在 createInstance 里写 ref.current 呢?”

这就涉及到 React 的架构哲学了。

createInstance 只是负责“造砖头”。这时候砖头还没砌好,也没放对位置。如果你这时候就把砖头引用给出去,万一后续发现这个砖头放错了位置,或者需要调整属性,你还得去改 ref.current。这会导致不必要的频繁更新。

completeWork 是“收尾工作”。它确保了:

  1. 砖头(DOM)已经造好了。
  2. 砖头已经砌到正确的墙上了(appendChild)。
  3. 砖头的属性(className, style)都设置好了。

在这个最终状态确定的一瞬间,React 才去更新 ref。这保证了 ref.current 永远指向一个稳定且正确的 DOM 实例。

第八幕:更新时的宿主绑定

好了,咱们刚才讲的是首次渲染(挂载)时的场景。那如果是更新(Re-render)呢?比如你点击了按钮,组件重新渲染了,useRef 里的值会变吗?

答案是:值不变,但引用可能会变。

让我们看看更新流程。

当组件重新渲染时,React 会生成一个新的 workInProgress Fiber 节点。如果类型没变(比如还是 <div>),React 会复用之前的 DOM 节点(stateNode)。

completeWork 的更新路径中,逻辑是这样的:

function completeWork(current, workInProgress) {
  // ... 省略部分代码 ...

  if (workInProgress.tag === HostComponent) {
    // 1. 复用 DOM 实例
    // React 发现 current 节点也是 div,于是直接复用 workInProgress.stateNode
    // 这个 stateNode 就是旧的 DOM 元素
    workInProgress.stateNode = current.stateNode;

    // 2. 处理子节点更新
    // ...

    // 3. 再次处理 ref
    if (workInProgress.memoizedProps.ref) {
       // 这里不需要重新赋值,因为 DOM 节点没变,引用没变
       // ref.current 依然是同一个 DOM 对象
       // 但是!如果是函数式 ref,它会再次被调用!
       if (typeof workInProgress.memoizedProps.ref === 'function') {
         workInProgress.memoizedProps.ref(workInProgress.stateNode);
       }
    }

    return workInProgress;
  }
}

这里有一个非常容易踩坑的点!

如果你使用的是函数式 ref

const ref = (node) => {
  console.log("Ref callback called with:", node);
  myDomRef = node;
};
<div ref={ref} />

每次组件渲染,completeWork 都会再次执行这个函数!即使 DOM 没变,只要父组件重新渲染了,这个函数就会被触发。这就是为什么有些老铁在函数式 ref 里做副作用要小心,不要在里面做太重的事情。

如果你使用的是对象式 ref

const myRef = React.createRef();
<div ref={myRef} />

myRef.current 的值只有在组件首次挂载时才会从 null 变成 DOM。在后续的更新中,除非你手动把它设为 null 或者替换组件,否则它永远不会变

第九幕:卸载时的宿主绑定

最后,咱们得聊聊“分手”的时候。

当组件卸载(Unmount)时,completeWork 的逻辑会发生反转。React 会进入 unmount 阶段。

在 React 18 的并发模式下,卸载逻辑也是通过某种形式的 completeWork 变体或者专门的 unmountChildFibers 来处理的。

当 React 决定销毁这个组件树时,它会遍历所有节点,执行清理工作。

// 卸载时的简化逻辑
function unmountFiber(fiber) {
  // 1. 如果有 ref,把 current 设为 null
  if (fiber.memoizedProps.ref) {
    if (typeof fiber.memoizedProps.ref === 'function') {
      fiber.memoizedProps.ref(null);
    } else {
      fiber.memoizedProps.current = null;
    }
  }

  // 2. 从 DOM 树中移除节点
  if (fiber.stateNode) {
    // 如果是 HostComponent,移除 DOM
    // 如果是 HostText,移除文本节点
    // ...
  }

  // 3. 递归卸载子节点
  // ...
}

这一步非常重要!如果你在 useRef 里保存了 DOM 引用,然后在组件卸载后还想访问它,你会得到一个悬空引用

React 的开发者非常聪明,他们在卸载阶段会帮你把 ref.current 置为 null。这就像是你搬家时,房东帮你把钥匙收走了,虽然房子还在那里,但你再也进不去了。

第十幕:Ref 回调 vs Ref 对象——谁更灵活?

既然我们聊到了宿主绑定,咱们就深入对比一下这两种 ref 的实现差异。

1. Ref 对象 (createRef)

  • 实现React.createRef() 返回一个 { current: null } 对象。
  • 宿主绑定时机:在 completeWork 阶段,React 发现 props.ref 是一个对象,它直接执行 props.ref.current = domNode
  • 特点:简单,直接。你只要访问 ref.current 就能拿到 DOM。
  • 缺点:无法在挂载前(null 时)和卸载后(null 时)做自定义逻辑。

2. Ref 回调函数

  • 实现:一个普通函数 ref(node) => { ... }
  • 宿主绑定时机:同样在 completeWork 阶段,React 执行 ref(domNode)
  • 特点:极度灵活。你可以在函数里做任何事情:存入数组、存入 Map、或者仅仅是打印日志。
  • 缺点:每次渲染都会调用。这意味着如果你在函数里做了副作用,必须极其小心,否则会导致内存泄漏或逻辑错误。

代码对比:

// Ref 对象模式
function MyComponent() {
  const inputRef = React.useRef(null);

  React.useEffect(() => {
    // 这里 inputRef.current 一定有值
    console.log(inputRef.current.value); 
  }, []);

  return <input ref={inputRef} />;
}

// Ref 回调模式
function MyComponent2() {
  const [refs, setRefs] = React.useState([]);

  const handleRef = (node) => {
    if (node) {
      // 挂载时
      setRefs(prev => [...prev, node]);
    } else {
      // 卸载时
      setRefs(prev => prev.filter(n => n !== node));
    }
  };

  return <input ref={handleRef} />;
}

completeWork 的眼里,这两种模式没有本质区别,都是执行一段代码来“绑定”或“解绑”DOM 引用。

第十一幕:进阶——宿主绑定与 Fiber 的协同

为了更深刻地理解,咱们得看看 completeWork 的返回值。

function completeWork(current, workInProgress) {
  // ... 处理逻辑 ...

  // 如果是 HostComponent
  if (tag === HostComponent) {
    // ... 宿主绑定逻辑 ...

    // 返回 workInProgress
    return workInProgress;
  }

  // 如果是 HostText
  if (tag === HostText) {
    // ... 文本节点宿主绑定逻辑 ...
    return workInProgress;
  }

  // 如果是 Fragment (多个子节点)
  if (tag === Fragment) {
    // ... ...
    return workInProgress;
  }

  // 如果是 Portal (挂载到外部 DOM)
  if (tag === Portal) {
    // ...
    return workInProgress;
  }

  return null;
}

completeWork 的核心目的就是返回一个 Fiber 节点,这个节点现在不仅包含逻辑,还包含了宿主绑定(stateNode)和 Ref 状态。

这个返回值会被传给 Commit 阶段。Commit 阶段拿到这个节点,才会真正地把 stateNode(DOM)插入到页面里,或者把 ref.current 的值同步到浏览器的渲染环境。

可以说,completeWork 是 React 渲染逻辑(JS)与 宿主逻辑(DOM)之间的唯一桥梁。

第十二幕:总结与展望

好了,老铁们,咱们把时间轴拉回来。

我们今天穿越了 React 的内部世界,站在了 completeWork 的指挥台上。

  1. 起因:组件渲染,生成 Fiber 树。
  2. 过程completeWork 遍历 Fiber 树,针对 HostComponent 类型,调用 createInstance 生成 DOM,调用 appendInitialChild 挂载 DOM。
  3. 高潮:在 DOM 完全就绪、挂载完毕的瞬间,React 检查 props.ref。如果是对象,赋值 current;如果是函数,调用函数。
  4. 结果useRef 指向了一个真实的、稳定的 DOM 实例。

这就是 useRef 在 React 生命周期中的宿主绑定机制。

最后一点小贴士
如果你在写高性能应用,或者在做深度定制 React 的库,理解 completeWork 非常重要。因为很多 React 的特性,比如 refkeychildren 的处理,都是在这一层完成的。

如果你发现你的 ref 没有更新,或者 ref.currentnull,请第一时间检查:

  1. 你是不是在 useEffect 里访问 ref.current,而组件还没渲染完?(这是异步问题,不是宿主绑定问题)。
  2. 你是不是把 ref 写在了 forwardRef 的子组件里,而父组件没有传递?(这是 API 使用问题)。
  3. 你是不是在 completeWork 之前就想访问 DOM?(这是逻辑顺序问题)。

React 的宿主绑定就像一个耐心的管家,它永远等你把事情都做完了,再给你结果。这就是为什么 React 能让前端开发变得如此丝滑——因为它把繁杂的 DOM 操作都封装在了这些看似简单的 completeWork 流程里。

希望这篇讲座能让你对 useRef 有了全新的认识。下次当你敲下 ref.current 的时候,别忘了,在内存深处,有一个 completeWork 正在微笑着看着你,手里紧紧攥着那个刚刚诞生的 DOM 节点。

谢谢大家,下课!

发表回复

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