[7625] 導讀 一文看懂 Vue.j 3.0 的優化

我們的課程是要解讀 Vue.js 框架的源碼,所以在進入課程之前我們先來了解一下 Vue.js 框架演進的過程,也就是 Vue.js 3.0 主要做了哪些優化。

Vue.js 從 1.x 到 2.0 版本,最大的升級就是引入了虛擬 DOM 的概念,它爲後續做服務端渲染以及跨端框架 Weex 提供了基礎。

Vue.js 2.x 發展了很久,現在周邊的生態設施都已經非常完善了,而且對於 Vue.js 用戶而言,它幾乎滿足了我們日常開發的所有需求。你可能覺得 Vue.js 2.x 已經足夠優秀,但是在 Vue.js 作者尤小右的眼中它還不夠完美。在迭代 2.x 版本的過程中,小右發現了很多需要解決的痛點,比如源碼自身的維護性,數據量大後帶來的渲染和更新的性能問題,一些想捨棄但爲了兼容一直保留的雞肋 API 等;另外,小右還希望能給開發人員帶來更好的編程體驗,比如更好的 TypeScript 支持、更好的邏輯複用實踐等,所以他希望能從源碼、性能和語法 API 三個大的方面優化框架。

那麼接下來,我們就一起來看一下 Vue.js 3.0 具體做了哪些優化。相信你學習完這篇文章,不僅能知道 Vue.js 3.0 的升級給我們開發帶來的收益,還能學習到一些設計思想和理念,並在自己的開發工作中應用,獲得提升。

源碼優化

首先是源碼優化,也就是小右對於 Vue.js 框架本身開發的優化,它的目的是讓代碼更易於開發和維護。源碼的優化主要體現在使用 monorepo 和 TypeScript 管理和開發源碼,這樣做的目標是提升自身代碼可維護性。接下來我們就來看一下這兩個方面的具體變化。

1. 更好的代碼管理方式:monorepo

首先,源碼的優化體現在代碼管理方式上。Vue.js 2.x 的源碼託管在 src 目錄,然後依據功能拆分出了 compiler(模板編譯的相關代碼)、core(與平臺無關的通用運行時代碼)、platforms(平臺專有代碼)、server(服務端渲染的相關代碼)、sfc(.vue 單文件解析相關代碼)、shared(共享工具代碼) 等目錄:

Drawing 0.png

而到了 Vue.js 3.0 ,整個源碼是通過 monorepo 的方式維護的,根據功能將不同的模塊拆分到 packages 目錄下面不同的子目錄中:

Drawing 1.png

可以看出相對於 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 的數據結構做依賴管理,參考下圖:

1.png

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. 編譯優化

最後是編譯優化,爲了便於理解,我們先來看一張圖:

2.png

這是 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 的數據更新並觸發重新渲染的粒度是組件級的:

3.png

雖然 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 過程如圖所示:

圖片1.png

可以看到,因爲這段代碼中只有一個動態節點,所以這裏有很多 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 去編寫組件時,這些邏輯關注點是非常分散的:

Drawing 6.png

Vue.js 3.0 提供了一種新的 API:Composition API,它有一個很好的機制去解決這樣的問題,就是將某個邏輯關注點相關的代碼全都放在一個函數裏,這樣當需要修改一個功能時,就不再需要在文件中跳來跳去。

通過下圖,我們可以很直觀地感受到 Composition API 在邏輯組織方面的優勢:

Drawing 7.png

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 的一些變化,以及爲什麼會產生這些變化,幫助你瞭解它的前因後果。

過渡期

接下來,我想再帶你來了解一下 Vue.js 各版本迭代的過渡期,希望能夠對你在 Vue.js 的技術選型方面和學習方向上有所幫助。

通常框架的 major 版本從升級到大規模投入使用,都需要經歷相當長的一段過渡期。不過, Vue.js 1.x 到 Vue.js 2.0 的升級過渡期不長,主要是因爲那個時候 Vue.js 的用戶還不多,生態也不完善,很多用戶都是直接上手的 2.0 版本,沒有舊項目的歷史包袱。

而 Vue.js 2.x 的發展歷經了 3 年多的時間,用戶衆多,而且周邊生態也已經非常完善了。通常 major 版本的升級會有很多 breaking change,這就意味着想從 2.x 升級到 3.0 的項目需要改代碼,而且不僅僅項目的代碼要修改,所依賴的周邊生態也需要升級。這其實是一個相當大的工作量,也需要承擔一定的風險,所以如果你的項目非常龐大且已經相對穩定,沒有什麼特別的痛點,那麼升級要慎重。

Vue.js 3.0 使用 ES2015 的語法開發,有些 API 如 Proxy 是沒有 polyfill 的,這就意味着官方需要單獨出一個 IE11 compat 版本來支持 IE11。如果你的項目需要兼容 IE11,你就不得不小心使用某些 API,這也就帶來了一些額外的心智負擔。

因此可能在 Vue.js 3.0 出來的相當長的一段時間,複雜的大項目都不會考慮去升級,而一些小的、對瀏覽器兼容要求不高的新項目可以考慮嚐鮮了。

官方會繼續維護 Vue.js 2.x 版本 18 個月,如果你的有些項目一輩子都不打算升級 Vue.js 3.0,那麼你應該去認真學習 Vue.js 2.x 的源碼,在官方不再維護的時候遇到問題你可以自己去修改它的源碼來解決。

不過,雖然 Vue.js 3.0 距離大規模應用還有相當長一段時間,但是越早開始學習你就越能在未來掌握主動權。這段時間裏,你可以關注它的發展,去學習它的設計思想,也可以去爲它的生態建設貢獻代碼,從而提升自己的技術能力。另外也可以嘗試在一些小項目中應用 Vue.js 3.0,不僅可以享受 Vue.js 3.0 帶來的性能方面的優勢以及 Composition API 在邏輯複用方面便利,也爲了將來某一天全面升級 Vue.js 3.0 做技術儲備。

總結

這節課我們主要講解了 Vue.js 3.0 升級做了幾個方面的優化,以及爲什麼會需要這些優化。希望學習完後我們也可以像小右一樣去審視自己的工作,有哪些痛點,找到可以改進和努力的方向並實施,只有這樣你才能夠不斷提升自己的能力,工作上也會有不錯的產出。

Vue.js 3.0 做了這麼多改進,相信你也一定對它的實現細節非常感興趣,那麼在接下來的課程裏,就讓我對 Vue.js 的源碼抽絲剝繭,一層層爲你揭開 Vue.js 背後的實現原理和細節。那麼還等什麼,快上車吧!


精選評論

**昊:

沙發

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章