vue3 全面深入原理講解

語言: CN / TW / HK

前言

這篇文章的大部分內容成於去年8月。當時自己對 vue3 非常感興趣,才有了這些整理與探索。其實內容放到今天也完全不過時,甚至在掘金上也很少看到有人寫這些偏原理的東西,全都在討論API。

自己最近在整理前端知識圖譜,想到之前有過Vue3相關的整理,便拿了出來(比較懶,原封不動拿出來的,後續會整理整理,新增一些解釋性的內容)

為什麼要升級到Vue3

  1. 更小
  2. 核心程式碼 + Composition Api: 13.5kb(vue2為 31.94kb)
  3. 所有Runtime: 22.5kb(vue2 為32kb)
  4. 更快
  5. SSR速度提高了2~3倍
  6. 初始渲染/更新最高可提速一倍
  7. 更優

image.png - update效能提高1.3~2倍 - 記憶體佔用減小了一半 4. 更易 - 更好的TypeScript支援 - 更多友好特性和檢測 - ......

Vue3有哪些新特性

  1. Tree-shaking支援 ( 按需載入 )
  2. 靜態樹提升
  3. 靜態屬性提升
  4. 虛擬 DOM 重構
  5. 插槽優化
  6. Suspense、Fragment、Teleport
  7. 支援TS ( 原生Class Api 和 TSX )
  8. 基於 Proxy 的新資料監聽系統(Composition API)
  9. 自定義渲染平臺(Custom Render)
    ......

按需載入

非常用功能可以按需載入,比如:v-model, Transition等 ``` js

{{ msg }}

//編譯後代碼 import { toDisplayString as _toDisplayString, createVNode as _createVNode, vModelText as _vModelText, withDirectives as _withDirectives, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", null, _toDisplayString(_ctx.msg), 1 / TEXT /), _withDirectives(_createVNode("input", { "onUpdate:modelValue": _cache[1] || (_cache[1] = $event => (_ctx.msg = $event)) }, null, 512 / NEED_PATCH /), [ [_vModelText, _ctx.msg] ]) ], 64 / STABLE_FRAGMENT /)) } ```

``` js // 在 Vue2 中,初始化一個應用 import Vue from 'vue' import App from './App' import router from './router' import store from './store'

const app = new Vue({ router, store render: h => h(App) }) app.$mount('#app')

// 在 Vue3 中,初始化一個應用 import { createApp } from 'vue' import App from './app' import router from './router' import store from './store'

createApp(App).use(router).use(store).mount('#app') `` Vue2 中通過 new 一個 Vue 例項初始化,而 Vue3 通過鏈式呼叫來建立。這樣可以去做tree-shaking,不需要的模組則不打包進去。而通過物件建立時webpack是無法處理動態語言物件上的屬性的,而且也無法對這些屬性進行優化,比如通過uglify`來縮短屬性名稱

靜態提升

``` js

{{ msg }}
{{ msg2 }}
msg3

//編譯後代碼 import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = { class: "msg2" } const _hoisted_2 = /#PURE/_createVNode("div", { class: "msg3" }, "msg3", -1 / HOISTED /)

export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", null, _toDisplayString(_ctx.msg), 1 / TEXT /), _createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 / TEXT /), _hoisted_2 ], 64 / STABLE_FRAGMENT /)) } ```

```js hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3 hello vue3

{{msg}}

//編譯後代碼 import { createVNode as _createVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = /#PURE/_createStaticVNode("hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3hello vue3", 10)

export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createBlock(_Fragment, null, [ _hoisted_1, _createVNode("div", null, _toDisplayString(_ctx.msg), 1 / TEXT /) ], 64 / STABLE_FRAGMENT /)) } ```

Cache Handler

