一、React KeepAlive 的艰辛之路
在最早接触 React 的时候,就很好奇他没有 Vue 那种 KeepAlive 的机制,那缓存要如何去实现,后来我去 react issue,发现从 18 年开始就有人问到这个问题:(React 未来可以支持 KeepAlive 吗)。
想来结果显而易见,不然 React 也不会说拖这么久……尴尬
KeepAlive 缺陷
为此 React 的解释是(好像是 React 团队的人写的,很长的一篇文章,我简单概括下):
-
Vue 的 KeepAlive的 API 原理,要求只能包裹一个 Child (这点看过 KeepAlive 原理的应该晓得,他说的是 Vue 是拿 KeepAlive 组件内部的第一个 slot 作为缓存的虚拟 DOM 实例),如果这里面放了 v-for 就不起作用,React 不会去设计这种非常有限的 API -
KeepAlive 既然是缓存了组件实例,那么就一直保存着大量的数据在内存当中,容易造成内存泄漏
当然,现如今 React18 了,即将拥抱 <Offscreen /> 组件,据说该组件就类似于 React 的 KeepAlive,不过还没正式出就是了。(期待.ing)
2 种实现方式
先不提上面的缺陷,毕竟,有缓存真的香。在当下能实现的 KeepAlive 的方式,issue 里也回答了 2 种
- 单独缓存状态数据
- 利用样式
display: none或者html 的 hidden属性
第一种解决方法,有些局限性,我之前遇到过一些需求,要求弹窗是可以拖动的,且没有 mask 遮罩,也就是说可以多个弹窗弹出来,且可以拖动,还可以点击到其他页面。但到其他页面之后,弹窗要消失,在点回来,弹窗位置,内容,需要没有任何变化。。。显然第一种解决方法,就不太合适了…………总不能去记录每个弹窗的 px 位置吧……
所以,目前当下流行的还是第二种方式
二、思路
本文是在阅读了 KeepAlive 等多位大佬的文章之后,做的一个吸纳总结。
功能最终实现模仿于 Vue 的 KeepAlive组件。 我们想要的是 切换路由 的时候,那个路由没有被卸载,且要有 UnActived(失活) / Actived(激活) 这种生命周期的功能
大致思路是,将需要缓存的路由组件,外部包裹一个 Item 组件 ,那么真正的页面组件就变为了该 Item 组件的 child
那这时候有意思了,我们 Item 组件内部不去挂载该 child,而是通过 Context 把这个 child 传递上去,那么在路由切换的时候, Item 组件的加载 / 卸载就可以作为 Actived / UnActived 的标志
而因为我们并没有将 child 挂载在 Item 组件上,所以 child(真正的页面组件) 是不会卸载的。child 的卸载由我们向上传递到的那个组件 A控制(因为组件 A内部真正挂载了 child)
三、实现
大致梳理一下,首先需要一个 Provider 作为数据管理,便于把 Child 传递到外层组件
KeepAliveProvider
// 由于后续调用实例 keepRef 的方法(比如 push)会需要重新渲染
// 所以添加一个 update 方法,用于调用 rerender
const useKeeper = (maxSize?: number) => {
const [, update] = useState({})
const keepRef = useRef<KeepAlive>()
if (!keepRef.current) {
keepRef.current = new KeepAlive(update, maxSize)
}
return keepRef.current
}
const KeepAliveProvider = ({ children, maxSize = 10 }) => {
// 获取 Keeper类 实例的 hooks
const keepRef = useKeeper(maxSize)
const memoRef = useMemo<KeepContextType>(() => {
return {
// 用于修改状态,添加child,parent 等
dispatch: keepRef.dispatch.bind(keepRef),
// 用于添加生命周期 actived / unActived
pushCycle: keepRef.pushCycle.bind(keepRef),
}
}, [keepRef])
// 只有 status 为 CREATED 或 ACTIVED 的时候才显示
const hiddenCheck = useCallback((status: CYCLE_ENUM) => {
return ['CACHE_CREATED', 'CACHE_ACTIVED'].includes(status)
}, [])
return (
<KeepAliveContext.Provider value={memoRef}>
<div className='keep-alive-provider'>{children}</div>
<div className='scope-item-list'>
{/* 将 保存在 Map 里的 child 在这里渲染,只要 cacheMap 里对应的组件没卸载,那么 child 就不会被卸载 */}
{[...keepRef.cacheMap?.values()].map((v, i) => {
{
/**
* 这里的 ScopeItem 的作用是
* 1. 为了 将 child 放到真实的DOM位置上
* 2. 且真正卸载时候,也需要这个组件内部去将 child DOM卸载掉
*/
}
return v?.cacheId && <ScopeItem key={v.cacheId} {...v} hidden={!hiddenCheck(v?.status)} />
})}
</div>
</KeepAliveContext.Provider>
)
}
KeepAliveClass
上述代码中 useKeeper 内部用到的 new KeepAlive() ,主要负责处理 缓存数据,生命周期等。
每当 dispatch 被调用,那么会修改 cacheMap ,紧接着重新 render , ScopeItem 组件就会根据 cacheMap 的变化而变化。
class KeepAlive {
maxSize: number // 缓存大小
updateFn: Function // 更新方法
cacheMap: Map<string, CacheMapType> // 缓存 MAP
callbackMap: Map<string, any> // 生命周期 MAP
constructor(update: Function, maxSize: number = 10) {
this.maxSize = maxSize
this.updateFn = update
this.cacheMap = new Map([])
this.callbackMap = new Map([])
}
// 根据 type 调用 CACHE_CREATED 等方法,修改 cacheMap 内容
dispatch(type: CYCLE_ENUM, payload: any) {
this?.[type] && this[type]?.(payload)
}
// 添加生命周期,修改 callbackMap 内容
pushCycle(type: CYCLE_ENUM, payload: any) {
const { cacheId, callback } = payload
set(this.callbackMap, cacheId, { [type]: callback })
}
private dispatchCycle(type: CYCLE_ENUM, cacheId: string) {
this.callbackMap.get(cacheId)?.[type]?.()
}
private CACHE_CREATED(payload: any) {
const { cacheId } = payload
if (this.cacheMap.has(cacheId)) {
this.CACHE_ACTIVED(payload)
} else {
// 将 cacheId 的数据添加到 cacheMap 中,然后调用 update函数
// update 函数是由外部 useKeeper 里传递的,所以会进而会触发 rerender
set(this.cacheMap, cacheId, {
/* ...状态,children等数据 */
})
this.updateFn({})
}
}
}
ScopeItem
这层内部主要是操作真实 DOM,把 child 挂载到真正的 parent 节点(也即 keep-alive-item) 上。
【注】看到这里大家应该就能发现,其实 child 就是从 parent 组件传给 context 的,然后让 child 在 provide 层渲染完之后,再把真实 DOM 挂载回去,这样是为了 parent 组件卸载的时候,child 不卸载,达到缓存的效果
const ScopeItem = (props: ScopeItemType) => {
const { cacheId, children, hidden, parentDOM } = props
const currentDOM = useRef<HTMLDivElement>()
// 在这里组件的时候需要移除掉DOM节点上的DOM
useLayoutEffect(() => {
return () => {
currentDOM.current?.remove?.()
}
}, [])
useEffect(() => {
if (parentDOM && currentDOM?.current) {
// 这一步是为了避免缓存节点 非缓存节点,有一瞬间同时出现的bug
parentDOM.appendChild?.(currentDOM.current)
}
}, [parentDOM, currentDOM?.current])
// 必须在外层包裹住一个 div 占位,因为我们底下的 currentDOM 一开始被我们移动到其他地方了
// 如果没有这个 div 占位,那么 react 执行到卸载时候,会出错
return (
<div className='keep-alive-placeholder'>
<div ref={currentDOM} hidden={hidden}>
{/* LifeCycleIdContext 是为了让 child 里的 生命周期hook 能获取到 cacheId */}
<LifeCycleIdContext.Provider value={cacheId}>{children}</LifeCycleIdContext.Provider>
</div>
</div>
)
}
KeepAliveItem
child 和 parent 就是从这个组件里获取到的,cacheId 作为唯一标识符。
将该组件的 加载/卸载 转换成 child 的 激活/失活
const KeepAliveItem = ({ cacheId, children }) => {
// 之所以需要 parentDOM 是为了把真实dom挂载到单一节点上
// 避免未缓存节点 和 缓存节点 有那么一瞬间同时出现(因为之前都是2个挂载不同节点上)
// 且有了 parentDOM 可以把真实页面渲染到对应的节点,避免渲染位置出错
const parentDOM = useRef<HTMLDivElement>()
const keepRef = useContext(KeepAliveContext)
useEffect(() => {
// CACHE_CREATED 里判断是首次进入,还是再次进入(再次进入需要切换状态,且触发 useActived 生命周期)
keepRef.dispatch?.(CYCLE_ENUM.CACHE_CREATED, {
cacheId,
children,
parentDOM: parentDOM?.current,
})
return () => {
// keepAliveItem 卸载,修改状态为 UnActived,其children没卸载,进入失活状态
keepRef.dispatch?.(CYCLE_ENUM.CACHE_UNACTIVED, {
cacheId,
})
}
}, [cacheId])
return <div ref={parentDOM} className={`keep-alive-item`} />
}
useActived
在页面组件内调用 useActived 时,会调用 pushCycle 将生命周期函数的 callback 存储起来。然后根据 keepAliveItem组件 的 加载/卸载 去处理生命周期。 useUnActived 同理。
const useActived = (callback: Function) => {
const cacheId = useContext(LifeCycleIdContext)
const keepRef = useContext(KeepAliveContext)
useEffect(() => {
if (cacheId) {
keepRef.pushCycle?.(CYCLE_ENUM.CACHE_ACTIVED, {
cacheId,
callback,
})
}
}, [])
}
四、问题总结
-
缓存页面 和 非缓存页面切换的时候,会有一瞬间同时出现(通过节点移动解决)
-
卸载缓存页面时,DOM 没有清除掉(添加
currentDOM.remove()解决) -
卸载的时候,有时候会触发 UnActived,导致多次 rerender (曾想通过防抖解决,结果又出现新的问题,暂时不做处理)