源碼優化
首先是源碼優化,也就是小右對於 Vue.js 框架本身開發的優化,它的目的是讓代碼更易於開發和維護。源碼的優化主要體現在使用 monorepo 和 TypeScript 管理和開發源碼,這樣做的目標是提升自身代碼可維護性。接下來我們就來看一下這兩個方面的具體變化。
1. 更好的代碼管理方式:monorepo
首先,源碼的優化體現在代碼管理方式上。Vue.js 2.x 的源碼托管在 src 目錄,然后依據功能拆分出了 compiler(模板編譯的相關代碼)、core(與平台無關的通用運行時代碼)、platforms(平台專有代碼)、server(服務端渲染的相關代碼)、sfc(.vue 單文件解析相關代碼)、shared(共享工具代碼) 等目錄:
而到了 Vue.js 3.0 ,整個源碼是通過 monorepo 的方式維護的,根據功能將不同的模塊拆分到 packages 目錄下面不同的子目錄中:
可以看出相對於 Vue.js 2.x 的源碼組織方式,monorepo 把這些模塊拆分到不同的 package 中,每個 package 有各自的 API、類型定義和測試。這樣使得模塊拆分更細化,職責划分更明確,模塊之間的依賴關系也更加明確,開發人員也更容易閱讀、理解和更改所有模塊源碼,提高代碼的可維護性。
另外一些 package(比如 reactivity 響應式庫)是可以獨立於 Vue.js 使用的,這樣用戶如果只想使用 Vue.js 3.0 的響應式能力,可以單獨依賴這個響應式庫而不用去依賴整個 Vue.js,減小了引用包的體積大小,而 Vue.js 2 .x 是做不到這一點的。
2. 有類型的 JavaScript:TypeScript
其次,源碼的優化還體現在 Vue.js 3.0 自身采用了 TypeScript 開發。Vue.js 1.x 版本的源碼是沒有用類型語言的,小右用 JavaScript 開發了整個框架,但對於復雜的框架項目開發,使用類型語言非常有利於代碼的維護,因為它可以在編碼期間幫你做類型檢查,避免一些因類型問題導致的錯誤;也可以利於它去定義接口的類型,利於 IDE 對變量類型的推導。
因此在重構 2.0 的時候,小右選型了 Flow,但是在 Vue.js 3.0 的時候拋棄 Flow 轉而采用 TypeScript 重構了整個項目,這里有兩方面原因,接下來我們具體說一下。
首先,Flow 是 Facebook 出品的 JavaScript 靜態類型檢查工具,它可以以非常小的成本對已有的 JavaScript 代碼遷入,非常靈活,這也是 Vue.js 2.0 當初選型它時一方面的考量。但是 Flow 對於一些復雜場景類型的檢查,支持得並不好。記得在看 Vue.js 2.x 源碼的時候,在某行代碼的注釋中看到了對 Flow 的吐槽,比如在組件更新 props 的地方出現了:
const propOptions: any = vm.$options.props // wtf flow?
什么意思呢?其實是由於這里 Flow 並沒有正確推導出 vm.$options.props 的類型 ,開發人員不得不強制申明 propsOptions 的類型為 any,顯得很不合理;另外他也在社區平台吐槽過 Flow 團隊的爛尾。
其次,Vue.js 3.0 拋棄 Flow 后,使用 TypeScript 重構了整個項目。 TypeScript提供了更好的類型檢查,能支持復雜的類型推導;由於源碼就使用 TypeScript 編寫,也省去了單獨維護 d.ts 文件的麻煩;就整個 TypeScript 的生態來看,TypeScript 團隊也是越做越好,TypeScript 本身保持着一定頻率的迭代和更新,支持的 feature 也越來越多。
此外,小右和 TypeScript 團隊也一直保持了良好的溝通,我們可以期待 TypeScript 對 Vue.js 的支持會越來越好。
性能優化
性能優化一直是前端老生常談的問題。那么對於 Vue.js 2.x 已經足夠優秀的前端框架,它的性能優化可以從哪些方面進行突破呢?
1. 源碼體積優化
首先是源碼體積優化,我們在平時工作中也經常會嘗試優化靜態資源的體積,因為 JavaScript 包體積越小,意味着網絡傳輸時間越短,JavaScript 引擎解析包的速度也越快。
那么,Vue.js 3.0 在源碼體積的減少方面做了哪些工作呢?
首先,移除一些冷門的 feature(比如 filter、inline-template 等);
其次,引入 tree-shaking 的技術,減少打包體積。
第一點很好理解,所以這里我們來看看 tree-shaking,它的原理很簡單,tree-shaking 依賴 ES2015 模塊語法的靜態結構(即 import 和 export),通過編譯階段的靜態分析,找到沒有引入的模塊並打上標記。
舉個例子,一個 math 模塊定義了 2 個方法 square(x) 和 cube(x) :
export function square(x) { return x * x } export function cube(x) { return x * x * x }
我們在這個模塊外面只引入了 cube 方法:
import { cube } from './math.js' // do something with cube
最終 math 模塊會被 webpack 打包生成如下代碼:
/* 1 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { 'use strict'; /* unused harmony export square */ /* harmony export (immutable) */ __webpack_exports__['a'] = cube; function square(x) { return x * x; } function cube(x) { return x * x * x; } });
可以看到,未被引入的 square 模塊被標記了, 然后壓縮階段會利用例如 uglify-js、terser 等壓縮工具真正地刪除這些沒有用到的代碼。
也就是說,利用 tree-shaking 技術,如果你在項目中沒有引入 Transition、KeepAlive 等組件,那么它們對應的代碼就不會打包,這樣也就間接達到了減少項目引入的 Vue.js 包體積的目的。
2. 數據劫持優化
其次是數據劫持優化。Vue.js 區別於 React 的一大特色是它的數據是響應式的,這個特性從 Vue.js 1.x 版本就一直伴隨着,這也是 Vue.js 粉喜歡 Vue.js 的原因之一,DOM 是數據的一種映射,數據發生變化后可以自動更新 DOM,用戶只需要專注於數據的修改,沒有其余的心智負擔。
在 Vue.js 內部,想實現這個功能是要付出一定代價的,那就是必須劫持數據的訪問和更新。其實這點很好理解,當數據改變后,為了自動更新 DOM,那么就必須劫持數據的更新,也就是說當數據發生改變后能自動執行一些代碼去更新 DOM,那么問題來了,Vue.js 怎么知道更新哪一片 DOM 呢?因為在渲染 DOM 的時候訪問了數據,我們可以對它進行訪問劫持,這樣就在內部建立了依賴關系,也就知道數據對應的 DOM 是什么了。以上只是大體的思路,具體實現要比這更復雜,內部還依賴了一個 watcher 的數據結構做依賴管理,參考下圖:
Vue.js 1.x 和 Vue.js 2.x 內部都是通過 Object.defineProperty 這個 API 去劫持數據的 getter 和 setter,具體是這樣的:
Object.defineProperty(data, 'a',{ get(){ // track }, set(){ // trigger } })
但這個 API 有一些缺陷,它必須預先知道要攔截的 key 是什么,所以它並不能檢測對象屬性的添加和刪除。盡管 Vue.js 為了解決這個問題提供了 $set 和 $delete 實例方法,但是對於用戶來說,還是增加了一定的心智負擔。
另外 Object.defineProperty 的方式還有一個問題,舉個例子,比如這個嵌套層級比較深的對象:
export default { data: { a: { b: { c: { d: 1 } } } } }
由於 Vue.js 無法判斷你在運行時到底會訪問到哪個屬性,所以對於這樣一個嵌套層級較深的對象,如果要劫持它內部深層次的對象變化,就需要遞歸遍歷這個對象,執行 Object.defineProperty 把每一層對象數據都變成響應式的。毫無疑問,如果我們定義的響應式數據過於復雜,這就會有相當大的性能負擔。
為了解決上述 2 個問題,Vue.js 3.0 使用了 Proxy API 做數據劫持,它的內部是這樣的:
observed = new Proxy(data, { get() { // track }, set() { // trigger } })
由於它劫持的是整個對象,那么自然對於對象的屬性的增加和刪除都能檢測到。
但要注意的是,Proxy API 並不能監聽到內部深層次的對象變化,因此 Vue.js 3.0 的處理方式是在 getter 中去遞歸響應式,這樣的好處是真正訪問到的內部對象才會變成響應式,而不是無腦遞歸,這樣無疑也在很大程度上提升了性能,我會在后面分析響應式章節詳細介紹它的具體實現原理。
3. 編譯優化
最后是編譯優化,為了便於理解,我們先來看一張圖:
這是 Vue.js 2.x 從 new Vue 開始渲染成 DOM 的流程,上面說過的響應式過程就發生在圖中的 init 階段,另外 template compile to render function 的流程是可以借助 vue-loader 在 webpack 編譯階段離線完成,並非一定要在運行時完成。
所以想優化整個 Vue.js 的運行時,除了數據劫持部分的優化,我們可以在耗時相對較多的 patch 階段想辦法,Vue.js 3.0 也是這么做的,並且它通過在編譯階段優化編譯的結果,來實現運行時 patch 過程的優化。
我們知道,通過數據劫持和依賴收集,Vue.js 2.x 的數據更新並觸發重新渲染的粒度是組件級的:
雖然 Vue 能保證觸發更新的組件最小化,但在單個組件內部依然需要遍歷該組件的整個 vnode 樹,舉個例子,比如我們要更新這個組件:
<template> <div id="content"> <p class="text">static text</p> <p class="text">static text</p> <p class="text">{{message}}</p> <p class="text">static text</p> <p class="text">static text</p> </div> </template>
整個 diff 過程如圖所示:
可以看到,因為這段代碼中只有一個動態節點,所以這里有很多 diff 和遍歷其實都是不需要的,這就會導致 vnode 的性能跟模版大小正相關,跟動態節點的數量無關,當一些組件的整個模版內只有少量動態節點時,這些遍歷都是性能的浪費。
而對於上述例子,理想狀態只需要 diff 這個綁定 message 動態節點的 p 標簽即可。
Vue.js 3.0 做到了,它通過編譯階段對靜態模板的分析,編譯生成了 Block tree。Block tree 是一個將模版基於動態節點指令切割的嵌套區塊,每個區塊內部的節點結構是固定的,而且每個區塊只需要以一個 Array 來追蹤自身包含的動態節點。借助 Block tree,Vue.js 將 vnode 更新性能由與模版整體大小相關提升為與動態內容的數量相關,這是一個非常大的性能突破,我會在后續的章節詳細分析它是如何實現的。
除此之外,Vue.js 3.0 在編譯階段還包含了對 Slot 的編譯優化、事件偵聽函數的緩存優化,並且在運行時重寫了 diff 算法,這些性能優化的內容我在后續特定的章節與你分享。
語法 API 優化:Composition API
除了源碼和性能方面,Vue.js 3.0 還在語法方面進行了優化,主要是提供了 Composition API,那么我們一起來看一下它為我們提供了什么幫助。
1. 優化邏輯組織
首先,是優化邏輯組織。
在 Vue.js 1.x 和 2.x 版本中,編寫組件本質就是在編寫一個“包含了描述組件選項的對象”,我們把它稱為 Options API,它的好處是在於寫法非常符合直覺思維,對於新手來說這樣很容易理解,這也是很多人喜歡 Vue.js 的原因之一。
Options API 的設計是按照 methods、computed、data、props 這些不同的選項分類,當組件小的時候,這種分類方式一目了然;但是在大型組件中,一個組件可能有多個邏輯關注點,當使用 Options API 的時候,每一個關注點都有自己的 Options,如果需要修改一個邏輯點關注點,就需要在單個文件中不斷上下切換和尋找。
舉一個官方例子 Vue CLI UI file explorer,它是 vue-cli GUI 應用程序中的一個復雜的文件瀏覽器組件。這個組件需要處理許多不同的邏輯關注點:
跟蹤當前文件夾狀態並顯示其內容
處理文件夾導航(比如打開、關閉、刷新等)
處理新文件夾的創建
切換顯示收藏夾
切換顯示隱藏文件夾
處理當前工作目錄的更改
如果我們按照邏輯關注點做顏色編碼,就可以看到當使用 Options API 去編寫組件時,這些邏輯關注點是非常分散的:
Vue.js 3.0 提供了一種新的 API:Composition API,它有一個很好的機制去解決這樣的問題,就是將某個邏輯關注點相關的代碼全都放在一個函數里,這樣當需要修改一個功能時,就不再需要在文件中跳來跳去。
通過下圖,我們可以很直觀地感受到 Composition API 在邏輯組織方面的優勢:
2. 優化邏輯復用
其次,是優化邏輯復用。
當我們開發項目變得復雜的時候,免不了需要抽象出一些復用的邏輯。在 Vue.js 2.x 中,我們通常會用 mixins 去復用邏輯,舉一個鼠標位置偵聽的例子,我們會編寫如下函數 mousePositionMixin:
const mousePositionMixin = { data() { return { x: 0, y: 0 } }, mounted() { window.addEventListener('mousemove', this.update) }, destroyed() { window.removeEventListener('mousemove', this.update) }, methods: { update(e) { this.x = e.pageX this.y = e.pageY } } } export default mousePositionMixin
然后在組件中使用:
<template> <div> Mouse position: x {{ x }} / y {{ y }} </div> </template> <script> import mousePositionMixin from './mouse' export default { mixins: [mousePositionMixin] } </script>
使用單個 mixin 似乎問題不大,但是當我們一個組件混入大量不同的 mixins 的時候,會存在兩個非常明顯的問題:命名沖突和數據來源不清晰。
首先每個 mixin 都可以定義自己的 props、data,它們之間是無感的,所以很容易定義相同的變量,導致命名沖突。另外對組件而言,如果模板中使用不在當前組件中定義的變量,那么就會不太容易知道這些變量在哪里定義的,這就是數據來源不清晰。但是Vue.js 3.0 設計的 Composition API,就很好地幫助我們解決了 mixins 的這兩個問題。
我們來看一下在 Vue.js 3.0 中如何書寫這個示例:
import { ref, onMounted, onUnmounted } from 'vue' export default function useMousePosition() { const x = ref(0) const y = ref(0) const update = e => { x.value = e.pageX y.value = e.pageY } onMounted(() => { window.addEventListener('mousemove', update) }) onUnmounted(() => { window.removeEventListener('mousemove', update) }) return { x, y } }
這里我們約定 useMousePosition 這個函數為 hook 函數,然后在組件中使用:
<template> <div> Mouse position: x {{ x }} / y {{ y }} </div> </template> <script> import useMousePosition from './mouse' export default { setup() { const { x, y } = useMousePosition() return { x, y } } } </script>
可以看到,整個數據來源清晰了,即使去編寫更多的 hook 函數,也不會出現命名沖突的問題。
Composition API 除了在邏輯復用方面有優勢,也會有更好的類型支持,因為它們都是一些函數,在調用函數時,自然所有的類型就被推導出來了,不像 Options API 所有的東西使用 this。另外,Composition API 對 tree-shaking 友好,代碼也更容易壓縮。
雖然 Composition API 有諸多優勢,它也不是一點缺點都沒有,關於它的具體用法和設計原理,我們會在后續的章節詳細說明。這里還需要說明的是,Composition API 屬於 API 的增強,它並不是 Vue.js 3.0 組件開發的范式,如果你的組件足夠簡單,你還是可以使用 Options API。
引入 RFC:使每個版本改動可控
作為一個流行開源框架的作者,小右可能每天都會收到各種各樣的 feature request。但並不是社區一有新功能的需求,框架就會立馬支持,因為隨着 Vue.js 的用戶越來越多,小右會更加重視穩定性,會仔細考慮所做的每一個可能對最終用戶影響的更改,以及有意識去防止新 API 對框架本身實現帶來的復雜性的提升。
因此在 Vue.js 2.x 版本開發到后期的階段 ,小右就啟用了 RFC ,它的全稱是 Request For Comments,旨在為新功能進入框架提供一個一致且受控的路徑。當社區有一些新需求的想法時,它可以提交一個 RFC,然后由社區和 Vue.js 的核心團隊一起討論,如果這個 RFC 最終被通過了,那么它才會被實現。比如 2.6 版本對於 slot 新 API 的改動,就是這條 RFC 里。
到了 Vue.js 3.0 ,小右在實現代碼前就大規模啟用 RFC,來確保他的改動和設計都是經過討論並確認的,這樣可以避免走彎路。Vue.js 3.0 版本有很多重大的改動,每一條改動都會有對應的 RFC,通過閱讀這些 RFC,你可以了解每一個 feature 采用或被廢棄掉的前因后果。
Vue.js 3.0 目前已被實現並合並的 RFC 都在這里,通過閱讀它們,你也可以大致了解 Vue.js 3.0 的一些變化,以及為什么會產生這些變化,幫助你了解它的前因后果。