React KeepAlive 实现
大家好!打工人打工魂,我是阿祎,是一个前端开发经验3年+的菜菜子打工人👷🏻♂️,略微懂些后端和运维部署,React开发框架不支持KeepAlive组件,是用过Vue KeepAlive组件的银耳会呼吸的痛! 为了不让这个痛这么强烈,那么今天我就拉着大家的手简单(理解)实现一下React的KeepAlive组件。
KeepAlive组件实现特点Features
- 使用React几个支持的api,Legacy除外,React18也可以放心使用。
- 将缓存节点放在内存中,而不是挂载到body之外通过dispaly none隐藏方式,因此大幅减小对页面性能影响。
为什么要实现这个捏
首先实现某项功能都是有成本的,比如时间和精力投入,比如是否可能增加项目的复杂度,降低可维护性和稳定性的等等问题,需要在里面做一个平衡。对于很简单的管理系统而言,其实没必要过于复杂,简单高效的快速实现就行。 硬要实现这个的话,我觉得主要理由有如下。
- 管理系统异常庞大,很多个菜单页面交互,很多表单页面交互,表单填写的时候需要去查看其他页面的内容,然后go back继续填写,这就涉及到之前表单的数据需要被保留下来,当再次切换到这个表单时之前未提交的填写的内容需要将其呈现,方便继续填写,作为开发也不想那么多页面一个个手动去(local/session storage 例如使用ahooks的useLocalStorageState或useSessionStorageState)存取清除管理。
- 很多页面需要保留之前请求的数据,避免在tab切换的时候多次重复请求,比如报表数据,本来就慢,切回来看还得等半天才加载出来,1s, 2s, 3s 嗯... 没啥体验感。(有人说了可以使用请求缓存,对可以在请求封装上边加上一层cache,或者直接使用Redux Toolkit的 RTK query, 感觉不是那么一劳永逸,(内心逼逼嗯,缓存有风险,后端都没有做,我还是不做吧))
RTK Query Overview | Redux Toolkit
So,不如实现(抄)一个吧。
核心原理实现
使用useRoutes
Api 通过routes
路由配置和location获得当前路由下面需要渲染的页面ReactElement
节点。
jsx
// 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时,冲出去成为一个现眼包,闪亮登场。
jsx
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" />
jsx
const activeKey = useMemo(() => {
return location.pathname + location.search
}, [location])
cacheReactNode
的被操纵(拿捏🫴)的过程
jsx
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删除关闭时需要清除对应的缓存组件
jsx
useImperativeHandle(
aliveRef,
() => ({
// 获取缓存节点数组
getCaches: () => cacheReactNodes,
// 清除某一个缓存
removeCache: (name: string) => {
setCacheReactNodes(cacheReactNodes => {
return filter(({ name: cacheName }) => !equals(cacheName, name), cacheReactNodes)
})
},
// 毁灭吧,哟鸡喔挤哇卡咧
clearCache: () => {
setCacheReactNodes([])
}
}),
[cacheReactNodes],
)
差不多都ok了,还差一个onActive,子页面激活的时候,子页面怎么知道我被激活了呢,也没有传props给它,也木有使用legacy api(cloneElement
),这时候id⭐️有大用,就派上用场啦。id其实就是url路径对吧,那么子页面去获取包裹它的元素也就是那个targetElement Div身上的id值,然后对当前路由进行比较,就知道自己是否被激活了,那么封装一个hook就搞定!
useOnActive实现
jsx
/**
* @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
}
useOnActive使用
jsx
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>
)
}
Summary
由于篇幅有限。多tab实现很简单就不用细说啦,我把实现方案已经放在github代码库了,欢迎star和提issue。
完结散花
灵感来自于 github.com/liuye1296/r... 基于此优化加强