React篇 | KeepAlive组件
前言
如果你同时写过 Vue 和 React,一定遇到过这个场景:用户在列表页滚动到中间,点击某条进入详情页,返回时列表回到了顶部、筛选条件丢失——所有状态都重置了。
在 Vue 里,解决方案很简单:<keep-alive> 一包就行。但在 React 中,你会发现根本没有这个组件。
于是面试中经常出现这样的灵魂拷问:
- Vue 有 keep-alive,React 为什么不做?
- 社区那些 keep-alive 方案(react-activation、react-router cache)是怎么实现的?
- React 未来的 OffscreenComponent / Activity API 是什么?
- 如果让你手写一个简化版 KeepAlive,思路是什么?
这些问题考察的不是 API 记忆力,而是你对组件生命周期、DOM 操作、React 渲染机制的综合理解。
本章就来把这个话题聊透。
诊断自测
Q1:Vue 的 <keep-alive> 做了什么?React 中对应的行为是什么样的?
点击查看答案
Vue 的 <keep-alive> 会缓存被包裹组件的组件实例和 DOM 节点。当组件被切换走时,不销毁而是”停用”(deactivated),切换回来时”激活”(activated),所有状态和滚动位置都保留。
React 中,组件被卸载(unmount)时会彻底销毁:state 重置、DOM 移除、effect 清理。重新挂载时一切从零开始。React 没有内置的缓存组件机制。
Q2:为什么不能简单地用 CSS display: none 来”隐藏”组件实现缓存?有什么问题?
点击查看答案
用 display: none 可以保留 DOM 和组件状态,但有几个问题:
- 所有隐藏的组件仍然在 React 树中,它们的 effect 仍然在运行(比如定时器、事件监听、数据轮询)
- 性能开销:隐藏的组件仍然参与 reconciliation,增加不必要的渲染成本
- 内存泄漏风险:大量缓存的组件占用内存,且没有自动回收机制
- 语义不清:组件”看不见”但”活着”,容易引发各种意外行为
真正的 keep-alive 需要能够暂停组件的副作用,而不仅仅是隐藏 DOM。
一、Vue 的 keep-alive:它到底做了什么?
要理解 React 为什么不做 keep-alive,先得搞清楚 Vue 的 keep-alive 到底做了什么。
1.1 Vue keep-alive 的核心行为
<template>
<keep-alive>
<component :is="currentTab" />
</keep-alive>
</template>
当 currentTab 从 A 切换到 B 时,Vue 做的不是销毁 A、创建 B,而是:
- A 组件:从 DOM 树中移除 A 的根元素,但保留 A 的组件实例和 VNode 缓存。触发
deactivated钩子 - B 组件:如果 B 之前被缓存过,直接把 B 的 DOM 插入回去,触发
activated钩子。如果是第一次创建,正常走 mount 流程并缓存
关键点:Vue 的组件实例和 DOM 节点是强关联的,Vue 直接操控真实 DOM 的移除和插入,且组件实例在切换期间一直存在于内存中。
1.2 Vue 能做到的根本原因
Vue 的模板编译和运行时对 DOM 的操控是直接且细粒度的。Vue 的渲染器可以精确地把组件的 DOM 子树从文档中”拔出来”保存到一个离屏容器,需要时再”插回去”。
而且 Vue 的响应式系统是基于依赖追踪的——一个组件被 deactivated 后,因为没有人读取它的响应式数据,所以它的 watcher 自然不会触发更新。这使得”暂停”一个组件的开销非常低。
二、React 为什么不内置 keep-alive?
这不是因为 React 团队”懒”,而是有深层的设计原因。
2.1 React 的心智模型:UI = f(state)
React 最核心的理念是:UI 是状态的函数。 给定相同的 state 和 props,组件应该渲染出相同的 UI。
在这个模型下,组件的”生”和”死”是非常干净的:
- mount 时:从初始状态开始渲染
- unmount 时:清理所有副作用,释放所有资源
- 重新 mount 时:又从初始状态开始
keep-alive 打破了这个模型——它让一个”看起来被移除”的组件实际上还”活着”。这带来了一系列复杂性:
- 副作用管理:隐藏的组件里的
useEffect该不该继续运行?定时器呢?事件监听呢? - 内存管理:缓存多少组件?什么时候清理?谁来决定?
- 渲染一致性:一个被缓存的组件恢复时,如果它依赖的 context 已经变了怎么办?
2.2 技术层面的挑战
React 的 reconciliation 算法假设:当一个组件从树中移除时,它的整个 Fiber 子树会被清理。 keep-alive 意味着要保留一个已经从树中移除的 Fiber 子树,还要能在恢复时正确地重新接入。这涉及对 Fiber 树操作的非平凡修改。
此外,React 的 effect 系统是基于”mount/unmount”的——useEffect 的清理函数在组件 unmount 时执行。如果组件不真正 unmount,清理函数就不会执行,可能导致资源泄漏。但如果执行了清理函数,恢复时又需要重新执行 effect,这和”保留状态”的目标矛盾。
三、社区方案的实现原理
虽然 React 官方不提供 keep-alive,但社区有不少优秀的实现。它们的核心思路大致可以分为两类。
3.1 方案一:DOM 移动法(react-activation)
react-activation 是社区最流行的 keep-alive 方案之一。它的核心思路是:
把要"隐藏"的组件的 DOM 节点移动到一个离屏容器中,
需要时再移回来。
简化的实现原理:
// 1. 在组件树顶层创建一个"缓存容器"
function AliveScope({ children }) {
const [caches, setCaches] = useState({); // 缓存池
const containerRef = useRef(null); // 离屏容器
return (
<AliveScopeContext.Provider value={{ caches, containerRef }}>
{children}
{/* 离屏容器:缓存的组件实际渲染在这里 */}
<div ref={containerRef} style={{ display: 'none' }}>
{Object.values(caches).map(cache => cache.element)}
</div>
</AliveScopeContext.Provider>
);
}
// 2. KeepAlive 组件负责"搬运" DOM
function KeepAlive({ id, children }) {
const { caches, containerRef } = useContext(AliveScopeContext);
const placeholderRef = useRef(null);
useEffect(() => {
// 把离屏容器中缓存的 DOM 移到占位符位置
const cachedDOM = getCachedDOM(id);
if (cachedDOM) {
placeholderRef.current.appendChild(cachedDOM);
}
return () => {
// 组件卸载时,把 DOM 移回离屏容器
if (cachedDOM) {
containerRef.current.appendChild(cachedDOM);
}
};
}, [id]);
return <div ref={placeholderRef}>{!caches[id] && children}</div>;
}
优点:
- 真正保留了 DOM 状态(滚动位置、表单输入、视频播放状态)
- 组件的 React state 也通过保持在树中而得到保留
缺点:
- 依赖 DOM 操作,绕过了 React 的控制,可能与某些 React 特性冲突
- 被缓存的组件仍然”活在” React 树中,effect 仍在运行
- 无法优雅地暂停副作用
3.2 方案二:状态缓存法
另一种思路是不缓存 DOM,而是缓存组件的状态:
function KeepAlive({ activeKey, children }) {
const cacheRef = useRef({});
return React.Children.map(children, child => {
const key = child.key;
const isActive = key === activeKey;
// 用 display: none 隐藏非活跃组件
return (
<div style={{ display: isActive ? 'block' : 'none' }}>
{child}
</div>
);
});
}
这种方案更简单,但前面自测题提到的所有 display: none 的问题都存在。
3.3 React Router 中的缓存方案
在路由场景中,常见的做法是配合 react-router 做路由级缓存:
function CacheRoute({ component: Component, ...rest }) {
const [cached, setCached] = useState(false);
const [show, setShow] = useState(false);
return (
<Route
{...rest}
children={({ match }) => {
if (match) {
setCached(true);
setShow(true);
} else {
setShow(false);
}
return cached ? (
<div style={{ display: show ? 'block' : 'none' }}>
<Component />
</div>
) : null;
}}
/>
);
}
本质上还是 display: none 的思路,只是在路由层面做了封装。
四、React 未来的方案:Activity API(原 OffscreenComponent)
React 团队其实一直在规划一个官方的解决方案,最初叫 OffscreenComponent,后来改名为 Activity API。
4.1 Activity 的设计理念
Activity 的目标是提供一个React 原生的”可见/不可见”状态切换机制:
// 未来的 API(可能会变)
function App() {
const [tab, setTab] = useState('home');
return (
<>
<TabBar onChange={setTab} />
<Activity mode={tab === 'home' ? 'visible' : 'hidden'}>
<HomePage />
</Activity>
<Activity mode={tab === 'profile' ? 'visible' : 'hidden'}>
<ProfilePage />
</Activity>
</>
);
}
4.2 Activity 与社区方案的本质区别
Activity 和社区方案最大的区别在于:它是在 React 运行时层面实现的,能做到社区方案做不到的事情。
副作用管理:
// 当 Activity 切换到 hidden 时,React 会:
// 1. 执行所有 useEffect 的清理函数(相当于"暂停"副作用)
// 2. 但不销毁组件的 state 和 Fiber 子树
// 当 Activity 切换回 visible 时,React 会:
// 1. 重新执行 useEffect(相当于"恢复"副作用)
// 2. 组件的 state 完全保留
这解决了 display: none 方案最大的问题——隐藏的组件不会继续运行副作用。
渲染优先级:
Activity 与并发模式深度集成。切换到 hidden 的组件的更新会被标记为最低优先级——如果这个组件在 hidden 状态下接收到了新的 props 或 context,它的重渲染不会阻塞任何可见组件的渲染。
内存管理:
Activity 保留组件的 state 和 Fiber 结构,但可以选择释放 DOM 节点。React 运行时可以在内存压力大时自动清理部分缓存。
4.3 当前状态
截至 2025 年底,Activity API 仍然是实验性的,尚未正式发布。React 团队在 React Labs 的博文中多次提到这个特性,但没有给出确切的发布时间表。在它正式发布之前,我们仍然需要依赖社区方案。
五、手写简化版 KeepAlive 的思路
面试中经常会要求你手写一个简化版的 KeepAlive。不需要考虑所有边界情况,但要展示出对核心原理的理解。
5.1 核心思路
1. 维护一个缓存池(Map),key 是缓存标识,value 是 React 元素
2. 当前活跃的组件正常渲染
3. 非活跃的组件用 display:none 隐藏(简化方案)
4. 切换时不卸载组件,只切换 display
5.2 实现
import { useRef, useState, useCallback, createContext, useContext } from 'react';
const KeepAliveContext = createContext(null);
function KeepAliveProvider({ children }) {
const [caches, setCaches] = useState({});
const addCache = useCallback((key, element) => {
setCaches(prev => {
if (prev[key]) return prev;
return { ...prev, [key]: element };
});
}, []);
const removeCache = useCallback((key) => {
setCaches(prev => {
const next = { ...prev };
delete next[key];
return next;
});
}, []);
return (
<KeepAliveContext.Provider value={{ caches, addCache, removeCache }}>
{children}
{/* 所有缓存的组件在这里渲染 */}
{Object.entries(caches).map(([key, element]) => (
<div key={key} id={`keep-alive-${key}`} style={{ display: 'none' }}>
{element}
</div>
))}
</KeepAliveContext.Provider>
);
}
function KeepAlive({ cacheKey, active, children }) {
const { caches, addCache } = useContext(KeepAliveContext);
const initialized = useRef(false);
if (!initialized.current) {
addCache(cacheKey, children);
initialized.current = true;
}
// active 时把对应的缓存 DOM 显示出来
// 这里为了简化,直接用 CSS 控制
return (
<div>
{Object.keys(caches).map(key => (
<div
key={key}
style={{ display: key === cacheKey && active ? 'block' : 'none' }}
>
{caches[key]}
</div>
))}
</div>
);
}
5.3 面试中怎么答
面试时不需要把上面的代码一字不差写出来,更重要的是说清楚思路:
- 为什么要一个 Provider? 因为缓存池需要跨组件共享,用 Context 是最自然的方式
- 为什么用 display:none 而不是 unmount? 因为 unmount 会销毁 state,而我们要保留它
- 这个简化版有什么问题? 副作用不会暂停、所有缓存组件都参与 reconciliation、内存没有上限
- 如果要做得更好呢? 可以用 DOM 移动法(react-activation 的思路)、或者等 React 官方的 Activity API
常见误区
误区一:“React 用 useMemo 或 useRef 就能实现 keep-alive”
useMemo 缓存的是值,useRef 保持的是引用,它们都不能保留组件的 DOM 和完整状态。当组件被 unmount 时,useMemo 和 useRef 本身也会被销毁。它们解决的是”避免重复计算”,不是”避免重新挂载”。
误区二:“display: none 就够了,不需要搞那么复杂”
对于简单场景确实够用,但在实际项目中,display: none 的组件仍然执行 effect(定时器、WebSocket、轮询等),仍然参与 reconciliation,仍然占用内存。如果缓存的页面有重度逻辑(如实时图表、视频播放),这些看不见的开销会显著影响性能。
误区三:“React 未来的 Activity API 和 Vue 的 keep-alive 完全一样”
虽然目标类似,但设计思路不同。Vue 的 keep-alive 是基于组件实例缓存的——组件实例及其 DOM 完整保留。React 的 Activity 是基于Fiber 子树的状态保留 + 副作用暂停/恢复——更强调与并发模式的集成和副作用的正确管理。Activity 在 hidden 状态下会执行 effect 清理,这是 Vue keep-alive 不做的。
误区四:“社区方案稳定可靠,可以放心用在大型项目里”
社区方案(如 react-activation)的核心实现依赖于 React 的内部行为(如 DOM 操作、事件系统),这些行为在 React 版本更新时可能会变化。在 React 18 的并发模式下,某些社区方案可能出现兼容性问题。使用前一定要充分测试,并关注方案的维护状态。
小结
本章我们围绕”React 为什么没有 keep-alive”这个问题,分析了 Vue keep-alive 的原理、React 不内置的原因、社区方案的实现思路、以及 React 未来的 Activity API。
核心要点
- Vue keep-alive 通过缓存组件实例和 DOM 节点实现,依赖于 Vue 响应式系统的依赖追踪特性
- React 不内置的原因:与 “UI = f(state)” 心智模型冲突、副作用管理复杂、Fiber 树清理假设
- 社区方案主要有两类:DOM 移动法(react-activation)和 display:none 隐藏法
- Activity API(原 OffscreenComponent)是 React 未来的官方方案,能暂停/恢复副作用,与并发模式集成
- 手写思路:Provider + 缓存池 + display 切换,面试中说清思路和局限比代码完美更重要
本章思维导图
- Vue keep-alive 原理
- 缓存组件实例 + DOM 节点
- deactivated / activated 生命周期
- 依赖响应式系统的依赖追踪
- React 为什么不内置
- 与 UI = f(state) 心智模型冲突
- 副作用管理(effect 该不该继续运行?)
- Fiber 子树清理假设
- 内存管理复杂
- 社区方案
- DOM 移动法(react-activation)
- 离屏容器 + DOM 搬运
- 优点:完整保留 DOM 状态
- 缺点:绕过 React 控制,副作用不暂停
- display:none 隐藏法
- 简单粗暴
- 问题:副作用不停、参与 reconciliation、内存泄漏
- 路由级缓存(react-router 配合)
- DOM 移动法(react-activation)
- Activity API(React 未来方案)
- visible / hidden 状态切换
- effect 暂停/恢复
- 与并发模式集成(低优先级渲染)
- 实验阶段,未正式发布
- 手写思路
- Provider + Context 共享缓存池
- display 切换保留状态
- 局限:副作用不暂停、内存无上限
练习挑战
第一题 ⭐(基础):判断下面哪种方式能保留组件状态
// 方式 A:条件渲染
{show && <Counter />}
// 方式 B:key 不变 + display
<div style={{ display: show ? 'block' : 'none' }}>
<Counter />
</div>
// 方式 C:key 变化
<Counter key={show ? 'a' : 'b'} />
点击查看答案与解析
- 方式 A:不能保留。
show为 false 时<Counter />被卸载,state 清空 - 方式 B:能保留。
Counter始终在 React 树中,只是 DOM 被隐藏了,state 不受影响 - 方式 C:不能保留。key 变化会导致 React 认为这是两个不同的组件,先卸载旧的再挂载新的
面试中可以追问:方式 B 有什么问题?答案是 Counter 的 effect 仍然在运行,比如定时器不会因为隐藏而停止。
第二题 ⭐⭐(进阶):实现一个带 LRU 缓存上限的 KeepAlive
在第五节手写方案的基础上,增加一个 maxCache 参数,当缓存数量超过上限时,移除最久未使用的缓存。
点击查看答案与解析
function KeepAliveProvider({ children, maxCache = 5 }) {
const [caches, setCaches] = useState(new Map());
const addCache = useCallback((key, element) => {
setCaches(prev => {
const next = new Map(prev);
// 如果已存在,先删再加(移到 Map 末尾,表示"最近使用")
if (next.has(key)) {
next.delete(key);
}
next.set(key, element);
// 超出上限,删除最早的(Map 的第一个 key)
if (next.size > maxCache) {
const firstKey = next.keys().next().value;
next.delete(firstKey);
}
return next;
});
}, [maxCache]);
// ... 省略 Provider 渲染逻辑
}
核心思路是利用 JavaScript Map 的插入顺序保证:Map 按插入顺序遍历,最早插入的 key 在最前面。每次”使用”一个缓存时,先删再插,把它移到末尾。超出上限时删除第一个(最久未使用)。这就是一个简化版的 LRU(Least Recently Used)算法。
第三题 ⭐⭐⭐(综合):分析 react-activation 在 React 18 并发模式下可能遇到的问题
请从至少两个角度分析,react-activation 的 DOM 移动方案在 React 18 并发模式下可能出现什么问题。
点击查看答案与解析
角度一:DOM 操作时机问题。 react-activation 在 useEffect 中执行 DOM 移动操作。在并发模式下,React 可能会准备多个版本的 UI(比如在 transition 更新中),如果某个版本的渲染被中断并丢弃,但 DOM 移动已经在 effect 中执行了,就可能导致 DOM 状态与 React 内部状态不一致。
角度二:事件系统冲突。 React 18 的事件委托挂载在 root 元素上(而非 document)。react-activation 把 DOM 节点从一个位置移动到另一个位置时,React 的事件系统可能无法正确识别这些节点的事件冒泡路径,导致事件处理出现异常。
角度三:Suspense 集成问题。 当缓存的组件内部包含 Suspense 边界时,DOM 移动可能干扰 Suspense 的 fallback 显示和恢复逻辑。React 内部对 Suspense 的 DOM 操作有严格的假设,外部 DOM 操作可能打破这些假设。
这个问题在面试中答出两个角度就很好了。核心思想是:任何绕过 React 的 DOM 操作在并发模式下都有风险,因为并发模式假设 React 对 DOM 有完全控制权。
自我检测
读完本章后,对照下面的清单检验一下自己的掌握程度。
- 能解释 Vue keep-alive 的核心原理(组件实例缓存 + DOM 保留 + 生命周期钩子)
- 能说出 React 不内置 keep-alive 的至少三个原因
- 能描述社区方案的两种主要实现思路(DOM 移动法 vs display:none),以及各自的优缺点
- 能解释 React Activity API 的设计目标和它与社区方案的区别
- 能在面试中白板手写简化版 KeepAlive,并说出其局限性
- 能说出
display: none方案的至少三个问题 - 在实际项目中选择 keep-alive 方案时,能基于场景做出合理选择
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90