React 是怎樣煉成的

segmentfault發表於2018-03-04

  本文主要講述 React 的誕生過程和優化思路。

  內容整理自 2014 年的 OSCON - React Architecture by vjeux,雖然從今天(2018)來看可能會有點歷史感,但仍然值得學習瞭解。以史為鑑,從中也可以管窺 Facebook 優秀的工程管理文化。

 字元拼接時代 - 2004

  時間回到 2004 年,Mark Zuckerberg 當時還在宿舍搗鼓最初版的 Facebook 。

  這一年,大家都在用 PHP 的字串拼接(String Concatenation)功能來開發網站。

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

  這種網站開發方式在當時看來是非常正確的,因為不管是後端開發還是前端開發,甚至根本沒有開發經驗,都可以使用這種方式搭建一個大型網站。

  唯一不足的是,這種開發方式容易造成 XSS 注入等安全問題。如果 $talk->name 中包含惡意程式碼,而又沒有做任何防護措施的話,那麼攻擊者就可以注入任意 JS 程式碼。於是就催生了“永遠不要相信使用者的輸入”的安全守則。

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

 XHP 時代 - 2010

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

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

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

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

  而這恰恰是 Semantic Web 和 Web Components 概念的一種實現方式。

$content = <talk:list />;
foreach ($talks as $talk) {
  $content->appendChild(<talk talk={$talk} />);
}

  之後,Facebook 在 JS 中嘗試了更多的新技術方式以減小客戶端和服務端之間的延時。比如跨瀏覽器 DOM 庫和資料繫結,但是都不是很理想。

 JSX - 2013

  等到 2013 年,突然有一天,前端工程師 Jordan Walke 向他的經理提出了一個大膽的想法:把 XHP 的擴充功能遷移到 JS 中。最開始大家都以為他瘋了,因為這與當時大家都看好的 JS 框架格格不入。不過他最終還是執著地說服了經理,允許他用 6 個月的時間來驗證這個想法。這裡不得不說 Facebook 良好的工程師管理哲學讓人敬佩,值得借鑑。

附:Lee Byron 談 Facebook 工程師文化:Why Invest in Tools

  要想把 XHP 的擴充功能遷移到 JS ,首要任務是需要一個擴充來讓 JS 支援 XML 語法,該擴充稱為 JSX 。當時,隨著 Node.js 的興起,Facebook 內部對於轉換 JS 已經有相當多的工程實踐了。所以實現 JSX 簡直輕而易舉,僅僅花費了大概一週的時間。

const content = (
  <TalkList>
    { talks.map(talk => <Talk talk={talk} />)}
  </TalkList>
);

 React

  自此,開始了 React 的萬里長征,更大的困難還在後頭。其中,最棘手的是如何再現 PHP 中的更新機制。

  在 PHP 中,每當有資料改變時,只需要跳到一個由 PHP 全新渲染的新頁面即可。

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

  只要有資料變更,就重新渲染整個頁面。

  雖然簡單粗暴,但是這種方式的缺點也尤為突出,那就是它非常慢。

  “You need to be right before being good”,意思是說,為了驗證遷移方案的可行性,開發者必須快速實現一個可用版本,暫時不考慮效能問題。

  DOM

  取自於 PHP 的靈感,在 JS 中實現重新渲染的最簡單辦法是:當任何內容改變時,都重新構建整個 DOM,然後用新 DOM 取代舊 DOM 。

  這種方式是可以工作的,但在有些場景下不適用。

  比如它會失去當前聚焦的元素和游標,以及文字選擇和頁面滾動位置,這些都是頁面的當前狀態。

  換句話來說,DOM 節點是包含狀態的。

  既然包含狀態,那麼記下舊 DOM 的狀態然後在新 DOM 上還原不就行了麼?

  但是非常不幸,這種方式不僅實現起來複雜而且也無法覆蓋所有情況。

  在 OSX 電腦上滾動頁面時,會伴隨著一定的滾動慣性。但是 JS 並沒有提供相應的 API 來讀取或者寫入滾動慣性。

  對包含 iframe 的頁面來說,情況則更復雜。如果它來自其他域,那麼瀏覽器安全策略限制根本不會允許我們檢視其內部的內容,更不用說還原了。

  因此可以看出,DOM 不僅僅有狀態,它還包含隱藏的、無法觸達的狀態。

  既然還原狀態行不通,那就換一種方式繞過去。

  對於沒有改變的 DOM 節點,讓它保持原樣不動,僅僅建立並替換變更過的 DOM 節點。

  這種方式實現了 DOM 節點複用(Reuse)。

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

  Diff

  說到對比差異,相信大家馬上就能聯想到版本控制(Version Control)。它的原理很簡單,記錄多個程式碼快照,然後使用 diff 演算法比對前後兩個快照,從而生成一系列諸如“刪除 5 行”、“新增 3 行”、“替換單詞”等的改動;通過把這一系列的改動應用到先前的程式碼快照就可以得到之後的程式碼快照。

  而這正是 React 所需要的,只不過它的處理物件是 DOM 而不是文字檔案。

  難怪有人說:“I tend to think of React as Version Control for the DOM” 。

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

  假如頁面中有 10,000 個 DOM 節點,這個數字看起來很龐大,但其實並不是不可想象。為了計算該複雜度的數量級大小,我們還假設在一個 CPU 週期我們可以完成單次對比操作(雖然不可能完成),且 CPU 主頻為 1 GHz 。這種情況下,diff 要花費的時間如下:

  整整有 17 分鐘之長,簡直無法想象!

  雖然說驗證階段暫不考慮效能問題,但是我們還是可以簡單瞭解下該演算法是如何實現的。

