從面試題入手,暢談 Vue 3 效能優化

2023-01-11 18:02:36

前言

今年又是一個非常寒冷的冬天,很多公司都開始人員精簡。市場從來不缺前端,但對高階前端的需求還是特別強烈的。一些大廠的面試官為了區分候選人對前端領域能力的深度,經常會在面試過程中考察一些前端框架的原始碼性知識點。Vuejs 作為世界頂尖的框架之一,幾乎在所有的面試場景中或多或少都會被提及。

筆者之前在螞蟻集團就職,對於 Vue 3 的考點還是會經常問的。接下來,我將根據多年的面試以及被面試經驗,為小夥伴們梳理最近大廠愛問的 Vue 3 問題。然後我們再根據問題舉一反三,深入學習 Vue 3 原始碼知識!

場景一:Vue 3.x 相對於 Vue 2.x 做了那些額外的效能優化?

要理解 Vue 3 的效能優化的核心,就需要了解 Vuejs 的核心設計理念。我們知道 Vuejs 官網上有一句話總結的特別到位:漸進式 JavaScript 框架,易學易用,效能出色,適用於場景豐富的 Web 框架。 其實我們的答案就蘊藏在這句話裡。

首先,我們知道當我們瀏覽 Web 網頁時,有兩類場景會制約 Web 網頁的效能

  1. 網路傳輸的瓶頸
  2. CPU的瓶頸

所以要回答這個問題,就可以直接從這兩方面入手。

網路傳輸的瓶頸優化

對於前端框架而言,制約網路傳輸的因素最大的就是程式碼體積,程式碼體積越大,傳輸效率越慢。尤其對於 SPA 單頁應用的 CSR(使用者端渲染) 而言。一個大體積的框架資源,就意味著使用者需要等待白屏的時間越長。而 Vue 3 在減少原始碼體積方面做的最多的就是通過精細化的 Tree-Shacking 機制來構建 漸進式 程式碼。

1. /*#__PURE__*/ 標記

我們知道 Tree-Shaking 可以刪除一些 DC(dead code) 程式碼。但是對於一些有副作用的函數程式碼,卻是無法進行很好的識別和刪除,舉個例子:

foo()

function foo(obj) {
  obj?.a
}

上述程式碼中,foo 函數本身是沒有任何意義的,僅僅是對物件 obj 進行了屬性 a 的讀取操作,但是 Tree-Shaking 是無法刪除該函數的,因為上述的屬性讀取操作可能會產生副作用,因為 obj 可能是一個響應式物件,我們可能對 obj 定了一個 gettergetter 中觸發了很多不可預期的操作。

如果我們確認 foo 函數是一個不會有副作用的純淨的函數,那麼這個時候 /*#__PURE__*/ 就派上用場了,其作用就是告訴打包器,對於 foo 函數的呼叫不會產生副作用,你可以放心地對其進行 Tree-Shaking

另外,值得一提的是,在 Vue 3 原始碼中,包含了大量的 /*#__PURE__*/ 識別符號,可見 Vue 3 對原始碼體積的控制是多麼的用心!

2. 特性開關

Vue 3 原始碼中的 rollup.config.mjs 中有這樣一段程式碼:

{
  __FEATURE_OPTIONS_API__: isBundlerESMBuild ? `__VUE_OPTIONS_API__` : true,
}

其中 __FEATURE_OPTIONS_API__ 是一個構建時的環境變數,我們知道 Vue 3 在某些 API 方面是相容 Vue 3 寫法的,比如 Options API。但是如果我們在專案中僅僅使用 Compositon API 而不想使用 Options API 那麼我們就可以在專案構建時關閉這個選項,從而減少程式碼體積。我們看看這個變數在 Vue 3 原始碼中是如何使用的:

// 相容 2.x 選項式 API
if (__FEATURE_OPTIONS_API__) {
  currentInstance = instance
  pauseTracking()
  applyOptions(instance, Component)
  resetTracking()
  currentInstance = null
}

使用者可以通過設定 __VUE_OPTIONS_API__ 預定義常數的值來控制是否要包含這段程式碼。通常使用者可以使用 webpack.DefinePlugin 外掛來實現:

// webpack.DefinePlugin 外掛設定
new webpack.DefinePlugin({
  __VUE_OPTIONS_API__: JSON.stringify(true) // 開啟特性
})

除此之外,類似的開發環境會通過 __DEV__ 來輸出告警規則,而在生產環境剔除這些告警降低構建後的包體積都是類似的手段:

if (__DEV__) {
  console.warn(`value cannot be made reactive: ${String(target)}`)
}

CPU 瓶頸優化

當專案變得龐大、元件數量繁多時,就容易遇到CPU的瓶頸。主流瀏覽器重新整理頻率為60Hz,即每(1000ms / 60Hz)16.6ms瀏覽器重新整理一次。

