前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

設計 TypeScript JavaScript 代碼開發 2019-06-15

鏈接:https://zhuanlan.zhihu.com/p/68477600

概要

將 2.x 中與組件邏輯相關的選項API 函數的形式重新設計。

基本例子:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

設計動機

邏輯組合與複用

組件 API 設計所面對的核心問題之一就是如何組織邏輯,以及如何在多個組件之間抽取和複用邏輯。基於 Vue 2.x 目前的 API 我們有一些常見的邏輯複用模式,但都或多或少存在一些問題。這些模式包括:

①Mixins

②高階組件 (Higher-order Components, aka HOCs)

③Renderless Components (基於 scoped slots / 作用域插槽封裝邏輯的組件)

網絡上關於這些模式的介紹很多,這裡就不再贅述細節。總體來說,以上這些模式存在以下問題:

1.模版中的數據來源不清晰。舉例來說,當一個組件中使用了多個 mixin 的時候,光看模版會很難分清一個屬性到底是來自哪一個 mixin。HOC 也有類似的問題。

2.命名空間衝突。由不同開發者開發的 mixin 無法保證不會正好用到一樣的屬性或是方法名。HOC 在注入的 props 中也存在類似問題。

3.性能。HOC 和 Renderless Components 都需要額外的組件實例嵌套來封裝邏輯,導致無謂的性能開銷。

Function-based API 受 React Hooks 的啟發,提供了一個全新的邏輯複用方案,且不存在上述問題。使用基於函數的 API,我們可以將相關聯的代碼抽取到一個 "composition function"(組合函數)中 —— 該函數封裝了相關聯的邏輯,並將需要暴露給組件的狀態以響應式的數據源的方式返回出來。這裡是一個用組合函數來封裝鼠標位置偵聽邏輯的例子:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

從以上例子中可以看到:

  1. 暴露給模版的屬性來源清晰(從函數返回);
  2. 返回值可以被任意重命名,所以不存在命名空間衝突;
  3. 沒有創建額外的組件實例所帶來的性能損耗。

類型推導

3.0 的一個主要設計目標是增強對 TypeScript 的支持。原本我們期望通過 Class API 來達成這個目標,但是經過討論和原型開發,我們認為 Class 並不是解決這個問題的正確路線,基於 Class 的 API 依然存在類型問題。

基於函數的 API 天然對類型推導很友好,因為 TS 對函數的參數、返回值和泛型的支持已經非常完備。更值得一提的是基於函數的 API 在使用 TS 或是原生 JS 時寫出來的代碼幾乎是完全一樣的。

打包尺寸

基於函數的 API 每一個函數都可以作為 named ES export 被單獨引入,這使得它們對 tree-shaking 非常友好。沒有被使用的 API 的相關代碼可以在最終打包時被移除。同時,基於函數 API 所寫的代碼也有更好的壓縮效率,因為所有的函數名和 setup 函數體內部的變量名都可以被壓縮,但對象和 class 的屬性/方法名卻不可以。

設計細節

setup() 函數:

我們將會引入一個新的組件選項,setup()。顧名思義,這個函數將會是我們 setup 我們組件邏輯的地方,它會在一個組件實例被創建時,初始化了 props 之後調用。setup() 會接收到初始的 props 作為參數:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

需要留意的是這裡傳進來的 props 對象是響應式的 —— 它可以被當作數據源去觀測,當後續 props 發生變動時它也會被框架內部同步更新。但對於用戶代碼來說,它是不可修改的(會導致警告)。

在 setup 內部可以使用 this,但你大部分時候不會需要它。

組件狀態

類似 data(),setup() 可以返回一個對象 —— 這個對象上的屬性將會被暴露給模版的渲染上下文:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

上面這個例子跟 data() 一摸一樣:msg 可以在模版中被直接使用,它甚至可以被模版中的內聯函數修改。但如果我們想要創建一個可以在 setup() 內部被管理的值,可以使用 value 函數:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

