简单实现React KeepAlive不依赖第三方库(附源码)

8,603 阅读6分钟
image.png

React KeepAlive 实现

大家好!打工人打工魂,我是阿祎,React开发框架不支持KeepAlive组件,是用过Vue KeepAlive组件的银耳会呼吸的痛! 为了不让这个痛这么强烈,那么今天我就拉着大家的手简单(理解)实现一下React的KeepAlive组件!这个组件已经发布为npm包,欢迎使用!

npm install keepalive-for-react

KeepAlive组件实现特点Features

  • 使用React几个支持的api,Legacy除外,React18也可以放心使用。
  • 将缓存节点放在内存中,而不是挂载到body之外通过dispaly none隐藏方式,因此大幅减少dom数量对页面性能影响。

为什么要实现这个捏

首先实现某项功能都是有成本的,比如时间和精力投入,比如是否可能增加项目的复杂度,降低可维护性和稳定性的等等问题,需要在里面做一个平衡。对于很简单的管理系统而言,其实没必要过于复杂,简单高效的快速实现就行。 硬要实现这个的话,我觉得主要理由有如下。

  • 管理系统异常庞大,很多个菜单页面交互,很多表单页面交互,表单填写的时候需要去查看其他页面的内容,然后go back继续填写,这就涉及到之前表单的数据需要被保留下来,当再次切换到这个表单时之前未提交的填写的内容需要将其呈现,方便继续填写,作为开发也不想那么多页面一个个手动去(local/session storage 例如使用ahooks的useLocalStorageStateuseSessionStorageState)存取清除管理。
  • 很多页面需要保留之前请求的数据,避免在tab切换的时候多次重复请求,比如报表数据,本来就慢,切回来看还得等半天才加载出来,1s, 2s, 3s 嗯... 没啥体验感。(有人说了可以使用请求缓存,对可以在请求封装上边加上一层cache,或者直接使用Redux Toolkit的 RTK query, 感觉不是那么一劳永逸,(内心逼逼嗯,缓存有风险,后端都没有做,我还是不做吧))

RTK Query Overview | Redux Toolkit

So,不如实现(抄)一个吧。

先看一下效果

体验地址 Super Admin

ezgif.com-video-to-gif (1).gif

核心原理实现

使用useRoutes Api 通过routes路由配置和location获得当前路由下面需要渲染的页面ReactElement节点。

// Layout 组件内部
import {  useRoutes } from "react-router-dom"

// 匹配 当前路径要渲染的路由
const ele = useRoutes(routes, location)

CacheComponent组件实现

使用CacheComponent组件缓存children也就是上面的ele节点,再使用核心api createPortal(children, targetElement)挂载到组件内部的state上的div里面, 当tab切换和打开时,将其在父容器renderDiv上进行挂载和卸载。这里一个CacheComponent就相当于一个路由页面,通过active props 决定是否需要挂载或移除。里面蹲着的子页面蓄势待发就听active为true时,冲出去成为一个现眼包,闪亮登场。

function CacheComponent({ active, children, name, renderDiv }: CacheComponentProps) {
    const [targetElement] = useState(() => document.createElement("div"))
    const activatedRef = useRef(false)
    activatedRef.current = activatedRef.current || active
    useEffect(() => {
        if (active) {
            // 挂载路由ReactElement(chidren)节点
            renderDiv.current?.appendChild(targetElement)
        } else {
            try {
                // 卸载路由ReactElement(chidren)节点
                renderDiv.current?.removeChild(targetElement)
            } catch (e) {
                console.log(e, "removeChild error")
            }
        }
    }, [active, renderDiv, targetElement])
    useEffect(() => {
        // 设置id 用于区分不同的路由ReactElement节点 获取激活状态 这里的id⭐️有大用 后面说
        targetElement.setAttribute("id", name)
    }, [name, targetElement])
    // 把当前的 chidren ReactElement 挂载到targetElement里面
    return <Fragment>{activatedRef.current && createPortal(children, targetElement)}</Fragment>
}

KeepAlive组件实现

KeepAlive组件接管CacheComponent的生死存亡,核心就在这里啦,我们把需要缓存的页面都放在cacheReactNodes里面, 展示子页面的父容器就在这里 上面的renderDiv就是 containerRef <div ref={containerRef} className="keep-alive" />

const activeKey = useMemo(() => {
    return location.pathname + location.search
}, [location])

cacheReactNode的被操纵(拿捏🫴)的过程

