《Vue.js設計與實現》day02:第一章 權衡的藝術

語言: CN / TW / HK

highlight: arduino-light

一、前言

“框架設計裡到處都體現了權衡的藝術。”

1. 為什麼要討論檢視層框架設計?

當我們設計一個框架的時候, 框架本身的各個模組之間並不是相互獨立的,而是相互關聯、相互制約的。

作為框架設計者,一定要對框架對定位和方向擁有全域性的把控, 這樣才能做好後續的模組設計和拆分。

做為學習者,我們在學習框架的時候, 也應該從全域性的角度對框架的設計擁有清晰的認知, 否則容易被細節困住,看不清全貌。

2. 體現“權衡”的藝術

  • 從正規化角度來看, 我們的框架應該設計成命令式還是宣告式?
  • 「命令式」和「宣告式」這兩種正規化有何優缺點?我們能否汲取兩者的優點?
  • 框架要設計成「純執行時」還是「純編譯時」的,甚至是「執行時+編譯時」的呢?
  • 「純執行時」、「純編譯時」、「執行時+編譯時」之間有何差異?有何優缺點?

二、本章內容

1.1 命令式和宣告式

作為框架設計這, 應該對兩種正規化都有足夠的認知,這樣才能做出正確的選擇, 甚至想辦法汲取兩者的優點並將其捏合。

- 獲取id為app的div標籤 - 它的文字內容為 hello world - 為其繫結點選時間 - 當點選時彈出提示:ok

1.1.1 命令式框架的概念

早年流行的jQuery 就是典型的命令是框架。特點:命令式框架的一大特點就是 關注過程。

$('#app')//獲取div .text('hello world')//設定文字內容 .on('click',()=>{alert('ok')}) //繫結點選事件

const div = document.querySelector('#app') //獲取div div.innerText = 'hello world' div.addEventListener('click',()=>{alert('ok')}) //繫結點選事件

以上jQuery和 原生JavaScript當實現中可以看到, 自然語言描述能夠與程式碼產生一一對應的關係,程式碼本身描述的就是“做事兒的過程”。

1.1.2 宣告式框架的概念

與命令式框架更加關注過程不同,宣告式框架更加 關注結果。

```

hello world

```

以上類似HMTL的模版就是Vue.js 實現案例功能的方式。可以看到,提供一個“結果”,至於如何實現這個“結果”,並不關心。實現該“結果”的過程, 則是由Vue.js幫我們完成的。

換句話說:Vue.js是幫我們封裝了過程。

因此,我們可以猜到Vue.js的內部實現一定是「命令式」的,而暴露給使用者的卻「更加宣告式」

1.2 效能與可維護性的權衡

結論先行:宣告式程式碼的效能不優於命令式程式碼的效能。

1.2.1 效能比較

舉例:假設我們要將div標籤的文字內容修改為hello vue3,那麼如何使用命令式程式碼實現呢?

因為可以明確知道修改的是什麼,可以直接呼叫相關命令操作

div.textContent = 'hello vue3' //直接修改

理論上:命令式程式碼可以做到極致的效能優化,因為我們明確知道哪些發生了變更,只做必要的修改就可以啦。

但是宣告式程式碼不一定能做到這一點,因為它描述的是結果:

```

hello world
hello vue3

```

對於框架來說, 為了視線最優的更新效能, 它需要找到前後的差異並只更新變化的地方。但是最終完成這次更新的程式碼仍然是:

div.textContent = 'hello vue3' //直接修改

  • 效能比較:假設定義「直接修改」的效能消耗為A,把「找出差異」的效能消耗定義為B,那麼有:

    • 命令式程式碼的更新效能消耗 = A
    • 宣告式程式碼的更新效能消耗 = B + A

宣告式程式碼會比命令式程式碼多出找出差異的效能消耗,因此最理想的情況是:當找出差異的效能消耗為0的時候, 宣告式程式碼與命令式程式碼的效能相同,但無法做到超越。

畢竟框架本身就是封裝來命令式程式碼才實現面向使用者的宣告式。 得出一開始的效能結論:宣告式程式碼的效能不優於命令式程式碼的效能。

