面试手写 KeepAlive:React 组件缓存的实现原理

面试手写 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,更新后的 cacheactiveId 已存在,下次 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 节点不被卸载,从而保住所有组件内部状态。

相关推荐
2分钟速写快排19 分钟前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60611 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角2 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!2 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰2 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
Interview Aid1123 小时前
Walmart Global Tech SDE 三轮面经|基础、并发、压力面
面试·职场和发展
mayaairi4 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen4 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马5 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒6 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端