• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

当路由切换的时候,react router 会销毁之前路由的组件,然后渲染新的路由对应的组件。

在一些场景下,这样是有问题的。

比如移动端很多长列表,用户划了很久之后,点击某个列表项跳到详情页,之后又跳回来,但是这时候列表页的组件销毁重新创建,又回到了最上面。

比如移动端填写了某个表单,有的表单需要跳到别的页面获取数据,然后跳回来,跳回来发现组件销毁重新创建,之前填的都没了。

类似这种场景,就需要路由切换的时候不销毁组件,也就是 keepalive。

我们先复现下这个场景:

npx create-vite

进入项目,安装依赖,把开发服务跑起来:

npm install
npm run dev

安装 react-router:

npm i --save react-router-dom

在 App.tsx 写下路由:

import { useState } from "react";
import {
    Link,
    useLocation,
    RouterProvider,
    createBrowserRouter,
    Outlet,
} from "react-router-dom";

const Layout = () => {
    const { pathname } = useLocation();

    return (
        <div>
            <div>当前路由: {pathname}</div>
            <Outlet />
        </div>
    );
};

const Aaa = () => {
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>{count}</p>
            <p>
                <button onClick={() => setCount((count) => count + 1)}>
                    加一
                </button>
            </p>
            <Link to="/bbb">去 Bbb 页面</Link>
            <br />
            <Link to="/ccc">去 Ccc 页面</Link>
        </div>
    );
};

const Bbb = () => {
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>{count}</p>
            <p>
                <button onClick={() => setCount((count) => count + 1)}>
                    加一
                </button>
            </p>
            <Link to="/">去首页</Link>
        </div>
    );
};

const Ccc = () => {
    return (
        <div>
            <p>ccc</p>
            <Link to="/">去首面</Link>
        </div>
    );
};

const routes = [
    {
        path: "/",
        element: <Layout></Layout>,
        children: [
            {
                path: "/",
                element: <Aaa></Aaa>,
            },
            {
                path: "/bbb",
                element: <Bbb></Bbb>,
            },
            {
                path: "/ccc",
                element: <Ccc></Ccc>,
            },
        ],
    },
];

export const router = createBrowserRouter(routes);

const App = () => {
    return <RouterProvider router={router} />;
};

export default App;

这里有 /、/bbb、/ccc 这三个路由。

一级路由渲染 Layout 组件,里面通过 Outlet 指定渲染二级路由的地方。

二级路由 / 渲染 Aaa 组件,/bbb 渲染 Bbb 组件,/ccc 渲染 Ccc 组件。

这里的 Outlet 组件,也可以换成 useOutlet,效果一样:

注释掉 index.css 还有 StrictMode:

在浏览器看一下:

默认路由切换,对应的组件就会销毁:

我们有时候不希望切换路由时销毁页面组件,也就是希望能实现 keepalive。

怎么做呢?

其实很容易想到,我们把所有需要 keepalive 的组件保存到一个全局对象。

然后渲染的时候把它们都渲染出来,路由切换只是改变显示隐藏。

按照这个思路来写一下:

新建 keepalive.tsx

import React, { createContext, useContext } from "react";
import { useOutlet, useLocation, matchPath } from "react-router-dom";
import type { FC, PropsWithChildren, ReactNode } from "react";

interface KeepAliveLayoutProps extends PropsWithChildren {
    keepPaths: Array<string | RegExp>;
    keepElements?: Record<string, ReactNode>;
    dropByPath?: (path: string) => void;
}

type KeepAliveContextType = Omit<Required<KeepAliveLayoutProps>, "children">;

const keepElements: KeepAliveContextType["keepElements"] = {};

export const KeepAliveContext = createContext<KeepAliveContextType>({
    keepPaths: [],
    keepElements,
    dropByPath(path: string) {
        keepElements[path] = null;
    },
});