1.2.2 維護性比較

既然在效能層面命令式程式碼是更好的選擇, 那麼為什麼Vue.js要選擇宣告式的設計方案呢?

  • 宣告式程式碼的可維護性更強。
  • 在採用命令式程式碼開發的時候, 需要維護實現目標的整個過程

  • 包括要手動完成DOM元素的建立、更新、刪除等工作

  • 採用宣告式程式碼展示的就是我們要的結果

    • 看上去更加直觀
    • 做事兒的過程,並不需要我們關心
    • 因為Vuejs都封裝好了

以上就體現了我們在框架設計上要做的關於可維護性與效能之間的權衡。 在採用宣告式提升可維護性的同時,效能就會有一定的損失,而框架設計者要做的就是:在保持可維護性的同時讓效能損失最小化。

1.3 虛擬DOM的效能到底如何

前文提到:宣告式程式碼的更新效能消耗 = 找出差異的效能消耗 + 直接修改的效能消耗, 因此,如果能夠最小化找出差異的效能消耗, 就可以讓宣告式程式碼的效能無限接近於命令式程式碼的效能。

所謂的虛擬DOM,就是為了最小化找出差異這一步的效能消耗而出現的。

採用虛擬DOM的更新技術的效能理論上 不可能比原生JavaScript操作DOM更高。為什麼是理論上?

在大部分情況下, 我們很難寫出絕對優化的命令式程式碼, 尤其當應用程式的規模很大的時候,即使寫出啦極致優化的程式碼, 也一定耗費了巨大的精力,投入產出比其實並不高。

1.3.1 最佳選擇是什麼?

有什麼辦法可以看讓我們不需要付出太多的努力(寫宣告式程式碼),還能夠保證程式的效能下限,讓應用程式的效能不至於太差,甚至想辦法逼近命令式程式碼的效能?

  • 其實以上問題,就是虛擬DOM要解決的問題。

思考:

  • 使用innerHTML操作頁面和虛擬DOM相比效能如何?
  • innerHTML和document.createElement等DOM操作方法有何差異?

第一個問題:

為了比較效能,需要了解innerHTML和虛擬DOM的建立、更新頁面的過程。

1、innerHTML建立頁面的過程

`` const html =

...
` //構造一段HTML字串

div.innerHTML = html //將HTML字串賦值給DOM元素的innerHTML屬性 ```

渲染頁面的過程是:先把字串解析成DOM樹(這是一個DOM層面的計算)。

涉及DOM的運算要遠比JavaScript層面的計算效能差。

案例如圖:



第一行是純JavaScript層面的計算,迴圈10000次,每次建立一個JavaScript物件並將其結果新增到陣列中

第二行是DOM操作,每次建立一個DOM元素並將其新增到頁面中。

通過跑分結果顯示,純javascript層面的操作要比DOM操作快的多, 它們不再一個數量級上。

基於這個背景,我們可以用一個公式來表達通過innerHTML建立頁面的效能:

innerHTML建立頁面的效能: HTML字串拼接的計算量+ innerHTML的DOM計算量。

2、虛擬DOM建立頁面的過程

虛擬DOM建立頁面的過程分為兩步:

  • 建立JavaScript物件,這個物件可以理解為真實DOM的描述
  • 第二步是遞迴地遍歷虛擬DOM樹並且、建立真實DOM。

用一個公式來表達:虛擬DOM建立頁面時的效能= 建立JavaScript物件的計算量+建立真實DOM的計算量。

直觀比較innerHTML和虛擬DOM在建立頁面時的效能。



無論是純JavaScript層面的計算,還是DOM層面的計算, 其實兩者差距並不大。 這裡我們從巨集觀角度只看數量級上的差異。如果在同一個數量級,則認為沒有差異。在建立頁面的時候,都需要新建所有DOM元素。

3、innerHTML更新頁面

使用innerHTML更新頁面的過程是 重新構建HTML字串,再重新設定DOM元素的innerHTML屬性。 哪怕只更改了一個文字, 也要重新設定innerHTML屬性。

重新設定innerHTML屬性就等價於銷燬所有舊的DOM元素,再全力量建立新的DOM元素。

