一些对于 react 的 keep-alive 性能相干常识在这里(上)
下一篇讲这类插件的 ”大坑
“, 如果你想全面理解的话肯定要读下一篇哦。
背景
这是在 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
官网才会注明上面这段话:
四、插件的架构设计介绍
先看用法:
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
节点, 所以如果 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>
此时咱们并不要真的在 Keeper
组件外面来渲染组件, 把 props.children
储存起来, 在 Keeper
外面放一个 div
来占位, 并且当检测到有数据中有须要被缓存的 dom
时, 则应用 appendChild
把 dom
放到本人的外部。
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
隐没, 然而跳页了并且以后页面的所有 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}
end
下一篇讲这类插件的 ”大坑
“, 如果你想全面理解的话肯定要读下一篇哦, 这次就是这样, 心愿与你一起提高。