一些关于react的keep-alive功能相关知识在这里(上)

SegmentFault

共 22312字,需浏览 45分钟

 ·

2022-04-13 09:50

作者:lulu_up

来源:SegmentFault  思否社区 


下一篇讲这类插件的"大坑", 如果你想全面了解的话一定要读下一篇哦。


背景



这是在2022年开发中PM提的一个需求, 某个table被用户输入了一些搜搜条件并且浏览到了第3页, 那如果我跳转到其他路由后返回当前页面, 希望搜索条件还在, 并且仍处于第三页, 这不就是vue里面的keep-alive标签吗, 但我当前的项目是用react编写的。


此次讲述了我经历了 "使用外部插件"-> "放弃外部插件"-> "学习并自研插件"-> "理解了相关插件的困境" -> "期待react18的Offscreen", 所以结论是推荐耐心等待react18的自支持, 但是学习当前类似插件的原理对自己还是很有启发的。


一个库不能只说自己的优点也要把缺点展示出来, 否则会给使用者代码隐患, 但我阅读了很多网上的文章与官网, 大多都没有讲出相关的原理的细节, 并且没有人对当前存在的bug进行分析, 我这里会对相关奇怪的问题进行详细的讲解, 我下面展示代码是参考了网上的几种方案后稍作改良的。


一、插件调研



我们一起看一下市场上现在有哪些'成熟'的方案。


第一个: react-keep-alive : 官网很正规, 851 Star, 用法上也与vue的keep-alive很接近, 但是差评太多了, 以及3年没更新了, 并且很多网上的文章也都说这个库很坑, 一起看看它的评论吧 (抬走下一位)。


     

第二个: react-router-cache-route : 这个库是针对路由级别的一个缓存, 无法对组件级别生效, 引入后要替换掉当前的路由组件库, 风险不小并且缓存的量级太大了 (抬走下一位)。


第三个: react-activation : 这个库是网上大家比较认可的库, issues也比较少并且不'致命', 并且可以支持组件级别的缓存( 其实它做不到, 还是有bug ), 我尝试着使用到自己团队的项目里后效果还可以, 但是由于此插件没有大团队支持并且内部全是中文, 最后也没有进行使用。


通过上述调研, 让我对 react-activation 的原理产生了兴趣, 遂想在团队内部开发一款类似的插件不就可以了吗, 对keep-alive的探究从此揭开序幕。


二、核心原理



先赘述一下前提, react的虚拟dom结构是一棵树, 这棵树的某个节点被移除会导致所有子节点也被销毁 所以写代码时才需要用 Memo进行包裹。(记住这张图)



比如我想缓存"B2组件"的状态, 那其实要做的就是让"B组件"被销毁时 "B2组件不被销毁", 从图上可知当"B组件"被销毁时"A组件"是不会被销毁的, 因为"A组件"不在"B组件"的下级, 所以我们要做的就是让"A组件"来生成"B2组件", 再把"B2"组件插入到"B组件内部"。


所谓的在"A组件"下渲染, 就是在"A组件"里面:


function A(){
  return (
    <div>
      <B1></B1>
    </div>
  )
}


再使用 appendChild 将div里面的dom元素全部转移到"B组件"里面即可。


三、appendChild后react依然正常执行



虽然使用appendChild把"A组件"里面的dom元素插入到"B组件", 但是react内部的各种渲染已经完成, 比如我们在 "B1组件" 内使用 useState 定义了一个变量叫 'n' , 当 'n' 变化时触发的dom变化也都已经被react记录, 所以不会影响每次进行dom diff 后的元素操作。


并且在"A组件"下面也可以使用 "Consumer" 接收到"A组件"外部的 "Provider", 但也引出一个问题, 就是如果不是"A组件"外的"Provider"无法被接收到, 下面是react-actication的处理方式:


     

其实这样侵入react源代码逻辑的操作还是要慎重, 我们也可以用粗俗一点的方式稍微代替一下, 主要利用 Provider 可以重复写的特性, 将Provider与其value传入进去实现context的正常, 但是这样也显然是不友好的。


所以 react-activation 官网才会注明下面这段话:



四、插件的架构设计介绍



我们使用KeepAliveProvider组件来储存需要被缓存的组件的相关信息,并且用来储存需要被缓存的组件的相关信息,并且用来渲染被缓存的组件,也就是充当“A组件”的角色。

KeepAliveProvider组件内部使用Keeper组件来标记组件应该渲染在哪里?也就是要用Keeper将“B1组件”+“B2组件”包裹起来,这样我们就知道初始化好的组件应该放到哪里。cacheld也就是缓存的id,每个id对应一个组件的缓存信息,后续会用来监控每个缓存的组件是否被激活,以及清理组件的缓存。


看用法:


const RootComponent: React.FC = () => (
        <KeepAliveProvider>
            <Router>
                <Routes>
                    <Route path={'/'} element={
                          <Keeper cacheId={'home'}> <Home/> </Keeper>
                        }
                    />
                </Routes>
            </Router>
        </KeepAliveProvider>
  )


