-
Notifications
You must be signed in to change notification settings - Fork 0
Deep Dive
假设你有一个 <video> 正在播放,它在页面上的"容器 A"里。现在你想把它移到"容器 B"里继续播放。
听起来很简单?
const video = containerA.querySelector('video');
containerB.appendChild(video);纯 DOM 操作确实可以,视频不会中断。但如果你用 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 是 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);这是最关键的一步。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 节点从 ContainerA 的子树里移到 ContainerB 的子树里?
这就是 react-reparenting 做的事。它修改 Fiber 的 child、sibling、return 指针,真正地把组件"搬"到新的父级下面。
但这会引发一系列副作用:
React 的 Context 是沿着 Fiber 树的 return 指针向上查找的:
VideoPlayer → ContainerA → App
↑
Context Provider 在这里
如果把 VideoPlayer 的 Fiber 移到 ContainerB 下面:
VideoPlayer → ContainerB → App
↑
可能经过不同的 Provider 链路
如果 ContainerA 和 ContainerB 上面的 Provider 不同(很常见,比如不同的 Store、不同的 Theme),VideoPlayer 拿到的 Context 值就突然变了。
React 在 Fiber 树结构变化时会触发 useEffect 的 cleanup + re-run。你的播放器里可能有这样的代码:
useEffect(() => {
player.loadStream(url); // 拉流
player.initPlugins(plugins); // 初始化插件
return () => player.destroy(); // 清理
}, []);Fiber 树一改,cleanup 先跑(player.destroy()),然后 effect 再跑(重新拉流、重新初始化)。等于播放器被"逻辑上"重建了一遍。
因为 Fiber 树结构完全没动。child、sibling、return 指针全是旧的。React 的 Context 查找、Effect 执行、reconciliation 都不会被触发。React 认为什么都没发生过。
用户点"返回"时,反向操作:
- 记录元素在 ContainerB 里的位置
- 取出,
position: fixed在原位 - CSS transition 飞回 ContainerA 的位置
- 动画结束后,用元素替换掉 placeholder
- 把 Fiber 的
stateNode指回真实元素
一切恢复原样,React 还是不知道。
__reactFiber$ 属性名是 React 内部实现,不是公开 API。如果 React 改了命名规则(比如改成 __reactInternalInstance$),库就找不到 Fiber 了。
我们做了兜底:如果找不到 Fiber 属性,会在 dev 模式下打一个 warning,不会静默失败。
如果 ContainerA re-render 时试图操作子元素(比如改 style),它会操作到 placeholder 上,不会影响真实元素。这通常不是问题,但在某些极端情况下(比如父组件直接 ref 操作子 DOM)可能有意外。
React 18 的并发模式下,fiber.alternate 可能在意想不到的时机被使用。目前测试下来没有问题,但这是一个潜在的风险点。
不碰 React 的组件树,只偷换 DOM 节点的引用。React 以为还在管理旧位置的元素,实际上元素已经在新位置了。
这是一个 hack,但是是一个经过生产环境验证的、最小化副作用的 hack。