一些对于react的keep-alive性能相干常识在这里(上)

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

背景

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

     此次讲述了我经验了 "应用内部插件"-> "放弃内部插件"-> "学习并自研插件"-> "了解了相干插件的窘境" -> "期待react18Offscreen", 所以论断是举荐急躁期待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>  )}

     再应用 appendChilddiv外面的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 官网才会注明上面这段话:

四、插件的架构设计介绍

     先看用法:

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

     咱们应用 KeepAliveProvider 组件来贮存须要被缓存的组件的相干信息, 并且用来渲染被缓存的组件, 也就是充当"A组件"的角色。

     KeepAliveProvider组件外部应用 Keeper 组件来标记组件应该渲染在哪里? 也就是要用 Keeper"B1组件"+"B2组件"包裹起来, 这样咱们就晓得初始化好的组件该放到哪里。

     cacheId也就是缓存的id, 每个id对应一个组件的缓存信息, 后续会用来监控每个缓存的组件是否被"激活", 以及清理组件缓存。

五、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节点, 所以如果domchildNodes为空, 咱们须要监听dom的状态, 当dom内被插入元素时执行。

 function HomePage() {    const [show, setShow] = useState(false)    useEffect(() => {        setShow(true)    }, [])    return show ? <div>home</div>: null; }

     将handleDivDom办法的代码做一些批改:

let initDom = falseconst 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>

     此时咱们并不要真的在Keeper组件外面来渲染组件, 把 props.children 储存起来, 在Keeper外面放一个div来占位, 并且当检测到有数据中有须要被缓存的dom时, 则应用 appendChilddom放到本人的外部。

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>}

     这里会多出一个div, 我也没发现太好的方法, 我尝试应用doms把这个div元素替换掉, 这就会导致没有react的数据驱动了, 也尝试将这个dom 设置 "hidden = true" 而后将doms插入到这个div的兄弟节点, 但最初也没胜利。

七、Portals属性介绍

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

     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隐没, 然而跳页了并且以后页面的所有domkeep-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    }

end

     下一篇讲这类插件的"大坑", 如果你想全面理解的话肯定要读下一篇哦, 这次就是这样, 心愿与你一起提高。