五、KeepAliveProvider开发



这里先列出一个"概念代码", 因为直接看完整的代码会晕掉。


import CacheContext from './cacheContext'
const KeepAliveProvider: React.FC = (props) => {
 const [catheStates, dispatch]: any = useReducer(cacheReducer, {})
     const mount = useCallback(
        ({ cacheId, reactElement }) => {
            if (!catheStates || !catheStates[cacheId]) {
                dispatch({
                    type: cacheTypes.CREATE,
                    payload: {
                        cacheId,
                        reactElement
                    }
                })
            }
        },
        [catheStates]
    )
 return (
        <CacheContext.Provider value={{ catheStates, mount }}>
            {props.children}
            {Object.values(catheStates).map((item: any) => {
                const { cacheId = '', reactElement } = item
                const cacheState = catheStates[`${cacheId}`];
                const handleDivDom = (divDom: Element) => {
                     const doms = Array.from(divDom.childNodes)
                        if (doms?.length) {
                            dispatch({
                                type: cacheTypes.CREATED,
                                payload: {
                                    cacheId,
                                    doms
                                }
                            })
                        }
                }
                return (
                    <div 
                     key={`${cacheId}`} 
                     id={`cache-外层渲染-${cacheId}`} 
                     ref={(divDom) => divDom && handleDivDom(divDom)}>
                        {reactElement}
                    </div>
        </CacheContext.Provider>
    )
}

export default KeepAliveProvider


代码讲解


1. catheStates 存储所有的缓存信息

它的数据格式如下:


{
  cacheId: 缓存id,
  reactElement: 真正要渲染的内容,
  status: 状态,
  doms?: dom元素,
 }


2. mount 用来初始化组件

将组件状态变为 'CREATE', 并且将要渲染的组件储存起来, 就是上图里面"B1组件",

const mount = useCallback(({ cacheId, reactElement }) => {
            if (!catheStates || !catheStates[cacheId]) {
                dispatch({
                    type: cacheTypes.CREATE,
                    payload: {
                        cacheId,
                        reactElement}
                })
            }
        },
        [catheStates]
    )

3. CacheContext 传递与储存信息

CacheContext 是我们专门创建用来储存数据的, 他会向各个 Keeper 分发各种方法。


import React from "react";
let CacheContext = React.createContext()
export default CacheContext;


4. {props.children} 渲染 KeepAliveProvider 标签中的内容

5. div渲染需要缓存的组件

这里放一个div作为渲染组件的容器, 当我们可以获取到这个div的实例时则对其childNodes储存到catheStates, 但是这里有个问题, 这种写法只能处理同步渲染的子组件, 如果组件异步渲染则无法储存正确的childNodes。

6. 异步渲染的组件

假设有如下这种异步的组件, 则无法获取到正确的dom节点, 所以如果dom的childNodes为空, 我们需要监听dom的状态, 当dom内被插入元素时执行。
 
function HomePage() {
    const [show, setShow] = useState(false)
    useEffect(() => {
        setShow(true)
    }, [])
    return show ? <div>home</div>: null;
 }


将handleDivDom方法的代码做一些修改:


let initDom = false
const handleDivDom = (divDom: Element) => {
    handleDOMCreated()
    !initDom && divDom.addEventListener('DOMNodeInserted', handleDOMCreated)
    function handleDOMCreated() {
        if (!cacheState?.doms) {
            const doms = Array.from(divDom.childNodes)
            if (doms?.length) {
                initDom = true
                dispatch({
                    type: cacheTypes.CREATED,
                    payload: {
                        cacheId,
                        doms
                    }
                })
            }
        }
    }
}


当没有获取到 childNodes 则为div添加 "DOMNodeInserted"事件,

来监测是否有dom插入到了div内部。


所以总结来说, 上述代码就是负责了初始化相关数据, 并且负责渲染组件, 但是具体渲染什么组件还需要我们使用Keeper组件。


六、编写渲染占位的Keeper


在使用插件的时候, 我们实际需要被缓存的组件都是写在Keeper组件里的, 就像下面这种写法:


<Keeper cacheId="home">
  <Home />
  <User />
  <footer>footer</footer>
</Keeper>


import React, { useContext, useEffect } from 'react'
import CacheContext from './cacheContext'

export default function Keeper(props: any) {
    const { cacheId } = props
    const divRef = React.useRef(null)
    const { catheStates, dispatch, mount } = useContext(CacheContext)
    useEffect(() => {
        const catheState = catheStates[cacheId]
        if (catheState && catheState.doms) {
            const doms = catheState.doms
            doms.forEach((dom: any) => {
                (divRef?.current as any)?.appendChild?.dom
            })
        } else {
            mount({
                cacheId,
                reactElement: props.children
            })
        }
    }, [catheStates])
    return <div id={`keeper-原始位置-${cacheId}`} ref={divRef}></div>
}


七、Portals属性介绍


看到网上有些插件没有使用 appendChild 而是使用react提供的 来实现的, 感觉挺好玩的就在这里也聊一下。


此时我们并不要真的在Keeper组件里面来渲染组件,把props.children储存起来,在Keeper里面放一个div来占位,并且当检测到有数据中有需要被缓存的dom时,则使用appendChild把dom放到自己的内部。


Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案, 直白说就是可以指定我要把 child 渲染到哪个dom元素中, 用法如下:


ReactDOM.createPortal(child, "目标dom")


react官网是这样描述的: 一个 portal 的典型用例是当父组件有 overflow: hidden 或 z-index 样式时,但你需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框:


由于这里需要指定在哪里渲染 child, 所以大需要有明确的child属性与目标dom, 但是我们这个插件可能更适合异步操作, 也就是我们只是将数据放在 catheStates 里面, 需要取的时候来取, 而不是渲染时就要明确指定的形式来设计。


八、监控缓存被激活


我们要实时监控到底哪个组件被"激活", "激活"的定义是组件被初始化后被缓存起来, 之后的每次使用缓存都叫"激活", 并且每次组件被激活调用 activeCache 方法来告诉用户当前哪个组件被"激活"了。


为什么要告诉用户哪个组件被激活了? 大家可以想想这样一个场景, 用户点击了table的第三条数据的编辑按钮跳转到编辑页面, 编辑后返回列表页, 此时可能需要我们更新一下列表里第三条的状态, 此时就需要知道哪些组件被激活了。


还有一种情况如下图所示, 这是一种鼠标悬停会出现tip提示语, 如果此时点击按钮发生跳转页面会导致, 当你返回列表页面时这个tip竟然还在....


当然我指的不是element-ui, 是我们自己的ui库, 当时看了一下原因, 是因为这个组件只有检测到鼠标离开某些元素才会让tip消失, 但是跳页了并且当前页面的所有dom被 keep-alive被缓存下来了, 导致了这个tip没有被清理。



它的代码如下:


useEffect(() => {
        const catheState = catheStates[cacheId]
        if (catheState && catheState.doms) {
            console.log('激活了:', cacheId)
            activeCache(cacheId)
        }
    }, [])


之所以useEffect的参数只传了个空数组, 因为每次组件被"激活"都可以执行, 因为每次Keeper组件每次会被销毁的, 所以这里可以执行。


最终使用演示

在组件中使用来检测指定的组件是否被更新, 第一个参数是要监测的id, 也就是Keeper身上的cacheId, 第二个参数是callback。

用户使用插件时, 可以在自己的组件内按下面的写法来进行监控:

useEffect(() => {
        const cb = () => {
            console.log('home被激活了')
        }
        cacheWatch(['home'], cb)
        return () => {
            removeCacheWatch(['home'], cb)
        }
    }, [])

具体实现

在KeepAliveProvider中定义activeCache方法:

每次激活组件, 就去数组内寻找监听方法进行执行。


const [activeCacheObj, setActiveCacheObj] = useState<any>({})
    const activeCache = useCallback(
        (cacheId) => {
            if (activeCacheObj[cacheId]) {
                activeCacheObj[cacheId].forEach((fn: any) => {
                    fn(cacheId)
                })
            }
        },
        [catheStates, activeCacheObj]
    )


添加一个检测方法:


每次都把callback放到对应的对象身上。


const cacheWatch = useCallback(
     (ids: string[], fn) => {
        ids.forEach((id: string) => {
            if (activeCacheObj[id]) {
                activeCacheObj[id].push(fn)
            } else {
                activeCacheObj[id] = [fn]
            }
        })
        setActiveCacheObj({
            ...activeCacheObj
        })
      },
     [activeCacheObj]
    )


还要有一个移除监控的方法:

    

const removeCacheWatch = (ids: string[], fn: any) => {
        ids.forEach((id: string) => {
            if (activeCacheObj[id]) {
                const index = activeCacheObj[id].indexOf(fn)
                activeCacheObj.splice(index, 1)
            }
        })
        setActiveCacheObj({
            ...activeCacheObj
        })
    }

     删除缓存的方法, 需要在 cacheReducer 里面增加删除方法, 注意这里需要每个remove所有dom, 而不是仅对 cacheStates 的数据进行删除。

case cacheTypes.DESTROY:
    if (cacheStates[payload.cacheId]) {
        const doms = cacheStates?.[payload.cacheId]?.doms
        if (doms) {
            doms.forEach((element) => {
                element.remove()
            })
        }
    }
    delete cacheStates[payload.cacheId]
    return {
        ...cacheStates
    }


下一篇讲这类插件的"大坑", 如果你想全面了解的话一定要读下一篇哦, 这次就是这样, 希望与你一起进步。




点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,扫描下方”二维码“或在“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~

- END -


浏览 30
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报