vue3 迫不得已我硬著頭皮檢視了keepalive的原始碼,解決了線上的問題

aehyok 發表於 2022-05-30
Vue
  • 1、通過本文可以瞭解到vue3 keepalive功能
  • 2、通過本文可以瞭解到vue3 keepalive使用場景
  • 3、通過本文可以學習到vue3 keepalive真實的使用過程
  • 4、通過本文可以學習vue3 keepalive原始碼除錯
  • 5、通過本文可以學習到vue3 keepalive原始碼的精簡分析

1、keepalive功能

  • keepalive是vue3中的一個全域性元件
  • keepalive 本身不會渲染出來,也不會出現在dom節點當中,但是它會被渲染為vnode,通過vnode可以跟蹤到keepalive中的cache和keys,當然也是在開發環境才可以,build打包以後沒有暴露到vnode中(這個還要再確認一下)
  • keepalive 最重要的功能就是快取元件
  • keepalive 通過LRU快取淘汰策略來更新元件快取,可以更有效的利用記憶體,防止記憶體溢位,原始碼中的最大快取數max為10,也就是10個元件之後,就開始淘汰最先被快取的元件了

2、keepalive使用場景

  • 這裡先假設一個場景: A頁面是首頁=> B頁面列表頁面(需要快取的頁面)===> C 詳情頁
    由C詳情頁到到B頁面的時候,要返回到B的快取頁面,包括頁面的基礎資料和列表的滾動條位置資訊
    如果由B頁面返回到A頁面,則需要將B的快取頁清空
  • 上述另外一個場景:進入頁面直接快取,然後就結束了,這個比較簡單本文就不討論了

3、在專案中的使用過程

