Skip to content

Deep Dive

menglingyu.213 edited this page Mar 15, 2026 · 1 revision

深度解析:react-dom-transfer 工作原理

从一个问题说起

假设你有一个 <video> 正在播放,它在页面上的"容器 A"里。现在你想把它移到"容器 B"里继续播放。

听起来很简单?

const video = containerA.querySelector('video');
containerB.appendChild(video);

纯 DOM 操作确实可以,视频不会中断。但如果你用 React,问题就来了。

React 为什么做不到

React 不直接操作 DOM。它维护一棵虚拟的组件树,然后把这棵树"映射"到真实 DOM 上。

React 组件树                    真实 DOM
─────────────                  ─────────
<App>                          <div id="app">
  <ContainerA>                   <div class="a">
    <VideoPlayer />     →          <video src="...">
  </ContainerA>                  </div>
  <ContainerB />                 <div class="b"></div>
</App>                         </div>

当你想把 <VideoPlayer /><ContainerA> 移到 <ContainerB> 时,你需要改组件树的结构:

// 之前
<ContainerA><VideoPlayer /></ContainerA>
<ContainerB />

// 之后
<ContainerA />
<ContainerB><VideoPlayer /></ContainerB>

React 看到这个变化后会做什么?

它会销毁 ContainerA 里的 <VideoPlayer />,然后在 ContainerB 里创建一个全新的。

因为在 React 的 reconciliation 算法看来,组件换了父节点就是一个新组件。旧的要 unmount,新的要 mount。对应的 DOM 节点(<video>)也是先删、再建。

视频重新开始了。

这个库怎么绕过的

我们的思路是:不改 React 的组件树,只偷偷搬 DOM。

React 认为的世界:                实际的 DOM:
─────────────────                ──────────
<ContainerA>                     <div class="a">
  <VideoPlayer />  →  stateNode → ??? (节点不在这了)
</ContainerA>
                                 <div class="b">
<ContainerB />                     <video> (节点在这!)
                                 </div>

React 的组件树完全没变。<VideoPlayer /> 还是 <ContainerA> 的子组件。但真实 DOM 里,<video> 元素已经被搬到 ContainerB 了。

React 不知道这件事。 只要它不去检查 DOM 和 Fiber 的一致性(它确实不会),一切正常运行。

具体分三步

第一步:实施 FLIP 动画

FLIP 是 First, Last, Invert, Play 的缩写。

1. First  - 记录元素当前位置  { left: 100, top: 200, width: 300, height: 200 }
2. 把元素从容器里取出来,贴到 <body> 上,position: fixed 在原位
3. Last   - 算出目标容器的位置  { left: 400, top: 50, width: 600, height: 400 }
4. 给元素加 CSS transition
5. Play   - 改 left/top/width/height → 浏览器自动补间动画
6. 动画结束后,把元素塞进目标容器,去掉 fixed 定位

这一步纯 DOM 操作,没有 React 参与。效果就是元素从 A "飞"到 B。

第二步:放置占位符

元素被取出后,源容器会塌陷。所以我们放一个同等大小的空 <div> 占住位置:

const placeholder = document.createElement('div');
placeholder.style.width = '300px';   // 和原元素一样
placeholder.style.height = '200px';
containerA.replaceChild(placeholder, videoElement);

第三步:替换 Fiber 的 stateNode

这是最关键的一步。React 内部用一种叫 Fiber 的数据结构来管理每个组件:

Fiber 节点 {
  type: 'div',
  stateNode: <实际的 DOM 元素>,   ← 这就是 React 操作 DOM 的入口
  child: ...,
  sibling: ...,
  return: ...,                    ← 这些是 Fiber 树的链表指针
  alternate: <另一棵 Fiber 树>,    ← React 的"双缓冲"
}

React 把 Fiber 信息挂在了 DOM 元素上,通过 __reactFiber$xxx 属性:

// 任何一个 React 渲染的 DOM 元素都有这个
element.__reactFiber$abc123 === Fiber节点

我们做的事情就是:

// 1. 找到 React 挂在元素上的 Fiber 相关属性
const reactKeys = Object.keys(element).filter(k => k.startsWith('__react'));
// 通常有:__reactFiber$xxx, __reactProps$xxx, __reactEvents$xxx

