前言
在上一篇 給我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圖:
從上面的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,從而將非同步子元件渲染到頁面上。
關注公眾號:【前端歐陽】,給自己一個進階vue的機會
另外歐陽寫了一本開源電子書vue3編譯原理揭秘,這本書初中級前端能看懂。完全免費,只求一個star。