```js

{{ msg }}
{{ msg2 }}
msg3

//編譯後代碼 import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = { class: "msg2" }

export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", { class: _ctx.msg1 }, _toDisplayString(_ctx.msg), 3 / TEXT, CLASS /), _createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 / TEXT /), _createVNode("div", { class: "msg3", onClick: _cache[1] || (_cache[1] = (...args) => (_ctx.msgClickHandler(...args))) }, "msg3") ], 64 / STABLE_FRAGMENT /)) } `` 在 Vue2 中,每次更新,render函式跑完之後vnode繫結的事件都是一個全新生成的function,就算它們內部的程式碼是一樣的 而在Vue3中傳入的事件會自動生成並快取一個行內函數在cache裡,變為一個靜態節點。這樣就算我們自己寫行內函數,也不會導致多餘的重複渲染。類似於React中的useCallback()`

享元模式:主要用於減少建立物件的數量,以減少記憶體佔用和提高效能。這種型別的設計模式屬於結構型模式,它提供了減少物件數量從而改善應用所需的物件結構的方式。享元模式嘗試重用現有的同類物件,如果未找到匹配的物件,則建立新物件。

Patch Flag

```js

{{ msg }}
{{ msg2 }}
msg3

//編譯後代碼 import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

const _hoisted_1 = { class: "msg2" } const _hoisted_2 = /#PURE/_createVNode("div", { class: "msg3" }, "msg3", -1 / HOISTED /)

export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", { class: _ctx.msg1, id: _ctx.msg1 }, _toDisplayString(_ctx.msg), 11 / TEXT, CLASS, PROPS /, ["id"]), _createVNode("div", _hoisted_1, _toDisplayString(_ctx.msg2), 1 / TEXT /), _hoisted_2 ], 64 / STABLE_FRAGMENT /)) } js const PatchFlagNames = { // 表示具有動態 textContent 的元素 [1 / TEXT /]: TEXT,

// 表示有動態 class 的元素
[2 /* CLASS */]: `CLASS`,

// 表示動態樣式
[4 /* STYLE */]: `STYLE`,

// 表示具有非類/樣式動態道具的元素。
[8 /* PROPS */]: `PROPS`,

// 表示帶有動態鍵的道具的元素,與上面三種相斥
[16 /* FULL_PROPS */]: `FULL_PROPS`,

// 表示帶有事件監聽器的元素
[32 /* HYDRATE_EVENTS */]: `HYDRATE_EVENTS`,

// 表示其子順序不變的片段 
[64 /* STABLE_FRAGMENT */]: `STABLE_FRAGMENT`,

// 表示帶有鍵控或部分鍵控子元素的片段。
[128 /* KEYED_FRAGMENT */]: `KEYED_FRAGMENT`,

// 表示帶有無key繫結的片段
[256 /* UNKEYED_FRAGMENT */]: `UNKEYED_FRAGMENT`,

// 表示具有動態插槽的元素
[1024 /* DYNAMIC_SLOTS */]: `DYNAMIC_SLOTS`,

// 表示只需要非屬性補丁的元素,例如ref或hooks
[512 /* NEED_PATCH */]: `NEED_PATCH`,

[-1 /* HOISTED */]: `HOISTED`,
[-2 /* BAIL */]: `BAIL`

}; 有多種 patchFlag 時進行疊加。TEXT=1, CLASS=2, PROPS=8,得到11; 之後`patch`函式拿到`flag`後,通過分別和1,2,4,8按位與,最後結果不為 0 表示含有該動態屬性。 000000001 1 text 000000010 2 class 000000100 4 style 000001000 8 props

000001011 11

11 & 1 //true 11 & 2 //true 11 & 4 //false 11 & 8 //true ``` image.png

與 React 對比

image.png React走了另外一條路,既然主要問題是diff導致卡頓,於是React走了類似 cpu 排程的邏輯,把vdom這棵樹微觀變成了連結串列,利用瀏覽器的空閒時間來做diff,如果超過了16ms,有動畫或者使用者互動的任務,就把主程序控制權還給瀏覽器,等空閒了繼續。實際上是在之前用不上的時間裡做了diff操作。

時間切片