4、虛擬DOM更新頁面

它需要重新建立JavaScript物件(虛擬DOM樹),然後比較新舊虛擬DOM,找到變化的元素並更新它。

在更新頁面時

  • 虛擬DOM在JavaScript層面的運算要比建立頁時多一個Diff的效能消耗, 而然它畢竟也是JavaScript層面的運算,所以不會產生數量級的差異。
  • DOM層面的運算, 虛擬DOM在更新頁面時只會更新必要的元素,但innerHTML需要全量更新虛擬DOM的優勢就體現出來了。

  • 影響虛擬DOM的效能因素與影響innerHTML的效能因素不同:

    • 虛擬DOM無論頁面多大,都只會更新變化的內容
    • innerHTML頁面越大,就意味著更新時的效能消耗越大。


粗略總結innerHTML、虛擬DOM以及原生JavaScript在更新頁面時的效能,如下:



從三個維度出發分析:心智負擔、可維護性和效能。

  • 原生DOM操作的方法

    • 心智負擔最大,因為需要手動建立、刪除、修改大量的DOM元素
    • 但它的效能最高,不過為了使其效能最佳,需要曾受巨大的心智負擔。
    • 這種方式編寫程式碼,可維護性也極差。
  • innerHMTL方法

    • 編寫頁面的過程有一部分是通過拼接HTML字串來實現的(一點點接近宣告式的意思),但是拼接字串總歸也有一定的心智負擔
    • 對於事件繫結之類的事情, 還需要通過使用原生JavaScript來處理。
    • innerHTML模版越大,則更新頁面的效能最差, 尤其是在只有少量更新的時候。
  • 虛擬DOM方法

    • 它是宣告式的,因此心智負擔小,可維護性強
    • 效能雖然比不上極致優化的原生JavaScript,但在保證心智負擔和可維護性的前提下是相當不錯的

思考:有沒有辦法做到,既宣告式的描述UI,又具備原生JavaScirpt的效能?

1.4 執行時和編譯時

1、什麼是執行時

舉個例子:假設我們設計了一個框架, 它提供了一個Render函式,使用者可以為該函式提供一個樹型結構的資料物件,然後Render函式會根據該物件遞迴地將資料渲染成DOM元素:

const obj = { tag:'div', children:{ {tag:'span',children:'hello world'} } }

每個物件都有兩個屬性:tag代表標籤名稱,children即可以是一個數組(代表子節點)、也可以直接是一段文字(代表文字子節點)。

``` function Render(obj,root){ const el = document.createElement(obj.tag) if(typeof obj.children === 'string'){ const text = document.createTextNode(obj.children) el.appendChild(text) }else{ //陣列,遞迴呼叫Render函式,使用el作為root引數 obj.children.forEach((child)=>Render(child,el)) }

//將元素新增到root root.appendChild(el) } ```

有了Render函式,使用者就可以如此使用它:

const obj = { tag:'div', children:[ { tag:'span',children:'hello world'} ] } //渲染到body下 Render(obj,document.body)

思考:使用者是如何使用Render函式的?

  • 使用者在使用它渲染內容時,直接為Render函式提供了一個樹型結構的資料物件。
  • 痛點:需要使用者手寫樹型結構的資料物件,會很麻煩,而且不止觀念。而且不能支援用類似於HTML標籤的方式描述樹型結構的資料物件。

這個Render函式,實際上這個框架就是一個純執行時的框架。

2、什麼是執行時+編譯時

為了滿足:支援用類似於HTML標籤的方式描述樹型結構的資料物件。思考,能不能引入編譯的手段。 把HTML標籤編譯成樹型結構的資料物件。這樣就不可以繼續使用Render函數了



因此,需要編寫一個叫做Compiler的程式, 他的作用就是把HTML字串編譯成樹型結構的資料物件,於是交付給使用者去用了。

使用者改如何使用?這也是我們設計框架需要考慮的問題,最簡單方式就是讓使用者分別呼叫Compiler函式和Render函式:

`` const html =

hello world

