為什麼不建議在非同步階段注入 Vue 3.0 的生命週期

百瓶技術 發表於 2022-05-24
Vue

公眾號名片
作者名片

前言

我們在使用 Vue 3.0 Composition API 時,通常會在 setup 週期中利用生命週期 hooks 函式(onMountedonBeforeDestroy 等)完成對生命週期鉤子的注入。那呼叫這些 API 時有沒有一些限制呢,答案是肯定的,我們先通過例子看一下現象。

不同階段注入生命週期鉤子對比

同步階段

這是我們通常書寫生命週期注入的方式,當該元件載入時,控制檯能正常列印出 mounted

<template>
  <div />
</template>

<script lang="ts">
import { onMounted } from 'vue';

export default {
  setup() {
    onMounted(() => {
      console.log('mounted');
    });
  },
};
</script>

非同步階段

如果我們突發奇想想要在非同步階段注入生命週期會發生什麼現象?

export default {
  setup() {
    setTimeout(() => {
      onMounted(() => {
        console.log('mounted');
      });
    });
  },
};

此時我們會發現,控制檯輸出了一個 Vue 的警告:[Vue warn]: onMounted is called when there is no active component instance to be associated with. Lifecycle injection APIs can only be used during execution of setup(). If you are using async setup(), make sure to register lifecycle hooks before the first await statement.

大概意思就是,onMounted 被呼叫時,當前並沒有活躍狀態的元件例項去處理生命週期鉤子的注入。生命週期鉤子的注入只能在 setup 同步執行期間進行,如果我們想要在 async 形態的非同步 setup 中注入生命週期鉤子,必須確保在第一個 await 之前進行。

從原始碼看現象

2.0 中的依賴收集和派發更新

Vue 2.0 在進行依賴收集時會將當前正在建立的元件例項 Watcher 存入到 Dep.target 這個變數中,這個做法就可以很容易地將當前元件例項和元件需要的變數依賴關聯起來。元件在建立時需要讀取一些變數,這些變數是經過 defineReactive 封裝過的,其中存在一個 Dep 例項用來維護所有依賴該變數的 Watcher。當某個變數被讀取時,這個變數的 getter 攔截器就會向屬於它的 Dep 例項中註冊當前正在建立的元件例項 Dep.target。當該變數更新時,變數的 setter 攔截器就會遍歷 dep.subs 佇列並通知每一個 Watcher 進行 update 更新。筆者簡單地描述了一下 Vue 2.0 中的依賴收集和派發更新的過程,其中一個關鍵的步驟就是將當前正在建立的元件點亮到全域性標記 Dep.target 上使得依賴變數能收集到它的被依賴者。

有了這個前提,那筆者大膽猜想一下,Vue 3.0 處理生命週期 Composition API 時也是藉助了類似的思想,將當前正在建立的元件例項點亮到全域性標記上來完成 hooks 和它所處元件的正確關聯。下面我們就通過 3.0 原始碼來驗證一下筆者的猜想是否正確。

3.0 中對生命週期 hooks 的定義

筆者在 3.0 的 packages/runtime-core/src/apiLifecycle.ts 中找到了對生命週期 hooks 函式的定義。

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)
export const onServerPrefetch = createHook(LifecycleHooks.SERVER_PREFETCH)
export const onRenderTriggered = createHook<DebuggerHook>(
  LifecycleHooks.RENDER_TRIGGERED
)
export const onRenderTracked = createHook<DebuggerHook>(
  LifecycleHooks.RENDER_TRACKED
)

這些 hooks 函式是由 createHook 函式建立出來的新函式,都傳入了 LifecycleHooks 列舉中的值來標明自己的身份,我們再來看看 createHook 函式做了什麼。

export const createHook =
  <T extends Function = () => any>(lifecycle: LifecycleHooks) =>
  (hook: T, target: ComponentInternalInstance | null = currentInstance) =>
    // post-create lifecycle registrations are noops during SSR (except for serverPrefetch)
    (!isInSSRComponentSetup || lifecycle === LifecycleHooks.SERVER_PREFETCH) &&
    injectHook(lifecycle, hook, target)