瀏覽器每間隔一定的時間重新繪製一下當前頁面。一般來說這個頻率是每秒60次。也就是說每16毫秒瀏覽器會有一個週期性地重繪行為,這每16毫秒我們稱為一幀。這一幀的時間裡面瀏覽器的主要工作有: 1. 執行JS 2. 計算Style 3. 構建佈局模型(Layout) 4. 繪製圖層樣式(Paint) 5. 組合計算渲染呈現結果(Composite)

如果這六個步驟總時間超過 16ms 了之後,使用者也許就能看到卡頓。如果任務不能在50毫秒內執行完,那麼為了不阻塞主執行緒,這個任務應該讓出主執行緒的控制權,使瀏覽器可以處理其他任務,隨後再回來繼續執行沒有執行完的任務。

image.png

Vue3放棄了時間切片支援

image.png

React為何支援
  • React的虛擬DOM操作(reconciliation )天生就比較慢
  • React使用JSX來渲染函式相對較於用模板來渲染更加難以優化,模板更易於靜態分析。
  • React Hooks將大部分元件樹級優化(即防止不必要的子元件的重新渲染)留給了開發人員,一個使用Hook的React應用在預設配置下會過度渲染
Vue3 為何放棄
  • 相比 React 本質上更簡單,因此虛擬DOM操作更快
  • 通過分析模板進行了大量的執行前編譯優化,減少了虛擬 DOM 操作的基本開銷。Benchmark顯示,對於一個典型的DOM程式碼塊來說,動態與靜態內容的比例大約是1:4,Vue3的原生執行速度甚至比Svelte更快,在CPU上花費的時間不到 React 的 1/10,而只有cpu 任務繁重時時間切片才有意義。
  • 智慧元件樹級優化通過響應式跟蹤,將插槽編譯成函式(避免子元素重複渲染)和自動快取內聯控制代碼(避免行內函數重複渲染)。除非必要,否則子元件永遠不需要重新渲染。這一切不需要開發人員進行任何手動優化。
  • 時間切片增加了額外的複雜性,Vue 3的執行時仍然只有當前 React + React DOM 的1/4大小
  • Vue3通過 Proxy 響應式 + 元件內部 vdom + 靜態標記,把任務顆粒度控制的足夠細緻,所以也不太需要 time-slice了
  • 時間切片特別解決了 React 中比其他框架更突出的問題,同時也帶來了成本。對於Vue 3來說,這種權衡似乎是不值得的

插槽優化

在vue2中,當父元件資料更新的時候執行會觸發重新渲染,最終執行父元件的 patch,在 patch 過程中,遇到元件 vnode,會執行新舊 vnodeprepatch,這個過程又會執行 updateChildComponent, 如果這個子元件 vnode 有插槽,會重新執行一次子元件的 forceUpdate(),這種情況下會觸發子元件的重新渲染。簡單來說,當父元件更新時,插槽會被重新渲染。vue3對這種場景進行了優化。

在Vue3中,所有由編譯器生成的 slot 都將是函式形式,並且在子元件的 render 函式被呼叫過程中才被呼叫。這使得 slot 中的依賴項 將被作為子元件的依賴項,而不是現在的父元件;從而意味著:1)當 slot 的內容發生變動時,只有子元件會被重新渲染;2)當父元件重新渲染時,如果子元件的內容未發生變動,子元件就沒必要重新渲染。 1. 靜態編譯時,給一個Component打上一個PatchFlag標記---是否是DynamicSlot 2. 遇到有傳入slot的元件,它的Children不是普通的vnode陣列,而是一個slot function的對映表,這些slot function用於在元件中懶生成slot中的vnodes。 3. 在子元件的render函式裡面,呼叫相應的slot生成函式,因此這個slot函式裡面的屬性都會被當前的元件例項所track

Suspense

一個非同步載入元件,抄自React
它可以在巢狀的元件樹渲染到螢幕上之前,在記憶體中進行渲染,可以檢測整顆樹裡面的非同步依賴,只有當將整顆樹的非同步依賴都渲染完成之後,也就是resolve之後,才會將元件樹渲染到螢幕上去。 <Suspense> is an experimental feature and its API will likely change.

