一些關於react的keep-alive功能相關知識在這裡(上)

lulu_up發表於2022-04-10

一些關於react的keep-alive功能相關知識在這裡(上)

下一篇講這類外掛的"大坑", 如果你想全面瞭解的話一定要讀下一篇哦。

背景

     這是在2022年開發中PM提的一個需求, 某個table被使用者輸入了一些搜搜條件並且瀏覽到了第3頁, 那如果我跳轉到其他路由後返回當前頁面, 希望搜尋條件還在, 並且仍處於第三頁, 這不就是vue裡面的keep-alive標籤嗎, 但我當前的專案是用react編寫的。

     此次講述了我經歷了 "使用外部外掛"-> "放棄外部外掛"-> "學習並自研外掛"-> "理解了相關外掛的困境" -> "期待react18Offscreen?", 所以結論是推薦耐心等待react18的自支援, 但是學習當前類似外掛的原理對自己還是很有啟發的。

     一個庫不能只說自己的優點也要把缺點展示出來, 否則會給使用者程式碼隱患, 但我閱讀了很多網上的文章與官網, 大多都沒有講出相關的原理的細節, 並且沒有人對當前存在的bug進行分析, 我這裡會對相關奇怪的問題進行詳細的講解, 我下面展示程式碼是參考了網上的幾種方案後稍作改良的。

一、外掛調研

     我們一起看一下市場上現在有哪些'成熟'的方案。

     第一個: react-keep-alive : 官網很正規, 851 Star, 用法上也與vue的keep-alive很接近, 但是差評太多了, 以及3年沒更新了, 並且很多網上的文章也都說這個庫很坑, 一起看看它的評論吧 (抬走下一位)。

image.png

     第二個: react-router-cache-route : 這個庫是針對路由級別的一個快取, 無法對元件級別生效, 引入後要替換掉當前的路由元件庫, 風險不小並且快取的量級太大了 (抬走下一位)。

     第三個: react-activation : 這個庫是網上大家比較認可的庫, issues也比較少並且不'致命', 並且可以支援元件級別的快取( 其實它做不到, 還是有bug ), 我嘗試著使用到自己團隊的專案裡後效果還可以, 但是由於此外掛沒有大團隊支援並且內部全是中文, 最後也沒有進行使用。

     通過上述調研, 讓我對 react-activation 的原理產生了興趣, 遂想在團隊內部開發一款類似的外掛不就可以了嗎, 對keep-alive的探究從此揭開序幕。

二、核心原理、

     先贅述一下前提, react的虛擬dom結構是一棵樹, 這棵樹的某個節點被移除會導致所有子節點也被銷燬 所以寫程式碼時才需要用 Memo進行包裹。(記住這張圖)

image.png

     比如我想快取"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的處理方式:

image.png

     其實這樣侵入react原始碼邏輯的操作還是要慎重, 我們也可以用粗俗一點的方式稍微代替一下, 主要利用 Provider 可以重複寫的特性, 將Provider與其value傳入進去實現context的正常, 但是這樣也顯然是不友好的。

     所以 react-activation 官網才會註明下面這段話:

image.png

四、外掛的架構設計介紹

     先看用法:

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 = 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時, 則使用 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沒有被清理。

image.png

     它的程式碼如下:

`    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

     下一篇講這類外掛的"大坑", 如果你想全面瞭解的話一定要讀下一篇哦, 這次就是這樣, 希望與你一起進步。

相關文章