我真的写了个“诈尸式”缓存组件:手撕React KeepAlive

别让路由切换,变成组件的"告别仪式"


从一次"页面回魂"需求说起

产品经理走过来,拍了拍我的肩膀:

"用户从首页进详情,再返回首页,希望滚动位置还在原来那里,表单填写的内容也别丢。"

我看了眼代码------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、滚动位置全部保留。

听起来简单,但实现时有两个关键点:

  1. 渲染容器必须独立于父组件,不能随父组件 re-render 而丢失
  2. 渲染逻辑要区分"首次挂载"和"后续显示/隐藏"

手写实现:从 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 ⚠️ 可模拟
内存回收 ✅ 自动 ⚠️ 需手动管理

我们的版本是"最小可用原型",适合理解原理,生产环境建议封装更完善的库。


实战场景:哪些地方非用不可?

  1. Tab 切换 (订单列表/商品列表)

    用户切换 Tab 后返回,不想重新加载数据

  2. 多步骤表单 (分步注册)

    用户从第 3 步跳到第 1 步,再回来时第 3 步内容还在

  3. 视频/音频播放器

    切换页面后继续播放,不中断

  4. 地图组件

    重新加载地图会重置视角和标记


性能考量:缓存不是银弹

  • 内存 :每个缓存的组件都保留完整 DOM 和 JS 实例,大型列表页慎用
  • 更新:隐藏的组件不会触发 re-render,但事件监听仍然存在
  • 最佳实践 :只缓存 "高频切换且构建成本高" 的组件

结语:从"轮子"到"理解"

手写 KeepAlive 最大的收获,不是写出了一个可用的组件,而是彻底理解了 React 渲染机制和 DOM 操作边界。

  • 原来组件不是非得卸载
  • 原来 DOM 可以"寄生"在任意容器里
  • 原来 useRef 可以跨渲染保存实例

下次产品再提"保留状态"的需求,你不会再头皮发麻------

你会微微一笑,掏出自己的 KeepAlive,然后说:

"放心,我让组件原地复活,不投胎。"

相关推荐
传人40 分钟前
悬停旋转放大和位移效果如何写
前端·javascript·css
江华森1 小时前
华为云CCE/Kubernetes云原生技术体系 — 总学习笔记
面试
zhangzeyuaaa1 小时前
Ruby 方法参数完全指南:默认值、可变参数与关键字参数
前端·python·ruby
粥里有勺糖1 小时前
视野修炼-技术周刊第135期 | 鼠标跟随宠物
前端·github·aigc
粥里有勺糖1 小时前
拿 Codex 协助清理磁盘,Nice!
前端·后端·github
Highcharts.js1 小时前
可视化商用图表库对比,如何选择?
前端·javascript·学习·信息可视化·highcharts·前端可视化
(Charon)2 小时前
【C++面试】RAII到底是什么:从资源管理理解C++对象生命周期
c++·算法·面试
TheITSea2 小时前
JavaScript Web APIs
开发语言·前端·javascript
恋猫de小郭3 小时前
Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机
android·前端·flutter
Wang's Blog3 小时前
Java 项目实战: 外卖平台优化-前端dist部署与Nginx反向代理rewrite
java·前端·nginx