目錄
13.1 異步組件的問題與解決方法
13.2 異步組件的實現原理
3.2.1 封裝 defineAsyncComponent 函數
13.2.2 超時與 Error 組件
13.2.3 延遲與 Loading 組件
13.2.4 重試機制
13.3 函數式組件
13.4 總結
在第12章,我們深入探討了組件的基本含義和實現方式。而在本章,我們將重點關注異步組件和函數式組件兩個關鍵概念。
異步組件的特性在于它可以異步地加載和渲染組件,這在進行代碼分割或服務端下發組件時尤其重要。
另一方面,函數式組件則允許我們用一個普通函數定義組件,并以該函數的返回值作為組件的渲染內容。函數式組件特點在于無狀態、簡潔且直觀。
雖然在 Vue2 中,函數式組件相比于有狀態組件有明顯的性能優勢,但在 Vue3 中,這兩者的性能差距已非常小。
如 Vue.js RFC 所言,我們在 Vue3 中使用函數式組件主要是因為其簡單易用,而非性能優越。
13.1 異步組件的問題與解決方法
從核心角度看,用戶完全可以自主實現異步組件,而無需依賴框架。例如,App 組件的同步渲染如下:
import App from 'App.vue'
createApp(App).mount('#app')
上述代碼可以輕松地改為異步渲染:
const loader = () => import('App.vue')
loader().then(App => {createApp(App).mount('#app')
})
此處,我們使用 import() 動態加載組件,返回一個 Promise 實例。組件加載成功后,使用 createApp 函數掛載,實現頁面的異步渲染。
如果我們想要部分頁面異步渲染,我們只需能夠異步加載某個組件。假設下面的代碼是 App.vue 組件:
<template><CompA /><component :is="asyncComp" />
</template><script>import { shallowRef } from 'vue'import CompA from 'CompA.vue'export default {components: { CompA },setup() {const asyncComp = shallowRef(null)// 異步加載 CompB 組件import('CompB.vue').then(CompB => asyncComp.value = CompB)return {asyncComp}}}
</script>
此代碼模板中,頁面由同步渲染的 和動態組件構成,動態組件綁定了 asyncComp 變量。
腳本塊異步加載 CompB 組件,加載成功后,設定 asyncComp 變量的值為 CompB,實現了 CompB 組件的異步加載和渲染。
雖然用戶可以自定義異步組件,但其實現有復雜性,因為完整的異步組件設計包括以下考慮:
- 如果組件加載失敗或超時,是否展示 Error 組件?
- 是否需要占位內容,例如 Loading 組件,于何時加載時展示?
- 是否設定延遲展示 Loading 組件的時間,避免由于組件加載過快導致的閃爍?
- 如果組件加載失敗,是否需要重試?
為了更優雅地解決這些問題,我們需在框架層面為異步組件提供封裝支持:
- 允許用戶指定加載錯誤時的渲染組件。
- 允許用戶指定 Loading 組件及其展示延遲。
- 允許用戶設置組件加載超時時長。
- 提供組件加載失敗后的重試功能。
總的來說,這些都是異步組件需要解決的核心問題
13.2 異步組件的實現原理
3.2.1 封裝 defineAsyncComponent 函數
異步組件基于封裝理念設計,其目標是提供易用的接口以減輕用戶使用難度。參考以下用戶代碼示例:
<template><AsyncComp />
</template><script>
export default {components: {// 用 defineAsyncComponent 函數定義異步組件,接收一個加載器作為參數AsyncComp: defineAsyncComponent(() => import('CompA'))}
}
</script>
上述代碼,通過 defineAsyncComponent 定義了異步組件,并在 components 組件選項中注冊。
這樣,異步組件可以像普通組件一樣在模板中使用。這種使用 defineAsyncComponent 的方式比我們在 13.1 節中自行實現的異步組件更直觀、簡潔。
defineAsyncComponent 是一個高階組件,它的基本實現如下:
function defineAsyncComponent(loader) {// 存儲異步加載的組件的變量let InnerComp = null// 返回一個包裝組件return {name: 'AsyncComponentWrapper',setup() {// 異步組件加載成功的標記const loaded = ref(false)// 執行加載器函數,返回一個 Promise 實例// 加載成功后,將組件賦值給 InnerComp,并將 loaded 標記為 trueloader().then(c => {InnerComp = cloaded.value = true})return () => {// 如果異步組件加載成功,則渲染該組件,否則渲染一個占位內容return loaded.value ? { type: InnerComp } : { type: Text, children: '' }}}}
}
關鍵點如下:
- defineAsyncComponent 本質是一個高階組件,返回一個包裝組件。
- 包裝組件根據加載器的狀態決定渲染內容。如果成功加載組件,渲染該組件;否則,渲染占位內容。
- 通常占位內容是注釋節點。在組件未成功加載時,頁面渲染一個注釋節點作占位。但在這里,我們用一個空文本節點作為占位。
13.2.2 超時與 Error 組件
異步組件加載通常涉及網絡請求,可能因網速慢導致加載時間過長,特別是在弱網環境下。
為了優化體驗,我們可以允許用戶設定超時時長。超過此時長仍未加載完成,則觸發超時錯誤。如果用戶配置了錯誤組件,此時則渲染錯誤組件。
為了實現這個功能,defineAsyncComponent 函數可以接受一個配置對象:
const AsyncComp = defineAsyncComponent({loader: () => import('CompA.vue'), // 指定異步組件的加載器timeout: 2000, // 設定超時時間(ms)errorComponent: MyErrorComp // 指定在發生錯誤時要渲染的組件
})
有了用戶接口后,我們可以給出實現代碼:
function defineAsyncComponent(options) {// options 可以是配置項,也可以是加載器if (typeof options === 'function') {// 如果 options 是加載器,則將其格式化為配置項形式options = {loader: options}}const { loader } = optionslet InnerComp = nullreturn {name: 'AsyncComponentWrapper',setup() {const loaded = ref(false)// 代表是否超時,默認為 false,即沒有超時const timeout = ref(false)loader().then(c => {InnerComp = cloaded.value = true})let timer = nullif (options.timeout) {// 如果指定了超時時長,則開啟一個定時器計時timer = setTimeout(() => {// 超時后將 timeout 設置為 truetimeout.value = true}, options.timeout)}// 包裝組件被卸載時清除定時器onUmounted(() => clearTimeout(timer))// 占位內容const placeholder = { type: Text, children: '' }return () => {if (loaded.value) {// 如果組件異步加載成功,則渲染被加載的組件return { type: InnerComp }} else if (timeout.value) {// 如果加載超時,并且用戶指定了 Error 組件,則渲染該組件return options.errorComponent ? { type: options.errorComponent } : placeholder}return placeholder}}}
}
關鍵點如下:
- 異步加載是否超時由 timeout.value 標志。
- 開始加載組件的同時,啟動一個定時器進行計時。如果超時,將 timeout.value 設置為 true。當包裝組件被卸載時,需要清除定時器。
- 包裝組件根據 loaded 和 timeout 的值來決定渲染內容。如果加載成功,渲染被加載的組件;如果超時并且用戶指定了錯誤組件,渲染錯誤組件。
為了更全面地處理異步組件加載過程中的錯誤(超時只是其中一種),我們希望為用戶提供以下能力:
- 當錯誤發生時,將錯誤對象作為錯誤組件的 props 傳遞,以便用戶做進一步處理。
- 除了超時,還能處理其他原因導致的加載錯誤,例如網絡失敗等。
為了達到這兩個目標,我們需要對代碼進行一些調整:
function defineAsyncComponent(options) {if (typeof options === 'function') {options = {loader: options}}const { loader } = optionslet InnerComp = nullreturn {name: 'AsyncComponentWrapper',setup() {const loaded = ref(false)// 定義 error,當錯誤發生時,用來存儲錯誤對象const error = shallowRef(null)loader().then(c => {InnerComp = cloaded.value = true})// 添加 catch 語句來捕獲加載過程中的錯誤.catch(err => (error.value = err))let timer = nullif (options.timeout) {timer = setTimeout(() => {// 超時后創建一個錯誤對象,并復制給 error.valueconst err = new Error(`Async component timed out after ${options.timeout}ms.`)error.value = err}, options.timeout)}const placeholder = { type: Text, children: '' }return () => {if (loaded.value) {return { type: InnerComp }} else if (error.value && options.errorComponent) {// 只有當錯誤存在且用戶配置了 errorComponent 時才展示 Error 組件,同時將 error 作為 props 傳遞return { type: options.errorComponent, props: { error: error.value } }} else {return placeholder}}}}
}
上述代碼,我們添加了對加載過程中的錯誤捕獲。當加載超時時,我們創建一個新的錯誤對象。
在渲染組件時,如果存在 error.value 并且用戶配置了錯誤組件,就直接渲染錯誤組件并將 error.value 傳遞作為 props。
這樣,用戶可以在他們的錯誤組件中接收錯誤對象,從而實現更精細的錯誤處理。
13.2.3 延遲與 Loading 組件
異步加載的組件因網絡因素影響,加載速度可能較慢或非常快。
在網絡環境良好的情況下,異步組件可能很快加載,這可能導致 Loading 組件剛出現就立即消失,導致頁面閃爍,這對用戶體驗來說是不好的。
我們可以通過設置延遲展示 Loading 組件的時間來解決這個問題,比如在超過 200ms 后才展示 Loading 組件。以下是這種策略的實現方法。
首先,定義異步組件時,我們添加 delay 和 loadingComponent 兩個選項:
defineAsyncComponent({loader: () => new Promise(r => { /* ... */ }),delay: 200, // 延遲 200ms 展示 Loading 組件loadingComponent: { // Loading 組件setup() {return () => {return { type: 'h2', children: 'Loading...' }}}}
})
然后在 defineAsyncComponent 函數中實現這兩個選項:
function defineAsyncComponent(options) {if (typeof options === 'function') {options = {loader: options}}const { loader } = optionslet InnerComp = nullreturn {name: 'AsyncComponentWrapper',setup() {const loaded = ref(false)const error = shallowRef(null)// 一個標志,代表是否正在加載,默認為 falseconst loading = ref(false)let loadingTimer = null// 如果配置項中存在 delay,則開啟一個定時器計時,當延遲到時后將 loading.value 設置為 trueif (options.delay) {loadingTimer = setTimeout(() => {loading.value = true}, options.delay)} else {// 如果配置項中沒有 delay,則直接標記為加載中loading.value = true}loader().then(c => {InnerComp = cloaded.value = true}).catch(err => (error.value = err)).finally(() => {loading.value = false// 加載完畢后,無論成功與否都要清除延遲定時器clearTimeout(loadingTimer)})let timer = nullif (options.timeout) {timer = setTimeout(() => {const err = new Error(`Async component timed out after ${options.timeout}ms.`)error.value = err}, options.timeout)}const placeholder = { type: Text, children: '' }return () => {if (loaded.value) {return { type: InnerComp }} else if (error.value && options.errorComponent) {return { type: options.errorComponent, props: { error: error.value } }} else if (loading.value && options.loadingComponent) {// 如果異步組件正在加載,并且用戶指定了 Loading 組件,則渲染 Loading 組件return { type: options.loadingComponent }} else {return placeholder}}}}
}
關鍵改動:
- 新增了一個狀態 loading,代表是否正在加載。
- 如果用戶設置了 delay,則在該延遲時間后,如果仍未加載完成,就將 loading 設置為 true。
- 不論加載是否成功,都要將 loading 設置為 false,并清除定時器。
- 在渲染函數中,如果正在加載且用戶設置了 loadingComponent ,就渲染該組件。
此外,我們需要支持 loadingComponent 的卸載。因此需要更新 unmount 函數,使其能夠卸載組件實例的渲染內容(即 subTree ):
function unmount(vnode) {if (vnode.type === Fragment) {vnode.children.forEach(c => unmount(c))return} else if (typeof vnode.type === 'object') {// 對于組件的卸載,本質上是卸載其所渲染的內容unmount(vnode.component.subTree)return}const parent = vnode.el.parentNodeif (parent) {parent.removeChild(vnode.el)}
}
組件的卸載,本質上是要卸載組件所渲染的內容,即 subTree。
所以在上面的代碼中,我們通過組件實例的 vnode.component 屬性得到組件實例,再遞歸地調用 unmount 函數完成 vnode.component.subTree 的卸載。
13.2.4 重試機制
重試機制在異步操作(如網絡請求)中非常常見。當異步加載組件失敗,尤其是在網絡不穩定的情況下,我們希望提供一種方式讓加載操作可以自動重試,這有助于提升用戶體驗。
首先,我們創建一個模擬網絡請求的 fetch 函數,該函數在1秒后返回一個錯誤。
function fetch() {return new Promise((resolve, reject) => {// 請求在1秒后失敗setTimeout(() => {reject('err')}, 1000);})
}
接著,創建一個 load 函數來實現請求失敗后的重試:
function load(onError) {const p = fetch() // 發起請求,得到 Promise 實例return p.catch(err => {return new Promise((resolve, reject) => {const retry = () => resolve(load(onError)) // 重試const fail = () => reject(err) // 失敗onError(retry, fail) // 當錯誤發生時,調用 onError 回調})})
}
用戶可以使用 load 函數進行資源加載,并在發生錯誤時重試:
load((retry) => {retry() // 失敗后重試}
).then(res => {console.log(res) // 成功
})
最后,我們可以將這個重試機制應用到異步組件加載中
function defineAsyncComponent(options) {if (typeof options === 'function') {options = {loader: options}}const { loader } = optionslet InnerComp = null// 記錄重試次數let retries = 0// 封裝 load 函數用來加載異步組件function load() {return (loader()// 捕獲加載器的錯誤.catch(err => {// 如果用戶指定了 onError 回調,則將控制權交給用戶if (options.onError) {// 返回一個新的 Promise 實例return new Promise((resolve, reject) => {// 重試const retry = () => {resolve(load())retries++}// 失敗const fail = () => reject(err)// 作為 onError 回調函數的參數,讓用戶來決定下一步怎么做options.onError(retry, fail, retries)})} else {throw error}}))}return {name: 'AsyncComponentWrapper',setup() {const loaded = ref(false)const error = shallowRef(null)const loading = ref(false)let loadingTimer = nullif (options.delay) {loadingTimer = setTimeout(() => {loading.value = true}, options.delay)} else {loading.value = true}// 調用 load 函數加載組件load().then(c => {InnerComp = cloaded.value = true}).catch(err => {error.value = err}).finally(() => {loading.value = falseclearTimeout(loadingTimer)})// 省略部分代碼}}
}
這段代碼與接口請求的重試機制很類似。在異步組件加載過程中,如果加載失敗,我們提供一個新的 Promise 實例,并將重試(retry)和失敗(fail)的處理函數作為 onError 回調的參數,讓用戶決定下一步怎么做。
13.3 函數式組件
函數式組件簡單易實現,其本質是一個返回虛擬 DOM 的函數。
在 Vue3 中,函數式組件的優點主要在于其簡潔性,而非性能。即使是有狀態組件,在 Vue3 中,其初始化的性能消耗也相對較低。
定義函數式組件如下:
function MyFuncComp(props) {return { type: 'h1', children: props.title }
}// 定義 props
MyFuncComp.props = {title: String
}
函數式組件無自身狀態,但仍能接收外部傳入的 props。對于 props 的定義,我們在組件函數上添加靜態的 props 屬性。
我們可以復用 mountComponent 函數來實現函數式組件的掛載,此外需要支持函數類型的 vnode.type。這可以在 patch 函數內實現:
function patch(n1, n2, container, anchor) {if (n1 && n1.type !== n2.type) {unmount(n1)n1 = null}const { type } = n2if (typeof type === 'string') {// 省略部分代碼} else if (type === Text) {// 省略部分代碼} else if (type === Fragment) {// 省略部分代碼} else if (// type 是對象 --> 有狀態組件// type 是函數 --> 函數式組件typeof type === 'object' ||typeof type === 'function') {// componentif (!n1) {mountComponent(n2, container, anchor)} else {patchComponent(n1, n2, anchor)}}
}
vnode.type 的類型用以判斷組件類型:對象類型表示有狀態組件,函數類型則表示函數式組件。
不論是哪種類型,都可以通過 mountComponent 完成掛載和通過 patchComponent 完成更新。
函數式組件的掛載可以在 mountComponent 函數中實現:
function mountComponent(vnode, container, anchor) {// 檢查是否是函數式組件const isFunctional = typeof vnode.type === 'function'let componentOptions = vnode.typeif (isFunctional) {// 如果是函數式組件,則將 vnode.type 作為渲染函數,將 vnode.type.props 作為 props 選項定義componentOptions = {render: vnode.type,props: vnode.type.props}}// 省略部分代碼
}
在 mountComponent 函數內,如果組件類型是函數式組件,則直接將組件函數作為組件選項對象的 render 選項,同時將組件函數的靜態 props 屬性作為組件的 props 選項。
從這里可以看出,由于函數式組件無需初始化 data 和生命周期鉤子,其初始化性能消耗會比有狀態組件少
13.4 總結
本章首先深入探討了異步組件的作用和需求。異步組件在提高頁面性能、進行包裹分解和服務端下發組件等場景中發揮了重要作用。盡管異步組件的實現可以完全在用戶層面完成,但為考慮到加載失敗、加載中的顯示、超時設定以及重試機制等復雜問題,框架的內置支持成為了必要。因此,Vue3 提供了 defineAsyncComponent 函數來定義異步組件。
我們接著討論了異步組件加載超時和加載錯誤的處理。通過給 defineAsyncComponent 函數指定參數,我們可以設置超時時間并指定錯誤發生時展示的組件。
考慮到網絡狀況的不穩定,加載異步組件可能耗時較長。為了提供更好的用戶體驗,我們需要在加載時展示 Loading 組件。同時,我們提供了 loadingComponent 選項和 delay 選項,允許用戶自定義 Loading 組件并設置展示的延遲時間,避免 Loading 組件的閃爍問題。
在面對加載錯誤時,我們設計了重試機制。這種處理方式與處理接口請求錯誤時的重試機制相似。
最后,我們討論了函數式組件。它只是一個返回虛擬 DOM 的函數,它的實現可以復用有狀態組件的邏輯。對于函數式組件,我們在主函數上添加靜態 props 屬性來定義 props。同時,由于函數式組件無狀態且無生命周期概念,我們在初始化函數式組件時選擇性地復用有狀態組件的初始化邏輯。