` //呼叫Compiler編譯得到樹型結構的資料物件 const obj = Compiler(html)

//再呼叫Render進行渲染 Render(obj,document.body) ```

如此,上面這段程式碼就能很好地工作, 我們的框架就變成了 一個執行時+編譯時的框架。****

  • 它既支援執行時,使用者可以直接提供資料物件從而無需編譯
  • 又支援編譯時,使用者可以提供HTML字串,我們將其變異為資料物件後再交給執行時處理。
  • 準確滴說:上面的程式碼其實是執行時編譯, 就是程式碼執行的時候才開始編譯,這會產生一定的效能開銷(缺點)
  • 解決效能開銷問題:在構建的時候就執行Compiler程式將使用者提供的內容編譯好,等執行時就無須編譯了, 這對效能是非常友好的

思考:既然編譯器可以將HTML字串編譯成資料物件, 那麼可不可以直接編譯成命令式程式碼呢?

3、什麼是編譯時



這樣我們就可以只需要一個Compiler函式就可以了, 連Render都不需要了, 此時就變成了一個純編譯時的框架。 因為我們不支援任何執行時內容, 使用者的程式碼通過編譯器編譯後才能執行。

4、優缺點對比

  • 純執行時框架
    • 因為沒有編譯的過程, 因此我們沒辦法分析使用者提供的內容(缺點)
  • 執行時+編譯時

    • 純執行時框架加入了編譯步驟, 我們就可以分析使用者提供的內容。
    • 看看那些內容未來可能會改變, 哪些內容可能永遠不會改變,這樣我們就可以在編譯的時候提取這些資訊,然後將其傳遞給Render函式
    • Render函式得到這些資訊之後,就可以進一步做優化了。
  • 純編譯時框架

  • 他可以分析使用者提供的內容(優點)

    • 由於不需要任何執行時,而是直接編譯成可執行的JavaScript程式碼, 因此效能可能會更好(優點)
    • 但是這種做法有損靈活性,即使用者的內容必須編譯後才能用。(缺點)
    • 純編譯時框架:Svelte,其中真實的效能可能達不到理論的高度

Vue.js3仍然保持了執行時+編譯時的框架,在保證靈活的基礎上能夠儘可能地去優化。當你瞭解到在對Vue3對編譯優化相關內容的時候,你會看到Vue.js3在保留執行時的情況下,其效能甚至不輸純編譯時的框架。

總結

  • 討論命令式和宣告式兩種正規化的差異。(框架設計者要想辦法儘量使效能損耗最小化

  • 命令式更加關注過程

    • 宣告式更加關注結果
    • 命令式在理論上可以做到極致優化,但是使用者要承受巨大的心智負擔
    • 宣告式能夠有效減輕使用者的心智負擔,但效能上有一定的犧牲
  • 討論虛擬DOM的效能。(選擇哪種更新策略,需要結合心智負擔、可維護性等因素綜合考慮)

    • 公式: 宣告式的更新效能消耗 = 找出差異的效能消耗+直接修改的效能消耗
    • 虛擬DOM的意義:使找出差異的效能消耗最小化
    • 原生JavaScript操作DOM的方法(document.createElement)、虛擬DOM和innerHTML三者操作頁面的效能,不可以簡單的下定論。

      • 頁面大小、變更部分的大小都有關係
      • 建立頁面還是更新頁面也有關係
  • 執行行時和編譯時相關知識

    • 純執行時支援的框架的特點

      • 沒有編譯的過程
      • 沒辦法分析使用者提供的內容
    • 純編譯時支援的框架的特點

      • 可以分析使用者提供的內容
      • 不需要任何執行時,直接編譯成可執行的JavaScript程式碼,因此效能可能更好
      • 缺點:有損靈活性,即使用者提供的內容必須編譯後才能用
      • Svelte是純編譯時的框架
    • 執行時+編譯時框架的特點

      • Vue.js3仍然保持 執行時+編譯時的架構
      • 在保持靈活性的基礎上能夠儘可能地去優化
  • Vue.js3是一個 編譯時+執行時的框架

    • 它在保持靈活效能的基礎上,還能夠通過編譯手段分析使用者提供的內容,從而進一步提升更新效能。