首頁 > 軟體

Vue3生命週期Hooks原理與排程器Scheduler關係

2022-07-07 10:00:03

寫在最前:本文章的目標

Vue3的生命週期的實現原理是比較簡單的,但要理解整個Vue3的生命週期則還要結合整個Vue的執行原理,又因為Vue3的一些生命週期的執行機制是通過Vue3的排程器來完成的,所以想要徹底瞭解Vue3的生命週期原理還必須要結合Vue3的排程器的實現原理來理解。同時通過對Vue3的排程器的理解,從而加深對Vue底層的一些設計原理和規則的理解,所以本文章的目標是理解Vue3生命週期Hooks的原理以及通過Vue3生命週期Hooks的執行了解Vue3排程器(Scheduler)的原理。

Vue3生命週期的實現原理

Vue3的生命週期Hooks函數的實現原理還是比較簡單的,就是把各個生命週期的函數掛載或者叫註冊到元件的範例上,然後等到元件執行到某個時刻,再去元件範例上把相應的生命週期的函數取出來執行。

下面來看看具體程式碼的實現

生命週期型別

// packages/runtime-core/src/component.ts
export const enum LifecycleHooks {
    BEFORE_CREATE = 'bc', // 建立之前
    CREATED = 'c', // 建立
    BEFORE_MOUNT = 'bm', // 掛載之前
    MOUNTED = 'm', // 掛載之後
    BEFORE_UPDATE = 'bu', // 更新之前
    UPDATED = 'u', // 更新之後
    BEFORE_UNMOUNT = 'bum', // 解除安裝之前
    UNMOUNTED = 'um', // 解除安裝之後
	// ...
}

各個生命週期Hooks函數的建立

// packages/runtime-core/src/apiLifecycle.ts
export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT)
export const onMounted = createHook(LifecycleHooks.MOUNTED)
export const onBeforeUpdate = createHook(LifecycleHooks.BEFORE_UPDATE)
export const onUpdated = createHook(LifecycleHooks.UPDATED)
export const onBeforeUnmount = createHook(LifecycleHooks.BEFORE_UNMOUNT)
export const onUnmounted = createHook(LifecycleHooks.UNMOUNTED)

可以看到各個生命週期的Hooks函數是通過createHook這個函數建立的

建立生命週期函數createHook

// packages/runtime-core/src/apiLifecycle.ts
export const createHook = (lifecycle) => (hook, target = currentInstance) => injectHook(lifecycle, hook, target)

createHook是一個閉包函數,通過閉包快取當前是屬於哪個生命週期的Hooks,target表示該生命週期Hooks函數被繫結到哪個元件範例上,預設是當前工作的元件範例。createHook底層又呼叫了一個injectHook的函數,那麼下面我們繼續來看看這個injectHook函數。

injectHook函數

injectHook是一個閉包函數,通過閉包快取繫結對應生命週期Hooks到對應的元件範例上。

// packages/runtime-core/src/apiLifecycle.ts
export function injectHook(type, hook, target) {
    if(target) {
        // 把各個生命週期的Hooks函數掛載到元件範例上,並且是一個陣列,因為可能你會多次呼叫同一個元件的同一個生命週期函數
        const hooks = target[type] || (target[type] = [])
        // 把生命週期函數進行包裝並且把包裝函數快取在__weh上
        const wrappedHook =
        hook.__weh ||
        (hook.__weh = (...args: unknown[]) => {
          if (target.isUnmounted) {
            return
          }
            // 當生命週期呼叫時 保證currentInstance是正確的
            setCurrentInstance(target)
            // 執行生命週期Hooks函數
            const  res = args ? hook(...args) : hook()
            unsetCurrentInstance()
          return res
        })
        // 把生命週期的包裝函數繫結到元件範例對應的hooks上
        hooks.push(wrappedHook)
        // 返回包裝函數
        return wrappedHook
    }
}

生命週期Hooks的呼叫

