在 React 中手写 KeepAlive

95 阅读9分钟

写在前面

在 Vue 中,<keep-alive> 是一个内置的组件

用于缓存动态组件的状态,避免重复创建和销毁。

它常用于 <component :is="..."><router-view> 等场景,实现“切换时不销毁组件实例”的效果。

我们知道 React 基于原生 JavaScript,具有更强的灵活性和定制能力,接下来我们将用 React 来实现这一机制。

一、组件的状态的丢失

在深入实现 KeepAlive 之前,我们可以先搭建一个简单场景,亲身体验一下:

当动态切换组件时,其内部状态为何会意外丢失

编写一个带状态的计数器组件

我们需要一个具备本地状态的组件——例如一个计数器。在多个同类组件之间切换时,可以直观观察其状态是否被保留。

// src/App.jsx
import { useState, useEffect } from 'react';
import KeepAlive from './components/KeepAlive.jsx';

// 普通计数器组件
const Counter = ({ name }) => {
  const [count, setCount] = useState(0);

  //监听组件的挂载与卸载生命周期
  useEffect(() => {
    console.log(` $ {name} 组件已挂载!`);
    return () => {
      console.log(` $ {name} 组件已被卸载...`);
    };
  }, []);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', margin: '10px' }}>
      <h3>{name} 视图</h3>
      <p>当前计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>加 1</button>
    </div>
  );
};

// 另一个计数器组件(逻辑完全一致)
const OtherCounter = ({ name }) => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`  $ {name} 组件已挂载!`);
    return () => {
      console.log(`  $ {name} 组件已被卸载...`);
    };
  }, []);

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', margin: '10px' }}>
      <h3>{name} 视图</h3>
      <p>当前计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>加 1</button>
    </div>
  );
};

我们可以看到,尽管两个组件逻辑相同,但由于它们是不同的函数引用,React 会将其视为不同类型。因此,在条件渲染中切换时,React 会卸载旧组件并重新挂载新组件,导致状态丢失。

当我们通过路由或状态控制在这两个组件之间切换时,会发现:每次返回原组件,计数器都会重置为 0

再深入一点, App.jsx 文件中,通过状态 activeTab 来控制显示哪一个组件。这样你可以体验到当组件被条件渲染时,默认情况下它们的状态是如何丢失的。