vue3 keepalive (1).png

  • keepalive元件總共有三個引數

    • include:可傳字串、正規表示式、陣列,名稱匹配成功的元件會被快取
    • exclude:可傳字串、正規表示式、陣列,名稱匹配成功的元件不會被快取
    • max:可傳數字,限制快取元件的最大數量,預設為10
  • 首先在App.vue根程式碼中新增引入keepalive元件,通過這裡可以發現,我這裡快取的相當於整個頁面,當然你也可以進行更細粒度的控制頁面當中的某個區域元件

        <template>
            <router-view v-slot="{ Component }">
                <keep-alive :include="keepAliveCache">
                    <component :is="Component" :key="$route.name" />
                </keep-alive>
            </router-view>
        </template>
        <script lang="ts" setup>
        import { computed } from "vue";
        import { useKeepAliverStore } from "@/store";
        const useStore = useKeepAliverStore();
        const keepAliveCache = computed(() => {
            return useStore.caches;
        });
    
        </script>
    
  • 通過App.vue可以發現,通過pinia(也就是vue2中使用的vuex)儲存要快取的頁面元件, 來處理include快取,和儲存頁面元件中的滾動條資訊資料

        import { defineStore } from "pinia";
    
        export const useKeepAliverStore = defineStore("useKeepAliverStore", {
            state: () => ({
                caches: [] as any,
                scrollList: new Map(),  // 快取頁面元件如果又滾動條的高度
            }),
    
            actions: {
                add(name: string) {
                    this.caches.push(name);
                },
                remove(name: string) {
                    console.log(this.caches, 'this.caches')
                    this.caches = this.caches.filter((item: any) => item !== name);
                    console.log(this.caches, 'this.caches')
                },
                clear() {
                    this.caches = []
                }
            }
        });
    
  • 元件路由剛剛切換時,通過beforeRouteEnter將元件寫入include, 此時元件生命週期還沒開始。如果都已經開始執行元件生命週期了,再寫入就意義了。所以這個鉤子函式就不能寫在setup中,要單獨提出來寫。當然你也可以換成路由的其他鉤子函式處理beforeEach,但這裡面使用的話,好像使用不了pinia,這個還需要進一步研究一下。

        import { useRoute, useRouter, onBeforeRouteLeave } from "vue-router";
        import { useKeepAliverStore } from "@/store";
        const useStore = useKeepAliverStore()
        export default {
            name:"record-month",
            beforeRouteEnter(to, from, next) {
                next(vm => {
                    if(from.name === 'Home' && to.name === 'record-month') {
                    useStore.add(to.name)
                    }
                });
            }
        }
        </script>
    
  • 元件路由離開時判斷,是否要移出快取,這個鉤子就直接寫在setup中就可以了。

        onBeforeRouteLeave((to, from) => {
            console.log(to.name, "onBeforeRouteLeave");
            if (to.name === "new-detection-detail") {
                console.log(to, from, "進入詳情頁面不做處理");
            } else {
                useStore.remove(from.name)
                console.log(to, from, "刪除元件快取");
            }
        });
    
  • 在keepalive兩個鉤子函式中進行處理scroll位置的快取,onActivated中獲取快取中的位置, onDeactivated記錄位置到快取

        onActivated(() => {
            if(useStore.scrollList.get(routeName)) {
                const top = useStore.scrollList.get(routeName)
                refList.value.setScrollTop(Number(top))
            }
        });
    
        onDeactivated(() => {
            const top = refList.value.getScrollTop()
            useStore.scrollList.set(routeName, top)
        });
    
  • 這裡定義一個方法,設定scrollTop使用了原生javascript的api

        const setScrollTop = (value: any) => {
            const dom = document.querySelector('.van-pull-refresh')
            dom!.scrollTop = value
        }
    
  • 同時高度怎麼獲取要先註冊scroll事件,然後通過getScrollTop 獲取當前滾動條的位置進行儲存即可

        onMounted(() => {
            scrollDom.value = document.querySelector('.van-pull-refresh') as HTMLElement
            const throttledFun = useThrottleFn(() => {
                console.log(scrollDom.value?.scrollTop, 'addEventListener')
                state.scrollTop = scrollDom.value!.scrollTop
            }, 500)
            if(scrollDom.value) {
                scrollDom.value.addEventListener('scroll',throttledFun)
            }
        })
    
        const getScrollTop = () => {
            console.log('scrollDom.vaue', scrollDom.value?.scrollTop)
            return state.scrollTop
        }
    
    
  • 上面註冊scroll事件中使用了一個useThrottleFn,這個類庫是@vueuse/core中提供的,其中封裝了很多工具都非常不錯,用興趣的可以研究研究

        https://vueuse.org/shared/usethrottlefn/#usethrottlefn
    
  • 此時也可以檢視找到例項的vnode查詢到keepalive,是在keepalive緊挨著的子元件裡

        const instance = getCurrentInstance()
        console.log(instance.vnode.parent) // 這裡便是keepalive元件vnode
    
        // 如果是在開發環境中可以檢視到cache物件
        instance.vnode.parent.__v_cache
    
        // vue原始碼中,在dev環境對cache進行暴露,生產環境是看不到的
        if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
            ;(instance as any).__v_cache = cache
        }
    

