Vue3的defineAsyncComponent是如何實現異步組件的呢?
前言
在上一篇 給我5分鐘,保證教會你在vue3中動態加載遠程組件文章中,我們通過defineAsyncComponent實現了動態加載遠程組件。這篇文章我們將通過debug源碼的方式來帶你搞清楚defineAsyncComponent是如何實現異步組件的。注:本文使用的vue版本為3.4.19
看個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圖:
圖片
從上面的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文件了。如下圖:
圖片
我們看到編譯后的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函數接收的參數可以是一個回調函數,也可以是包含loader、loadingComponent、errorComponent等字段的對象。因為如果我們傳入的是回調函數,在內部會將傳入的回調函數賦值給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定義了三個響應式變量:loaded、error、delayed。
- 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將異步組件渲染到頁面上。如下圖(圖后還有一個總結):
圖片
總結
本文講了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,從而將異步子組件渲染到頁面上。