instance.update = effect(() => {
    if (!instance.isMounted) {
        const { bm, m } = instance
        // 生命週期:beforeMount hook
        if (bm) {
            invokeArrayFns(bm)
        }
        // 元件初始化的時候會執行這裡
        // 為什麼要在這裡呼叫 render 函數呢
        // 是因為在 effect 內呼叫 render 才能觸發依賴收集
        // 等到後面響應式的值變更後會再次觸發這個函數  
        const subTree = (instance.subTree = renderComponentRoot(instance))
        patch(null, subTree, container, instance, anchor)
        instance.vnode.el = subTree.el 
        instance.isMounted = true
        // 生命週期:mounted
        if(m) {
            // mounted需要通過Scheduler的函數來呼叫
            queuePostFlushCb(m)
        }
    } else {
        // 響應式的值變更後會從這裡執行邏輯
        // 主要就是拿到新的 vnode ,然後和之前的 vnode 進行對比
        // 拿到最新的 subTree
        const { bu, u, next, vnode } = instance
        // 如果有 next 的話, 說明需要更新元件的資料(props,slots 等)
        // 先更新元件的資料,然後更新完成後,在繼續對比當前元件的子元素
        if(next) {
            next.el = vnode.el
            updateComponentPreRender(instance, next)
        }
        // 生命週期:beforeUpdate hook
        if (bu) {
            invokeArrayFns(bu)
        }
        const subTree = renderComponentRoot(instance)
        // 替換之前的 subTree
        const prevSubTree = instance.subTree
        instance.subTree = subTree
        // 用舊的 vnode 和新的 vnode 交給 patch 來處理
        patch(prevSubTree, subTree, container, instance, anchor)
        // 生命週期:updated hook
        if (u) {
            // updated 需要通過Scheduler的函數來呼叫
            queuePostFlushCb(u)
        }
    }
}, {
    scheduler() {
        queueJobs(instance.update)
    }
})

上面這個是Vue3元件範例化之後,通過effect包裝一個更新的副作用函數來和響應式資料進行依賴收集。在這個副作用函數裡面有兩個分支,第一個是元件掛載之前執行的,也就是生命週期函數beforeMount和mount呼叫的地方,第二個分支是元件掛載之後更新的時候執行的,在這裡就是生命週期函數beforeUpdate和updated呼叫的地方。 具體就是在掛載之前,還沒生成虛擬DOM之前就執行beforeMount函數,之後則去生成虛擬DOM經過patch之後,元件已經被掛載到頁面上了,也就是頁面上顯示檢視了,這個時候就去執行mount函數;在更新的時候,還沒獲取更新之後的虛擬DOM之前執行beforeUpdate,然後去獲取更新之後的虛擬DOM,然後再去patch,更新檢視,之後就執行updated。 需要注意的是beforeMount和beforeUpdate是同步執行的,都是通過invokeArrayFns來呼叫的。 invokeArrayFns函數

export const invokeArrayFns = (fns: Function[], arg?: any) => {
  for (let i = 0; i < fns.length; i++) {
    fns[i](arg)
  }
}

元件掛載和更新則是非同步的,需要通過Scheduler來處理。

Vue3排程器(Scheduler)原理

在Vue3的一些API,例如:元件的生命週期API、watch API、元件更新的回撥函數都不是立即執行的,而是放到非同步任務佇列裡面,然後按一定的規則進行執行的,比如說任務佇列裡面同時存在,watch的任務,元件更新的任務,生命週期的任務,它的執行順序是怎麼樣的呢?這個就是由排程器的排程演演算法決定,同時排程演演算法只排程執行的順序,不負責具體的執行。這樣設計的好處就是即便將來Vue3增加新的非同步回撥API,也不需要修改排程演演算法,可以極大的減少 Vue API 和 佇列間耦合。 Vue3的Scheduler提供了三個入列方式的API:

queuePreFlushCb API: 加入 Pre 佇列 元件更新前執行

