面试手写 KeepAlive:React 组件缓存的实现原理
面试官:"用过 Vue 的
<keep-alive>吗?如果让你在 React 中手写一个,你会怎么实现?"
这看似是一道框架 API 题,实际上考察的是你对 React 组件渲染机制 和 DOM 复用策略 的理解深度。本文将带你从零手写一个 KeepAlive 组件,把每一步的设计决策讲透彻。
先搞懂本质:KeepAlive 解决什么问题?
看一个具体场景。我们的 App 有两个 Tab:
jsx
// App.jsx
const App = () => {
const [activeTab, setActiveTab] = useState('A')
return (
<div>
<button onClick={() => setActiveTab('A')}>显示A组件</button>
<button onClick={() => setActiveTab('B')}>显示B组件</button>
<KeepAlive activeId={activeTab}>
{activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
</div>
)
}
Counter 组件内部有一个 count 状态:
jsx
const Counter = ({ name }) => {
const [count, setCount] = useState(0)
// 挂载/卸载的生命周期日志
useEffect(() => {
console.log('挂载', name)
return () => console.log('卸载', name)
}, [])
return (
<div>
<h3>{name}视图</h3>
<p>当前计数:{count}</p>
<button onClick={() => setCount(count + 1)}>加1</button>
</div>
)
}
没有 KeepAlive 时,用户在 A 组件把 count 点到 5,切换到 B,再切回 A:
css
切换 B → A 组件卸载(state 销毁,count 归零,DOM 移除)
切回 A → A 组件重新挂载(count 重新从 0 开始,useEffect 再次执行)
用户体验:辛辛苦苦点的数全白费了。
核心思路:把 JSX 元素存进一个对象里
React 的组件渲染本质上就是把 JSX 转成 Virtual DOM,再映射到真实 DOM。那如果我们不销毁这个 JSX 对应的 DOM,而是把它"藏起来"呢?
关键认知:JSX 本质上就是一个 JavaScript 对象引用。 只要引用不被 GC 回收,React 内部维护的 Fiber 节点和对应的真实 DOM 就不会被销毁。
设计数据结构:
jsx
// cache 对象的结构
{
'A': <Counter name="A" />, // JSX 对象引用
'B': <OtherCounter name="B" />,
}
- key :用
activeId作为缓存键,唯一标识每个需要缓存的视图 - value :存储该视图对应的 JSX 元素(注意:是首次渲染时的那个 JSX 对象,不是每次都创建新的)
一步步写出来
第一版:能跑就行的朴素实现
jsx
import { useState, useEffect } from 'react'
const KeepAlive = ({ activeId, children }) => {
const [cache, setCache] = useState({})
useEffect(() => {
if (!cache[activeId]) {
setCache(prev => ({
...prev,
[activeId]: children
}))
}
}, [activeId, children, cache])
return (
<>
{Object.entries(cache).map(([id, component]) => (
<div
key={id}
style={{ display: id === activeId ? 'block' : 'none' }}
>
{component}
</div>
))
}
</>
)
}
export default KeepAlive
逐行解析:
1. 缓存状态:const [cache, setCache] = useState({})
用一个对象存储所有被缓存过的视图。为什么用 useState 而不是 useRef?因为我们需要在状态更新时触发重新渲染------新的 children 被存入缓存后,必须让 React 重新执行 render 才能把新 DOM 渲染出来。
2. 缓存时机:if (!cache[activeId])
jsx
useEffect(() => {
if (!cache[activeId]) {
setCache(prev => ({
...prev,
[activeId]: children
}))
}
}, [activeId, children, cache])
这是整个组件的灵魂。判断逻辑是:
| 场景 | cache[activeId] 是否存在 |
行为 |
|---|---|---|
| 首次切换到某个 Tab | 不存在 | 保存 children 到缓存 |
| 再次切换回已缓存的 Tab | 已存在 | 什么都不做,复用旧缓存 |
注意:这里保存的是第一次渲染时的 children 引用 。一旦保存,后续即使 children 变化(其他 Tab 的 JSX),已缓存的引用不会被覆盖。这就是状态得以保留的根源------React 始终渲染的是最初那个 Fiber 节点。
3. 显示策略:display: block / none
jsx
{Object.entries(cache).map(([id, component]) => (
<div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
{component}
</div>
))}
所有被缓存过的组件全部渲染在 DOM 树中,但只把当前激活的那个设为可见:
- 激活的 Tab:
display: block(正常显示) - 隐藏的 Tab:
display: none(DOM 存在但不可见)
这是整个方案最巧妙的地方 :React 看到 {component} 引用没变,不会重新执行函数组件,不会触发 Hooks 重新计算,不会触发 useEffect。Fiber 节点一直挂在树上,状态完好无损。
当你从 B 切回 A 时,控制台不会打印"挂载 A",因为 A 组件的 Fiber 从未被卸载过。这就是 KeepAlive 的本质------DOM 存在但不显示,而非销毁后重建。
运行效果:对比控制台日志
css
// 初始加载
挂载 A ← useEffect 触发
// 切换到 B
挂载 B ← B 首次进入缓存,执行挂载
// 注意:没有 "卸载 A"!
// 切回 A
// 没有 "挂载 A"! ← A 从未卸载,缓存命中
// 再次切到 B
// 没有 "挂载 B"! ← B 也从未卸载
A 组件切走时,控制台没有打印"卸载 A" ,因为 display: none 只是隐藏,React 的 cleanup 函数不会执行。切回来时也没有"挂载 A",计数仍然保持离开时的数字。
面试进阶:面试官可能会追问什么
Q1:为什么用 children 而不是让 KeepAlive 自己去渲染?
jsx
// ❌ 不好的设计:KeepAlive 内部 import 组件
<KeepAlive activeId={activeTab} components={{ A: Counter, B: OtherCounter }} />
// ✅ 好的设计:通过 children 让父组件控制渲染
<KeepAlive activeId={activeTab}>
{activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
原因 :children 模式遵循 React 的组合优于继承原则。父组件完全控制子组件的 props、条件渲染逻辑,KeepAlive 只负责缓存,职责单一。
Q2:所有缓存组件都在 DOM 中,性能会不会有问题?
会有。每个隐藏的组件虽然不可见,但它的 DOM 节点和 Fiber 节点全部真实存在于内存中。如果你的 Tab 内容包含 1000 个列表项,那缓存 10 个 Tab 就是 10000 个 DOM 节点------对内存和首屏渲染性能都是负担。
生产级方案(如 react-activation)会做更精细的优化:通过 React Portal 把隐藏组件的 DOM 移到一个独立的、脱离文档流的容器中挂起。
Q3:useEffect 的依赖数组里有 cache,会不会导致无限循环?
cache[activeId] 不存在时才调用 setCache,更新后的 cache 中 activeId 已存在,下次 useEffect 执行时 if (!cache[activeId]) 为 false,不会再调用 setCache。所以不会无限循环。
但这里有一个可优化的点:依赖 cache 对象意味着每次缓存更新后 useEffect 都会对整个 cache 重新求值。更好的写法是用函数式 setState + 单独的 useEffect 监听:
jsx
useEffect(() => {
setCache(prev => {
if (prev[activeId]) return prev // 已缓存,不更新
return { ...prev, [activeId]: children }
})
}, [activeId, children])
这样去掉了对 cache 的依赖,效果一样但更简洁。
Q4:display: none 和条件渲染有什么区别?
display: none |
条件渲染 {visible && <Comp />} |
|
|---|---|---|
| DOM 存在 | ✅ 存在 | ❌ 移除 |
| state 保留 | ✅ 保留 | ❌ 销毁 |
| useEffect cleanup | ❌ 不触发 | ✅ 触发 |
| 组件函数是否重新执行 | ❌ 不执行 | ✅ 重新执行 |
条件渲染的本质是移除 DOM → 销毁 Fiber → 清除 state → 执行 cleanup。display: none 的本质是 DOM 还在 → Fiber 还在 → state 还在 → cleanup 不执行。前者是"删了重建",后者是"藏起来再拿出来"。
从面试代码到生产级方案
这个 25 行的实现抓住了 KeepAlive 的核心思想,但它缺少几个关键能力:
| 缺失能力 | 生产级方案(react-activation) |
|---|---|
| 滚动位置恢复 | 内置 saveScrollPosition 属性 |
| 缓存淘汰策略 | 支持 LRU,限制最大缓存数量 |
| 多实例管理 | AliveScope 全局缓存池统一调度 |
| 生命周期钩子 | useActivate / useUnactivate 替代 useEffect |
| SSR 兼容 | 提供 SSRKeepAlive 降级方案 |
| 动画过渡 | 切换时可配合 CSS Transition |
但面试官要看的不是你会不会用库------而是你是否理解状态保留的本质是保留 JSX 引用,保留引用的本质是不让 Fiber 卸载,不让 Fiber 卸载的本质是 DOM 不离树。
总结
手写 KeepAlive 是一个优质的面试题,它串起了 React 的多个核心概念:
arduino
JSX 对象引用 → useState 缓存 → display:none 保活
↘ Fiber 持久化 ↙
状态与 DOM 永不销毁
记住这一条线,你就能在任何面试中把 KeepAlive 的原理讲得明明白白。
一句话版本 :KeepAlive =
useState存 JSX 引用 +display: none隐藏非激活 DOM,让 React 的 Fiber 节点不被卸载,从而保住所有组件内部状态。