我們仔細看發現它直接返回了一個新函式,我們在業務程式碼中呼叫 onMounted 這樣的鉤子時,就會執行這個函式,它接收一個 hook 回撥函式,以及一個非必填的 target 物件,預設值是 currentInstance,我們稍後分析這個 target 物件是什麼。我們先不考慮 SSR 的情況,最終執行的 injectHook 是關鍵操作,看字面意思就是將 hook 回撥函式注入到 target 物件的指定生命週期 lifecycle 中。

我們繼續挖掘 injectHook 做了什麼,筆者刨去了不重要的部分。

export function injectHook(
  type: LifecycleHooks,
  hook: Function & { __weh?: Function },
  target: ComponentInternalInstance | null = currentInstance,
  prepend: boolean = false
): Function | undefined {
  if (target) {
    // 先根據指定的 lifecycle type 從 target 中尋找對應的 hooks 佇列
    const hooks = target[type] || (target[type] = [])
    // 省略
    // 這是經過包裝的 hook 執行函式,用於在實際呼叫 hook 鉤子時處理邊界、錯誤等情況
    const wrappedHook = /* 省略 */ (...args: unknown[]) => {
      // 省略
      const res = callWithAsyncErrorHandling(hook, target, type, args)
      // 省略
      return res
    }
    // 下面是關鍵步驟,我們發現它將 hook 回撥函式注入到了對應生命週期的 hooks 佇列中
    if (prepend) {
      hooks.unshift(wrappedHook)
    } else {
      hooks.push(wrappedHook)
    }
    return wrappedHook
  } else if (__DEV__) {
    // 省略
  }
}

我們可以看到 injectHook 確實是將 hook 回撥鉤子注入到 target 對應的生命週期佇列中。這就是 onMounted 呼叫後發生的效果。

target 是什麼

上面我們保留了一個疑問,就是 target 到底是什麼,我們很容易就能根據它的型別描述 ComponentInternalInstance 以及預設值 currentInstance 聯想到它是當前正在建立元件的例項。我們繼續驗證我們的猜想。

筆者通過 currentInstance 的引入位置 packages/runtime-core/src/component.ts 找到了它的一系列設定函式。

export let currentInstance: ComponentInternalInstance | null = null

export const getCurrentInstance: () => ComponentInternalInstance | null = () =>
  currentInstance || currentRenderingInstance

export const setCurrentInstance = (instance: ComponentInternalInstance) => {
  currentInstance = instance
  instance.scope.on()
}

export const unsetCurrentInstance = () => {
  currentInstance && currentInstance.scope.off()
  currentInstance = null
}

我們繼續找 setCurrentInstance 的呼叫源頭,筆者在同檔案中發現 setupStatefulComponent 函式呼叫了它,

function setupStatefulComponent(
  instance: ComponentInternalInstance,
  isSSR: boolean
) {
  const Component = instance.type as ComponentOptions
  // 省略
  // 0. create render proxy property access cache
  instance.accessCache = Object.create(null)
  // 1. create public instance / render proxy
  // also mark it raw so it's never observed
  // 省略
  // 2. call setup()
  const { setup } = Component
  if (setup) {
    // 建立 setup 函式上下文入參
    const setupContext = (instance.setupContext =
      setup.length > 1 ? createSetupContext(instance) : null)

    // 關鍵步驟,點亮當前元件例項,必須在 setup 函式被呼叫前
    setCurrentInstance(instance)
    pauseTracking()
    // 呼叫 setup 函式
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
    )
    resetTracking()
    // 關鍵步驟,解除當前點亮元件的設定
    unsetCurrentInstance()
    // 省略
  } else {
    // 省略
  }
}

同樣刨去不重要的邏輯,我們可以看到關鍵的邏輯中,確實做了在 setup 函式呼叫前點亮當前正在建立元件的例項這樣的操作,但是到目前為止還是不能明確地說明這個 instance(target) 就是元件的例項。我們繼續向上查詢,找到了一個叫 mountComponent 的函式,在其中進行了元件例項的建立和 setupComponent 的呼叫。