export function queuePreFlushCb(cb: SchedulerJob) {
  queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

queueJob API: 加入 queue 佇列 元件更新執行

export function queueJob(job: SchedulerJob) {
}

queuePostFlushCb API: 加入 Post 佇列 元件更新後執行

export function queuePostFlushCb(cb: SchedulerJobs) {
  queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}

由於Vue3只提供了入列方式的API並沒有提供出列方式的API,所以我們只能控制何時入列,而何時出列則由Vue3排程器本身控制。

那麼Vue3排程器如何控制出列方式呢?其實也很簡單。

function flushJobs(seen?) {
    isFlushPending = false
    // 元件更新前佇列執行
    flushPreFlushCbs(seen)
    try{
        // 元件更新佇列執行
        let job
        while (job = queue.shift()) {
            job && job()
        }
    } finally {
        // 元件更新後佇列執行
        flushPostFlushCbs(seen)
        // 如果在執行非同步任務的過程中又產生了新的佇列,那麼則繼續回撥執行
        if (
            queue.length ||
            pendingPreFlushCbs.length ||
            pendingPostFlushCbs.length
        ) {
            flushJobs(seen)
        }
    }
}

Vue父子元件的生命週期的執行順序

這裡有兩個概念需要釐清的概念,一:父子元件的執行順序,二:父子元件生命週期的執行順序。這兩個是不一樣的

父子元件的執行順序

這個是先執行父元件再執行子元件,先父元件範例化,然後去獲取父元件的虛擬DOM之後在patch的過程中,如果父元件的虛擬DOM中存在元件型別的虛擬DOM也就是子元件,那麼在patch的分支中就會去走元件初始化的流程,如此迴圈。

父子元件生命週期的執行順序

父子元件生命週期的執行順序是在父子元件的執行順序下通過排程演演算法按Vue的規則進行執行的。首先父元件先範例化進行執行,通過上面的生命週期的呼叫說明,我們可以知道,父元件在更新函數update第一次執行,也就是元件初始化的時候,先執行父元件的beforeMount,然後去獲取父元件的虛擬DOM,然後在patch的過程中遇到虛擬節點是元件型別的時候,就又會去走元件初始化的流程,這個時候其實就是子元件初始化,那麼之後子元件也需要走一遍元件的所有流程,子元件在更新update第一次執行的時候,先執行子元件的beforeMount,再去獲取子元件的虛擬DOM,然後patch子元件的虛擬DOM,如果過程中又遇到節點是元件型別的話,又去走一遍元件初始化的流程,直到子元件patch完成,然後執行子元件的mounted生命週期函數,接著回到父元件的執行棧,執行父元件的mounted生命週期。

所以在初始化建立的時候,是深度遞迴建立子元件的過程,父子元件的生命週期的執行順序是:

  • 父元件 -> beforeMount
  • 子元件 -> beforeMount
  • 子元件 -> mounted
  • 父元件 -> mounted

父子元件更新順序同樣是深度遞迴執行的過程:

  • 如果父子元件沒通過props傳遞資料,那麼更新的時候,就各自執行各自的更新生命週期函數。
  • 如果父子元件存在通過props傳遞資料的話,就必須先更新父元件,才能更新子元件。因為父元件 DOM 更新前,需要修改子元件的 props,子元件的 props 才是正確的值。

下面我們來看原始碼

if (next) {
    next.el = vnode.el
    // 在元件更新前,先更新一些資料
    updateComponentPreRender(instance, next, optimized)
} else {
    next = vnode
}

例如更新props,更新slots

  const updateComponentPreRender = (
    instance: ComponentInternalInstance,
    nextVNode: VNode,
    optimized: boolean
  ) => {
    nextVNode.component = instance
    const prevProps = instance.vnode.props
    instance.vnode = nextVNode
    instance.next = null
    // 更新props
    updateProps(instance, nextVNode.props, prevProps, optimized)
    // 更新slots
    updateSlots(instance, nextVNode.children, optimized)
	// ...
  }

所以在父子元件更新的時候,父子元件的生命週期執行順序是:

  • 父元件 -> beforeUpdate
  • 子元件 -> beforeUpdate
  • 子元件 -> updated
  • 父元件 -> updated

同樣解除安裝的時候父子元件也是深度遞迴遍歷執行的過程:

  • 父元件 -> beforeUnmount
  • 子元件 -> beforeUnmount
  • 子元件 -> unmounted
  • 父元件 -> unmounted

元件解除安裝的時候,是在解除安裝些什麼呢?

元件解除安裝的時候主要是解除安裝模版參照,清除effect裡面儲存的相關元件的更新函數的副作用函數,如果是快取元件,則清除相關快取,最後去移除真實DOM上相關節點。

另外元件 DOM 更新(instance.update)是有儲存在排程器的任務佇列中的,元件解除安裝的時候,也需要把相關的元件更新(instance.update)設定失效。

在原始碼的unmountComponent函數中,有這麼一段:

if (update) {
    // 把元件更新函數的active設定false
    update.active = false
    unmount(subTree, instance, parentSuspense, doRemove)
}

然後在Scheduler執行queue佇列任務的時候,那些job的active為false的則不執行

const job = queue[flushIndex]
// 那些job的active為false的則不執行
if (job && job.active !== false) {
    callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}

那麼元件 DOM 更新(instance.update)什麼時候會被刪除呢?

在原始碼的updateComponent函數可以找到刪除instance.update的設定

invalidateJob(instance.update)
// 立即執行更新任務
instance.update()

排程器刪除任務

export function invalidateJob(job: SchedulerJob) {
  // 找到 job 的索引
  const i = queue.indexOf(job)
  if (i > flushIndex) {
    // 刪除 Job
    queue.splice(i, 1)
  }
}

由此我們可以得知在一個元件更新的時候,會先把該元件在排程器裡的更新任務先刪除。因為元件更新也是一個遞迴執行更新的過程,在遞迴的過程中執行了子元件的更新,那麼排程器的任務佇列裡面的子元件更新任務就不需要再執行了,所以就要刪除掉,將來子元件依賴的響應式資料發生了更新,那麼則重新把子元件的更新任務放到排程器的任務佇列裡去。

元件更新的排程器裡的佇列任務的失效與刪除的區別

通過上述元件解除安裝的介紹我們可以總結一下元件更新的排程器裡的佇列任務的失效與刪除的區別

失效

  • 元件解除安裝時,將 Job 設定為失效,Job 從佇列中取出時,不再執行
  • 不能再次加入佇列,因為會被去重
  • 被解除安裝的元件,無論它依賴的響應式變數如何更新,該元件都不會更新了

刪除

  • 元件更新時,刪除該元件在排程器任務佇列中的 Job
  • 可以再次加入佇列
  • 刪除任務,因為已經更新過了,不需要重複更新。 如果依賴的響應式變數再次被修改,仍然需要加入排程器的任務佇列,等待更新

父子元件執行順序與排程器的關係

假設有有這樣一個場景,有一對父子元件,子元件使用watch API監聽某個子元件的響應式資料發生改變之後,然後去修改了N個父元件的響應式資料。那麼N個父元件的更新函數都將被放到排程器的任務佇列中等待執行。這種情況排程器怎麼確保最頂層的父元件的更新函數最先執行呢?

我們先看看排程器的任務佇列裡的Job的資料結構

export interface SchedulerJob extends Function {
  id?: number  // 用於對佇列中的 job 進行排序,id 小的先執行
  active?: boolean
  computed?: boolean
  allowRecurse?: boolean 
  ownerInstance?: ComponentInternalInstance	
}

Job是一個函數,並且帶有一些屬性。其中id,表示優先順序,用於實現佇列插隊,id 小的先執行,active通過上文我們可以知道active表示 Job 是否有效,失效的 Job 不執行,如元件解除安裝會導致 Job 失效。

排程器任務佇列的資料結構

const queue: SchedulerJob[] = []

是一個陣列

排程器任務佇列的執行

// 按任務id大小排序
queue.sort((a, b) => getId(a) - getId(b))
try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
        const job = queue[flushIndex]
        if (job && job.active !== false) {
            // 使用帶有 Vue 內部的錯誤處理常式執行job
            callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
        }
    }
} finally {
    // 清空 queue 佇列
    flushIndex = 0
    queue.length = 0
}