useLayoutEffect(() => {
    if (isNil(activeName)) {
        return
    }
    setCacheReactNodes(cacheReactNodes => {
        // maxLen最大缓存的页面数量 如果过大就把前面的页面缓存CacheComponent杀死
        if (length(cacheReactNodes) >= maxLen) {
            cacheReactNodes = slice(1, length(cacheReactNodes), cacheReactNodes)
        }
        // 找一下是不是已经存在里面蹲着了
        const cacheReactNode = cacheReactNodes.find(res => equals(res.name, activeName))
        if (isNil(cacheReactNode)) {
            // 没有就蹲进去吧,等待active为true的时候结束卧薪尝胆
            cacheReactNodes = append(
                {
                    name: activeName,
                    ele: children,
                },
                cacheReactNodes,
            )
        } else {
            // 已经在里面了 那就更新一下 children
            cacheReactNodes = map(res => {
                return equals(res.name, activeName) ? { ...res, ele: children } : res
            }, cacheReactNodes)
        }
        // 排除和包含
        return isNil(exclude) && isNil(include)
            ? cacheReactNodes
            : filter(({ name }) => {
                  if (exclude && includes(name, exclude)) {
                      return false
                  }
                  if (include) {
                      return includes(name, include)
                  }
                  return true
              }, cacheReactNodes)
    })
}, [children, activeName, exclude, maxLen, include])

给父亲管教的方法(给Layout方法)

当然我也要给它加一些手段,让它更灵活!在tab删除关闭时需要清除对应的缓存组件

useImperativeHandle(
    aliveRef,
    () => ({
        getCaches: () => cacheReactNodes,

        removeCache: (name: string) => {
            setTimeout(() => {
                setCacheReactNodes(cacheReactNodes => {
                    return cacheReactNodes.filter(res => res.name !== name)
                })
            }, 0)
        },
        cleanAllCache: () => {
            setCacheReactNodes([])
        },
        cleanOtherCache: () => {
            setCacheReactNodes(cacheReactNodes => {
                return cacheReactNodes.filter(({ name }) => name === activeName)
            })
        },
    }),
    [cacheReactNodes, setCacheReactNodes, activeName],
)

差不多都ok了,还差一个onActive,子页面激活的时候,子页面怎么知道我被激活了呢,也没有传props给它,也木有使用legacy api(cloneElement),这时候id⭐️有大用,就派上用场啦。id其实就是url路径对吧,那么子页面去获取包裹它的元素也就是那个targetElement Div身上的id值,然后对当前路由进行比较,就知道自己是否被激活了,那么封装一个hook就搞定!

useOnActive实现

/**
 * @description 当路由激活时执行回调 KeepAlive
 * @param cb 回调函数
 * @param skipMount 是否跳过首次挂载
 */
export default function useOnActive(cb: () => any, skipMount = true) {
    const domRef = useRef<HTMLDivElement>(null)
    const location = useLocation()
    // 记录是否已经挂载
    const isMount = useRef(false)
    useEffect(() => {
        let destroyCb: any
        if (domRef.current) {
            const parent = domRef.current?.parentElement
            if (parent) {
                const id = parent.id
                const fullPath = location.pathname + location.search
                if (id === fullPath) {
                     // 是否跳过首次挂载
                    if (skipMount) {
                        if (isMount.current) destroyCb = cb()
                    } else {
                        destroyCb = cb()
                    }
                }
            }
        }
        isMount.current = true
        return () => {
            if (destroyCb && typeof destroyCb === "function") {
                destroyCb()
            }
        }
    }, [location])
    return domRef
}

考虑到后面有不是基于router路由切换的使用方式增加了context组件和新的useOnActive

function KeepAliveProvider(props: { children?: ReactNode; initialActiveName?: string }) {
    const { initialActiveName, children } = props
    const [activeName, setActiveName] = useState<string | undefined>(initialActiveName)
    useLayoutEffect(() => {
        setActiveName(initialActiveName)
    }, [initialActiveName])
    return <KeepAliveContext.Provider value={{ activeName, setActiveName }}>{children}</KeepAliveContext.Provider>
}
export const useOnActive = (cb: () => any, skipMount = true) => {
    const domRef = useRef<HTMLDivElement>(null)
    const { activeName } = useKeepAliveContext()
    const isMount = useRef(false)
    useEffect(() => {
        let destroyCb: any
        const parent = domRef.current?.parentElement
        const name = parent?.id
        if (parent && name) {
            if (activeName === name) {
                if (skipMount) {
                    if (isMount.current) destroyCb = cb()
                } else {
                    destroyCb = cb()
                }
                isMount.current = true
                return () => {
                    if (destroyCb && typeof destroyCb === "function") {
                        destroyCb()
                    }
                }
            }
        }
    }, [activeName])
    return domRef
}