const mountComponent: MountComponentFn = (
  initialVNode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 2.x compat may pre-create the component instance before actually
  // mounting
  const compatMountInstance =
    __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
  const instance: ComponentInternalInstance =
    compatMountInstance ||
    (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ))
  // 省略
  // resolve props and slots for setup context
  if (!(__COMPAT__ && compatMountInstance)) {
    // 省略
    setupComponent(instance)
    // 省略
  }
  // 省略
}

至此,我們知道 target 確實是當前正在建立的元件例項,並且瞭解到了生命週期 hooks 和所在元件例項之間發生關聯的整個過程,猜想已全部驗證。

更多的思考

明確不能在非同步階段呼叫生命週期鉤子的原因

因為 Vue 在呼叫 setup 函式時是非阻塞式的,這意味著 setup 函式同步執行週期結束之後,Vue 就立馬解除了當前點亮元件的設定,這就很容易理解為什麼 Vue 對非同步生命週期 hooks 的注入發出了警告。

setCurrentInstance(instance)
// 省略
const setupResult = callWithErrorHandling(
  setup,
  instance,
  ErrorCodes.SETUP_FUNCTION,
  [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
// 省略
unsetCurrentInstance()

如何在非同步階段注入生命週期

根據 Vue 的建議,推薦我們在 setup 同步執行週期內注入生命週期。除此之外,我們通過觀察 createHook 函式所建立出的函式的入參(hook: T, target: ComponentInternalInstance | null = currentInstance)發現它允許我們手動傳一個元件例項進行生命週期的注入。

有了這種特性再結合 Vue 官方提供了 getCurrentInstance 函式用於獲取當前元件例項,筆者再大膽地猜想一下,我們可以非同步地注入元件銷燬階段的生命週期,接下來我們來嘗試一下。

<!-- parent.vue -->
<template>
  <div>
    <async-lifecycle v-if="isShow" />
    <button @click="hide">hide</button>
  </div>
</template>

<script lang="ts">
import { ref } from 'vue';
import AsyncLifecycle from './async-lifecycle.vue';

export default {
  components: {AsyncLifecycle},
  setup() {
    const isShow = ref(true);
    const hide = () => {
      isShow.value = false;
    };
    return {
      isShow,
      hide,
    };
  },
};
</script>

我們先定義一個父元件,在其中引入了一個子元件 async-lifecycle,由父元件控制其顯隱,預設處於顯示的狀態。子元件的定義如下:

<!-- async-lifecycle.vue -->
<template>
  <div />
</template>

<script lang="ts">
import { getCurrentInstance, onUnmounted } from 'vue';

export default {
  setup() {
    // getCurrentInstance 函式也必須在 setup 同步週期內呼叫
    const instance = getCurrentInstance();
    setTimeout(() => {
      // 非同步注入元件解除安裝時的生命週期
      onUnmounted(() => {
        console.log('unmounted');
      }, instance);
    });
  },
}
</script>

我們點選父元件的按鈕對子元件進行隱藏時,發現控制檯輸出了 unmounted,猜想成功!

小結

我們通過 Vue 原始碼瞭解到了生命週期類的 Composition API 與元件例項的關聯方式,一些和當前元件有關聯的其它型別 API 也是類似的思路,感興趣的同學可以去了解一下它們的實現原理。有了這些認知之後我們書寫 Composition API 時應當小心,儘可能避免非同步的呼叫,如果除錯時發現了類似問題也可以按這種思路順藤摸瓜尋找不恰當的呼叫時機。

最後推薦一個學習 Vue 3.0 實現原理的捷徑 mini-vue

當我們需要深入學習 vue3 時,我們就需要看原始碼來學習,但是像這種工業級別的庫,原始碼中有很多邏輯是用於處理邊緣情況或者是相容處理邏輯,是不利於我們學習的。

我們應該關注於核心邏輯,而這個庫的目的就是把 vue3 原始碼中最核心的邏輯剝離出來,以供大家學習。

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!