附:完整的 Tree diff 實現演算法

  1. 新樹上的每個節點與舊樹上的每個節點對比
  2. 如果父節點相同,繼續迴圈對比子樹

  在上圖的樹中,依據最小操作原則,可以找到三個巢狀的迴圈對比。

  但如果認真思考下,其實在 Web 應用中,很少有移動一個元素到另一個地方的場景。一個例子可能的是拖拽(Drag)並放置(Drop)元素到另一個地方,但它並不常見。

  唯一的常用場景是在子元素之間移動元素,例如在列表中新增、刪除和移動元素。既然如此,那可以僅僅對比同層級的節點。

  如上圖所示,僅對相同顏色的節點做 diff ,這樣能把時間複雜度降到了 O(n^2) 。

  key

  針對同級元素的比較,又引入了另一個問題。

  同層級元素名稱不同時,可以直接識別為不匹配;相同時,卻沒那麼簡單了。

  假如在某個節點下,上一次渲染了三個 <input />,然後下一次渲染變成了兩個。此時 diff 的結果會是什麼呢?

  最直觀的結果是前面兩個保持不變,刪除第三個。

  當然,也可以刪除第一個同時保持最後兩個。

  如果不嫌麻煩,還可以把舊的三個都刪除,然後新增兩個新元素。

  這說明,對於相同標籤名稱的節點,我們沒有足夠資訊來對比前後差異。

  如果再加上元素的屬性呢?比如 value ,如果前後兩次標籤名稱和 value 屬性都相同,那麼就認為元素匹配中,無須改動。但現實是這行不通,因為使用者輸入時值總是在變,會導致元素一直被替換,導致失去焦點;;更糟糕的是,並不是所有 HTML 元素都有這個屬性。

  那使用所有元素都有的 id 屬性呢?這是可以的,如上圖,我們可以容易的識別出前後 DOM 的差異。考慮表單情況,表單模型的輸入通常跟 id 關聯,但如果使用 AJAX 來提交表單的話,我們通常不會給 input 設定 id 屬性。因此,更好的辦法是引入一個新的屬性名稱,專門用來輔助 diff 演算法。這個屬性最終確定為 key 。這也是為什麼在 React 中使用列表時會要求給子元素設定 key 屬性的原因。

  結合 key ,再加上雜湊表,diff 演算法最終實現了 O(n) 的最優複雜度。

  至此,可以看到從 XHP 遷移到 JS 的方案可行的。接下來就可以針對各個環節進行逐步優化。