// 2. 把这些属性复制到占位符上
//   (因为 React 认为这个位置应该有个有效的 DOM,占位符在那个位置)
for (const key of reactKeys) {
  placeholder[key] = element[key];
}

// 3. 更新 Fiber 的 stateNode 指向
//    告诉 React:"你的 DOM 引用现在指向 placeholder"
//    然后当我们把真实元素搬到新位置时,React 不会去找它
fiber.stateNode = placeholder;
fiber.alternate.stateNode = placeholder;  // 双缓冲树也要改

等等,为什么是让 stateNode 指向 placeholder,而不是指向搬走的元素?

因为 React 的 Fiber 树结构里,<VideoPlayer /> 还是 <ContainerA> 的子节点。React 如果需要更新这个组件的 DOM(比如 re-render),它会通过 fiber.stateNode 找到对应的 DOM 元素去操作。我们让它指向 placeholder,这样 React 操作的是一个"替身",不会影响已经搬走的真实元素。

真实的 <video> 元素已经在 ContainerB 里了,它不受 React 管理,但它的状态(播放进度、音量、弹幕等)全部保留。

为什么不改 Fiber 树结构

你可能会想:为什么不直接把 Fiber 节点从 ContainerA 的子树里移到 ContainerB 的子树里?

这就是 react-reparenting 做的事。它修改 Fiber 的 childsiblingreturn 指针,真正地把组件"搬"到新的父级下面。

但这会引发一系列副作用:

Context 断裂

React 的 Context 是沿着 Fiber 树的 return 指针向上查找的:

VideoPlayer → ContainerA → App
                          ↑
                  Context Provider 在这里

如果把 VideoPlayer 的 Fiber 移到 ContainerB 下面:

VideoPlayer → ContainerB → App
                          ↑
                  可能经过不同的 Provider 链路

如果 ContainerA 和 ContainerB 上面的 Provider 不同(很常见,比如不同的 Store、不同的 Theme),VideoPlayer 拿到的 Context 值就突然变了。

Effects 重跑

React 在 Fiber 树结构变化时会触发 useEffect 的 cleanup + re-run。你的播放器里可能有这样的代码:

useEffect(() => {
  player.loadStream(url);         // 拉流
  player.initPlugins(plugins);    // 初始化插件
  return () => player.destroy();  // 清理
}, []);

Fiber 树一改,cleanup 先跑(player.destroy()),然后 effect 再跑(重新拉流、重新初始化)。等于播放器被"逻辑上"重建了一遍。

我们的做法没有这些问题

因为 Fiber 树结构完全没动。childsiblingreturn 指针全是旧的。React 的 Context 查找、Effect 执行、reconciliation 都不会被触发。React 认为什么都没发生过。

恢复(restore)怎么做

用户点"返回"时,反向操作:

  1. 记录元素在 ContainerB 里的位置
  2. 取出,position: fixed 在原位
  3. CSS transition 飞回 ContainerA 的位置
  4. 动画结束后,用元素替换掉 placeholder
  5. 把 Fiber 的 stateNode 指回真实元素

一切恢复原样,React 还是不知道。

什么时候会出问题

React 大版本升级

__reactFiber$ 属性名是 React 内部实现,不是公开 API。如果 React 改了命名规则(比如改成 __reactInternalInstance$),库就找不到 Fiber 了。

我们做了兜底:如果找不到 Fiber 属性,会在 dev 模式下打一个 warning,不会静默失败。

父组件 re-render

如果 ContainerA re-render 时试图操作子元素(比如改 style),它会操作到 placeholder 上,不会影响真实元素。这通常不是问题,但在某些极端情况下(比如父组件直接 ref 操作子 DOM)可能有意外。

并发模式

React 18 的并发模式下,fiber.alternate 可能在意想不到的时机被使用。目前测试下来没有问题,但这是一个潜在的风险点。

一句话总结

不碰 React 的组件树,只偷换 DOM 节点的引用。React 以为还在管理旧位置的元素,实际上元素已经在新位置了。

这是一个 hack,但是是一个经过生产环境验证的、最小化副作用的 hack