Fragment

抄自React
自動在template中增加一層虛擬節點,不再需要用根元素進行包裹 ``` js

{{ msg }}
{{ msg2 }}

//編譯後代碼 import { toDisplayString as _toDisplayString, createVNode as _createVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createBlock(_Fragment, null, [ _createVNode("div", null, _toDisplayString(_ctx.msg), 1 / TEXT /), _createVNode("div", null, _toDisplayString(_ctx.msg2), 1 / TEXT /) ], 64 / STABLE_FRAGMENT /)) } ```

Teleport

是一個全域性元件,抄自React中的Portal
提供了一種將子節點渲染到存在於父元件以外的 DOM 節點的優秀的方案。可以應用在彈窗等需要掛載到全域性的元件。 ```html

<teleport to="body">
  <div v-if="modalOpen" class="modal">
    <div>
      I'm a teleported modal! My parent is "body".
      <button @click="modalOpen = false">
        Close
      </button>
    </div>
  </div>
</teleport>

```

Composition API

指令式程式設計 --> 函數語言程式設計

  • 更好的邏輯複用與程式碼組織
  • 更好的型別推導
    棄用this後通過函式式的呼叫方式來支援Typescript image
mixin的問題:
  • 命名衝突
    Vue元件的預設合併策略是本地選項將覆蓋mixin選項(生命週期鉤子除外)。在跨多個元件和mixin處理命名屬性時,編寫程式碼變得越來越困難。一旦第三方mixin作為帶有自己命名屬性的npm包被新增進來,就會特別困難,因為它們可能會導致衝突。
  • 來源不清晰
    當有多層或多個mixin時,呼叫的屬性來源不清晰
  • 隱式依賴
    mixin和使用它的元件之間沒有層次關係。這意味著元件可以使用mixin中定義的資料屬性,但是mixin也可以使用假定在元件中定義的資料屬性。如果想重構一個元件,改變了mixin需要的變數的名稱,我們在看這個元件時,不會發現有什麼問題。linter也不會發現它,我們只會在執行時看到錯誤。

mixin模式表面上看起來很安全。然而,通過合併物件來共享程式碼,由於它給程式碼增加了脆弱性,並且掩蓋了推理功能的能力,因此成為一種反模式。Composition API 最聰明的部分是,它允許Vue依靠原生JavaScript中內建的保障措施來共享程式碼,比如將變數傳遞給函式和模組系統。

在大多數情況下,你堅持使用經典API是沒有問題的。但是,如果你打算重用程式碼,Composition API無疑是優越的。

Vue2響應式實現: Object.defineProperty

簡單來說就是攔截物件,給物件的屬性增加setget

Object.defineProperty缺點: - 有時無法監聽到陣列的變化 - 需要深度遍歷,浪費記憶體 - 對 Map、Set、WeakMap 和 WeakSet 的支援