附:詳細的 diff 理解:不可思議的 react diff

 持續優化

  Virtual DOM

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

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

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

  之所以有這麼多屬性,是因為 DOM 節點被用於瀏覽器渲染管道的很多過程中。

  瀏覽器首先根據 CSS 規則查詢匹配的節點,這個過程會快取很多元資訊,例如它維護著一個對應 DOM 節點的 id 對映表。

  然後,根據樣式計算節點佈局,這裡又會快取位置和螢幕定位資訊,以及其他很多的元資訊,瀏覽器會盡量避免重新計算佈局,所以這些資料都會被快取。

  可以看出,整個渲染過程會耗費大量的記憶體和 CPU 資源。

  現在回過頭來想想 React ,其實它只在 diff 演算法中用到了 DOM 節點,而且只用到了標籤名稱和部分屬性。

  如果用更輕量級的 JS 物件來代替複雜的 DOM 節點,然後把對 DOM 的 diff 操作轉移到 JS 物件,就可以避免大量對 DOM 的查詢操作。這種方式稱為 Virtual DOM 。

  其過程如下:

  1. 維護一個使用 JS 物件表示的 Virtual DOM,與真實 DOM 一一對應
  2. 對前後兩個 Virtual DOM 做 diff ,生成變更(Mutation)
  3. 把變更應用於真實 DOM,生成最新的真實 DOM

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

  至此,React 的兩大優化:diff 演算法和 Virtual DOM ,均已完成。再加上 XHP 時代嘗試的資料繫結,已經算是一個可用版本了。

  這個時候 Facebook 做了個重大的決定,那就是把 React 開源!

  React 的開源可謂是一石激起千層浪,社群開發者都被這種全新的 Web 開發方式所吸引,React 因此迅速佔領了 JS 開源庫的榜首。

  很多大公司也把 React 應用到生產環境,同時也有大批社群開發者為 React 貢獻了程式碼。

  接下來要說的兩大優化就是來自於開源社群。

  批處理(Batching)

  著名瀏覽器廠商 Opera 把重排和重繪(Reflow and Repaint)列為影響頁面效能的三大原因之一。

  我們說 DOM 是很慢的,除了前面說到的它的複雜和龐大,還有另一個原因就是重排和重繪。

  當 DOM 被修改後,瀏覽器必須更新元素的位置和真實畫素;

  當嘗試從 DOM 讀取屬性時,為了保證讀取的值是正確的,瀏覽器也會觸發重排和重繪。

  因此,反覆的“讀取、修改、讀取、修改...”操作,將會觸發大量的重排和重繪。

  另外,由於瀏覽器本身對 DOM 操作進行了優化,比如把兩次很近的“修改”操作合併成一個“修改”操作。

  所以如果把“讀取、修改、讀取、修改...”重新排列為“讀取、讀取...”和“修改、修改...”,會有助於減小重排和重繪的次數。但是這種刻意的、手動的級聯寫法是不安全的。

  與此同時,常規的 JS 寫法又很容易觸發重排和重繪。

  在減小重排和重繪的道路上,React 陷入了尷尬的處境。

  最終,社群貢獻者 Ben Alpert 使用批處理的方式拯救了這個尷尬的處境。

  在 React 中,開發者通過呼叫元件的 setState 方法告訴 React 當前元件要變更了。

  Ben Alpert 的做法是,呼叫 setState 時不立即把變更同步到 Virtual DOM,而是僅僅把對應元素打上“待更新”的標記。如果元件內呼叫多次 setState ,那麼都會進行相同的打標操作。

  等到初始化事件被完全廣播開以後,就開始進行從頂部到底部的重新渲染(Re-Render)過程。這就確保了 React 只對元素進行了一次渲染。

  這裡要注意兩點:

  1. 此處的重新渲染是指把 setState 變更同步到 Virtual DOM ;在這之後才進行 diff 操作生成真實的 DOM 變更。
  2. 與前文提到的“重新渲染整個 DOM ”不同的是,真實的重新渲染僅渲染被標記的元素及其子元素,也就是說上圖中僅藍色圓圈代表的元素會被重新渲染

  這也提醒開發者,應該讓擁有狀態的元件儘量靠近葉子節點,這樣可以縮小重新渲染的範圍。

  裁剪(Pruning)

  隨著應用越來越大,React 管理的元件狀態也會越來越多,這就意味著重新渲染的範圍也會越來越大。

  認真觀察上面批處理的過程可以發現,該 Virtual DOM 右下角的三個元素其實是沒有變更的,但是因為其父節點的變更也導致了它們的重新渲染,多做了無用操作。

  對於這種情況,React 本身已經考慮到了,為此它提供了 bool shouldComponentUpdate(nextProps, nextState) 介面。開發者可以手動實現該介面來對比前後狀態和屬性,以判斷是否需要重新渲染。這樣的話,重新渲染就變成如下圖所示過程。

  當時,React 雖然提供了 shouldComponentUpdate 介面,但是並沒有提供一個預設的實現方案(總是渲染),開發者必須自己手動實現才能達到預期效果。

  其原因是,在 JS 中,我們通常使用物件來儲存狀態,修改狀態時是直接修改該狀態物件的。也就是說,修改前後的兩個不同狀態指向了同一個物件,所以當直接比較兩個物件是否變更時,它們是相同的,即使狀態已經改變。

  對此,David Nolen 提出了基於不可變資料結構(Immutable Data Structure)的解決方案。

  該方案的靈感來自於 ClojureScript ,在 ClojureScript 中,大部分的值都是不可變的。換句話說就是,當需要更新一個值時,程式不是去修改原來的值,而是基於原來的值建立一個新值,然後使用新值進行賦值。

  David 使用 ClojureScript 寫了一個針對 React 的不可變資料結構方案:Om ,為 shouldComponentUpdate 提供了預設實現。

  不過,由於不可變資料結構並未被 Web 工程師廣為接受,所以當時並未把這項功能合併進 React 。

  遺憾的是,截止到目前,shouldComponentUpdate 也仍然未提供預設實現。

  但是 David 卻為廣大開發者開啟了一個很好的研究方向。

  如果真想利用不可變資料結構來提高 React 效能,可以參考與 React 師出同門的 Facebook Immutable.js,它是 React 好搭檔!

 結束語

  React 的優化仍在繼續,比如 React 16 中新引入 Fiber,它是對核心演算法的一次重構,即重新設計了檢測變更的方法和時機,允許渲染過程可以分段完成,而不必一次性完成。

  受篇幅限制,本文不會深入介紹 Fiber ,有興趣的可以參考 React Fiber是什麼

  最後,感謝 Facebook 給開源社群帶來了如此優秀的專案!

相關文章