const isKeepPath = (keepPaths: Array<string | RegExp>, path: string) => {
    let isKeep = false;
    for (let i = 0; i < keepPaths.length; i++) {
        let item = keepPaths[i];
        if (item === path) {
            isKeep = true;
        }
        if (item instanceof RegExp && item.test(path)) {
            isKeep = true;
        }
        if (typeof item === "string" && item.toLowerCase() === path) {
            isKeep = true;
        }
    }
    return isKeep;
};

export function useKeepOutlet() {
    const location = useLocation();
    const element = useOutlet();

    const { keepElements, keepPaths } = useContext(KeepAliveContext);
    const isKeep = isKeepPath(keepPaths, location.pathname);

    if (isKeep) {
        keepElements![location.pathname] = element;
    }

    return (
        <>
            {Object.entries(keepElements).map(([pathname, element]) => (
                <div
                    key={pathname}
                    style={{
                        height: "100%",
                        width: "100%",
                        position: "relative",
                        overflow: "hidden auto",
                    }}
                    className="keep-alive-page"
                    hidden={!matchPath(location.pathname, pathname)}>
                    {element}
                </div>
            ))}
            {!isKeep && element}
        </>
    );
}

const KeepAliveLayout: FC<KeepAliveLayoutProps> = (props) => {
    const { keepPaths, ...other } = props;

    const { keepElements, dropByPath } = useContext(KeepAliveContext);

    return (
        <KeepAliveContext.Provider
            value={{ keepPaths, keepElements, dropByPath }}
            {...other}
        />
    );
};

export default KeepAliveLayout;

代码比较多,从上到下来看。

首先,我们创建一个 context。

keepPaths 是要 keepalive 的页面路径,可以是 string 也可以是正则。

keepElements 是页面路径和组件的键值对,用来保存 keepalive 的组件。

dropByPath 是根据页面路径删除 keepElement 中的对应组件。

这里用到几个 TS 的内置类型:

Record 是创建一个 key value 的对象类型:

Requried 是去掉可选 -?

Omit 是删掉其中的部分属性:

KeepAliveLayoutProps 类型处理后就是这样的:

ts 类型编程部分的知识可以看我的 TS 类型体操小册

继续往下看:

暴露出一个组件,里面用 context.Provider 修改 context 中的值,主要是设置 keepPaths,其余的都用 useContext 从 context 中取。

然后暴露一个 useKeepOutlet 的 hook:

用 useLocation 拿到当前路由,用 useOutlet 拿到对应的组件。

判断下当前路由是否在需要 keepalive 的路由内,是的话就保存到 keepElements。

然后渲染所有的 keepElements,如果不匹配就隐藏。

并且如果当前路由不在 keepPaths 内,就直接渲染对应的组件。

isKeepPath 就比较简单了,根据是 string 还是 RegExp 分别做处理,判断路由是否在 keepPaths 内:

其实原理比较容易看懂:在 context 中保存所有需要 keepalive 的组件,全部渲染出来,通过路由是否匹配来切换对应组件的显示隐藏。

在 App.tsx 里引入测试下:

在外面包一层 KeepAliveLayout 组件:

然后把 useOutlet 换成 useKeepOutlet:

测试下:

可以看到,/bbb 路由的组件实现了 keepalive,而 / 路由的 组件没有。

我们配置下:

现在两个路由的组件就都 keepalive 了:

案例代码上传了小册仓库

总结

路由切换会销毁对应的组件,但很多场景我们希望路由切换组件不销毁,也就是 keepalive。

react router 并没有实现这个功能,需要我们自己做。

我们在 context 中保存所有需要 keepalive 的组件,然后渲染的时候全部渲染出来,通过路由是否匹配来切换显示隐藏。

这样实现了 keepalive。

这个功能是依赖 React Router 的 useLocation、useOutlet、matchPath 等 api 实现的,和路由功能密不可分。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
42.数据不可变:immutable和immer
Next
44.Historyapi和ReactRouter实现原理