React 是怎樣煉成的

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

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

 字符拼接時代 - 2004

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

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

$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) 接口。開發者能夠手動實現該接口來對比先後狀態和屬性,以判斷是否須要從新渲染。這樣的話,從新渲染就變成以下圖所示過程。

image

  當時,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 給開源社區帶來了如此優秀的項目!

原文連接:https://www.jianshu.com/p/ca6...

相關文章
相關標籤/搜索