React KeepAlive 缓存方案

2,839 阅读6分钟

一、React KeepAlive 的艰辛之路

在最早接触 React 的时候,就很好奇他没有 Vue 那种 KeepAlive 的机制,那缓存要如何去实现,后来我去 react issue,发现从 18 年开始就有人问到这个问题:(React 未来可以支持 KeepAlive 吗)

想来结果显而易见,不然 React 也不会说拖这么久……尴尬

KeepAlive 缺陷

为此 React 的解释是(好像是 React 团队的人写的,很长的一篇文章,我简单概括下):

  1. Vue 的 KeepAlive 的 API 原理,要求只能包裹一个 Child (这点看过 KeepAlive 原理的应该晓得,他说的是 Vue 是拿 KeepAlive 组件内部的第一个 slot 作为缓存的虚拟 DOM 实例),如果这里面放了 v-for 就不起作用,React 不会去设计这种非常有限的 API

  2. KeepAlive 既然是缓存了组件实例,那么就一直保存着大量的数据在内存当中,容易造成内存泄漏

当然,现如今 React18 了,即将拥抱 <Offscreen /> 组件,据说该组件就类似于 React 的 KeepAlive,不过还没正式出就是了。(期待.ing)

2 种实现方式

先不提上面的缺陷,毕竟,有缓存真的香。在当下能实现的 KeepAlive 的方式,issue 里也回答了 2 种

  1. 单独缓存状态数据
  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 ,紧接着重新 renderScopeItem 组件就会根据 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 的,然后让 childprovide 层渲染完之后,再把真实 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

childparent 就是从这个组件里获取到的,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 (曾想通过防抖解决,结果又出现新的问题,暂时不做处理)