vue3的defineAsyncComponent是如何實現非同步元件的呢?

前端欧阳發表於2024-08-13

前言

在上一篇 給我5分鐘,保證教會你在vue3中動態載入遠端元件文章中,我們透過defineAsyncComponent實現了動態載入遠端元件。這篇文章我們將透過debug原始碼的方式來帶你搞清楚defineAsyncComponent是如何實現非同步元件的。注:本文使用的vue版本為3.4.19

歐陽寫了一本開源電子書vue3編譯原理揭秘,這本書初中級前端能看懂。完全免費,只求一個star。

看個demo

還是一樣的套路,我們來看個defineAsyncComponent非同步元件的demo。

本地子元件local-child.vue程式碼如下:

<template>
  <p>我是本地元件</p>
</template>

非同步子元件async-child.vue程式碼如下:

<template>
  <p>我是非同步元件</p>
</template>

父元件index.vue程式碼如下:

<template>
  <LocalChild />
  <button @click="showAsyncChild = true">load async child</button>
  <AsyncChild v-if="showAsyncChild" />
</template>

<script setup lang="ts">
import { defineAsyncComponent, ref } from "vue";
import LocalChild from "./local-child.vue";

const AsyncChild = defineAsyncComponent(() => import("./async-child.vue"));
const showAsyncChild = ref(false);
</script>

我們這裡有兩個子元件,第一個local-child.vue,他和我們平時使用的元件一樣,沒什麼說的。

第二個子元件是async-child.vue,在父元件中我們沒有像普通元件local-child.vue那樣在最上面import匯入,而是在defineAsyncComponent接收的回撥函式中去動態import匯入async-child.vue檔案,這樣定義的AsyncChild元件就是非同步元件。

在template中可以看到,只有當點選load async child按鈕後才會載入非同步元件AsyncChild

我們先來看看執行效果,如下gif圖:
demo

從上面的gif圖可以看到,當我們點選load async child按鈕後,在network皮膚中才會去載入非同步元件async-child.vue

defineAsyncComponent除了像上面這樣直接接收一個返回Promise的回撥函式之外,還可以接收一個物件作為引數。demo程式碼如下:

const AsyncComp = defineAsyncComponent({
  // 載入函式
  loader: () => import('./async-child.vue'),

  // 載入非同步元件時使用的元件
  loadingComponent: LoadingComponent,
  // 展示載入元件前的延遲時間,預設為 200ms
  delay: 200,

  // 載入失敗後展示的元件
  errorComponent: ErrorComponent,
  // 如果提供了一個 timeout 時間限制,並超時了
  // 也會顯示這裡配置的報錯元件,預設值是:Infinity
  timeout: 3000
})

其中物件引數有幾個欄位:

  • loader欄位其實對應的就是前面那種寫法中的回撥函式。

  • loadingComponent為載入非同步元件期間要顯示的loading元件。

  • delay為顯示loading元件的延遲時間,預設200ms。這是因為在網路狀況較好時,載入完成得很快,載入元件和最終元件之間的替換太快可能產生閃爍,反而影響使用者感受。

  • errorComponent為載入失敗後顯示的元件。

  • timeout為超時時間。

在接下來的原始碼分析中,我們還是以前面那個接收一個返回Promise的回撥函式為例子進行debug除錯原始碼。

開始打斷點

我們在瀏覽器中接著來看父元件index.vue編譯後的程式碼,很簡單,在瀏覽器中可以像vscode一樣使用command(windows中是control)+p就可以喚起一個輸入框,然後在輸入框中輸入index.vue點選回車就可以在source皮膚中開啟編譯後的index.vue檔案了。如下圖:
command

我們看到編譯後的index.vue檔案程式碼如下:

