淺析前端框架如何更新檢視

pswyjz發表於2021-09-09

目錄

一、緣起
二、Prototype 與 jQuery
  Prototype
  jQuery
三、模板引擎
  實現原理
  jquery.tmpl
四、Virtual DOM
  簡史
  初探
  傳統 diff
  React
  Vue

一、緣起

1994 年,網景公司成立,釋出了第一款商業瀏覽器 Navigator。之後,微軟也推出了自家的 IE 瀏覽器。

同年,W3C 小組成立,負責 HTML 的發展路徑,其宗旨是促進通用協議的發展。

之後的 1995 年,JavaScript 誕生了。

有傳聞說是網景工程師布蘭登·艾克(Brendan Eich)只花了 10 天時間就設計出來的。但也因為工期太短的緣故,還存在許多瑕疵,因此一直被 “正統” 傳序員所嫌棄。

早期 JavaScript 沒有包管理機制,也沒有像 Java、C++ 那樣的打輔助用的 SDK,內建的方法也很少。

還有就是效能問題,關於使用 eval 還是 Function,使用哪種迴圈方式,該用parseInit 還是 ~~ 等等的討論都是為了提升那一點點的效能。

JavaScript 主要語言特徵:

  • 借鑑 C 語言的基本語法;

  • 借鑑 Java 語言的資料型別和記憶體管理;

  • 借鑑 Scheme 語言,將函式提升到"第一等公民"(first-class citizen)的地位;

  • 借鑑 Self 語言,使用基於原型(Prototype)的繼承機制。

圖片描述

二、Prototype 與 jQuery

Prototype、jQuery 等 js 庫的出現,在完善 JavaScript 的語言特性的同時也提高了 JavaScript 的效能。

這兩個 js 庫均採用直接操作 Dom 的方式更新頁面。

Prototype

這裡說的 Prototype 不是我們現在熟知的物件的原型,而是一個名為 Prototype 的 js 基礎類庫。由 Ruby 語言的大牛 Sam Stephenson 所寫。

在 prototype.js 中,prototype 物件是實現物件導向的一個重要機制。同時 Prototype 還創造了 Function.prototype.bind,並在陣列上增加了一大堆方法,其中很多方法也已經被標準化了。

jQuery

2006 年,jQuery 釋出。jQuery 發掘出大量的 DOM/BOM 相容方案,解決了最頭疼的瀏覽器相容性問題。

2009 年,jQuery 成功研發出 Sizzle 選擇器引擎,使其在效能上力壓一眾競品,Dojo、Prototype、ExtJS、MooTools 等。同時在處理 DOM 相容上,發掘出大量的 DOM/BOM 相容方案。

jQuery 以 DOM 為中心,開發者可以選一個或多個 DOM,轉變為 jQuery 物件,然後進行 鏈式操作。

開發者們已開始注重前後端分離,並要求不能汙染 Object 原型物件,不能汙染 window 全域性變數。jQuery 僅佔用兩個全域性變數。jQuery 精巧的原始碼實現使其大小壓縮後不到 30KB,網上湧現出大量關於其原始碼詳解的書藉。

jQuery 的出現也大大降低了前端門檻,讓更多人進入了這個行業,我也是透過 jQuery 入的前端這個坑。

當時還有不少段子,“超市收銀員邊工作邊看前端書籍,一個月後就直接去網際網路公司做前端了”,諸如此類。

三、模板引擎

實現原理

在我們使用 jQuery 時需要解決大段 HTML 的生成問題,雖然有 $.html$.append$before 等方法,但是為了更好地管理不同的 HTML,我們想將 HTML 分離出來,讓 HTML 獨立到不同的檔案中,然後直接插資料。

1994 年 PHP 誕生,實現了將動態內容嵌入 HTML,提升了編寫效率和可讀性,其界定符、迴圈語句等的發明,直接或間接地影響了 JavaScript 前端模板引擎的出現。

模板引擎可以簡單用一個公式裡描述:HTML = template(vars)