value() 返回的是一個 value wrapper (包裝對象)。一個包裝對象只有一個屬性:.value ,該屬性指向內部被包裝的值。在上面的例子中,msg 包裝的是一個字符串。包裝對象的值可以被直接修改:

// 讀取
console.log(msg.value) // 'hello'
// 修改
msg.value = 'bye'

為什麼需要包裝對象?

我們知道在 JavaScript 中,原始值類型如 string 和 number 是隻有值,沒有引用的。如果在一個函數中返回一個字符串變量,接收到這個字符串的代碼只會獲得一個值,是無法追蹤原始變量後續的變化的。

因此,包裝對象的意義就在於提供一個讓我們能夠在函數之間以引用的方式傳遞任意類型值的容器。這有點像 React Hooks 中的 useRef —— 但不同的是 Vue 的包裝對象同時還是響應式的數據源。有了這樣的容器,我們就可以在封裝了邏輯的組合函數中將狀態以引用的方式傳回給組件。組件負責展示(追蹤依賴),組合函數負責管理狀態(觸發更新):

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

包裝對象也可以包裝非原始值類型的數據,被包裝的對象中嵌套的屬性都會被響應式地追蹤。用包裝對象去包裝數組或是對象並不是沒有意義的:它讓我們可以對整個對象的值進行替換 —— 比如用一個 filter 過的數組去替代原數組:(引用不變

const numbers = value([1, 2, 3])
// 替代原數組,但引用不變
numbers.value = numbers.value.filter(n => n > 1)

如果你依然想創建一個沒有包裝的響應式對象,可以使用 stateAPI(和 2.x 的 Vue.observable()等同):

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

Value Unwrapping(包裝對象的自動展開)

在上面的一個例子中你可能注意到了,雖然 setup()返回的 msg是一個包裝對象,但在模版中我們直接用了 {{ msg }}這樣的綁定,沒有用 .value。這是因為當包裝對象被暴露給模版渲染上下文,或是被嵌套在另一個響應式對象中的時候,它會被自動展開 (unwrap) 為內部的值。

比如一個包裝對象的綁定可以直接被模版中的內聯函數修改:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

當一個包裝對象被作為另一個響應式對象的屬性引用的時候也會被自動展開:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

以上這些關於包裝對象的細節可能會讓你覺得有些複雜,但實際使用中你只需要記住一個基本的規則:只有當你直接以變量的形式引用一個包裝對象的時候才會需要用 .value 去取它內部的值 —— 在模版中你甚至不需要知道它們的存在。

Computed Value (計算值)

除了直接包裝一個可變的值,我們也可以包裝通過計算產生的值:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

計算值的行為跟計算屬性 (computed property) 一樣:只有當依賴變化的時候它才會被重新計算。

computed() 返回的是一個只讀的包裝對象,它可以和普通的包裝對象一樣在 setup() 中被返回 ,也一樣會在渲染上下文中被自動展開。默認情況下,如果用戶試圖去修改一個只讀包裝對象,會觸發警告。

雙向計算值可以通過傳給 computed 第二個參數作為 setter 來創建:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

Watchers

watch() API 提供了基於觀察狀態的變化來執行副作用的能力。

watch() 接收的第一個參數被稱作 “數據源”,它可以是:

  • 一個返回任意值的函數
  • 一個包裝對象
  • 一個包含上述兩種數據源的數組

第二個參數是回調函數。回調函數只有當數據源發生變動時才會被觸發:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

和 2.x 的 $watch 有所不同的是,watch() 的回調會在創建時就執行一次。這有點類似 2.x watcher 的 immediate: true 選項,但有一個重要的不同:默認情況下 watch() 的回調總是會在當前的 renderer flush 之後才被調用 —— 換句話說,watch()的回調在觸發時,DOM 總是會在一個已經被更新過的狀態下。 這個行為是可以通過選項來定製的。

在 2.x 的代碼中,我們經常會遇到同一份邏輯需要在 mounted 和一個 watcher 的回調中執行(比如根據當前的 id 抓取數據),3.0 的 watch() 默認行為可以直接表達這樣的需求。

觀察 props

上面提到了 setup() 接收到的 props 對象是一個可觀測的響應式對象:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

觀察包裝對象

watch()可以直接觀察一個包裝對象:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

觀察多個數據源

watch() 也可以觀察一個包含多個數據源的數組 - 這種情況下,任意一個數據源的變化都會觸發回調,同時回調會接收到包含對應值的數組作為參數:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

停止觀察

watch() 返回一個停止觀察的函數:

const stop = watch(...)
// stop watching
stop()

如果 watch() 是在一個組件的 setup() 或是生命週期函數中被調用的,那麼該 watcher 會在當前組件被銷燬時也一同被自動停止:

export default {
setup() {
// 組件銷燬時也會被自動停止
watch(/* ... */)
}
}

清理副作用

有時候當觀察的數據源變化後,我們可能需要對之前所執行的副作用進行清理。舉例來說,一個異步操作在完成之前數據就產生了變化,我們可能要撤銷還在等待的前一個操作。為了處理這種情況,watcher 的回調會接收到的第三個參數是一個用來註冊清理操作的函數。調用這個函數可以註冊一個清理函數。清理函數會在下屬情況下被調用:

  • 在回調被下一次調用前
  • 在 watcher 被停止前
watch(idValue, (id, oldId, onCleanup) => {
const token = performAsyncOperation(id)
onCleanup(() => {
// id 發生了變化,或是 watcher 即將被停止.
// 取消還未完成的異步操作。
token.cancel()
})
})

之所以要用傳入的註冊函數來註冊清理函數,而不是像 React 的 useEffect 那樣直接返回一個清理函數,是因為 watcher 回調的返回值在異步場景下有特殊作用。我們經常需要在 watcher 的回調中用 async function 來執行異步操作:

const data = value(null)
watch(getId, async (id) => {
data.value = await fetchData(id)
})

我們知道 async function 隱性地返回一個 Promise - 這樣的情況下,我們是無法返回一個需要被立刻註冊的清理函數的。除此之外,回調返回的 Promise 還會被 Vue 用於內部的異步錯誤處理。

Watcher 回調的調用時機

默認情況下,所有的 watcher 回調都會在當前的 renderer flush 之後被調用。這確保了在回調中 DOM 永遠都已經被更新完畢。如果你想要讓回調在 DOM 更新之前或是被同步觸發,可以使用 flush 選項:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

全部的 watch 選項(TS 類型聲明)

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

  1. lazy與 2.x 的 immediate 正好相反
  2. deep與 2.x 行為一致
  3. onTrack 和 onTrigger 是兩個用於 debug 的鉤子,分別在 watcher 追蹤到依賴和依賴發生變化的時候被調用,獲得的參數是一個包含了依賴細節的 debugger event。

生命週期函數

所有現有的生命週期鉤子都會有對應的 onXXX 函數(只能在 setup() 中使用):

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

依賴注入

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

如果注入的是一個包裝對象,則該注入綁定會是響應式的(也就是說,如果 Ancestor 修改了 count,會觸發 Descendent 的更新)。

類型推導

為了能夠在 TypeScript 中提供正確的類型推導,我們需要通過一個函數來定義組件:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

createComponent 從概念上來說和 2.x 的 Vue.extend 是一樣的,但在 3.0 中它其實是單純為了類型推導而存在的,內部實現是個 noop(直接返回參數本身)。它的返回類型可以用於 TSX 和 Vetur 的模版自動補全。如果你使用單文件組件,則 Vetur 可以自動隱式地幫你添加這個調用。

Required Props

Props 默認都是可選的,也就是說它們的類型都可能是 undefined。非可選的 props 需要聲明 required: true :

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

這裡需要注意我們在 props 選項後面加了一個 as const —— 這是 TS 3.4 提供的一個功能,可以避免 required: true 這樣的字面量在推導時被拓寬為 boolean 類型,從而讓 Vue 內部可以通過 extends true 來確定 props 是否可選。

注:我們可能應該把 props 改為默認 required,只有當聲明 optional: true 時才是可選。

複雜 Props 類型

Vue 提供的 PropType 類型可以用來聲明任意複雜度的 props 類型,但需要用 as any 進行一次強制類型轉換:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

依賴注入類型

依賴注入的 inject 方法是唯一必須手動聲明類型的 API:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

這裡的 Value 類型即是包裝對象的類型 ,通過泛型參數來聲明其內部包裝的值的類型。

缺點/潛在問題

新的 API 使得動態地檢視/修改一個組件的選項變得更困難(原來是一個對象,現在是一段無法被檢視的函數體)。

這可能是一件好事,因為通常在用戶代碼中動態地檢視/修改組件是一類比較危險的操作,對於運行時也增加了許多潛在的邊緣情況(特別是組件繼承和使用 mixin 的情況下)。新 API 的靈活性應該在絕大部分情況下都可以用更顯式的代碼達成同樣的結果。

缺乏經驗的用戶可能會寫出 “麵條代碼”,因為新 API 不像舊 API 那樣強制將組件代碼基於選項切分開來。

我們在 Class API RFC 和內部討論中聽到過好幾次這樣的聲音,但我認為這是一種沒有必要的擔憂。雖然理論上新的 API 確實制約更少,但我認為 “麵條代碼” 的情況不太可能發生,這裡詳細解釋一下。

基於函數的新 API 和基於選項的舊 API 之間的最大區別,就是新 API 讓抽取邏輯變得非常簡單 —— 就跟在普通的代碼中抽取函數一樣。也就是說,我們不必只在需要複用邏輯的時候才抽取函數,也可以單純為了更好地組織代碼去抽取函數。

基於選項的代碼只是看上去更整潔。一個複雜的組件往往需要同時處理多個不同的邏輯任務,每個邏輯任務所涉及的代碼在選項 API 下是被分散在多個選項之中的。舉例來說,從服務端抓取一份數據,可能需要用到 props, data(), mounted 和 watch。極端情況下,如果我們把一個應用中所有的邏輯任務都放在一個組件裡,這個組件必然會變得龐大而難以維護,因為每個邏輯任務的代碼都被選項切成了多個碎片分散在各處。

對比之下,基於函數的 API 讓我們可以把每個邏輯任務的代碼都整理到一個對應的函數中。當我們發現一個組件變得過大時,我們會將它切分成多個更小的組件;同樣地,如果一個組件的 setup() 函數變得很複雜,我們可以將它切分成多個更小的函數。而如果是基於選項,則無法做到這樣的切分,因為用 mixin 只會讓事情變得更糟糕。

從這個角度看,基於選項 vs. 基於函數就好像基於 HTML/CSS/JS 組織代碼 vs. 基於單文件組件來組織代碼。

升級策略

新的 API 和 2.x 的 API 理論上完全兼容(只是多了一個 setup()選項) 。但是,新 API 的引入實際上會讓相當一部分的舊選項長遠來說變得沒有必要。如果能夠去掉對這些舊選項的支持,可以獲得相當的代碼尺寸和性能提升。

因此,3.0 我們計劃提供兩個不同的版本:

  • 兼容版本:同時支持新 API 和 2.x 的所有選項;
  • 標準版本:只支持新 API 和部分 2.x 選項。

在兼容版本中,setup() 可以和舊選項(比如 data()) 一起使用,但順序上 setup() 會比舊選項優先調用。也就是說,在 setup() 中無法使用由舊選項聲明的屬性,但在舊選項中可以使用由 setup() 聲明的屬性。

2.x 的用戶可以從兼容版本開始逐步地減少對舊選項的使用,直到最終切換到標準版本。

保留的選項

以下選項行為和 2.x 保持一致,並在兼容和標準版本中都會支持。標有 * 的選項可能會有進一步的調整。

name

props

template

render

components

directives

filters*

delimiters*

comments *

由於本提案而不再必須的選項

以下選項將會在標準版本中被移除,只在兼容版本支持

  • data(由 setup() + value) + state) 取代)
  • computed(由 computed 取代)
  • methods( 由 setup() 中聲明的函數取代)
  • watch (由 watch() 取代)
  • provide/inject(由 provide() 和 inject() 取代)
  • mixins (由組合函數取代)
  • extends (由組合函數取代)
  • 所有的生命週期選項 (由 onXXX 函數取代)