4、vue3 keepalive原始碼除錯

  • 1、克隆程式碼

        git clone [email protected]:vuejs/core.git
    
  • 2、安裝依賴

        pnpm i
    
  • 3、如果不能使用pnpm,可以先通過npm安裝一下

        npm i pnpm -g
    
  • 4、安裝完成以後,找到根目錄package.json檔案中的scripts

    • 參考https://juejin.cn/post/6991653445161713671
        // 在dev命令後新增 --source-map是從已轉換的程式碼,對映到原始的原始檔
        "dev": "node scripts/dev.js  --sourcemap"
    
  • 5、執行pnpm run dev則會build vue原始碼

        pnpm run dev
    
        //則會出現以下,代表成功了(2022年5月27日),後期vue原始碼作者可能會更新,相應的提示可能發生變更,請注意一下
        > @3.2.36 dev H:\github\sourceCode\core
        > node scripts/dev.js  --sourcemap
    
        watching: packages\vue\dist\vue.global.js
    
        //到..\..\core\packages\vue\dist便可以看到編譯成功,以及可以檢視到examples樣例demo頁面
    
  • 6、然後在 ....\core\packages\vue\examples\composition中新增一個aehyok.html檔案,將如下程式碼進行拷貝,然後通過chrome瀏覽器開啟,F12,找到原始碼的Tab頁面,通過快捷鍵Ctrl+ P 輸入KeepAlive便可以找到這個元件,然後通過左側行標右鍵就可以新增斷點,進行除錯,也可以通過右側的【呼叫堆疊】進行快速跳轉程式碼進行除錯。

        <script src="../../dist/vue.global.js"></script>
    
        <script type="text/x-template" id="template-1">
            <div>template-1</div>
            <div>template-1</div>
        </script>
    
        <script type="text/x-template" id="template-2">
            <div>template-2</div>
            <div>template-2</div>
        </script>
        <script>
        const { reactive, computed } = Vue
    
        const Demo1 = {
            name: 'Demo1',
            template: '#template-1',
            setup(props) {
            }
        }
    
        const Demo2 = {
            name: 'Demo2',
            template: '#template-2',
            setup(props) {
            }
        }
        </script>
    
        <!-- App template (in DOM) -->
        <div id="demo">
            <div>Hello World</div>
            <div>Hello World</div>
            <div>Hello World</div>
            <button @click="changeClick(1)">元件一</button>
            <button @click="changeClick(2)">元件二</button>
            <keep-alive :include="includeCache">
                <component :is="componentCache" :key="componentName" v-if="componentName" />
            </keep-alive>
        </div>
        <!-- App script -->
        <script>
        Vue.createApp({
        components: {
            Demo1,
            Demo2
        },
        data: () => ({
            includeCache: [],
            componentCache: '',
            componentName: '',
        }),
        methods:{
            changeClick(type) {
                if(type === 1) {
                    if(!this.includeCache.includes('Demo1')) {
                        this.includeCache.push('Demo1')
                    }
                    console.log(this.includeCache, '000')
                    this.componentCache = Demo1
                    this.componentName = 'Demo1'
                }
                if(type === 2) {
                    if(!this.includeCache.includes('Demo2')) {
                        this.includeCache.push('Demo2')
                    }
                    console.log(this.includeCache, '2222')
                    this.componentName = 'Demo2'
                    this.componentCache = Demo2
                }
            }
        }
    
        }).mount('#demo')
        </script>
    
  • 7、除錯原始碼發現 keepalive中的render函式(或者說時setup中的return 函式)在子元件切換時就會去執行,變更邏輯快取

    • 第一次進入頁面初始化keepalive元件會執行一次,
    • 然後點選元件一,再次執行render函式
    • 然後點選元件二,會再次執行render函式
  • 8、除錯截圖說明

    Snipaste_2022-05-30_11-30-46.jpg

  • 9、除錯操作,小視訊觀看
    1.gif