模板引擎的實現需要解決 模板存放、模板獲取、模板解析編譯 的問題

  • 模板存放:模板一般放置在 textarea/input 等表單控制元件,或 script 等標籤中

  • 模板獲取:通常會給模板設定 id,透過 id 獲取

  • 模板解析編譯

    • 需要將模板中的 JS 語句和 html 分離出來,不同模板引擎所用的分隔符也不太一樣,常見的有 {{...}} 或是

    • 透過區別一些特殊符號比如 each、if 等來將字串拼接成函式式的字串,將兩部分各自經過處理後,再次拼接到一起

    • 最後將拼接好的字串採用 new Function() 的方式轉化成所需要的函式。

jquery.tmpl

這裡以 jquery.tmpl 為例,先來個小栗子

...

 

<!-- 模板1,測試${}、{{=}}標籤的使用 --&gt

 

    ${id}

    {{= name}}

    ${age}

    ${number}

 

  //手動初始化資料

  var users = [

    { id: 1, name: "xiaoming", age: 12, number: "001" },

    { id: 2, name: "xiaowang", age: 13, number: "002" },

  ];

  //呼叫模板進行渲染

  $("#demo").tmpl(users).appendTo("#div_demo");

...



jquery.tmpl 使用的模板存放於 id 為 demo 的 script 標籤內

模板的讀取依靠 jQuery 的選擇器,直接以模板為主體,呼叫 tmpl 解析資料,呼叫 jQuery 自帶的 appendTo 方法插入到父節點中

這裡模板的解析結合原始碼看一下 

function buildTmplFn(markup) {

  return new Function(

    "jQuery",

    "$item",

    "var $=jQuery,call,__=[],$data=$item.data;" +

      "with($data){__.push('" +

      jQuery

        .trim(markup) // 去前後空格

        .replace(/([\'])/g, "\$1") // 替換單引號

        .replace(/[rtn]/g, " ") // 替換掉換行、退格符

        .replace(/${([^}]*)}/g, "{{= $1}}") // 將 {{}} 語法通通換成 {{= }} 語法

        .replace(

          /{{(/?)(w+|.)(?:(((?:[^}]|}(?!}))*?)?))?(?:s+(.*?)?)?((((?:[^}]|}(?!}))*?)))?s*}}/g,

          function (all, slash, type, fnargs, target, parens, args) {

            ...

            return (

              "');" +

              tag[slash ? "close" : "open"]

                .split("$notnull_1")

                .join(

                  target

                    ? "typeof(" +

                        target +

                        ")!=='undefined' && (" +

                        target +

                        ")!=null"

                    : "true"

                )

                .split("$1a")

                .join(exprAutoFnDetect)

                .split("$1")

                .join(expr)

                .split("$2")

                .join(fnargs || def.$2 || "") +

              "__.push('"

            );

          }

        ) +

      "');}return __;"

  );

}


buildTmplFn 也是透過處理模板字元,最終生成一個可執行的函式。模板的解析依靠正則實現,程式碼雖少但卻實現了十分強大的模板能力。

最後返回的函式的函式體如下

var $ = jQuery,

  call,

  __ = [],

  $data = $item.data;