我們知道,JS可以操作DOM,GUI渲染執行緒JS執行緒是互斥的。所以JS指令碼執行瀏覽器佈局、繪製不能同時執行。

在每16.6ms時間內,需要完成如下工作:

JS指令碼執行 -----  樣式佈局 ----- 樣式繪製

當JS執行時間過長,超出了16.6ms,這次重新整理就沒有時間執行樣式佈局樣式繪製了,也就出現了丟幀的情況,會發生卡頓。

為了解決龐大元素元件渲染、更新卡頓的問題,Vue 的策略是一方面採用了元件級的細粒度更新,控制更新的影響面:Vue 3 中,每個元件都會生成一個渲染函數,這些渲染函數執行時會進行資料存取,此時這些渲染函數被收集進入副作用函數中,建立資料 -> 副作用的對映關係。當資料變更時,再觸發副作用函數的重新執行,即重新渲染。

另一方面則在編譯器中做了大量的靜態優化,得益於這些優化,才讓我們可以 易學易用的寫出效能出色的 Vue 專案。 下面簡單介紹幾種編譯時優化策略:

1. 靶向更新

假設有以下模板:

<template>
  <p>hello world</p>
  <p>{{ msg }}</p>
</template>>

其中一個 p 標籤的節點是一個靜態的節點,第二個 p 標籤的節點是一個動態的節點,如果當 msg 的值發生了變化,那麼理論上肉眼可見最優的更新方案應該是隻做第二個動態節點的 diff 而無需進行第一個 p 標籤節點的 diff

上述模版轉成 vnode 後的結果大致為:

const vnode = {
  type: Symbol(Fragment),
  children: [
    { type: 'p', children: 'hello world' },
    { type: 'p', children: ctx.msg, patchFlag: 1 /* 動態的 text */ },
  ],
  dynamicChildren: [
    { type: 'p', children: ctx.msg, patchFlag: 1 /* 動態的 text */ },
  ]
}

此時元件記憶體在了一個靜態的節點 <p>hello world</p>,在傳統的 diff 演演算法裡,還是需要對該靜態節點進行不必要的 diff

Vue3 則是先通過 patchFlag 來標記動態節點 <p>{{ msg }}</p> 然後配合 dynamicChildren 將動態節點進行收集,從而完成在 diff 階段只做靶向更新的目的。

2. 靜態提升

接下來,我們再來說一下,為什麼要做靜態提升呢? 如下模板所示:

<div>
  <p>text</p>
</div>

在沒有被提升的情況下其渲染函數相當於:

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("p", null, "text")
  ]))
}

很明顯,p 標籤是靜態的,它不會改變。但是如上渲染函數的問題也很明顯,如果元件記憶體在動態的內容,當渲染函數重新執行時,即使 p 標籤是靜態的,那麼它對應的 VNode 也會重新建立。

所謂的 「靜態提升」,就是將一些靜態的節點或屬性提升到渲染函數之外。如下面的程式碼所示:

import { createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "text", -1 /* HOISTED */)
const _hoisted_2 = [
  _hoisted_1
]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, _hoisted_2))
}

這就實現了減少 VNode 建立的效能消耗。

而這裡的靜態提升步驟生成的 hoists,會在 codegenNode 會在生成程式碼階段幫助我們生成靜態提升的相關程式碼。

預字串化

Vue 3 在編譯時會進行靜態提升節點的 預字串化。什麼是預字串化呢?一起來看個範例:

<template>
  <p></p>
  ... 共 20+ 節點
  <p></p>
</template>

對於這樣有大量靜態提升的模版場景,如果不考慮 預字串化 那麼生成的渲染函數將會包含大量的 createElementVNode 函數:假設如上模板中有大量連續的靜態的 p 標籤,此時渲染函數生成的結果如下:

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, null, -1 /* HOISTED */)
// ...
const _hoisted_20 = /*#__PURE__*/_createElementVNode("p", null, null, -1 /* HOISTED */)
const _hoisted_21 = [
  _hoisted_1,
  // ...
  _hoisted_20,
]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, _hoisted_21))
}

createElementVNode 大量連續性建立 vnode 也是挺影響效能的,所以可以通過 預字串化 來一次性建立這些靜態節點,採用 與字串化 後,生成的渲染函數如下:

const _hoisted_1 = /*#__PURE__*/_createStaticVNode("<p></p>...<p></p>", 20)
const _hoisted_21 = [
  _hoisted_1
]

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, _hoisted_21))
}

這樣一方面降低了 createElementVNode 連續建立帶來的效能損耗,也側面減少了程式碼體積。

小結

本小節為大家解讀了部分 Vue 3 效能優化的設計,更多的內容可以參考作者寫的小冊:《Vue 3 技術揭祕》

另外,附上小冊 50 個 6 折碼:jQqasaoW

數量有限。