5、vue3 keealive原始碼粗淺分析

  • 通過檢視vue3 KeepAlive.ts原始碼,原始碼路徑:https://github.com/vuejs/core/blob/main/packages/runtime-core/src/components/KeepAlive.ts
    
        // 在setup初始化中,先獲取keepalive例項
        // getCurrentInstance() 可以獲取當前元件的例項
        const instance = getCurrentInstance()!
        // KeepAlive communicates with the instantiated renderer via the
        // ctx where the renderer passes in its internals,
        // and the KeepAlive instance exposes activate/deactivate implementations.
        // The whole point of this is to avoid importing KeepAlive directly in the
        // renderer to facilitate tree-shaking.
        const sharedContext = instance.ctx as KeepAliveContext
    
        // if the internal renderer is not registered, it indicates that this is server-side rendering,
        // for KeepAlive, we just need to render its children
    
        /// SSR 判斷,暫時可以忽略掉即可。
        if (__SSR__ && !sharedContext.renderer) {
            return () => {
                const children = slots.default && slots.default()
                return children && children.length === 1 ? children[0] : children
            }
        }
    
        // 通過Map儲存快取vnode,
        // 通過Set儲存快取的key(在外面設定的key,或者vnode的type)
        const cache: Cache = new Map()
        const keys: Keys = new Set()
        let current: VNode | null = null
    
        if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
        ;(instance as any).__v_cache = cache
        }
    
        const parentSuspense = instance.suspense
    
        const {
        renderer: {
            p: patch,
            m: move,
            um: _unmount,
            o: { createElement }
        }
        } = sharedContext
    
        // 建立了隱藏容器
        const storageContainer = createElement('div')
    
        // 在例項上註冊兩個鉤子函式 activate,  deactivate
    
        sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
            const instance = vnode.component!
            move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
            // in case props have changed
            patch(
                instance.vnode,
                vnode,
                container,
                anchor,
                instance,
                parentSuspense,
                isSVG,
                vnode.slotScopeIds,
                optimized
            )
            queuePostRenderEffect(() => {
                instance.isDeactivated = false
                if (instance.a) {
                invokeArrayFns(instance.a)
                }
                const vnodeHook = vnode.props && vnode.props.onVnodeMounted
                if (vnodeHook) {
                invokeVNodeHook(vnodeHook, instance.parent, vnode)
                }
            }, parentSuspense)
    
            if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
                // Update components tree
                devtoolsComponentAdded(instance)
            }
        }
    
        sharedContext.deactivate = (vnode: VNode) => {
            const instance = vnode.component!
            move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
            queuePostRenderEffect(() => {
                if (instance.da) {
                invokeArrayFns(instance.da)
                }
                const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted
                if (vnodeHook) {
                invokeVNodeHook(vnodeHook, instance.parent, vnode)
                }
                instance.isDeactivated = true
            }, parentSuspense)
    
            if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
                // Update components tree
                devtoolsComponentAdded(instance)
            }
        }
    
    
        // 元件解除安裝
        function unmount(vnode: VNode) {
            // reset the shapeFlag so it can be properly unmounted
            resetShapeFlag(vnode)
            _unmount(vnode, instance, parentSuspense, true)
        }
    
        // 定義 include和exclude變化時,對快取進行動態處理
        function pruneCache(filter?: (name: string) => boolean) {
            cache.forEach((vnode, key) => {
                const name = getComponentName(vnode.type as ConcreteComponent)
                if (name && (!filter || !filter(name))) {
                pruneCacheEntry(key)
                }
            })
        }
    
        function pruneCacheEntry(key: CacheKey) {
            const cached = cache.get(key) as VNode
            if (!current || cached.type !== current.type) {
                unmount(cached)
            } else if (current) {
                // current active instance should no longer be kept-alive.
                // we can't unmount it now but it might be later, so reset its flag now.
                resetShapeFlag(current)
            }
            cache.delete(key)
            keys.delete(key)
        }
    
    
        // 可以發現通過include 可以配置被顯示的元件,
        // 當然也可以設定exclude來配置不被顯示的元件,
        // 元件切換時隨時控制快取
        watch(
        () => [props.include, props.exclude],
        ([include, exclude]) => {
            include && pruneCache(name => matches(include, name))
            exclude && pruneCache(name => !matches(exclude, name))
        },
        // prune post-render after `current` has been updated
        { flush: 'post', deep: true }
        )
    
        // 定義當前元件Key
        // cache sub tree after render
            let pendingCacheKey: CacheKey | null = null
    
            // 這是一個重要的方法,設定快取
            const cacheSubtree = () => {
            // fix #1621, the pendingCacheKey could be 0
            if (pendingCacheKey != null) {
                cache.set(pendingCacheKey, getInnerChild(instance.subTree))
            }
            }
            onMounted(cacheSubtree)
            onUpdated(cacheSubtree)
    
            // 元件解除安裝的時候,對快取列表進行迴圈判斷處理
            onBeforeUnmount(() => {
                cache.forEach(cached => {
                    const { subTree, suspense } = instance
                    const vnode = getInnerChild(subTree)
                    if (cached.type === vnode.type) {
                    // current instance will be unmounted as part of keep-alive's unmount
                    resetShapeFlag(vnode)
                    // but invoke its deactivated hook here
                    const da = vnode.component!.da
                    da && queuePostRenderEffect(da, suspense)
                    return
                    }
                    unmount(cached)
                })
            })
    
        // 同時在keepAlive元件setup生命週期中,return () => {} 渲染的時候,對元件進行判斷邏輯處理,同樣對include和exclude判斷渲染。
        
        // 判斷keepalive元件中的子元件,如果大於1個的話,直接警告處理了
        // 另外如果渲染的不是虛擬dom(vNode),則直接返回渲染即可。
    
        return () => {
            // eslint-disable-next-line no-debugger
            console.log(props.include, 'watch-include')
            pendingCacheKey = null
    
            if (!slots.default) {
                return null
            }
    
            const children = slots.default()
            const rawVNode = children[0]
            if (children.length > 1) {
                if (__DEV__) {
                warn(`KeepAlive should contain exactly one component child.`)
                }
                current = null
                return children
            } else if (
                !isVNode(rawVNode) ||
                (!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
                !(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
            ) {
                current = null
                return rawVNode
            }
    
            // 接下來處理時Vnode虛擬dom的情況,先獲取vnode
            let vnode = getInnerChild(rawVNode)
            // 節點型別
            const comp = vnode.type as ConcreteComponent
    
            // for async components, name check should be based in its loaded
            // inner component if available
            // 獲取元件名稱
            const name = getComponentName(
                isAsyncWrapper(vnode)
                ? (vnode.type as ComponentOptions).__asyncResolved || {}
                : comp
            )
    
            //這個算是最熟悉的通過props傳遞進行的引數,進行解構
            const { include, exclude, max } = props
    
            // include判斷 元件名稱如果沒有設定, 或者元件名稱不在include中,
            // exclude判斷 元件名稱有了,或者匹配了
            // 對以上兩種情況都不進行快取處理,直接返回當前vnode虛擬dom即可。
            if (
                (include && (!name || !matches(include, name))) ||
                (exclude && name && matches(exclude, name))
            ) {
                current = vnode
                return rawVNode
            }
    
            // 接下來開始處理有快取或者要快取的了
    
            // 先獲取一下vnode的key設定,然後看看cache快取中是否存在
            const key = vnode.key == null ? comp : vnode.key
            const cachedVNode = cache.get(key)
    
            // 這一段可以忽略了,好像時ssContent相關,暫時不管了,沒看明白??
            // clone vnode if it's reused because we are going to mutate it
            if (vnode.el) {
                vnode = cloneVNode(vnode)
                if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
                rawVNode.ssContent = vnode
                }
            }
    
            // 上面判斷了,如果沒有設定key,則使用vNode的type作為key值
            pendingCacheKey = key
    
            //判斷上面快取中是否存在vNode
    
            // if 存在的話,就將快取中的vnode複製給當前的vnode
            // 同時還判斷了元件是否為過渡元件 transition,如果是的話 需要註冊過渡元件的鉤子
            // 同時先刪除key,然後再重新新增key
    
            // else 不存在的話,就新增到快取即可
            // 並且要判斷一下max最大快取的數量是否超過了,超過了,則通過淘汰LPR演算法,刪除最舊的一個快取
            // 最後又判斷了一下是否為Suspense。也是vue3新增的高階元件。
            if (cachedVNode) {
                // copy over mounted state
                vnode.el = cachedVNode.el
                vnode.component = cachedVNode.component
                if (vnode.transition) {
                // recursively update transition hooks on subTree
                setTransitionHooks(vnode, vnode.transition!)
                }
                // avoid vnode being mounted as fresh
                vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
                // make this key the freshest
                keys.delete(key)
                keys.add(key)
            } else {
                keys.add(key)
                // prune oldest entry
                if (max && keys.size > parseInt(max as string, 10)) {
                pruneCacheEntry(keys.values().next().value)
                }
            }
            // avoid vnode being unmounted
            vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
    
            current = vnode
            return isSuspense(rawVNode.type) ? rawVNode : vnode
    

6、總結

通過這次檢視vue3 keepalive原始碼發現,其實也沒那麼難,當然這次檢視原始碼也只是粗略檢視,並沒有看的那麼細,主要還是先解決問題。動動手除錯一下,有時候真的就是不逼一下自己都不知道自己有多麼的優秀。原來我也能稍微看看原始碼了。以後有空可以多看看vue3原始碼,學習一下vue3的精髓。瞭解vue3更為細節的一些知識點。

https://github.com/aehyok/vue-qiankun
本文涉及到的程式碼後續會整理到該程式碼倉庫中

https://github.com/aehyok/2022
最後自己每天工作中的筆記記錄倉庫,主要以文章連結和問題處理方案為主。