被其它 RFC 提案廢棄的選項

以下選項將會在標準版本中被移除,只在兼容版本中支持。

  • el(應用將不再由 new Vue() 來創建,而是通過新的 createApp 來創建,詳見 RFC#29)
  • propsData(給 root component 的 props 通過新的 createApp API 創建的應用實例來提供。詳見 RFC#29)
  • functional(3.0 函數式組件直接用函數來聲明 ,詳見 RFC#27)
  • model(v-model 指令參數使得該選項不再必要,詳見 RFC#31)
  • inhertiAttrs (非 props 屬性的繼承行為改動使得該選項不再必要,詳見 RFC#26)

附錄

與 React Hooks 的對比

這裡提出的 API 和 React Hooks 有一定的相似性,具有同等的基於函數抽取和複用邏輯的能力,但也有很本質的區別。React Hooks 在每次組件渲染時都會調用,通過隱式地將狀態掛載在當前的內部組件節點上,在下一次渲染時根據調用順序取出。而 Vue 的 setup() 每個組件實例只會在初始化時調用一次 ,狀態通過引用儲存在 setup() 的閉包內。這意味著基於 Vue 的函數 API 的代碼:

  • 整體上更符合 JavaScript 的直覺;
  • 不受調用順序的限制,可以有條件地被調用;
  • 不會在後續更新時不斷產生大量的內聯函數而影響引擎優化或是導致 GC 壓力;
  • 不需要總是使用 useCallback 來緩存傳給子組件的回調以防止過度更新;
  • 不需要擔心傳了錯誤的依賴數組給 useEffect/useMemo/useCallback 從而導致回調中使用了過期的值 —— Vue 的依賴追蹤是全自動的。

注:React Hooks 的開創性毋庸置疑,也是本提案的靈感來源。Hooks 代碼和 JSX 並置使得對值的使用更簡潔也是其優點,但其設計確實存在上述問題,而 Vue 的響應式系統恰巧能夠讓我們繞過這些問題。

Class API 的類型問題

Class API 提案的主要目的是尋找一個能夠提供更好的 TypeScript 支持的組件聲明方式。但是由於 Vue 需要將來自多個選項的屬性混合到同一個渲染上下文上,這使得即使用了 Class,要得到良好的類型推導也不是很容易。

以 props 的類型推導為例。要將 props 的類型 merge 到 class 的 this 上,我們有兩個選擇:用 class 的泛型參數,或是用 decorator。

這是用泛型參數的例子:

前端基礎:Vue3.0最重要的修改——組件選項設計為函數的API形式

由於泛型參數是純類型層面的,所以我們還需要額外地進行一次運行時的 props 選項聲明來獲得正確的行為。這就導致需要進行雙重聲明。

使用 decorator 的例子如下:

class App extends Component<Props> {
@prop message: string
}

Decorators 存在如下問題:

①ES 的 decorator 提案仍然在 stage-2 且極其不穩定。過去一年內已經經歷了兩次徹底大改,且和 TS 現有的實現已經完全脫節。現在引入一個基於 TS decorator 實現的 API 風險太大。

②Decorator 只能聲明 class this 上的屬性,卻無法將某一類 decorator 聲明的屬性歸併到一個對象上(比如 $props),這就導致 this.$props 無法被推導,且影響 TSX 的使用。

③用戶很可能會覺得可以用 @prop message: string = 'foo'這樣的寫法去聲明默認值,但事實上技術層面無法做到符合語義的實現。

最後,class 還有一個問題,那就是目前 class method 不支持參數的 contextual typing,也就是說我們無法基於 class 本身的 fields 來推導某個 method 的參數類型,需要用戶自己去聲明。

相關推薦

推薦中...