那麼又怎麼確保父元件的更新函數的任務id是最小的呢?

通過檢視原始碼我們可以看在建立元件範例的createComponentInstance函數中有一個uid的屬性,並且它的初始值為0,後續則++

let uid = 0 // 初始化為0
export function createComponentInstance(
  vnode
  parent
  suspense
) {
  const instance: ComponentInternalInstance = {
    uid: uid++,
   // ...   
  }

然後在建立元件更新函數的時候可以看到,元件更新函數的id就是該元件範例的uid

const update = (instance.update = effect.run.bind(effect) as SchedulerJob)
update.id = instance.uid

元件建立的過程是深度遞迴建立子元件的過程,所以最先的父元件是0,後面的子元件則一路++上去,這樣就確保了子元件的更新函數的任務id是一定大於父元件更新函數的id的。所以當排程器的任務佇列裡面同時存在很多元件的更新函數的時候,通過優先順序排序,就可以確保一定父元件的更新函數最先執行了。

當前中途也可以進行插隊

export function queueJob(job: SchedulerJob) {
	// 沒有id的則push到最後
    if (job.id == null) {
      queue.push(job)
    } else {
      // 進行插隊處理
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
}

Hooks的本質

最後探討一下Hooks的本質

Vue的Hooks設計是從React的Hooks那裡借鑑過來的,React的Hooks的本質就是把狀態變數、副作用函數存到函陣列件的fiber物件上,等到將來狀態變數發生改變的時候,相關的函陣列件fiber就重新進行更新。Vue3這邊的實現原理也類似,通過上面的生命週期的Hooks實現原理,我們可以知道Vue3的生命週期的Hooks是繫結到具體的元件範例上,而狀態變數,則因為Vue的變數是響應式的,狀態變數會通過effect和具體的元件更新函數進行依賴收集,然後進行繫結,將來狀態變數發生改變的時候,相應的元件更新函數會重新進入排程器的任務佇列進行排程執行。

所以Hooks的本質就是讓那些狀態變數或生命週期函數和元件繫結起來,元件執行到相應時刻執行相應繫結的生命週期函數,那些繫結的變數發生改變的時候,相應的元件也重新進行更新。

最後

下一篇準備寫一下watch API的實現原理,同時watch API也需要和排程器結合進行理解,只有相互串聯理解才可以把Vue3底層設計和實現原理理解得更加透切一些。

最後推薦一個學習vue3原始碼的庫,它是基於崔效瑞老師的開源庫mini-vue而來,在mini-vue的基礎上實現更多的vue3核心功能,用於深入學習 vue3, 讓你更輕鬆地理解 vue3 的核心邏輯。

原始碼地址 https://github.com/amebyte/mini-vue3-plus

以上就是Vue3生命週期Hooks原理與排程器Scheduler關係的詳細內容,更多關於Vue3 Hooks與Scheduler關係的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com