const App = () => {
  // 使用 'A' 或 'B' 控制当前显示哪个组件
  const [activeTab, setActiveTab] = useState('A'); 

  return (
    <div>
      <div style={{ marginBottom: '20px' }}>
        {/* 按钮用于切换 activeTab 状态 */}
        <button onClick={() => setActiveTab('A')}>显示 A 组件</button>
        <button onClick={() => setActiveTab('B')}>显示 B 组件</button>
      </div>

      {/*  注意:这里的三元运算符会导致组件在切换时被销毁和重建 */}
      {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
    </div>
  );
};

我们运行这段代码时

  1. 点击“显示 A 组件”,然后多次点击“加 1”按钮,直到计数达到 5。
  2. 接着,点击“显示 B 组件”。此时,你应该会在控制台看到信息:“ A 组件已被卸载...” 和 “ B 组件已挂载!”。
  3. 最后,再次点击“显示 A 组件”。你会注意到控制台输出了“ A 组件已挂载!”,但遗憾的是,计数器已经重置为 0。

这种行为展示了 React 中条件渲染(的本质:每次当 activeTab 发生变化时,相应的组件会被卸载和重新挂载,导致其内部状态丢失。

而下一步,正是通过实现一个类似 Vue 中 <keep-alive> 的 KeepAlive 机制,让组件在切换后仍能保留其内部状态与 DOM 结构,从而提升用户体验与性能。

二、:KeepAlive 的核心原理

既然 React 在条件切换时会销毁组件并清空状态

那我们能不能换个思路——

不让组件被销毁,只让它“隐身”?

答案是肯定的。关键在于:

绕过 React 的卸载机制,用视觉隐藏代替真实移除。

2.1 核心思想

KeepAlive 的底层逻辑并不复杂,核心就两点:

所有需要缓存的子组件,一次性全部挂载到 DOM 中,永不卸载。

通过 CSS 的 display 属性控制谁“露脸”、谁“退场”。

只要组件的真实节点还留在 DOM 树里(哪怕被设为 display: none),React 就不会触发它的卸载流程——
useState 的值依然保留
useEffect 的清理函数不会执行
整个组件实例在内存中“活着”,随时可以重新激活

这其实就是一种障眼法

用户看到的是“切换视图”,而 React 看到的是“同一组组件,只是 visibility 变了”。

你不是在反复创建和销毁,而是在管理一组常驻组件的可见状态。既省去了重复初始化的开销,又完美保留了交互状态。


2.2 缓存结构设计

为了让这套机制可复用、可追踪,我们需要一个缓存容器,用来记录所有已经渲染过的子组件。

最直观的方式,就是用一个对象(或 Map)来维护:

const cache = {
  A: <Counter name="A" />,
  B: <OtherCounter name="B" />
};

Key:由外部传入的唯一标识(如 'A''B')决定,用于区分不同视图。

Value:对应的 React 元素(即 JSX 表达式),仅在首次访问时生成并存入缓存,后续直接复用。

虽然缓存的是 React 元素,但一旦它被挂载,其背后的状态、DOM 节点、Fiber 节点都会由 React 内部持续维护。只要不触发 unmount,一切状态都安然无恙。

最终渲染时,你只需遍历 cache,把所有缓存项都 render 出来,再根据当前激活的 key 动态设置 style={{ display: activeKey === key ? 'block' : 'none' }}

看起来简单,对吧?
但要把它封装成一个通用、健壮、支持动态 children 的 <KeepAlive> 组件,还需要处理不少细节。接下来,我们就动手实现它。


三、手写实战:实现 KeepAlive 组件

现在,我们正式动手编写一个具备缓存能力的 KeepAlive 容器。
它将接管子组件的渲染逻辑,确保状态在切换中得以保留。

Props 与内部状态设计

我们的 KeepAlive 组件需要两个关键输入:

  • activeId:标识当前应显示的视图(如 'A''B')。
  • children:当前传入的子组件(即待缓存的 React 元素)。

同时,组件内部需维护一个缓存池,用于持久化所有已渲染过的子组件。

import { useState, useEffect } from 'react';

const KeepAlive = ({ activeId, children }) => {
  // 缓存池:key 为视图 ID,value 为对应的 React 元素
  const [cache, setCache] = useState({});
  
  // ... 缓存与渲染逻辑
};

💡 注意:children 在 React 中本质是一个不可变的 React 元素(Element),将其存入状态是安全且合法的。


3.2 缓存策略:按需存储,避免重复

每当 activeId 或 children 发生变化时,我们需要判断:该视图是否首次出现?

如果是,则将其 children 存入缓存;否则,直接复用已有内容。

useEffect(() => {
  // 仅当当前 activeId 对应的组件尚未缓存时,才进行存储
  if (cache[activeId] == null) {
    setCache(prev => ({
      ...prev,
      [activeId]: children
    }));
  }
}, [activeId, children, cache]);

这里依赖 cache 作为依赖项看似冗余,但能确保在严格模式(Strict Mode)或并发渲染下行为一致。实际项目中也可通过 ref 优化,但为保持逻辑清晰,此处暂用状态驱动。


3.3 渲染机制:全量挂载 + CSS 显隐

最关键的一步来了:不要用条件渲染!

我们要将所有已缓存的组件全部挂载到 DOM 中,仅通过 display: none/block 控制可见性。这样,React 不会触发卸载,状态自然得以保留。

return (
  <>
    {Object.entries(cache).map(([id, component]) => (
      <div
        key={id}
        style={{
          display: id === activeId ? 'block' : 'none',
          // 可选防止隐藏组件影响布局
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%'
        }}
      >
        {component}
      </div>
    ))}
  </>
);

每个缓存项都始终存在于 DOM 树中,只是视觉上被隐藏。
React Fiber 树持续维护其状态,useStateuseRefuseEffect 均正常工作。


3.4 完整实现

整合上述逻辑,得到一个简洁而有效的 KeepAlive 组件:

import { useState, useEffect } from 'react';

const KeepAlive = ({ activeId, children }) => {
  const [cache, setCache] = useState({});

  useEffect(() => {
    if (cache[activeId] == null) {
      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>
      ))}
    </>
  );
};

0export default KeepAlive;

这个不到 40 行的组件,巧妙利用了 React 的渲染机制与 CSS 的显示控制,实现了类似 Vue <keep-alive> 的缓存能力。

接下来,只需在 App.jsx 中替换原来的三元表达式,就能见证“失忆症”的彻底治愈。

我们来看看效果:

首先点击“显示 A 组件”,并将计数增加至 5。

随后切换至“显示 B 组件”。

此时观察控制台输出:没有出现“A 组件被卸载”的日志,仅打印了“B 组件已挂载”

这表明 A 组件并未被销毁,其对应的 useEffect 清理函数未被执行。

接着,重新切换回“显示 A 组件”。控制台未输出“A 组件已挂载” ,说明该组件实例未经历重新创建过程;同时,界面上的计数值依然保持为 5,状态完整保留。

这一结果验证了 KeepAlive 组件的核心能力:通过避免真实卸载、仅控制视觉显隐,成功实现了动态组件的状态持久化。组件在切换过程中维持了其内部状态、DOM 结构及生命周期上下文,达到了与 Vue 中 <keep-alive> 相似的效果。

总结

通过手写实现,我们成功在 React 中模拟了类似 Vue <keep-alive> 的状态缓存能力。但值得注意的是,两者的实现机制和底层逻辑存在根本性差异

在 Vue 中,<keep-alive> 是一个深度集成于渲染器(renderer)的内置抽象组件。它通过操作虚拟 DOM 的 shapeFlag,直接干预组件的挂载(mount)与卸载(unmount)流程:被缓存的组件不会触发 beforeUnmount 和 unmounted,而是进入“停用”(deactivated)状态;重新激活时调用 activated 钩子,整个过程由 Vue 的响应式系统和组件生命周期体系原生支持。

而在 React 中,并无类似的底层钩子或渲染器扩展机制。因此,我们的实现本质上是一种上层应用级的“障眼法”

  • 所有子组件始终处于挂载状态,从未真正卸载;
  • 通过 CSS 的 display: none 实现视觉切换;
  • 依赖 React 对已挂载组件状态的自然保留能力来维持数据。

这意味着:

  • 优势:实现简单,无需侵入 React 内部机制,兼容性好;
  • 局限:所有缓存组件持续占用内存和 DOM 节点,可能影响性能(尤其在缓存大量复杂组件时);无法像 Vue 那样精确控制“激活/停用”生命周期(如暂停轮询、取消订阅等),需自行结合 useEffect 与 activeId 状态管理副作用。

因此,React 版 KeepAlive 更适合轻量级、状态为主、副作用较少的场景;而 Vue 的 <keep-alive> 则提供了更精细的生命周期控制和更高效的内存管理策略。

归根结底:Vue 是“框架帮你管”,React 是“你自己想办法管” ——这也正是两种框架哲学差异的典型体现。