React KeepAlive 组件学习笔记(含手写实现详解

0 阅读29分钟

React KeepAlive 组件学习笔记(含手写实现详解)

在React开发过程中,我们经常会遇到组件切换时需要保留组件状态、避免重复渲染和数据请求的场景,比如首页组件、列表页组件等。如果每次切换组件都重新挂载和卸载,不仅会造成性能损耗,还会导致用户状态丢失(如滚动位置、输入内容、计数器数值等)。此时,KeepAlive组件就成为了关键解决方案。本次学习笔记将围绕KeepAlive的核心概念、实际应用(基于react-activation库)、底层原理以及手写实现展开,结合提供的代码案例,深入解析每一个知识点,帮助彻底掌握KeepAlive的使用与实现逻辑。

一、前言:为什么需要KeepAlive组件?

在React中,组件的默认生命周期是“挂载(mount)→ 更新(update)→ 卸载(unmount)”。当我们通过路由切换、条件渲染等方式切换组件时,未显示的组件会被React自动卸载,其内部的状态(如useState保存的数据、DOM结构)会全部丢失。再次切换回该组件时,会重新执行挂载过程,重新渲染DOM、请求数据,这会带来两个主要问题:

  1. 性能损耗:重复的挂载和卸载会触发DOM操作、数据请求等耗时操作,尤其是复杂组件(如包含大量列表、图表的组件),会明显降低页面响应速度,影响用户体验。
  2. 状态丢失:用户在组件中的操作状态(如滚动到列表的某个位置、输入框中的内容、计数器的数值)会被重置,需要用户重新操作,体验极差。

举个常见的场景:首页是一个包含大量商品的列表页,用户滚动到页面底部查看商品,此时切换到其他页面(如个人中心),再切换回首页。如果没有KeepAlive缓存,首页会重新挂载,列表会重新渲染,用户需要再次滚动到之前的位置,同时列表数据可能会重新请求,既浪费流量,又影响体验。

而KeepAlive组件的核心作用,就是缓存指定的组件,使其在切换时不被卸载,保留组件的DOM结构和内部状态,再次显示时直接复用缓存的内容,避免重复渲染和状态丢失,从而提升页面性能和用户体验。

本次学习将结合react-activation库的实际应用,以及手写KeepAlive组件的过程,全面理解KeepAlive的工作机制。

二、React中KeepAlive的实际应用(基于react-activation库)

React官方并没有提供内置的KeepAlive组件,因此在实际开发中,我们通常会使用第三方库来实现KeepAlive功能,其中react-activation是最常用、最成熟的库之一。该库封装了完善的KeepAlive逻辑,支持缓存组件状态、滚动位置等,使用简单且灵活。

下面结合提供的代码案例,详细解析react-activation库的使用方法、核心配置及注意事项。

2.1 react-activation库核心特点

在使用之前,我们先了解react-activation库的核心优势,帮助我们理解其设计思路:

  1. 完整缓存:不仅缓存组件的DOM结构,还会缓存组件的内部状态(如useState、useReducer的数据),实现“界面和数据都保持”的效果。
  2. 灵活控制:支持通过配置指定需要缓存的组件,可手动控制组件的缓存与销毁。
  3. 滚动位置缓存:支持缓存组件的滚动位置,切换回组件时自动恢复到之前的滚动状态(如列表页的滚动位置)。
  4. 轻量高效:源码简洁,无过多冗余逻辑,性能损耗极低,兼容React 16+及以上版本。
  5. 使用简单:通过封装好的组件和组件,只需简单配置即可实现缓存功能。

2.2 安装与基础引入

首先需要安装react-activation库,使用npm或yarn即可完成安装:

npm install react-activation --save
# 或
yarn add react-activation

安装完成后,即可在项目中引入需要的组件,核心引入的是组件(用于缓存具体组件)和对应的页面组件(如Home组件):

import { KeepAlive } from 'react-activation';
import Home from '@/pages/Home';

2.3 缓存首页组件(核心案例解析)

在实际开发中,首页是最常需要缓存的组件之一(因为首页访问频率高、渲染内容多,重复渲染会严重影响性能)。提供的代码中,就实现了对Home组件的缓存,我们逐行解析:

const KeepAliveHome = () => {
    return (
        // saveScrollPosition="screen" 缓存滚动位置
        <KeepAlive name="home" saveScrollPosition="screen">
            <Home />
        </KeepAlive>
    )
}

export default KeepAliveHome;

这段代码的核心作用是:创建一个被KeepAlive缓存的Home组件(命名为KeepAliveHome),后续在项目中使用KeepAliveHome组件,即可实现Home组件的缓存。下面解析每一个关键部分:

2.3.1 组件的核心属性

  1. name属性(必填) :用于给缓存的组件指定一个唯一标识,相当于缓存的“key”。后续如果需要手动控制缓存(如清除缓存、判断缓存是否存在),都需要通过这个name来定位组件。这里指定name为"home",表示缓存的是Home组件,标识唯一且语义清晰。

  2. saveScrollPosition属性(可选) :用于控制是否缓存组件的滚动位置,取值为"screen"或false(默认false)。 这里给Home组件设置了saveScrollPosition="screen",说明Home组件是一个长页面(如包含首页轮播、商品列表等),需要保留用户的滚动状态,提升体验。

    1. 当设置为"screen"时:组件切换时,会保存当前组件的滚动位置,再次显示时自动恢复到之前的滚动位置(非常适合列表页、长页面)。
    2. 当设置为false时:不缓存滚动位置,再次显示组件时,滚动位置会重置到顶部。

2.3.2 组件嵌套逻辑

组件采用“嵌套包裹”的方式,将需要缓存的组件()作为其子元素。这样设计的原因是:组件需要对其子组件进行拦截,控制其挂载、卸载和缓存逻辑——当组件切换时,会阻止子组件(Home)被卸载,而是将其隐藏,再次显示时直接复用。

需要注意的是:被包裹的组件,其生命周期会发生变化——组件只会挂载一次,卸载时不会执行unmount生命周期(因为组件没有被真正卸载,只是被隐藏),再次显示时也不会执行mount生命周期,只会执行更新相关的生命周期(如useEffect的依赖项变化时)。

2.3.3 关键注意事项

代码开头明确标注了“home不能卸载,keep alive缓存首页组件”,这是首页缓存的核心要求,也是KeepAlive组件的核心作用。这里需要强调两点:

  1. Home组件被缓存后,不会被自动卸载,即使路由切换、条件渲染隐藏该组件,其DOM结构和内部状态依然会被保留在内存中,再次显示时直接复用。
  2. 如果需要手动卸载Home组件(如用户退出登录、清除缓存),可以通过react-activation库提供的其他API(如useAliveController)来实现,后续会补充相关用法。

2.4 react-activation的底层实现逻辑(简单铺垫)

为了更好地理解后续手写KeepAlive组件的思路,这里简单铺垫一下react-activation库的底层逻辑,结合代码中提到的关键点:

  1. 缓存方式:通过“display: none”实现组件隐藏,而非真正卸载。当组件需要隐藏时,会给组件的容器添加display: none样式,使其离开文档流(不显示在页面上),但组件的DOM结构和状态依然保留;当需要显示时,再将display改为block,组件重新显示。
  2. 缓存容器:使用类似Map的数据结构来存储缓存的组件,key为组件的name属性,value为组件的DOM结构和状态,这样可以快速通过name定位到对应的缓存组件,实现高效的缓存读取和更新。
  3. AliveScope组件:代码中提到了“keepAlive + AliveScope”,这里需要补充:是react-activation库的根组件,用于提供缓存的上下文环境,所有被包裹的组件,都必须放在内部,否则缓存功能无法生效。通常我们会在项目的入口文件(如App.jsx、main.jsx)中用<AliveScope>包裹整个应用,示例如下:
import { AliveScope } from 'react-activation';
import KeepAliveHome from './KeepAliveHome';

function App() {
  return (
    <AliveScope>
      <div className="App">
        <KeepAliveHome />
        {/* 其他组件 */}
      </div>
    </AliveScope>
  );
}

export default App;

AliveScope的作用是统一管理所有KeepAlive组件的缓存,提供缓存的上下文,确保多个KeepAlive组件之间的缓存互不干扰,同时支持全局缓存控制(如清除所有缓存)。

三、核心知识点铺垫:实现KeepAlive所需的React及JS知识

在手写KeepAlive组件之前,我们需要先掌握一些核心的React和JavaScript知识,这些知识是实现KeepAlive的基础,也是代码中重点涉及的内容。结合提供的手写代码,逐一拆解讲解。

3.1 JavaScript知识点:Map数据结构

代码中提到:“缓存组件 keep alive,使用Map es6新增加的数据结构,key超越string,object key可以是任意类型”。Map是实现组件缓存的核心数据结构,我们需要深入理解其特点、用法以及与JSON的区别。

3.1.1 Map的核心特点

Map是ES6引入的一种新的集合类型,用于存储键值对(key-value),其核心特点如下:

  1. key可以是任意类型:与Object不同,Map的key可以是字符串、数字、布尔值、对象、函数、Symbol等任意类型,而Object的key只能是字符串或Symbol(如果传入其他类型,会自动转换为字符串)。
  2. 键值对是有序的:Map会按照键值对的插入顺序进行存储和遍历,而Object在ES6之前是无序的(ES6之后Object的键值对顺序有所调整,但依然不建议依赖其顺序)。
  3. 可直接遍历:Map提供了多种遍历方法(如forEach、for...of),可以直接遍历键值对,而Object需要通过Object.keys()、Object.values()、Object.entries()等方法转换后才能遍历。
  4. 支持动态增删改查:提供了set()(添加/修改键值对)、get()(获取值)、delete()(删除键值对)、has()(判断键是否存在)等方法,操作灵活。
  5. 适合存储临时缓存:由于其key可以是任意类型,且操作高效,非常适合用于存储组件缓存(如用组件的name或组件实例作为key,组件本身作为value)。

3.1.2 Map的基本用法

// 1. 创建Map实例
const cacheMap = new Map();

// 2. 添加键值对(key可以是任意类型)
cacheMap.set('home', HomeComponent); // key为字符串
cacheMap.set(123, OtherComponent); // key为数字
cacheMap.set({}, TestComponent); // key为对象

// 3. 获取值
const homeComponent = cacheMap.get('home');

// 4. 判断键是否存在
const hasHome = cacheMap.has('home'); // true

// 5. 删除键值对
cacheMap.delete('home');

// 6. 遍历Map
cacheMap.forEach((value, key) => {
  console.log(key, value); // 输出键和对应的值
});

// 7. 清空所有键值对
cacheMap.clear();

3.1.3 Map与JSON的区别

代码中明确提出:“Map与json的区别?都是key-value结构,但是Map的key可以是任意类型,json的key只能是字符串”。这里我们展开详细说明,避免混淆:

对比维度MapJSON
数据结构键值对(key-value)集合,属于JavaScript内置对象键值对(key-value)格式的字符串,属于数据交换格式
key类型任意类型(字符串、数字、对象、函数等)只能是字符串(如果传入其他类型,会自动转换为字符串)
值类型任意类型(包括undefined、null、函数、对象等)支持字符串、数字、布尔值、null、数组、对象,不支持undefined、函数、Symbol
有序性有序(按插入顺序存储)无序(JSON字符串本身是无序的,解析后得到的Object也不依赖顺序)
可遍历性支持直接遍历(forEach、for...of)需要先解析为JavaScript对象,再通过Object相关方法转换后遍历
用途适合在JavaScript代码中存储临时数据、缓存(如组件缓存)适合数据交换(如前后端通信、本地存储)

结合KeepAlive的需求:我们需要缓存组件实例,而组件实例是对象类型,如果使用JSON作为缓存容器,无法将组件实例作为key(会自动转换为字符串"[object Object]"),导致无法准确定位缓存;而Map可以直接将组件的name(字符串)或组件实例(对象)作为key,精准存储和读取缓存,因此Map是实现组件缓存的最佳选择。

3.2 JavaScript知识点:Object.entries()方法

代码中提到:“Object.entries 就是把一个对象的键值对‘拆’成一个由[key,value]组成的数组,二维数组[[key1,value1],[key2,value2]]”。该方法是实现KeepAlive组件切换显示的核心方法,我们详细讲解其用法和作用。

3.2.1 Object.entries()的核心作用

Object.entries() 方法会将一个JavaScript对象的可枚举键值对,转换为一个包含多个[key, value]数组的二维数组,其中每个子数组的第一个元素是对象的key(字符串类型),第二个元素是对应的value(任意类型)。

简单来说,就是“将对象拆分为数组”,方便我们通过数组的方法(如map、forEach)遍历对象的键值对。

3.2.2 基本用法示例

// 定义一个普通对象
const cache = {
  home: HomeComponent,
  about: AboutComponent,
  user: UserComponent
};

// 使用Object.entries()转换对象
const entries = Object.entries(cache);
console.log(entries);
// 输出结果(二维数组):
// [
//   ['home', HomeComponent],
//   ['about', AboutComponent],
//   ['user', UserComponent]
// ]

// 遍历转换后的数组(核心用法)
entries.map(([id, component]) => {
  // 解构赋值,id对应对象的key,component对应对象的value
  console.log(id, component);
  return component;
});

3.2.3 在KeepAlive中的作用

在手写KeepAlive组件时,我们会用一个对象(或Map)来存储缓存的组件,而React中渲染列表需要使用数组(通过map方法遍历渲染)。此时,Object.entries()的作用就是将缓存对象转换为二维数组,方便我们通过map方法遍历所有缓存的组件,然后根据activeId(当前激活的组件id)控制组件的显示与隐藏。

简单来说:Object.entries() 是“对象”和“数组”之间的桥梁,解决了“无法直接遍历对象并渲染组件”的问题。

3.3 React知识点:组件状态与生命周期

手写KeepAlive组件需要用到React的状态管理(useState)和副作用(useEffect),这两个Hook是React函数组件的核心,也是实现缓存逻辑的基础。结合代码中的手写实现,我们回顾相关知识点。

3.3.1 useState:状态管理

useState是React提供的用于管理组件内部状态的Hook,其核心作用是:在函数组件中创建可修改的状态,当状态发生变化时,组件会重新渲染。

在手写KeepAlive组件中,我们用useState创建一个cache状态,用于存储所有缓存的组件:

const [cache, setCache] = useState({}); // 缓存组件的

这里的cache是一个对象,key为组件的activeId(激活标识),value为对应的组件实例,通过setCache方法可以修改cache对象,添加或更新缓存的组件。

需要注意的是:setCache是异步的,修改缓存后不会立即更新cache的值,因此在useEffect中使用时,需要将cache作为依赖项,确保副作用能感知到cache的变化。

3.3.2 useEffect:副作用管理

useEffect是React提供的用于处理组件副作用的Hook,其核心作用是:处理组件挂载、更新、卸载时的副作用操作(如数据请求、DOM操作、缓存更新等)。

useEffect的基本语法:

useEffect(() => {
  // 副作用操作(如更新缓存、监听事件等)
  return () => {
    // 清理函数(组件卸载或依赖项变化时执行)
  };
}, [依赖项数组]); // 依赖项变化时,重新执行副作用

在手写KeepAlive组件中,useEffect的作用是:监听activeId(当前激活的组件id)和children(需要缓存的组件)的变化,当这两个值变化时,更新缓存——如果缓存中没有当前activeId对应的组件,就将其添加到缓存中:

useEffect(()=>{
    // active update 切换显示
    // children update 切换保存
    // 如果缓存中没有activeId 组件 就缓存起来
    if(!cache[activeId]){ // activeId key
        setCache((prev) => ({
            ...prev,
            [activeId]: children
        }))
    }
},[activeId,children,cache])

我们逐行解析这段代码的逻辑:

  1. 依赖项数组为[activeId, children, cache]:当activeId(切换组件)、children(组件内容变化)、cache(缓存变化)中的任意一个值发生变化时,都会重新执行这个副作用函数。
  2. 判断条件if(!cache[activeId]):检查当前缓存中是否存在activeId对应的组件,如果不存在(说明是第一次渲染该组件),就执行缓存添加操作。
  3. setCache((prev) => ({ ...prev, [activeId]: children })):使用函数式更新的方式修改cache状态,prev表示上一次的缓存对象,通过展开运算符(...prev)保留之前的缓存,然后添加当前activeId对应的组件(children),实现“新增缓存不覆盖原有缓存”的效果。

这里需要强调:使用函数式更新(prev => ...)是因为setCache是异步的,我们需要确保获取到的是最新的缓存状态(prev),避免出现“缓存更新不及时”的问题。

3.4 React知识点:条件渲染与display样式控制

KeepAlive组件的核心逻辑之一是“切换显示”,而切换显示的实现方式,就是通过条件渲染控制组件的display样式——显示时设置display: block,隐藏时设置display: none,这样可以实现“组件不卸载,只隐藏”的效果。

在React中,我们可以通过inline样式(style属性)动态控制组件的display值,结合activeId判断当前需要显示的组件:

style={{display: id===activeId ? 'block':'none'}}

这段代码的逻辑很简单:

  • 当遍历到的组件id(id)与当前激活的组件id(activeId)相等时,设置display: block,组件显示在页面上。
  • 当不相等时,设置display: none,组件隐藏(离开文档流,不占用页面空间),但组件的DOM结构和内部状态依然保留在缓存中。

这里需要区分“display: none”和“visibility: hidden”的区别,避免混淆:

  1. display: none:组件隐藏,离开文档流,不占用任何页面空间,其子元素也会被隐藏,相当于“从页面中移除,但内存中依然存在”。
  2. visibility: hidden:组件隐藏,但依然占用页面空间,其子元素也会被隐藏,相当于“透明显示”。

KeepAlive组件选择使用display: none,是因为我们希望隐藏的组件不占用页面空间,同时保留其DOM结构和状态,符合“缓存不卸载”的核心需求。

四、手写KeepAlive组件(完整解析)

掌握了上述核心知识点后,我们就可以动手手写一个简易的KeepAlive组件了。手写KeepAlive的核心目标是:实现“组件缓存、切换显示、状态保留”的基本功能,理解其底层工作机制。结合提供的手写代码,我们逐行解析实现过程、逻辑思路以及注意事项。

4.1 手写KeepAlive的核心需求

在动手之前,我们先明确手写KeepAlive组件的核心需求,确保实现的功能符合预期:

  1. 缓存组件:将需要缓存的组件(children)存储在缓存容器中,首次渲染时添加缓存,后续切换时不再重新创建组件。
  2. 切换显示:通过activeId控制当前显示的组件,显示时设置display: block,隐藏时设置display: none,不卸载组件。
  3. 状态保留:缓存的组件内部状态(如计数器数值)在切换时不丢失,再次显示时复用之前的状态。
  4. 灵活复用:支持传入不同的activeId和children,实现多个组件的缓存与切换。

4.2 手写实现步骤(逐行解析)

我们按照“引入依赖→定义组件→状态管理→副作用处理→渲染组件”的步骤,逐行解析手写代码:

4.2.1 引入所需Hook

import {
    useState,
    useEffect,
} from 'react'

首先引入React的useState和useEffect Hook,useState用于管理缓存状态,useEffect用于处理缓存的更新逻辑(监听activeId和children的变化)。

4.2.2 定义KeepAlive组件,接收props

const KeepAlive = ({
    activeId,
    children
})=>{
    // 组件逻辑
}

KeepAlive组件接收两个核心props:

  1. activeId:当前激活的组件标识(key),用于判断哪个组件需要显示,由父组件传入(如路由参数、tab切换标识)。
  2. children:需要缓存的组件,由父组件通过嵌套方式传入(如)。

这里使用children props,是为了提升组件的灵活性——父组件可以传入任意需要缓存的组件,无需在KeepAlive组件内部固定写死组件类型。

4.2.3 创建缓存状态

const [cache,setCache] = useState({}); // 缓存组件的

使用useState创建cache状态,初始值为一个空对象,用于存储缓存的组件。缓存对象的结构为:{ activeId1: 组件1, activeId2: 组件2, ... },其中key为activeId,value为对应的children组件。

这里为什么用对象而不是Map?因为手写简易版本,对象的操作更简洁,且能满足基本需求;如果是生产环境的实现(如react-activation),会使用Map,因为其key可以是任意类型,且遍历效率更高。后续我们会将对象改为Map,优化实现。

4.2.4 编写副作用函数,更新缓存

useEffect(()=>{
    // active update 切换显示
    // children update 切换保存
    // 如果缓存中没有activeId 组件 就缓存起来
    if(!cache[activeId]){ // activeId key
        setCache((prev) => ({
            ...prev,
            [activeId]: children
        }))
    }
    // console.log(cache,'cache');
},[activeId,children,cache])

这是手写KeepAlive的核心逻辑,我们再次详细解析:

  1. 副作用函数的作用:监听activeId、children、cache的变化,当这些值变化时,更新缓存——确保首次渲染的组件被添加到缓存中,后续切换时不再重复添加。
  2. 判断条件if(!cache[activeId]) :检查当前缓存对象中,是否存在以activeId为key的组件。如果不存在(说明是第一次渲染该组件),就执行缓存添加操作;如果存在(说明组件已经被缓存),则不做任何操作,避免重复缓存。
  3. setCache的函数式更新:使用setCache((prev) => ({ ...prev, [activeId]: children })),其中prev是上一次的缓存对象,通过展开运算符(...prev)保留之前所有的缓存组件,然后添加当前activeId对应的children组件,实现“增量缓存”(新增缓存不覆盖原有缓存)。
  4. 依赖项数组:[activeId, children, cache],确保当activeId(切换组件)、children(组件内容变化)、cache(缓存变化)中的任意一个值发生变化时,重新执行副作用函数,更新缓存。

举个例子:当父组件切换activeId从'A'到'B'时,副作用函数会执行,检查cache中是否有'B'对应的组件,如果没有,就将children(B组件)添加到缓存中;当再次切换回'A'时,cache中已经有'A'对应的组件,不会重复添加,直接复用缓存。

4.2.5 渲染缓存的组件,控制显示与隐藏

return(
    <div>
        {
            // Object.entries 会将对象字面量转换为数组,因为map是数组的方法,所以需要将其转换为数组
            // [key,value] 又方便使用
            Object.entries(cache).map(([id,component]) => (
                <div 
                key={id}
                style={{display: id===activeId ? 'block':'none'}}
                >
                    {component}
                </div>
            ))
        }
    </div>
)

这部分代码的作用是:遍历缓存对象中的所有组件,根据activeId控制其显示与隐藏,核心逻辑如下:

  1. Object.entries(cache) :将缓存对象(cache)转换为二维数组,每个子数组为[key, value],其中key是activeId,value是对应的组件。
  2. map遍历数组:通过map方法遍历转换后的二维数组,对每个缓存的组件进行渲染。这里使用解构赋值([id, component]),将子数组的第一个元素赋值给id(即activeId),第二个元素赋值给component(即缓存的组件)。
  3. key属性:给每个渲染的div添加key={id},确保React能准确识别每个组件,避免渲染错误(React要求列表渲染必须有唯一的key)。
  4. display样式控制:通过inline样式设置display的值——当id(缓存的组件标识)与activeId(当前激活的标识)相等时,设置display: block,组件显示;否则设置display: none,组件隐藏,实现“切换显示不卸载”的效果。
  5. 渲染组件:在div内部渲染缓存的组件({component}),即children组件,实现组件的复用。

4.2.6 导出组件

export default KeepAlive

将手写的KeepAlive组件导出,供其他组件引入使用。

4.3 手写KeepAlive的测试案例(解析)

为了验证手写的KeepAlive组件是否能正常工作,提供了测试案例(Counter、OtherCounter、App组件),我们解析测试案例的逻辑,看看如何使用手写的KeepAlive组件,以及验证其缓存效果。

4.3.1 测试组件:Counter和OtherCounter

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'}}>
      <h3>{name}视图</h3>
      <p>当前计算结果:{count}</p>
      <button onClick={() => setCount(count + 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'}}>
      <h3>{name}视图</h3>
      <p>当前计算结果:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  )
}

这两个组件是测试用的计数器组件,结构和逻辑完全一致,核心作用是:

  1. 使用useState创建count状态,初始值为0,点击“增加”按钮可以使count加1(用于验证组件状态是否被保留)。
  2. 使用useEffect添加挂载和卸载的日志:useEffect的依赖项为空数组,说明该副作用只在组件挂载时执行一次,卸载时执行清理函数(输出卸载日志)。通过日志可以验证组件是否被卸载——如果KeepAlive缓存生效,组件切换时不会输出卸载日志。
  3. 接收name属性,用于区分不同的组件(如“A组件”“B组件”)。

4.3.2 父组件:App(使用KeepAlive组件)

const App = () => {
  const [activeTab, setActiveTab] = useState('A'); 
  return (
    <div>
      <div style={{marginBottom: '20px'}}>
        <button onClick={() => setActiveTab('A')}>显示A组件</button>
        <button onClick={() => setActiveTab('B')}>显示B组件</button>
      </div>
      {/* children 提升组件的定制能力 父组件方便 */}
      <KeepAlive activeId={activeTab} >
        {activeTab === 'A' ? <Counter name="A" />:<OtherCounter name="B" />}
      </KeepAlive>
    </div>
  )
}
export default App

App组件是测试案例的父组件,核心逻辑是:通过tab切换(activeTab)控制KeepAlive组件的activeId和children,验证缓存效果,我们逐行解析:

  1. activeTab状态:使用useState创建activeTab状态,初始值为'A',用于控制当前显示的组件(A组件或B组件)。

  2. tab切换按钮:两个按钮,点击分别将activeTab设置为'A'和'B',实现组件切换。

  3. 使用KeepAlive组件

    1. 传入activeId={activeTab}:将当前激活的tab标识作为activeId,传递给KeepAlive组件,控制组件的显示与隐藏。
    2. children为条件渲染:当activeTab为'A'时,传入;当为'B'时,传入,实现不同组件的缓存与切换。

4.3.3 缓存效果验证(关键测试点)

运行App组件后,我们可以通过以下步骤验证手写KeepAlive的缓存效果,这也是面试中考察的重点:

  1. 首次渲染:页面默认显示A组件,控制台输出“A组件挂载完成”,点击“A组件”的“增加”按钮,使count变为3(记录当前状态)。

  2. 切换到B组件:点击“显示B组件”按钮,此时:

    1. 页面显示B组件,控制台输出“B组件挂载完成”(首次渲染B组件,添加到缓存)。
    2. 控制台不会输出“A组件卸载完成” ,说明A组件没有被卸载,只是被隐藏(display: none),缓存生效。
    3. 点击B组件的“增加”按钮,使count变为5(记录B组件状态)。
  3. 切换回A组件:点击“显示A组件”按钮,此时:

    1. 页面显示A组件,count依然是3(A组件的状态被保留,没有重置),说明缓存生效。
    2. 控制台不会输出“A组件挂载完成”和“B组件卸载完成” ,说明A组件复用了之前的缓存,B组件被隐藏,没有被卸载。
  4. 再次切换到B组件:B组件的count依然是5,状态被保留,控制台没有输出任何挂载/卸载日志,缓存效果完全符合预期。

如果没有KeepAlive组件,切换组件时,之前的组件会被卸载(控制台输出卸载日志),再次切换回来时,count会重置为0,状态丢失。这就充分证明了手写KeepAlive组件的核心功能——缓存组件、保留状态、避免重复挂载卸载。

4.4 手写KeepAlive的优化的方向

当前手写的KeepAlive组件是简易版本,满足了核心需求,但与react-activation库相比,还有很多可以优化的地方,这些优化点也是面试中常被问到的,我们逐一说明:

4.4.1 将缓存容器从对象改为Map

当前版本使用对象作为缓存容器,虽然简洁,但存在局限性(key只能是字符串或Symbol)。优化为Map后,key可以是任意类型(如组件实例、Symbol等),更灵活,且遍历效率更高。优化代码如下:

// 优化1:将缓存容器改为Map
const [cache, setCache] = useState(new Map());

useEffect(()=>{
    if(!cache.has(activeId)){ // Map的has方法判断key是否存在
        setCache((prev) => {
            const newCache = new Map(prev); // 复制之前的缓存
            newCache.set(activeId, children); // 添加新缓存
            return newCache;
        })
    }
},[activeId,children,cache])

// 渲染时,使用Map的entries()方法遍历
return(
    <div>
        {
            Array.from(cache.entries()).map(([id,component]) => (
                <div 
                key={id}
                style={{display: id===activeId ? 'block':'none'}}
                >
                    {component}
                </div>
            ))
        }
    </div>
)

优化说明:Map的has()方法用于判断key是否存在,set()方法用于添加键值对,entries()方法用于获取键值对迭代器,通过Array.from()转换为数组后,即可使用map方法遍历。

4.4.2 增加缓存滚动位置功能

当前版本没有实现滚动位置缓存,优化后可以添加saveScrollPosition属性,缓存组件的滚动位置,切换时自动恢复。核心思路:

  1. 给KeepAlive组件添加saveScrollPosition props(默认false)。
  2. 当组件隐藏时(id !== activeId),记录组件的滚动位置(scrollTop、scrollLeft),存储在缓存中。
  3. 当组件显示时(id === activeId),恢复之前记录的滚动位置。

4.4.3 增加手动清除缓存的功能

当前版本无法手动清除缓存,优化后可以添加clearCache方法,通过props传递给父组件,允许父组件手动清除指定或所有缓存。核心思路:

  1. 使用useCallback创建clearCache方法,用于清除缓存(可清除指定activeId的缓存,或清空所有缓存)。
  2. 通过props的onClearCache将clearCache方法传递给父组件,父组件可调用该方法清除缓存。

4.4.4 避免children重复渲染

当前版本中,当父组件的activeTab变化时,children会重新创建(如activeTab从'A'变为'B',children从Counter变为OtherCounter),可能会导致不必要的渲染。优化思路:使用React.memo包裹children,避免重复渲染,或通过useMemo缓存children。

4.4.5 支持缓存组件的卸载控制

当前版本中,缓存的组件会一直保留在内存中,不会被卸载。优化后可以添加maxCacheCount属性,设置最大缓存数量,当缓存数量超过阈值时,自动清除最早的缓存,避免内存泄漏。

五、KeepAlive组件的面试重点解析

手写KeepAlive组件是React面试中的高频考点,考察的不仅是代码实现能力,更是对React核心知识点(Hook、组件生命周期、DOM操作)和JavaScript基础(Map、Object.entries)的掌握程度。结合本次学习内容,我们总结面试中常被问到的重点问题及答案。

5.1 面试题1:React中为什么需要KeepAlive组件?它解决了什么问题?

参考答案:

React组件默认的生命周期是“挂载→更新→卸载”,当组件切换(路由切换、条件渲染)时,未显示的组件会被自动卸载,导致两个核心问题:

  1. 性能损耗:重复挂载卸载会触发DOM操作、数据请求等耗时操作,尤其是复杂组件,影响页面响应速度。
  2. 状态丢失:用户操作状态(如滚动位置、计数器数值、输入内容)会被重置,影响用户体验。

KeepAlive组件的核心作用是缓存指定组件,使其切换时不被卸载,保留组件的DOM结构和内部状态,再次显示时直接复用,从而解决上述两个问题,提升页面性能和用户体验。

5.2 面试题2:KeepAlive组件的底层实现原理是什么?

参考答案(结合手写实现):

  1. 缓存容器:使用 Map(生产环境首选)或对象作为缓存容器,存储组件键值对(key 为组件唯一标识,如 activeId、name;value 为组件实例、DOM 结构及内部状态),实现组件的高效存储、读取与更新,解决组件重复创建的问题。
  2. 组件隐藏与显示:核心是 “不卸载,只隐藏”,通过动态控制组件容器的 display 样式实现 —— 显示时设为 display: block,组件正常渲染;隐藏时设为 display: none,组件离开文档流但不卸载,其 DOM 结构和内部状态(如 useState 数据)始终保留在内存中。
  3. 缓存更新与控制:通过 React 的 useEffect 监听组件标识(activeId)和待缓存组件(children)的变化,首次渲染时将组件添加到缓存容器,后续切换时复用缓存;配合状态管理(useState)维护缓存容器,实现 “增量缓存”(不覆盖原有缓存),同时可扩展滚动位置缓存、手动清除缓存等功能(如 react-activation 的 AliveScope 提供全局缓存上下文管理)。

补充关键区别:不同于组件卸载(unmount),KeepAlive 通过隐藏实现缓存,被缓存组件仅挂载一次,不执行卸载生命周期,再次显示时也不重复执行挂载生命周期,仅触发更新相关逻辑