Vue3的defineAsyncComponent是如何實(shí)現(xiàn)異步組件的呢?
前言
在上一篇 給我5分鐘,保證教會(huì)你在vue3中動(dòng)態(tài)加載遠(yuǎn)程組件文章中,我們通過(guò)defineAsyncComponent實(shí)現(xiàn)了動(dòng)態(tài)加載遠(yuǎn)程組件。這篇文章我們將通過(guò)debug源碼的方式來(lái)帶你搞清楚defineAsyncComponent是如何實(shí)現(xiàn)異步組件的。注:本文使用的vue版本為3.4.19
看個(gè)demo
還是一樣的套路,我們來(lái)看個(gè)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>
我們這里有兩個(gè)子組件,第一個(gè)local-child.vue,他和我們平時(shí)使用的組件一樣,沒(méi)什么說(shuō)的。
第二個(gè)子組件是async-child.vue,在父組件中我們沒(méi)有像普通組件local-child.vue那樣在最上面import導(dǎo)入,而是在defineAsyncComponent接收的回調(diào)函數(shù)中去動(dòng)態(tài)import導(dǎo)入async-child.vue文件,這樣定義的AsyncChild組件就是異步組件。
在template中可以看到,只有當(dāng)點(diǎn)擊load async child按鈕后才會(huì)加載異步組件AsyncChild。
我們先來(lái)看看執(zhí)行效果,如下gif圖:
圖片
從上面的gif圖可以看到,當(dāng)我們點(diǎn)擊load async child按鈕后,在network面板中才會(huì)去加載異步組件async-child.vue。
defineAsyncComponent除了像上面這樣直接接收一個(gè)返回Promise的回調(diào)函數(shù)之外,還可以接收一個(gè)對(duì)象作為參數(shù)。demo代碼如下:
const AsyncComp = defineAsyncComponent({
// 加載函數(shù)
loader: () => import('./async-child.vue'),
// 加載異步組件時(shí)使用的組件
loadingComponent: LoadingComponent,
// 展示加載組件前的延遲時(shí)間,默認(rèn)為 200ms
delay: 200,
// 加載失敗后展示的組件
errorComponent: ErrorComponent,
// 如果提供了一個(gè) timeout 時(shí)間限制,并超時(shí)了
// 也會(huì)顯示這里配置的報(bào)錯(cuò)組件,默認(rèn)值是:Infinity
timeout: 3000
})
其中對(duì)象參數(shù)有幾個(gè)字段:
- loader字段其實(shí)對(duì)應(yīng)的就是前面那種寫(xiě)法中的回調(diào)函數(shù)。
- loadingComponent為加載異步組件期間要顯示的loading組件。
- delay為顯示loading組件的延遲時(shí)間,默認(rèn)200ms。這是因?yàn)樵诰W(wǎng)絡(luò)狀況較好時(shí),加載完成得很快,加載組件和最終組件之間的替換太快可能產(chǎn)生閃爍,反而影響用戶感受。
- errorComponent為加載失敗后顯示的組件。
- timeout為超時(shí)時(shí)間。
在接下來(lái)的源碼分析中,我們還是以前面那個(gè)接收一個(gè)返回Promise的回調(diào)函數(shù)為例子進(jìn)行debug調(diào)試源碼。
開(kāi)始打斷點(diǎn)
我們?cè)跒g覽器中接著來(lái)看父組件index.vue編譯后的代碼,很簡(jiǎn)單,在瀏覽器中可以像vscode一樣使用command(windows中是control)+p就可以喚起一個(gè)輸入框,然后在輸入框中輸入index.vue點(diǎn)擊回車(chē)就可以在source面板中打開(kāi)編譯后的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主要分為兩塊,第一塊為_(kāi)sfc_main對(duì)象中的setup方法,對(duì)應(yīng)的是我們的script模塊。第二塊為_(kāi)sfc_render,也就是我們常說(shuō)的render函數(shù),對(duì)應(yīng)的是template中的內(nèi)容。
我們想要搞清楚defineAsyncComponent方法的原理,那么當(dāng)然是給setup方法中的defineAsyncComponent方法打斷點(diǎn)。刷新頁(yè)面,此時(shí)代碼將會(huì)停留在斷點(diǎn)defineAsyncComponent方法處。
defineAsyncComponent方法
然后將斷點(diǎn)走進(jìn)defineAsyncComponent函數(shù)內(nèi)部,在我們這個(gè)場(chǎng)景中簡(jiǎn)化后的defineAsyncComponent函數(shù)代碼如下:
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分為三部分。
- 第一部分為:處理傳入的參數(shù)。
- 第二部分為:load函數(shù)用于加載異步組件。
- 第三部分為:返回defineComponent定義的組件。
第一部分:處理傳入的參數(shù)
我們看第一部分:處理傳入的參數(shù)。代碼如下:
function defineAsyncComponent(source) {
if (isFunction(source)) {
source = { loader: source };
}
const { loader, loadingComponent, errorComponent, delay = 200 } = source;
let resolvedComp;
// ...省略
}
首先使用isFunction(source)判斷傳入的source是不是函數(shù),如果是函數(shù),那么就將source重寫(xiě)為包含loader字段的對(duì)象:source = { loader: source }。然后使用const { loader, loadingComponent, errorComponent, delay = 200 } = source解構(gòu)出對(duì)應(yīng)的loading組件、加載失敗組件、延時(shí)時(shí)間。
看到這里我想你應(yīng)該明白了為什么defineAsyncComponent函數(shù)接收的參數(shù)可以是一個(gè)回調(diào)函數(shù),也可以是包含loader、loadingComponent、errorComponent等字段的對(duì)象。因?yàn)槿绻覀儌魅氲氖腔卣{(diào)函數(shù),在內(nèi)部會(huì)將傳入的回調(diào)函數(shù)賦值給loader字段。不過(guò)loading組件、加載失敗組件等參數(shù)不會(huì)有值,只有delay延時(shí)時(shí)間默認(rèn)給了200。
接著就是定義了load函數(shù)用于加載異步組件,這個(gè)函數(shù)是在第三部分的defineComponent中調(diào)用的,所以我們先來(lái)講defineComponent函數(shù)部分。
第三部分:返回defineComponent定義的組件
我們來(lái)看看defineAsyncComponent的返回值,是一個(gè)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函數(shù)的接收的參數(shù)是一個(gè)vue組件對(duì)象,返回值也是一個(gè)vue組件對(duì)象。他其實(shí)沒(méi)有做什么事情,單純的只是提供ts的類型推導(dǎo)。
我們接著來(lái)看vue組件對(duì)象,對(duì)象中只有兩個(gè)字段:name屬性和setup函數(shù)。
name屬性大家都很熟悉,表示當(dāng)前vue組件的名稱。
大家平時(shí)<script setup>語(yǔ)法糖用的比較多,這個(gè)語(yǔ)法糖經(jīng)過(guò)編譯后就是setup函數(shù),當(dāng)然vue也支持讓我們自己手寫(xiě)setup函數(shù)。
提個(gè)問(wèn)題:setup函數(shù)對(duì)應(yīng)的是<script setup>,我們平時(shí)寫(xiě)代碼都有template模塊對(duì)應(yīng)的是視圖部分,也就是熟悉的render函數(shù)。為什么這里沒(méi)有render函數(shù)呢?
給setup函數(shù)打個(gè)斷點(diǎn),當(dāng)渲染異步組件時(shí)會(huì)去執(zhí)行這個(gè)setup函數(shù)。代碼將會(huì)停留在setup函數(shù)的斷點(diǎn)處。
在setup函數(shù)中首先使用ref定義了三個(gè)響應(yīng)式變量:loaded、error、delayed。
- loaded是一個(gè)布爾值,作用是記錄異步組件是否加載完成。
- error記錄的是加載失敗時(shí)記錄的錯(cuò)誤信息,如果同時(shí)傳入了errorComponent組件,在加載異步組件失敗時(shí)就會(huì)顯示errorComponent組件。
- delayed也是一個(gè)布爾值,由于loading組件不是立馬就顯示的,而是延時(shí)一段時(shí)間后再顯示。這個(gè)delayed布爾值記錄的是是當(dāng)前是否還在延時(shí)階段,如果是延時(shí)階段那么就不顯示loading組件。
接下來(lái)判斷傳入的參數(shù)中設(shè)置設(shè)置了delay延遲,如果是就使用setTimeout延時(shí)delay毫秒才將delayed的值設(shè)置為false,當(dāng)delayed的值為false后,在loading階段才會(huì)去顯示loading組件。代碼如下:
if (delay) {
setTimeout(() => {
delayed.value = false;
}, delay);
}
接下來(lái)就是執(zhí)行l(wèi)oad函數(shù),這個(gè)load函數(shù)就是我們前面說(shuō)的defineAsyncComponent函數(shù)中的第二部分代碼。代碼如下:
load()
.then(() => {
loaded.value = true;
})
.catch((err) => {
onError(err);
error.value = err;
});
從上面的代碼可以看到load函數(shù)明顯返回的是一個(gè)Promise,所以才可以在后面使用.then()和.catch()。并且這里在.then()中將loaded的值設(shè)置為true,將斷點(diǎn)走進(jìn)load函數(shù),代碼如下:
const load = () => {
return loader()
.catch(() => {
// ...省略
})
.then((comp) => {
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === "Module")
) {
comp = comp.default;
}
resolvedComp = comp;
return comp;
});
};
這里的load函數(shù)代碼也很簡(jiǎn)單,在里面直接執(zhí)行l(wèi)oader函數(shù)。還記得這個(gè)loader函數(shù)是什么嗎?
defineAsyncComponent函數(shù)可以接收一個(gè)異步加載函數(shù),這個(gè)異步加載函數(shù)可以在運(yùn)行時(shí)去import導(dǎo)入組件。這個(gè)異步加載函數(shù)就是這里的loader函數(shù),執(zhí)行l(wèi)oader函數(shù)就會(huì)去加載異步組件。在我們這里是異步加載async-child.vue組件,代碼如下:
const AsyncChild = defineAsyncComponent(() => import("./async-child.vue"));
所以這里執(zhí)行l(wèi)oader函數(shù)就是在執(zhí)行() => import("./async-child.vue"),執(zhí)行了import()后就可以在network面板看到加載async-child.vue文件的網(wǎng)絡(luò)請(qǐng)求。import()返回的是一個(gè)Promise,等import的文件加載完了后就會(huì)觸發(fā)Promise的then(),所以這里的then()在此時(shí)不會(huì)觸發(fā)。
接著將斷點(diǎn)走出load函數(shù)回到setup函數(shù)的最后一個(gè)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的返回值是一個(gè)函數(shù),不是我們經(jīng)??匆?jiàn)的對(duì)象。由于這里返回的是函數(shù),此時(shí)代碼將不會(huì)走到返回的函數(shù)里面去,給return的函數(shù)打個(gè)斷點(diǎn)。我們暫時(shí)先不看函數(shù)中的內(nèi)容,讓斷點(diǎn)走出setup函數(shù)。發(fā)現(xiàn)setup函數(shù)是由vue中的setupStatefulComponent函數(shù)調(diào)用的,在我們這個(gè)場(chǎng)景中簡(jiǎn)化后的setupStatefulComponent函數(shù)代碼如下:
function setupStatefulComponent(instance) {
const Component = instance.type;
const { setup } = Component;
const setupResult = callWithErrorHandling(setup, instance, 0, [
instance.props,
setupContext,
]);
handleSetupResult(instance, setupResult);
}
上面的callWithErrorHandling函數(shù)從名字你應(yīng)該就能看出來(lái),調(diào)用一個(gè)函數(shù)并且進(jìn)行錯(cuò)誤處理。在這里就是調(diào)用setup函數(shù),然后將調(diào)用setup函數(shù)的返回值丟給handleSetupResult函數(shù)處理。
將斷點(diǎn)走進(jìn)handleSetupResult函數(shù),在我們這個(gè)場(chǎng)景中handleSetupResult函數(shù)簡(jiǎn)化后的代碼如下:
function handleSetupResult(instance, setupResult) {
if (isFunction(setupResult)) {
instance.render = setupResult;
}
}
在前面我們講過(guò)了我們這個(gè)場(chǎng)景setup函數(shù)的返回值是一個(gè)函數(shù),所以isFunction(setupResult)的值為true。代碼將會(huì)走到instance.render = setupResult,這里的instance是當(dāng)前vue組件實(shí)例,執(zhí)行這個(gè)后就會(huì)將setupResult賦值給render函數(shù)。
我們知道render函數(shù)一般是由template模塊編譯而來(lái)的,執(zhí)行render函數(shù)就會(huì)生成虛擬DOM,最后由虛擬DOM生成對(duì)應(yīng)的真實(shí)DOM。
當(dāng)setup的返回值是一個(gè)函數(shù)時(shí),這個(gè)函數(shù)就會(huì)作為組件的render函數(shù)。這也就是為什么前面defineComponent中只有name熟悉和setup函數(shù),卻沒(méi)有render函數(shù)。
在執(zhí)行render函數(shù)生成虛擬DOM時(shí)就會(huì)去執(zhí)行setup返回的函數(shù),由于我們前面給返回的函數(shù)打了一個(gè)斷點(diǎn),所以代碼將會(huì)停留在setup返回的函數(shù)中?;仡櫼幌聅etup返回的函數(shù)代碼如下:
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);
}
};
},
由于此時(shí)還沒(méi)將異步組件加載完,所以loaded的值也是false,此時(shí)代碼不會(huì)走進(jìn)第一個(gè)if中。
同樣的組件都還沒(méi)加載完也不會(huì)有error,代碼也不會(huì)走到第一個(gè)else if中。
如果我們傳入了loading組件,此時(shí)代碼也不會(huì)走到第二個(gè)else if中。因?yàn)榇藭r(shí)的delayed的值還是true,代表還在延時(shí)階段。只有等到前面setTimeout的回調(diào)執(zhí)行后才會(huì)將delayed的值設(shè)置為false。
并且由于delayed是一個(gè)ref響應(yīng)式變量,所以在setTimeout的回調(diào)中改變了delayed的值就會(huì)重新渲染,也就是再次執(zhí)行render函數(shù)。前面講了這里的render函數(shù)就是setup中返回的函數(shù),代碼就會(huì)重新走到第二個(gè)else if中。
此時(shí)else if (loadingComponent && !delayed.value),其中的loadingComponent是loading組件,并且delayed.value的值也是false了。代碼就會(huì)走到createVNode(loadingComponent)中,執(zhí)行這個(gè)函數(shù)就會(huì)將loading組件渲染到頁(yè)面上。
加載異步組件
前面我們講過(guò)了在渲染異步組件時(shí)會(huì)執(zhí)行l(wèi)oad函數(shù),在里面其實(shí)就是執(zhí)行() => import("./async-child.vue")加載異步組件async-child.vue,我們也可以在network面板中看到多了一個(gè)async-child.vue文件的請(qǐng)求。
我們知道import()的返回值是一個(gè)Promise,當(dāng)文件加載完成后就會(huì)觸發(fā)Promise的then()。此時(shí)代碼將會(huì)走到第一個(gè)then()中,回憶一下代碼:
const load = () => {
return loader()
.catch(() => {
// ...省略
})
.then((comp) => {
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === "Module")
) {
comp = comp.default;
}
resolvedComp = comp;
return comp;
});
};
在then()中判斷加載進(jìn)來(lái)的文件是不是一個(gè)es6的模塊,如果是就將模塊的default導(dǎo)出重寫(xiě)到comp組件對(duì)象中。并且將加載進(jìn)來(lái)的vue組件對(duì)象賦值給resolvedComp變量。
執(zhí)行完第一個(gè)then()后代碼將會(huì)走到第二個(gè)then()中,回憶一下代碼:
load()
.then(() => {
loaded.value = true;
})
第二個(gè)then()代碼很簡(jiǎn)單,將loaded變量的值設(shè)置為true,也就是標(biāo)明已經(jīng)將異步組件加載完啦。由于loaded是一個(gè)響應(yīng)式變量,改變他的值就會(huì)導(dǎo)致頁(yè)面重新渲染,將會(huì)再次執(zhí)行render函數(shù)。前面我們講了這里的render函數(shù)就是setup中返回的函數(shù),代碼就會(huì)重新走到第二個(gè)else if中。
再來(lái)回顧一下setup中返回的函數(shù),代碼如下:
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);
}
};
},
由于此時(shí)loaded的值為true,并且resolvedComp的值為異步加載vue組件對(duì)象,所以這次render函數(shù)返回的虛擬DOM將是createInnerComp(resolvedComp, instance)的執(zhí)行結(jié)果。
createInnerComp函數(shù)
接著將斷點(diǎn)走進(jìn)createInnerComp函數(shù),在我們這個(gè)場(chǎng)景中簡(jiǎn)化后的代碼如下:
function createInnerComp(comp, parent) {
const { ref: ref2, props, children } = parent.vnode;
const vnode = createVNode(comp, props, children);
vnode.ref = ref2;
return vnode;
}
createInnerComp函數(shù)接收兩個(gè)參數(shù),第一個(gè)參數(shù)為要異步加載的vue組件對(duì)象。第二個(gè)參數(shù)為使用defineAsyncComponent創(chuàng)建的vue組件對(duì)應(yīng)的vue實(shí)例。
然后就是執(zhí)行createVNode函數(shù),這個(gè)函數(shù)大家可能有所耳聞,vue提供的h()函數(shù)其實(shí)就是調(diào)用的createVNode函數(shù)。
在我們這里createVNode函數(shù)接收的第一個(gè)參數(shù)為子組件對(duì)象,第二個(gè)參數(shù)為要傳給子組件的props,第三個(gè)參數(shù)為要傳給子組件的children。createVNode函數(shù)會(huì)根據(jù)這三個(gè)參數(shù)生成對(duì)應(yīng)的異步組件的虛擬DOM,將生成的異步組件的虛擬DOM進(jìn)行return返回,最后就是根據(jù)虛擬DOM生成真實(shí)DOM將異步組件渲染到頁(yè)面上。如下圖(圖后還有一個(gè)總結(jié)):
圖片
總結(jié)
本文講了defineAsyncComponent是如何實(shí)現(xiàn)異步組件的:
- 在defineAsyncComponent函數(shù)中會(huì)返回一個(gè)vue組件對(duì)象,對(duì)象中只有name屬性和setup函數(shù)。
- 當(dāng)渲染異步組件時(shí)會(huì)執(zhí)行setup函數(shù),在setup函數(shù)中會(huì)執(zhí)行內(nèi)置的一個(gè)load方法。在load方法中會(huì)去執(zhí)行由defineAsyncComponent定義的異步組件加載函數(shù),這個(gè)加載函數(shù)的返回值是一個(gè)Promise,異步組件加載完成后就會(huì)觸發(fā)Promise的then()。
- 在setup函數(shù)中會(huì)返回一個(gè)函數(shù),這個(gè)函數(shù)將會(huì)是組件的render函數(shù)。
- 當(dāng)異步組件加載完了后會(huì)走到前面說(shuō)的Promise的then()方法中,在里面會(huì)將loaded響應(yīng)式變量的值修改為true。
- 修改了響應(yīng)式變量的值導(dǎo)致頁(yè)面重新渲染,然后執(zhí)行render函數(shù)。前面講過(guò)了此時(shí)的render函數(shù)是setup函數(shù)中會(huì)返回的回調(diào)函數(shù)。執(zhí)行這個(gè)回調(diào)函數(shù)會(huì)調(diào)用createInnerComp函數(shù)生成異步組件的虛擬DOM,最后就是根據(jù)虛擬DOM生成真實(shí)DOM,從而將異步子組件渲染到頁(yè)面上。