從 React 歷史的長河裡聊虛擬DOM及其價值

從 React 歷史的長河裡聊虛擬DOM及其價值

最近我發現很多面試題裡面都有「如何理解虛擬 DOM」這個題,我覺得這個題應該沒有想象中那麼好答,因為很多人沒有真正理解虛擬 DOM 它的價值所在,我這篇從虛擬 DOM 的誕生過程來引出它的價值以及歷史地位,幫助你深入的理解它。

什麼是虛擬 DOM

本質上是 JavaScript 對象,這個對象就是更加輕量級的對 DOM 的描述。

對,就是這麼簡單!

就是一個複雜一點的對象而已,沒什麼好說的,重點是為什麼要有這個東西,以及有了這個描述有什麼好處才是我們今天要介紹的內容。

為什麼要有虛擬 DOM

再談為什麼要用虛擬 DOM 之前,先來聊一聊 React 是怎麼誕生的,畢竟在瞭解歷史背景,再去思考他的誕生,就知道是必然會出現的。

再查了很多關於 React 的歷史相關的文章,這篇文章我感覺比較值得令我信服:React 是怎樣煉成的

[1]

眾所周知,Facebook 是 PHP 大戶,所以 React 最開始的靈感就來至於 PHP。

字符串拼接時代 - 2004

在 2004 年這個時候,大家都還在用 PHP 的字符串拼接來開發網站:

<code>$str = '
    ';
    foreach ($talks as $talk) {
    $str += '
  • ' . $talk->name . '
  • ';
    }
    $str += '
';
/<code>

這種方式代碼寫出來不好看不說,還容易造成 XSS 等安全問題。

應對方法是對用戶的任何輸入都進行轉義(Escape)。但是如果對字符串進行多次轉義,那麼反轉義的次數也必須是相同的,否則會無法得到原內容。如果又不小心把 HTML 標籤(Markup)給轉義了,那麼 HTML 標籤會直接顯示給用戶,從而導致很差的用戶體驗。

XHP 時代 - 2010

到了 2010 年,為了更加高效的編碼,同時也避免轉義 HTML 標籤的錯誤,Facebook 開發了 XHP 。XHP 是對 PHP 的語法拓展,它允許開發者直接在 PHP 中使用 HTML 標籤,而不再使用字符串。

<code>$content = 
    ;
    foreach ($talks as $talk) {
    $content->appendChild(
  • {$talk->name}
  • );
    }
    /<code>

這樣的話,所有的 HTML 標籤都使用不同於 PHP 的語法,我們可以輕易的分辨哪些需要轉義哪些不需要轉義。

不久的後來,Facebook 的工程師又發現他們還可以創建自定義標籤,而且通過組合自定義標籤有助於構建大型應用。

JSX - 2013

到了 2013 年,前端工程師 Jordan Walke 向他的經理提出了一個大膽的想法:把 XHP 的拓展功能遷移到 JS 中。首要任務是需要一個拓展來讓 JS 支持 XML 語法,該拓展稱為 JSX。因為當時由於 Node.js 在 Facebook 已經有很多實踐,所以很快就實現了 JSX。

可以猜想一下為什麼要遷移到 js 中,我猜想應該是前後端分離導致的。

<code>const content = (
<talklist>
{ talks.map(talk => <talk>)}
/<talklist>
);
/<code>

React

在這個時候,就有另外一個很棘手的問題,那就是在進行更新的時候,需要去操作 DOM,傳統 DOM API 細節太多,操作複雜,所以就很容易出現 Bug,而且代碼難以維護。

然後就想到了 PHP 時代的更新機制,每當有數據改變時,只需要跳到一個由 PHP 全新渲染的新頁面即可。

從開發者的角度來看的話,這種方式開發應用是非常簡單的,因為它不需要擔心變更,且界面上用戶數據改變時所有內容都是同步的。

為此 React 提出了一個新的思想,即始終整體“刷新”頁面

當發生前後狀態變化時,React 會自動更新 UI,讓我們從複雜的 UI 操作中解放出來,使我們只需關於狀態以及最終 UI 長什麼樣。

下面看看局部刷新和整體刷新的區別。

从 React 历史的长河里聊虚拟DOM及其价值

圖片來自於極客時間王沛老師的《React 進階與實戰》

局部刷新:

<code>// 下面是偽代碼
var ul = find(ul) // 先找到 ul
ul.append(`
  • ${message3}
  • `) //然後再將message3插到最後

    // 想想如果是不插到最後一個,而是插到中間的第n個
    var ul = find(ul) // 先找到 ul
    var preli = find(li(n-1)) // 再找到 n-1 的一個 li
    preli.next(`
  • ${message3}
  • `) // 再插入到 n-1 個的後面
    /<code>

    整體刷新:

    <code>UI = f(messages) // 整體刷新 3 條消息,只需要調用 f 函數

    // 這個是在初始渲染的時候就定義好的,更新的時候不用去管
    function f(messages) {
    return

      {messages.map(message =>
    • { message }
    • )}

    }
    /<code>

    這個時候,我只需要關係我的狀態(數據是什麼),以及 UI 長什麼樣(佈局),不再需要關係操作細節。

    這種方式雖然簡單粗暴,但是很明顯的缺點,就是很慢。

    另外還有一個問題就是這樣無法包含節點的狀態。比如它會失去當前聚焦的元素和光標,以及文本選擇和頁面滾動位置,這些都是頁面的當前狀態。

    Diff

    為了解決上面說的問題,對於沒有改變的 DOM 節點,讓它保持原樣不動,僅僅創建並替換變更過的 DOM 節點。這種方式實現了 DOM 節點複用(Reuse)。

    至此,只要能夠識別出哪些節點改變了,那麼就可以實現對 DOM 的更新。於是問題就轉化為如何比對兩個 DOM 的差異

    說道對比差異,可能很容易想到版本控制(git)。

    DOM 是樹形結構,所以 diff 算法必須是針對樹形結構的。目前已知的完整樹形結構 diff 算法複雜度為 O(n^3) 。

    完整的 Tree diff 實現算法。[2]

    但是時間複雜度 O(n^3) 太高了,所以 Facebook 工程師考慮到組件的特殊情況,然後將複雜度降低到了 O(n)。

    附:詳細的 diff 理解:不可思議的 react diff 。[3]

    Virtual DOM

    前面說到,React 其實實現了對 DOM 節點的版本控制。

    做過 JS 應用優化的人可能都知道,DOM 是複雜的,對它的操作(尤其是查詢和創建)是非常慢非常耗費資源的。看下面的例子,僅創建一個空白的 div,其實例屬性就達到 231 個。

    <code>// Chrome v63
    const div = document.createElement('div');
    let m = 0;
    for (let k in div) {
    m++;
    }
    console.log(m); // 231
    /<code>

    對於 DOM 這麼多屬性,其實大部分屬性對於做 Diff 是沒有任何用處的,所以如果用更輕量級的 JS 對象來代替複雜的 DOM 節點,然後把對 DOM 的 diff 操作轉移到 JS 對象,就可以避免大量對 DOM 的查詢操作。這個更輕量級的 JS 對象就稱為 Virtual DOM 。

    那麼現在的過程就是這樣:

    1. 維護一個使用 JS 對象表示的 Virtual DOM,與真實 DOM 一一對應

    2. 對前後兩個 Virtual DOM 做 diff ,生成變更(Mutation)

    3. 把變更應用於真實 DOM,生成最新的真實 DOM

    可以看出,因為要把變更應用到真實 DOM 上,所以還是避免不了要直接操作 DOM ,但是 React 的 diff 算法會把 DOM 改動次數降到最低。

    剩下的歷史就不談了,已經引出這篇文章的重點:虛擬 DOM。詳細的歷史可見:React 是怎樣煉成的[4],文中歷史部分內容很多摘抄與此。

    總結

    傳統前端的編程方式是命令式的,直接操縱 DOM,告訴瀏覽器該怎麼幹。這樣的問題就是,大量的代碼被用於操作 DOM 元素,且代碼可讀性差,可維護性低。

    React 的出現,將命令式變成了聲明式,摒棄了直接操作 DOM 的細節,只關注數據的變動,DOM 操作由框架來完成,從而大幅度提升了代碼的可讀性和可維護性。

    在初期我們可以看到,數據的變動導致整個頁面的刷新,這種效率很低,因為可能是局部的數據變化,但是要刷新整個頁面,造成了不必要的開銷。

    所以就有了 Diff 過程,將數據變動前後的 DOM 結構先進行比較,找出兩者的不同處,然後再對不同之處進行更新渲染。

    但是由於整個 DOM 結構又太大,所以採用了更輕量級的對 DOM 的描述—虛擬 DOM。

    不過需要注意的是,虛擬 DOM 和 Diff 算法的出現是為了解決由命令式編程轉變為聲明式編程、數據驅動後所帶來的性能問題的。換句話說,直接操作 DOM 的性能並不會低於虛擬 DOM 和 Diff 算法,甚至還會優於。

    這麼說的原因是因為 Diff 算法的比較過程,比較是為了找出不同從而有的放矢的更新頁面。但是比較也是要消耗性能的。而直接操作 DOM 就是有的放矢,我們知道該更新什麼不該更新什麼,所以不需要有比較的過程。所以直接操作 DOM 效率可能更高。

    React 厲害的地方並不是說它比 DOM 快,而是說不管你數據怎麼變化,我都可以以最小的代價來進行更新 DOM。方法就是我在內存裡面用新的數據刷新一個虛擬 DOM 樹,然後新舊 DOM 進行比較,找出差異,再更新到 DOM 樹上。

    框架的意義在於為你掩蓋底層的 DOM 操作,讓你用更聲明式的方式來描述你的目的,從而讓你的代碼更容易維護。沒有任何框架可以比純手動的優化 DOM 操作更快,因為框架的 DOM 操作層需要應對任何上層 API 可能產生的操作,它的實現必須是普適的。

    如果你想了解更多的虛擬 DOM 與性能的關係,請看下面公眾號裡面的兩篇文章和那個知乎話題,會讓你對虛擬 DOM 又更深層次的理解。

    • 別再說虛擬 DOM 快了,要被打臉的

    • 深入理解虛擬 DOM,它真的不快

    • 網上都說操作真實 DOM 慢,但測試結果卻比 React 更快,為什麼[5]

    另外再提一個點,很多人會把 Diff 、數據更新、提升性能等概念綁定起來,但是你想想這個問題:React 由於只觸發更新,而不能知道精確變化的數據,所以需要 diff 來找出差異然後 patch 差異隊列。Vue 採用數據劫持的手段可以精準拿到變化的數據,為什麼還要用虛擬 DOM?

    虛擬 DOM 的作用

    要想回答上面那個問題,真的不要僅僅以為虛擬 DOM 或者 React 是來解決性能問題的,好處可還有很多呢。下面我總結了一些虛擬 DOM 好作用。

    • Virtual DOM 在犧牲(犧牲很關鍵)部分性能的前提下,增加了可維護性,這也是很多框架的通性。

    • 實現了對 DOM 的集中化操作,在數據改變時先對虛擬 DOM 進行修改,再反映到真實的 DOM 中,用最小的代價來更新 DOM,提高效率(提升效率要想想是跟哪個階段比提升了效率,別隻記住了這一條)。

    • 打開了函數式 UI 編程的大門。

    • 可以渲染到 DOM 以外的端,使得框架跨平臺,比如 ReactNative,React VR 等。

    • 可以更好的實現 SSR,同構渲染等。這條其實是跟上面一條差不多的。

    • 組件的高度抽象化。

    既然虛擬 DOM 有這麼多作用,那麼上面的問題,Vue 採用虛擬 DOM 的原因是什麼呢?

    Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染過程抽象化了,從而使得組件的抽象能力也得到提升,並且可以適配 DOM 以外的渲染目標。來自尤大文章:Vue 的理念問題[6]

    虛擬 DOM 的缺點

    • 首次渲染大量 DOM 時,由於多了一層虛擬 DOM 的計算,會比 innerHTML 插入慢。

    • 虛擬 DOM 需要在內存中的維護一份 DOM 的副本(更上面一條其實也差不多,上面一條是從速度上,這條是空間上)。

    • 如果虛擬 DOM 大量更改,這是合適的。但是單一的,頻繁的更新的話,虛擬 DOM 將會花費更多的時間處理計算的工作。所以,如果你有一個 DOM 節點相對較少頁面,用虛擬 DOM,它實際上有可能會更慢。但對於大多數單頁面應用,這應該都會更快。

    總結

    本文在介紹虛擬 DOM 並沒有像其他文章一樣去解釋它的實現以及相關的 Diff 算法,關於 Diff 算法可以看這篇 虛擬 DOM 到底是什麼?文中介紹了很多庫的 diff 算法,可見其實 React 的 diff 算法並不算太快。

    而是通過歷史來得出他的價值體現,從歷史怎麼看大牛們是怎麼一步一步的去解決問題,從歷史中看為什麼別人能做出這麼偉大的東西,而我們不能?

    每個偉大的產品都會有非常多的背景支持,都是一步一步發展而來的。

    另外洗清了一個錯誤觀念:很多人認為虛擬 DOM 最大的優勢是 diff 算法,減少 JavaScript 操作真實 DOM 的帶來的性能消耗。

    雖然這一個虛擬 DOM 帶來的一個優勢,但並不是全部。虛擬 DOM 最大的優勢在於抽象了原本的渲染過程,實現了跨平臺的能力,而不僅僅侷限於瀏覽器的 DOM,可以是安卓和 IOS 的原生組件,可以是近期很火熱的小程序,也可以是各種 GUI。

    最後希望大家多思考,跟隨者浪潮站在浪潮之巔。

    參考鏈接

    • 虛擬 DOM 為何出現以及性能問題?[7]

    • React 是怎樣煉成的[8]

    [1]

    React 是怎樣煉成的: https://segmentfault.com/a/1190000013365426#item-4

    [2]

    完整的 Tree diff 實現算法。: https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf

    [3]

    不可思議的 react diff 。: https://zhuanlan.zhihu.com/p/20346379

    [4]

    React 是怎樣煉成的: https://segmentfault.com/a/1190000013365426

    [5]

    網上都說操作真實 DOM 慢,但測試結果卻比 React 更快,為什麼: https://www.zhihu.com/question/31809713

    [6]

    Vue 的理念問題: https://zhuanlan.zhihu.com/p/23752826

    [7]

    虛擬DOM為何出現以及性能問題?: https://blog.csdn.net/hjc256/article/details/97135687

    [8]

    React 是怎樣煉成的: https://segmentfault.com/a/1190000013365426


    分享到:


    相關文章: