别让路由切换,变成组件的"告别仪式"
从一次"页面回魂"需求说起
产品经理走过来,拍了拍我的肩膀:
"用户从首页进详情,再返回首页,希望滚动位置还在原来那里,表单填写的内容也别丢。"
我看了眼代码------react-router 一切换路由,组件直接 unmount,DOM 连渣都不剩。
这哪是返回首页,这是让首页重新投胎。
于是我开始思考:能不能让某些组件假死------看起来没了,实际上藏在内存里,等需要时再"还魂"?
这种模式,在 Vue 里叫 <KeepAlive>,在 React 里......没有官方实现。
那我干脆手写一个。
为什么 React 没有内置 KeepAlive?
Vue 的 <KeepAlive> 基于其模板编译器和组件实例化机制,可以轻松控制组件是否销毁。
而 React 的哲学是 "UI = f(state)",组件销毁/重建是常态,缓存反而破坏了"单向数据流"的纯粹性。
但现实业务不跟你讲哲学------高频切换的 Tab、详情页返回列表、表单分步填写,用户根本不关心你的渲染模式,他们只关心:
"我刚才填的东西怎么没了?"
所以,我们需要自己造轮子。
核心思路:不卸载,只隐藏
React 组件卸载是因为 DOM 被移除。
那我们换个做法:
- 首次渲染:正常挂载 DOM
- 切换离开 :不卸载,只把
display设为none - 切换回来 :把
display改回block
这样组件实例一直存在,state、ref、滚动位置全部保留。
听起来简单,但实现时有两个关键点:
- 渲染容器必须独立于父组件,不能随父组件 re-render 而丢失
- 渲染逻辑要区分"首次挂载"和"后续显示/隐藏"
手写实现:从 0 到 1 的 KeepAlive
直接上完整代码(基于你提供的版本,我做了增强和注释):
jsx
import {
useEffect,
useRef
} from 'react';
import ReactDOM from 'react-dom';
export default function KeepAlive({ active, children }) {
const containerRef = useRef(null);
const contentRef = useRef(null);
useEffect(() => {
// 挂载
if(!contentRef.current) {
contentRef.current = document.createElement('div');
containerRef.current.appendChild(contentRef.current);
ReactDOM.render(children, contentRef.current);
}
return () => {
contentRef.current.style.display = 'none';
}
}, []);
useEffect(() => {
// 更新
if(contentRef.current) {
if(active) {
contentRef.current.style.display = 'block';
} else {
contentRef.current.style.display = 'none';
}
}
}, [active]);
return (
<div ref={containerRef}>
{children}
</div>
);
}
使用示例
tsx
import { useState } from 'react';
import KeepAlive from './KeepAlive';
export default function App() {
const [tab, setTab] = useState<'home' | 'about'>('home');
return (
<div>
<button onClick={() => setTab('home')}>首页</button>
<button onClick={() => setTab('about')}>关于</button>
<KeepAlive active={tab === 'home'}>
<HomePage />
</KeepAlive>
<KeepAlive active={tab === 'about'}>
<AboutPage />
</KeepAlive>
</div>
);
}
当 active 为 false 时,组件 DOM 隐藏但实例保留,状态完整。
避坑指南:我踩过的 3 个坑
坑 1:ReactDOM.render 已经废弃?
在 React 18 中,ReactDOM.render 被标记为 Legacy。
如果你用 React 18+,建议改用 createRoot:
tsx
import { createRoot } from 'react-dom/client';
// 在首次挂载时
const root = createRoot(div);
root.render(<>{children}</>);
// 记得保存root实例以便后续更新
坑 2:children 变化时怎么更新?
上面的实现只在首次挂载时渲染一次 children。如果 children 本身会变(比如传入了不同组件),你需要额外处理:
tsx
useEffect(() => {
if (contentRef.current) {
// 重新渲染更新内容
root.render(<>{children}</>);
}
}, [children]);
但这样会丢失子组件内部状态,所以 KeepAlive 通常只缓存同一个组件实例。
坑 3:多个 KeepAlive 同时存在会怎样?
每个 KeepAlive 都会创建独立的 DOM 容器,互不干扰。但要注意:
- 如果同时缓存 10 个大型组件,内存占用会上升
- 建议配合 LRU 策略,只缓存最近 N 个
进阶:支持"销毁"和"恢复"生命周期
Vue 的 KeepAlive 提供了 activated / deactivated 钩子。
我们可以在 React 中模拟:
tsx
useEffect(() => {
if (active) {
// 组件被激活(从隐藏变为显示)
console.log('activated');
} else {
// 组件被停用(从显示变为隐藏)
console.log('deactivated');
}
}, [active]);
结合 useMemo 或 useCallback 传递回调,就能让子组件感知缓存状态。
对比 Vue 的 KeepAlive:我们差在哪里?
| 特性 | Vue 内置 | 手写实现 |
|---|---|---|
| 缓存实例 | ✅ 自动 | ✅ 手动控制 |
| 最大缓存数 | ✅ LRU | ❌ 需自己实现 |
| 条件排除 | ✅ include/exclude | ❌ 需业务层判断 |
| 生命周期钩子 | ✅ activated/deactivated | ⚠️ 可模拟 |
| 内存回收 | ✅ 自动 | ⚠️ 需手动管理 |
我们的版本是"最小可用原型",适合理解原理,生产环境建议封装更完善的库。
实战场景:哪些地方非用不可?
-
Tab 切换 (订单列表/商品列表)
用户切换 Tab 后返回,不想重新加载数据
-
多步骤表单 (分步注册)
用户从第 3 步跳到第 1 步,再回来时第 3 步内容还在
-
视频/音频播放器
切换页面后继续播放,不中断
-
地图组件
重新加载地图会重置视角和标记
性能考量:缓存不是银弹
- 内存 :每个缓存的组件都保留完整 DOM 和 JS 实例,大型列表页慎用
- 更新:隐藏的组件不会触发 re-render,但事件监听仍然存在
- 最佳实践 :只缓存 "高频切换且构建成本高" 的组件
结语:从"轮子"到"理解"
手写 KeepAlive 最大的收获,不是写出了一个可用的组件,而是彻底理解了 React 渲染机制和 DOM 操作边界。
- 原来组件不是非得卸载
- 原来 DOM 可以"寄生"在任意容器里
- 原来
useRef可以跨渲染保存实例
下次产品再提"保留状态"的需求,你不会再头皮发麻------
你会微微一笑,掏出自己的 KeepAlive,然后说:
"放心,我让组件原地复活,不投胎。"