Vue3響應式實現: Proxy

  • reactive 大致實現過程 ```js const toProxy = new WeakMap(); // 存放被代理過的物件 const toRaw = new WeakMap(); // 存放已經代理過的物件

function reactive(target) { // 建立響應式物件 return createReactiveObject(target); }

function isObject(target) { return typeof target === "object" && target !== null; }

function hasOwn(target,key){ return target.hasOwnProperty(key); }

function createReactiveObject(target) { if (!isObject(target)) { return target; }

let observed = toProxy.get(target);
if(observed){ // 判斷是否被代理過
    return observed;
}
if(toRaw.has(target)){ // 判斷是否要重複代理
    return target;
}

const handlers = {
    get(target, key, receiver) {
        // 取值
        let res = Reflect.get(target, key, receiver);
        track(target,'get',key); //依賴收集
        // 懶代理,只有當取值時再次做代理,vue2中一上來就會全部遞迴增加getter,setter
        return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
        let oldValue = target[key];
        let hadKey = hasOwn(target,key);
        let result = Reflect.set(target, key, value, receiver);
        if(!hadKey){
            trigger(target,'add',key); // 觸發新增
        }else if(oldValue !== value){
            trigger(target,'set',key); // 觸發修改
        }
        return result;
    },
    deleteProperty(target, key) {
        //...
        const result = Reflect.deleteProperty(target, key);
        return result;
    }
};

// 開始代理
observed = new Proxy(target, handlers);
toProxy.set(target,observed);
toRaw.set(observed,target); // 做對映表
return observed;

} - effect的大致實現 js const activeReactiveEffectStack = []; // 存放響應式effect

function effect(fn) { const effect = function() { // 響應式的effect return run(effect, fn); }; effect(); // 先執行一次 return effect; }

function run(effect, fn) { try { activeReactiveEffectStack.push(effect); return fn(); // 先讓fn執行,執行時會觸發get方法,可以將effect存入對應的key屬性 } finally { activeReactiveEffectStack.pop(effect); } } js const targetMap = new WeakMap(); function track(target,type,key){ // 檢視是否有effect const effect = activeReactiveEffectStack[activeReactiveEffectStack.length-1]; if(effect){ let depsMap = targetMap.get(target); if(!depsMap){ // 不存在map targetMap.set(target,depsMap = new Map()); } let dep = depsMap.get(target); if(!dep){ // 不存在則set depsMap.set(key,(dep = new Set())); } if(!dep.has(effect)){ dep.add(effect); // 將effect新增到依賴中 } } } js function trigger(target, type, key) { const depsMap = targetMap.get(target); if (!depsMap) { return; } let effects = depsMap.get(key); if (effects) { effects.forEach(effect => { effect(); }); } // 處理如果當前型別是增加屬性,如果用到陣列的length的effect應該也會被執行 if (type === "add") { let effects = depsMap.get("length"); if (effects) { effects.forEach(effect => { effect(); }); } } } ```

image.png

  • 預設為惰性監測。在 Vue2中,任何響應式資料都會在啟動的時候被監測。如果資料量很大,在應用啟動時,就可能造成可觀的效能消耗。而在Vue3 中,只有應用的初始可見部分所用到的資料會被監測。
  • 更精準的變動通知。舉個例子:在 Vue2 中,通過 Vue.set 強制新增一個新的屬性,將導致所有依賴於這個物件的 watch 函式都會被執行一次;而在 Vue3 中,只有依賴於這個具體屬性的 watch 函式會被通知到。
  • 不可變監測物件。我們可以建立一個物件的“不可變”版本,這種機制可以用來凍結傳遞到元件屬性上的物件和處在 mutation 範圍外的 Vuex 狀態樹。
  • 更良好的可除錯能力。通過使用新增的 renderTrackedrenderTriggered 鉤子,我們可以精確地追蹤到一個元件發生重渲染的觸發時機和完成時機,及其原因。

自定義渲染平臺( Custom Render )

通過這個API理論上你可以自定義任意平臺的渲染函式,把VNode渲染到不同的平臺上,比如小程式;你可以對著@vue/runtime-dom複製一個@vue/runtime-miniprogram出來, 再比如遊戲:@vue/runtime-canvas
這個 API 的到來,將使得那些如 WeexNativeScript 的“渲染為原生應用”的專案保持與 Vue 的同步更新變得更加容易。

一些討論

  1. 現有的專案該升級嗎
  2. 新增的 Composition API 相容 Vue2,只需要在專案中單獨引入 @vue/composition-api 這個包就可以。
  3. 2.x 的最後一個次要版本將成為 LTS,並在 3.0 釋出後繼續享受 18 個月的 bug 和安全修復更新。
  4. 當前專案生態中的幾個庫都面臨巨大升級,以及升級後的諸多坑要填,比如:vue-router、vuex、ElementUI/ViewUI/AntDesignVue 等
  5. element不更新後,元件庫該怎麼辦