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

一些关于react的keep-alive功能相关知识在这里(上) 下一篇讲这类插件的"大坑", 如果你想全面了解的话一定要读下一篇哦。
背景 ???? 这是在2022年开发中PM提的一个需求, 某个table被用户输入了一些搜搜条件并且浏览到了第3页, 那如果我跳转到其他路由后返回当前页面, 希望搜索条件还在, 并且仍处于第三页, 这不就是vue里面的keep-alive标签吗, 但我当前的项目是用react编写的。
???? 此次讲述了我经历了 "使用外部插件"-> "放弃外部插件"-> "学习并自研插件"-> "理解了相关插件的困境" -> "期待react18Offscreen", 所以结论是推荐耐心等待react18的自支持, 但是学习当前类似插件的原理对自己还是很有启发的。
???? 一个库不能只说自己的优点也要把缺点展示出来, 否则会给使用者代码隐患, 但我阅读了很多网上的文章与官网, 大多都没有讲出相关的原理的细节, 并且没有人对当前存在的bug进行分析, 我这里会对相关奇怪的问题进行详细的讲解, 我下面展示代码是参考了网上的几种方案后稍作改良的。
一、插件调研 ???? 我们一起看一下市场上现在有哪些'成熟'的方案。
???? 第一个: react-keep-alive : 官网很正规, 851 Star, 用法上也与vue的keep-alive很接近, 但是差评太多了, 以及3年没更新了, 并且很多网上的文章也都说这个库很坑, 一起看看它的评论吧 (抬走下一位)。
一些关于react的keep-alive功能相关知识在这里(上)
文章图片

???? 第二个: react-router-cache-route : 这个库是针对路由级别的一个缓存, 无法对组件级别生效, 引入后要替换掉当前的路由组件库, 风险不小并且缓存的量级太大了 (抬走下一位)。
???? 第三个: react-activation : 这个库是网上大家比较认可的库, issues也比较少并且不'致命', 并且可以支持组件级别的缓存( 其实它做不到, 还是有bug ), 我尝试着使用到自己团队的项目里后效果还可以, 但是由于此插件没有大团队支持并且内部全是中文, 最后也没有进行使用。
???? 通过上述调研, 让我对 react-activation 的原理产生了兴趣, 遂想在团队内部开发一款类似的插件不就可以了吗, 对keep-alive的探究从此揭开序幕。
二、核心原理、 ???? 先赘述一下前提, react的虚拟dom结构是一棵树, 这棵树的某个节点被移除会导致所有子节点也被销毁 所以写代码时才需要用 Memo进行包裹。(记住这张图)
一些关于react的keep-alive功能相关知识在这里(上)
文章图片

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

function A(){ return () }

???? 再使用 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的keep-alive功能相关知识在这里(上)
文章图片

???? 其实这样侵入react源代码逻辑的操作还是要慎重, 我们也可以用粗俗一点的方式稍微代替一下, 主要利用 Provider 可以重复写的特性, 将Provider与其value传入进去实现context的正常, 但是这样也显然是不友好的。
???? 所以 react-activation 官网才会注明下面这段话:
一些关于react的keep-alive功能相关知识在这里(上)
文章图片

四、插件的架构设计介绍 ???? 先看用法:
const RootComponent: React.FC = () => ( } /> )

???? 我们使用 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 ( {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 ( divDom && handleDivDom(divDom)}> {reactElement} ) }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 ? home: 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组件里的, 就像下面这种写法:
footer

???? 此时我们并不要真的在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, 我也没发现太好的办法, 我尝试使用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没有被清理。
一些关于react的keep-alive功能相关知识在这里(上)
文章图片

???? 它的代码如下:
`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({}) 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
【一些关于react的keep-alive功能相关知识在这里(上)】???? 下一篇讲这类插件的"大坑", 如果你想全面了解的话一定要读下一篇哦, 这次就是这样, 希望与你一起进步。

    推荐阅读