keepAlive 完整代码

import type { ReactNode, RefObject } from "react"
import { Fragment, memo, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"
import CacheComponent from "../CacheComponent"
import KeepAliveProvider from "../KeepAliveProvider"

function isNil(value: any) {
    return value === null || value === undefined
}

export interface ComponentReactElement {
    children?: ReactNode | ReactNode[]
}

export type KeepAliveRef = {
    getCaches: () => Array<{ name: string; ele?: ReactNode }>
    /**
     * 清除指定缓存
     * @param name
     */
    removeCache: (name: string) => void
    /**
     * 清除所有缓存
     */
    cleanAllCache: () => void
    /**
     * 清除其他缓存 除了当前的
     */
    cleanOtherCache: () => void
}

interface Props extends ComponentReactElement {
    activeName: string
    include?: Array<string>
    exclude?: Array<string>
    maxLen?: number
    cache?: boolean
    aliveRef?: RefObject<KeepAliveRef>
}

const KeepAlive = memo(function KeepAlive(props: Props) {
    const { activeName, cache, children, exclude, include, maxLen, aliveRef } = props
    const containerRef = useRef<HTMLDivElement>(null)
    const [cacheReactNodes, setCacheReactNodes] = useState<
        Array<{
            name: string
            ele?: ReactNode
            cache: boolean
        }>
    >([])

    useImperativeHandle(
        aliveRef,
        () => ({
            getCaches: () => cacheReactNodes,

            removeCache: (name: string) => {
                setTimeout(() => {
                    setCacheReactNodes(cacheReactNodes => {
                        return cacheReactNodes.filter(res => res.name !== name)
                    })
                }, 0)
            },
            cleanAllCache: () => {
                setCacheReactNodes([])
            },
            cleanOtherCache: () => {
                setCacheReactNodes(cacheReactNodes => {
                    return cacheReactNodes.filter(({ name }) => name === activeName)
                })
            },
        }),
        [cacheReactNodes, setCacheReactNodes, activeName],
    )

    useLayoutEffect(() => {
        if (isNil(activeName)) {
            return
        }
        setCacheReactNodes(cacheReactNodes => {
            if (cacheReactNodes.length >= (maxLen || 20)) {
                cacheReactNodes = cacheReactNodes.slice(1, cacheReactNodes.length)
            }
            // remove exclude
            if (exclude && exclude.length > 0) {
                cacheReactNodes = cacheReactNodes.filter(({ name }) => !exclude?.includes(name))
            }
            // only keep include
            if (include && include.length > 0) {
                cacheReactNodes = cacheReactNodes.filter(({ name }) => include?.includes(name))
            }
            // remove cache false
            cacheReactNodes = cacheReactNodes.filter(({ cache }) => cache)
            const cacheReactNode = cacheReactNodes.find(res => res.name === activeName)
            if (isNil(cacheReactNode)) {
                cacheReactNodes.push({
                    cache: cache ?? true,
                    name: activeName,
                    ele: children,
                })
            } else {
                // important update children when activeName is same
                // this can trigger children onActive
                cacheReactNodes = cacheReactNodes.map(res => {
                    return res.name === activeName ? { ...res, ele: children } : res
                })
            }
            return cacheReactNodes
        })
    }, [children, cache, activeName, exclude, maxLen, include])

    return (
        <Fragment>
            <div ref={containerRef} className="keep-alive" />
            <KeepAliveProvider initialActiveName={activeName}>
                {cacheReactNodes?.map(({ name, cache, ele }) => (
                    <CacheComponent
                        active={name === activeName}
                        renderDiv={containerRef}
                        cache={cache}
                        name={name}
                        key={name}
                    >
                        {ele}
                    </CacheComponent>
                ))}
            </KeepAliveProvider>
        </Fragment>
    )
})

export default KeepAlive

useOnActive使用

function Theme() {
    const domRef = useOnActive(() => {
        console.log("active Theme")
        return () => {
            console.log("clean Theme")
        }
    })
    return (
        <div ref={domRef}>
            <h1>Theme</h1>
            <Input placeholder="输入一个值 然后切换tab组件不会被销毁" />
        </div>
    )
}

npm 包已发布

安装

npm install --save keepalive-for-react 
# or
pnpm add keepalive-for-react 

使用参考

文档:npm keepalive-for-react doc

简单用法Demo:codesandbox

Summary

由于篇幅有限。多tab实现很简单就不用细说啦,我把实现方案已经放在github代码库了,欢迎star和提issue。

github.com/irychen/sup…

完结散花

灵感来自于 github.com/liuye1296/r… 基于此优化加强