with ($data) {

  // === buildTmplFn 最後一個替換生成如下程式碼====

  __.push('

       ');

  if (typeof id !== "undefined" && id != null) {

    __.push($.encode(typeof id === "function" ? id.call($item) : id));

  }

  __.push('

       ');

  if (typeof name !== "undefined" && name != null) {

    __.push($.encode(typeof name === "function" ? name.call($item) : name));

  }

  __.push('

       ');

  if (typeof age !== "undefined" && age != null) {

    __.push($.encode(typeof age === "function" ? age.call($item) : age));

  }

  __.push('

       ');

  if (typeof number !== "undefined" && number != null) {

    __.push(

      $.encode(typeof number === "function" ? number.call($item) : number)

    );

  }

  __.push(

    "

     
"

    // =======

  );

}

return __;


最後生成的函式被執行,輸出帶有資料的 html 字串,再插入到指定父節點中。

模板引擎更新檢視的方式即 替換指定 Dom 元素的所有子節點。

當然也存在其弊端,有部分的替換會引起 迴流。並且如果只是修改個別資料,使用模板時需要重新渲染整片區域,這是沒有必要的,也是耗效能的。

四、Virtual DOM

簡史

時間來到 2009 年 NodeJs 誕生,隨著 NodeJS 的發展冒出一大堆模組、路由、狀態管理、資料庫、MVC 框架(Backbone.js 也屬於 MVC 框架,強依賴於 jQuery)

之後大公司開始入局,MVVM 框架出現,比較有代表性的如:谷歌的 Angular,微軟的 Knockout.js,蘋果的 Ember.js,Facebook 的 React。

MVVM 的檢視模型是一個值轉換器,包括四個部分:

  • 模型 模型是指代表真實狀態內容的領域模型(物件導向),或指代表內容的資料訪問層(以資料為中心)。

  • 檢視 就像在 MVC 和 MVP 模式中一樣,檢視是使用者在螢幕上看到的結構、佈局和外觀(UI)。

  • 檢視模型 檢視模型是暴露公共屬性和命令的檢視的抽象。MVVM 沒有 MVC 模式的控制器,也沒有 MVP 模式的 presenter,有的是一個繫結器。在檢視模型中,繫結器在檢視和資料繫結器之間進行通訊。

  • 繫結器 宣告性資料和命令繫結隱含在 MVVM 模式中。在 Microsoft 解決方案堆中,繫結器是一種名為 XAML 的標記語言。繫結器使開發人員免於被迫編寫樣板式邏輯來同步檢視模型和檢視。在微軟的堆之外實現時,宣告性資料繫結技術的出現是實現該模式的一個關鍵因素。


    圖片描述    
    圖片來源:維基百科
大公司的介入,無疑給開發者帶來巨大影響,畢竟 “迷信” 大公司是這一行的老傳統了,jQuery 因為沒有大公司支撐很快就被邊緣化了。

2013 Facebook 將 React 開源,支援 JSX 語法,一開始這種寫法讓人難以接受,在 2017 年 Facebook 推出 React Native,人們才開始接受 JSX 這種寫法,也開始研究其背後的 虛擬 DOM 技術。
(由於 JSX 需要額外編譯,又間接促成了 Babel 與 webpack 的壯大)

谷歌在釋出 Angular 時,同時釋出了一個名為 Polymer 的框架,使用 Web Components 的瀏覽器自定義元件技術;雖然這個框架最後沒火起來,但是它將 Script、Style、Template 三種內容混在一個檔案的設計,成功啟發了一個留美華人,搞出了 Vue.js,這人就是 尤雨溪。

圖片描述    
    打成共識.jpg

最後提一下國內的特色終端——小程式

  • 底層執行的迷你 React 的虛擬 DOM

  • 內建元件是使用 Web Component

  • API 來源於 Hybird 的橋方法

  • 打包使用 webpack

  • 除錯臺是 Chrome console 的簡化版

  • WXML、WXSS 的語法高亮也應該是 webpack 或 VS Code 的外掛

  • 模組機制是 Node.js 的 CommonJS

(為了方便介紹,後文將使用 VD 指代 Virtual DOM)

初探

本質上來說,VD 只是一個簡單的 JS 物件,基礎屬性包括 標籤名(tag)、屬性(props) 和 子元素物件(children)。不同的框架對這三個屬性的命名會有點差別,但表達的意思基本是一致的。

以下是 Vue 中的 VD 結構

export default class VNode {

  tag: string | void;

  data: VNodeData | void;

  children: ?Array;

  text: string | void;

  elm: Node | void;

  ns: string | void;

  context: Component | void; // rendered in this component's scope

  key: string | number | void;

  componentOptions: VNodeComponentOptions | void;

  componentInstance: Component | void; // component instance

  parent: VNode | void; // component placeholder node


  // strictly internal

  ...

}


下面是擷取的 React 的 VD 結構,也就是 Fiber

export type Fiber = {

  tag: WorkTag,

  key: null | string,

  elementType: any,

  type: any,

  stateNode: any,

  return: Fiber | null,

  child: Fiber | null,

  sibling: Fiber | null,

  index: number,

  ...

|};


兩邊都存在 tag 屬性,不同的地方是 Vue 中子節點存放於 children 中,而 React 透過 child 指向子節點,如果存在多個子節點,子節點再透過 sibling 屬性連線上其餘的子節點,如下圖

圖片描述

VD 與 Dom 物件具有一一對應的關係,藉助 VD 將頁面的狀態抽象為 JS 物件,再配合不同的渲染工具,即可達到 跨平臺渲染 的效果。

在進行頁面更新的時候真實 DOM 的改變可以藉助 VD 在記憶體中進行比較,也就是常說的 diff。

傳統 diff

使用 VD 的框架,一般的設計思路都是 頁面等於頁面狀態的對映,即 UI = render(state)。當需要更新頁面的時候,無需關心 DOM 具體的變換方式,只需要改變 state 即可,剩下的事情(render)將由框架代勞。

當 state 發生變化時,我們重新生成整個 VD ,觸發比較的操作。
上述過程大致可分為以下四步:

  • state 變化,生成新的 VD

  • 比較新舊 VD 的異同

  • 生成差異物件(patch)

  • 遍歷差異物件並更新 DOM

這裡我們講一下傳統 diff 演算法,就是將新舊兩棵 VD 樹的節點依次進行對比,最後再進行真實節點的更新。

圖片描述

如上圖所示,左側新 VD 上的節點需要一一與右側舊 VD 的節點對比。為了後續方便計算時間複雜度,我們假設理想狀況下新 VD 樹的節點個數與舊 VD 樹的節點個數都為 n。

很多文章都會直接告訴你,傳統 diff 演算法時間複雜度為 O(n^3),至於為什麼,那是眾說紛紜,這個說法的出處已經無從考證(有了解的小夥伴歡迎留言或私信)

圖片描述    

疑惑.jpg


有兩種普遍的說法:

  • 第一種是常規思路

    • 新 VD 樹任一節點與舊 VD 樹節點對比,時間複雜度為 O(n)

    • 而新 VD 樹有 n 個節點,因此對比完新舊兩棵樹的所有節點,時間複雜度為 O(n^2)

    • 遍歷完成後得到兩棵樹的差異物件,嚴格來講這裡還涉及到最小距離轉換(transform cost)問題,這裡我們可以簡單理解為遍歷舊 VD 樹(當前真實節點)完成更新操作;最終時間複雜度為 O(n^3)

  • 第二種就複雜了,涉及到兩棵樹的編輯距離問題,講從 1979 到 2011,將樹的編輯距離演算法的時間複雜度降到了 O(n^3)

最後說一下我的看法,我認為 O(n^3)這個值應該是取的早期主流 diff 演算法的時間複雜度的均值,畢竟我們也不知道所謂的傳統 diff 演算法到底長什麼樣,哪些演算法能被稱為傳統 diff 演算法。

React

不打算展開,實在是篇幅不允許,後面再單獨出一篇 React diff 演算法的。

React 的 diff 演算法有個核心思路,即:結合框架的事務機制將多次比較的結果合併後一次性更新到頁面,從而有效地減少頁面渲染的次數,提高渲染效率

Vue

這個也不打算展開,理由同上,Vue 還有個 3.0 版,更有的聊了。

Vue 的 diff 演算法採用多指標(這裡指索引下標非記憶體地址),有的文章說雙指標,其實不止,嚴格來講有四個指標:

  • 新 VD 佇列的隊首

  • 新 VD 佇列的隊尾

  • 舊 VD 佇列的隊首

  • 舊 VD 佇列的隊尾

首尾兩個指標向中心移動,藉助原生 JS 的內建方法,“實時” 地更新真實節點

同時與 React 一樣,採用 key 來提升演算法效率,藉助 map 以空間換時間來降低 diff 演算法的時間複雜度

這些介紹都比較籠統,順手點個關注,來蹲一下 React/Vue diff 演算法的解析?

圖片描述


作者:Hlianfa


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/755/viewspace-2797393/,如需轉載,請註明出處,否則將追究法律責任。

相關文章