import { defineComponent as _defineComponent } from "/node_modules/.vite/deps/vue.js?v=868545d8";
import {
  defineAsyncComponent,
  ref,
} from "/node_modules/.vite/deps/vue.js?v=868545d8";
import LocalChild from "/src/components/defineAsyncComponentDemo/local-child.vue?t=1723193310324";
const _sfc_main = _defineComponent({
  __name: "index",
  setup(__props, { expose: __expose }) {
    __expose();
    const showAsyncChild = ref(false);
    const AsyncChild = defineAsyncComponent(() =>
      import("/src/components/defineAsyncComponentDemo/async-child.vue")
    );
    const __returned__ = { showAsyncChild, AsyncChild, LocalChild };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  // ...省略
}

export default _export_sfc(_sfc_main, [["render", _sfc_render]]);

從上面的程式碼可以看到編譯後的index.vue主要分為兩塊,第一塊為_sfc_main物件中的setup方法,對應的是我們的script模組。第二塊為_sfc_render,也就是我們常說的render函式,對應的是template中的內容。

我們想要搞清楚defineAsyncComponent方法的原理,那麼當然是給setup方法中的defineAsyncComponent方法打斷點。重新整理頁面,此時程式碼將會停留在斷點defineAsyncComponent方法處。

defineAsyncComponent方法

然後將斷點走進defineAsyncComponent函式內部,在我們這個場景中簡化後的defineAsyncComponent函式程式碼如下:

function defineAsyncComponent(source) {
  if (isFunction(source)) {
    source = { loader: source };
  }
  const { loader, loadingComponent, errorComponent, delay = 200 } = source;
  let resolvedComp;

  const load = () => {
    return loader()
      .catch(() => {
        // ...省略
      })
      .then((comp) => {
        if (
          comp &&
          (comp.__esModule || comp[Symbol.toStringTag] === "Module")
        ) {
          comp = comp.default;
        }
        resolvedComp = comp;
        return comp;
      });
  };

  return defineComponent({
    name: "AsyncComponentWrapper",
    setup() {
      const instance = currentInstance;
      const loaded = ref(false);
      const error = ref();
      const delayed = ref(!!delay);
      if (delay) {
        setTimeout(() => {
          delayed.value = false;
        }, delay);
      }
      load()
        .then(() => {
          loaded.value = true;
        })
        .catch((err) => {
          onError(err);
          error.value = err;
        });
      return () => {
        if (loaded.value && resolvedComp) {
          return createInnerComp(resolvedComp, instance);
        } else if (error.value && errorComponent) {
          return createVNode(errorComponent, {
            error: error.value,
          });
        } else if (loadingComponent && !delayed.value) {
          return createVNode(loadingComponent);
        }
      };
    },
  });
}

從上面的程式碼可以看到defineAsyncComponent分為三部分。

  • 第一部分為:處理傳入的引數。

  • 第二部分為:load函式用於載入非同步元件。

  • 第三部分為:返回defineComponent定義的元件。

第一部分:處理傳入的引數

我們看第一部分:處理傳入的引數。程式碼如下:

function defineAsyncComponent(source) {
  if (isFunction(source)) {
    source = { loader: source };
  }
  const { loader, loadingComponent, errorComponent, delay = 200 } = source;
  let resolvedComp;
  // ...省略
}

首先使用isFunction(source)判斷傳入的source是不是函式,如果是函式,那麼就將source重寫為包含loader欄位的物件:source = { loader: source }。然後使用const { loader, loadingComponent, errorComponent, delay = 200 } = source解構出對應的loading元件、載入失敗元件、延時時間。

看到這裡我想你應該明白了為什麼defineAsyncComponent函式接收的引數可以是一個回撥函式,也可以是包含loaderloadingComponenterrorComponent等欄位的物件。因為如果我們傳入的是回撥函式,在內部會將傳入的回撥函式賦值給loader欄位。不過loading元件、載入失敗元件等引數不會有值,只有delay延時時間預設給了200。

接著就是定義了load函式用於載入非同步元件,這個函式是在第三部分的defineComponent中呼叫的,所以我們先來講defineComponent函式部分。

第三部分:返回defineComponent定義的元件

我們來看看defineAsyncComponent的返回值,是一個defineComponent定義的元件,程式碼如下:

function defineAsyncComponent(source) {
  // ...省略

  return defineComponent({
    name: "AsyncComponentWrapper",
    setup() {
      const instance = currentInstance;
      const loaded = ref(false);
      const error = ref();
      const delayed = ref(!!delay);
      if (delay) {
        setTimeout(() => {
          delayed.value = false;
        }, delay);
      }
      load()
        .then(() => {
          loaded.value = true;
        })
        .catch((err) => {
          onError(err);
          error.value = err;
        });
      return () => {
        if (loaded.value && resolvedComp) {
          return createInnerComp(resolvedComp, instance);
        } else if (error.value && errorComponent) {
          return createVNode(errorComponent, {
            error: error.value,
          });
        } else if (loadingComponent && !delayed.value) {
          return createVNode(loadingComponent);
        }
      };
    },
  });
}

defineComponent函式的接收的引數是一個vue元件物件,返回值也是一個vue元件物件。他其實沒有做什麼事情,單純的只是提供ts的型別推導。

我們接著來看vue元件物件,物件中只有兩個欄位:name屬性和setup函式。

name屬性大家都很熟悉,表示當前vue元件的名稱。

大家平時<script setup>語法糖用的比較多,這個語法糖經過編譯後就是setup函式,當然vue也支援讓我們自己手寫setup函式。

提個問題:setup函式對應的是<script setup>,我們平時寫程式碼都有template模組對應的是檢視部分,也就是熟悉的render函式。為什麼這裡沒有render函式呢?

setup函式打個斷點,當渲染非同步元件時會去執行這個setup函式。程式碼將會停留在setup函式的斷點處。

setup函式中首先使用ref定義了三個響應式變數:loadederrordelayed

  • loaded是一個布林值,作用是記錄非同步元件是否載入完成。

  • error記錄的是載入失敗時記錄的錯誤資訊,如果同時傳入了errorComponent元件,在載入非同步元件失敗時就會顯示errorComponent元件。

  • delayed也是一個布林值,由於loading元件不是立馬就顯示的,而是延時一段時間後再顯示。這個delayed布林值記錄的是是當前是否還在延時階段,如果是延時階段那麼就不顯示loading元件。

接下來判斷傳入的引數中設定設定了delay延遲,如果是就使用setTimeout延時delay毫秒才將delayed的值設定為false,當delayed的值為false後,在loading階段才會去顯示loading元件。程式碼如下:

if (delay) {
  setTimeout(() => {
    delayed.value = false;
  }, delay);
}

接下來就是執行load函式,這個load函式就是我們前面說的defineAsyncComponent函式中的第二部分程式碼。程式碼如下:

load()
  .then(() => {
    loaded.value = true;
  })
  .catch((err) => {
    onError(err);
    error.value = err;
  });

從上面的程式碼可以看到load函式明顯返回的是一個Promise,所以才可以在後面使用.then().catch()。並且這裡在.then()中將loaded的值設定為true,將斷點走進load函式,程式碼如下:

const load = () => {
  return loader()
    .catch(() => {
      // ...省略
    })
    .then((comp) => {
      if (
        comp &&
        (comp.__esModule || comp[Symbol.toStringTag] === "Module")
      ) {
        comp = comp.default;
      }
      resolvedComp = comp;
      return comp;
    });
};

這裡的load函式程式碼也很簡單,在裡面直接執行loader函式。還記得這個loader函式是什麼嗎?

defineAsyncComponent函式可以接收一個非同步載入函式,這個非同步載入函式可以在執行時去import匯入元件。這個非同步載入函式就是這裡的loader函式,執行loader函式就會去載入非同步元件。在我們這裡是非同步載入async-child.vue元件,程式碼如下:

const AsyncChild = defineAsyncComponent(() => import("./async-child.vue"));

所以這裡執行loader函式就是在執行() => import("./async-child.vue"),執行了import()後就可以在network皮膚看到載入async-child.vue檔案的網路請求。import()返回的是一個Promise,等import的檔案載入完了後就會觸發Promise的then(),所以這裡的then()在此時不會觸發。

接著將斷點走出load函式回到setup函式的最後一個return部分,程式碼如下:

setup() {
  // ...省略
  return () => {
    if (loaded.value && resolvedComp) {
      return createInnerComp(resolvedComp, instance);
    } else if (error.value && errorComponent) {
      return createVNode(errorComponent, {
        error: error.value,
      });
    } else if (loadingComponent && !delayed.value) {
      return createVNode(loadingComponent);
    }
  };
},

注意看,這裡的setup的返回值是一個函式,不是我們經常看見的物件。由於這裡返回的是函式,此時程式碼將不會走到返回的函式里面去,給return的函式打個斷點。我們暫時先不看函式中的內容,讓斷點走出setup函式。發現setup函式是由vue中的setupStatefulComponent函式呼叫的,在我們這個場景中簡化後的setupStatefulComponent函式程式碼如下:

function setupStatefulComponent(instance) {
  const Component = instance.type;
  const { setup } = Component;
  const setupResult = callWithErrorHandling(setup, instance, 0, [
    instance.props,
    setupContext,
  ]);
  handleSetupResult(instance, setupResult);
}

上面的callWithErrorHandling函式從名字你應該就能看出來,呼叫一個函式並且進行錯誤處理。在這裡就是呼叫setup函式,然後將呼叫setup函式的返回值丟給handleSetupResult函式處理。

將斷點走進handleSetupResult函式,在我們這個場景中handleSetupResult函式簡化後的程式碼如下:

function handleSetupResult(instance, setupResult) {
  if (isFunction(setupResult)) {
    instance.render = setupResult;
  }
}

在前面我們講過了我們這個場景setup函式的返回值是一個函式,所以isFunction(setupResult)的值為true。程式碼將會走到instance.render = setupResult,這裡的instance是當前vue元件例項,執行這個後就會將setupResult賦值給render函式。

我們知道render函式一般是由template模組編譯而來的,執行render函式就會生成虛擬DOM,最後由虛擬DOM生成對應的真實DOM。

setup的返回值是一個函式時,這個函式就會作為元件的render函式。這也就是為什麼前面defineComponent中只有name熟悉和setup函式,卻沒有render函式。

在執行render函式生成虛擬DOM時就會去執行setup返回的函式,由於我們前面給返回的函式打了一個斷點,所以程式碼將會停留在setup返回的函式中。回顧一下setup返回的函式程式碼如下:

setup() {
  // ...省略
  return () => {
    if (loaded.value && resolvedComp) {
      return createInnerComp(resolvedComp, instance);
    } else if (error.value && errorComponent) {
      return createVNode(errorComponent, {
        error: error.value,
      });
    } else if (loadingComponent && !delayed.value) {
      return createVNode(loadingComponent);
    }
  };
},

由於此時還沒將非同步元件載入完,所以loaded的值也是false,此時程式碼不會走進第一個if中。

同樣的元件都還沒載入完也不會有error,程式碼也不會走到第一個else if中。

如果我們傳入了loading元件,此時程式碼也不會走到第二個else if中。因為此時的delayed的值還是true,代表還在延時階段。只有等到前面setTimeout的回撥執行後才會將delayed的值設定為false。

並且由於delayed是一個ref響應式變數,所以在setTimeout的回撥中改變了delayed的值就會重新渲染,也就是再次執行render函式。前面講了這裡的render函式就是setup中返回的函式,程式碼就會重新走到第二個else if中。

此時else if (loadingComponent && !delayed.value),其中的loadingComponent是loading元件,並且delayed.value的值也是false了。程式碼就會走到createVNode(loadingComponent)中,執行這個函式就會將loading元件渲染到頁面上。

載入非同步元件

前面我們講過了在渲染非同步元件時會執行load函式,在裡面其實就是執行() => import("./async-child.vue")載入非同步元件async-child.vue,我們也可以在network皮膚中看到多了一個async-child.vue檔案的請求。

我們知道import()的返回值是一個Promise,當檔案載入完成後就會觸發Promise的then()。此時程式碼將會走到第一個then()中,回憶一下程式碼:

const load = () => {
  return loader()
    .catch(() => {
      // ...省略
    })
    .then((comp) => {
      if (
        comp &&
        (comp.__esModule || comp[Symbol.toStringTag] === "Module")
      ) {
        comp = comp.default;
      }
      resolvedComp = comp;
      return comp;
    });
};

then()中判斷載入進來的檔案是不是一個es6的模組,如果是就將模組的default匯出重寫到comp元件物件中。並且將載入進來的vue元件物件賦值給resolvedComp變數。

執行完第一個then()後程式碼將會走到第二個then()中,回憶一下程式碼:

load()
  .then(() => {
    loaded.value = true;
  })

第二個then()程式碼很簡單,將loaded變數的值設定為true,也就是標明已經將非同步元件載入完啦。由於loaded是一個響應式變數,改變他的值就會導致頁面重新渲染,將會再次執行render函式。前面我們講了這裡的render函式就是setup中返回的函式,程式碼就會重新走到第二個else if中。

再來回顧一下setup中返回的函式,程式碼如下:

setup() {
  // ...省略
  return () => {
    if (loaded.value && resolvedComp) {
      return createInnerComp(resolvedComp, instance);
    } else if (error.value && errorComponent) {
      return createVNode(errorComponent, {
        error: error.value,
      });
    } else if (loadingComponent && !delayed.value) {
      return createVNode(loadingComponent);
    }
  };
},

由於此時loaded的值為true,並且resolvedComp的值為非同步載入vue元件物件,所以這次render函式返回的虛擬DOM將是createInnerComp(resolvedComp, instance)的執行結果。

createInnerComp函式

接著將斷點走進createInnerComp函式,在我們這個場景中簡化後的程式碼如下:

function createInnerComp(comp, parent) {
  const { ref: ref2, props, children } = parent.vnode;
  const vnode = createVNode(comp, props, children);
  vnode.ref = ref2;
  return vnode;
}

createInnerComp函式接收兩個引數,第一個引數為要非同步載入的vue元件物件。第二個引數為使用defineAsyncComponent建立的vue元件對應的vue例項。

然後就是執行createVNode函式,這個函式大家可能有所耳聞,vue提供的h()函式其實就是呼叫的createVNode函式。

在我們這裡createVNode函式接收的第一個引數為子元件物件,第二個引數為要傳給子元件的props,第三個引數為要傳給子元件的children。createVNode函式會根據這三個引數生成對應的非同步元件的虛擬DOM,將生成的非同步元件的虛擬DOM進行return返回,最後就是根據虛擬DOM生成真實DOM將非同步元件渲染到頁面上。如下圖(圖後還有一個總結):
progress

總結

本文講了defineAsyncComponent是如何實現非同步元件的:

  • defineAsyncComponent函式中會返回一個vue元件物件,物件中只有name屬性和setup函式。

  • 當渲染非同步元件時會執行setup函式,在setup函式中會執行內建的一個load方法。在load方法中會去執行由defineAsyncComponent定義的非同步元件載入函式,這個載入函式的返回值是一個Promise,非同步元件載入完成後就會觸發Promise的then()

  • setup函式中會返回一個函式,這個函式將會是元件的render函式。

  • 當非同步元件載入完了後會走到前面說的Promise的then()方法中,在裡面會將loaded響應式變數的值修改為true。

  • 修改了響應式變數的值導致頁面重新渲染,然後執行render函式。前面講過了此時的render函式是setup函式中會返回的回撥函式。執行這個回撥函式會呼叫createInnerComp函式生成非同步元件的虛擬DOM,最後就是根據虛擬DOM生成真實DOM,從而將非同步子元件渲染到頁面上。

關注公眾號:【前端歐陽】,給自己一個進階vue的機會

另外歐陽寫了一本開源電子書vue3編譯原理揭秘,這本書初中級前端能看懂。完全免費,只求一個star。

相關文章