漫談前端效能 突破 React 應用瓶頸

LucasHC發表於2018-08-14

React 狀態管理與同構實戰

效能一直以來是前端開發中非常重要的話題。隨著前端能做的事情越來越多,瀏覽器能力被無限放大和利用:從 web 遊戲到複雜單頁面應用,從 NodeJS 服務到 web VR/AR、資料視覺化,前端工程師總是在突破極限。隨之而來的效能問題有的被迎刃而解,有的成為難以逾越的盾牆。

那麼,當我們在談論效能時,到底在說什麼?基於 React 框架開發的應用,在效能上又有哪些特點?

這篇文章我們從瀏覽器和 JavaScript 引擎角度來剖析前端效能,同時創新 React,充分利用瀏覽器能力突破侷限。


在文章開始之前,我想先向大家介紹一本書。

從去年起,我和知名技術大佬顏海鏡開始了合著之旅,今年我們共同打磨的書籍《React 狀態管理與同構實戰》終於正式出版了!這本書以 React 技術棧為核心,在介紹 React 用法的基礎上,從原始碼層面分析了 Redux 思想,同時著重介紹了服務端渲染和同構應用的架構模式。書中包含許多專案例項,不僅為使用者開啟了 React 技術棧的大門,更能提升讀者對前沿領域的整體認知。

如果各位對圖書內容或接下來的內容感興趣,還望多多支援!文末有詳情,不要走開!


效能問題的阿喀琉斯之踵

事實上,效能問題多種多樣:瓶頸可能出現在網路傳輸過程,造成前端資料呈現延遲;也可能是 hybrid 應用中,webview 容器帶來限制。但是在分析效能問題時,經常逃不開一個概念——JavaScript 單執行緒

**瀏覽器解析渲染 DOM Tree 和 CSS Tree,解析執行 JavaScript,幾乎所有的操作都是在主執行緒中執行。**因為 JavaScript 可以操作 DOM,影響渲染,所以 JavaScript 引擎執行緒和 UI 執行緒是互斥的。換句話說,JavaScript 程式碼執行時會阻塞頁面的渲染。

通過下面的圖示來進行了解:

JavaScript 主執行緒

圖中的幾個關鍵角色:

Call Stack:呼叫棧,即 JavaScript 程式碼執行的地方,Chrome 和 NodeJS 中對應 V8 引擎。當它執行完當前所有任務時,棧為空,等待接收 Event Loop 中 next Tick 的任務。

Browser APIs:這是連線 JavaScript 程式碼和瀏覽器內部的橋樑,使得 JavaScript 程式碼可以通過 Browser APIs 操作 DOM,呼叫 setTimeout,AJAX 等。

Event queue: 每次通過 AJAX 或者 setTimeout 新增一個非同步回撥時,回撥函式一般會加入到 Event queue 當中。

Job queue: 這是預留給 promise 且優先順序較高的通道,代表著“稍後執行這段程式碼,但是在 next Event Loop tick 之前執行”。它屬於 ES 規範,注意區別對待,這裡暫不展開。

Next Tick: 表示呼叫棧 call stack 在下一 tick 將要執行的任務。它由一個 Event queue 中的回撥,全部的 job queue,部分或者全部 render queue 組成。注意 current tick 只會在 Job queue 為空時才會進入 next tick。這就涉及到 task 優先順序了,可能大家對於 microtask 和 macrotask 更加熟悉,這裡不再展開。

Event Loop: 它會“監視”(輪詢)call stack 是否為空,call stack 為空時將會由 Event Loop 推送 next tick 中的任務到 call stack 中。

在瀏覽器主執行緒中,JavaScript 程式碼在呼叫棧 call stack 執行時,可能會呼叫瀏覽器的 API,對 DOM 進行操作。也可能執行一些非同步任務:這些非同步任務如果是以回撥的方式處理,那麼往往會被新增到 Event queue 當中;如果是以 promise 處理,就會先放到 Job queue 當中。這些非同步任務和渲染任務將會在下一個時序當中由呼叫棧處理執行。

理解了這些,大家就會明白:**如果呼叫棧 call stack 執行一個很耗時的指令碼,比如解析一個圖片,call stack 就會像北京上下班高峰期的環路入口一樣,被這個複雜任務堵塞。**主執行緒其他任務都要排隊,進而阻塞 UI 響應。這時候使用者點選、輸入、頁面動畫等都沒有了響應。

這樣的效能瓶頸,就如同阿喀琉斯之踵一樣,在一定程度上限制著 JavaScript 的發揮。

兩方效能解藥

我們一般有兩種方案突破上文提到的瓶頸:

  • 將耗時高、成本高、易阻塞的長任務切片,分成子任務,並非同步執行

這樣一來,這些子任務會在不同的 call stack tick 週期執行,進而主執行緒就可以在子任務間隙當中執行 UI 更新操作。設想常見的一個場景:如果我們需要渲染一個由十萬條資料組成的列表,那麼相比一次性渲染全部資料,我們可以將資料分段,使用 setTimeout API 去分步處理,構建渲染列表的工作就被分成了不同的子任務在瀏覽器中執行。在這些子任務間隙,瀏覽器得以處理 UI 更新。

  • 另外一個創新性的做法:使用HTML5 Web worker

Web worker 允許我們將 JavaScript 指令碼在不同的瀏覽器執行緒中執行。因此,一些耗時的計算過程我們都可以放在 Web worker 開啟的執行緒當中處理。下文會有詳解。

React 框架效能剖析

社群上關於 React 效能的內容往往聚焦在業務層面,主要是使用框架的“最佳實踐”。這裡我們不去談論“使用 shoulComponentUpdate 減少不必要的渲染”、“減少 render 函式中 inline-function”等已經“老生常談”的話題,本文主要從 React 框架實現層面分析其效能瓶頸和突破策略。

**原生 JavaScript 一定是最高效的,這個毫無爭議。**相比其他框架,React 在 JavaScript 執行層面花費的時間較多,這是因為:

Virtual DOM 構建 -> 計算 DOM diff -> 生成 render patch

這一系列複雜過程所造成的。也就是說,在一定程度上:React 著名的排程策略 -- stack reconcile 是 React 的效能瓶頸。

這並不難理解,因為 DOM 更新只是 JavaScript 呼叫瀏覽器的 APIs,這個過程對所有框架以及原生 JavaScript 來講是一樣黑盒執行的,這一部分的效能消耗是同等且不可避免的。

再來看我們的 React:stack reconcile 過程會深度優先遍歷所有的 Virtual DOM 節點,進行 diff。整棵 Virtual DOM 計算完成之後,將任務出棧釋放主執行緒。所以,瀏覽器主執行緒被 React 更新狀態任務佔據的時候,使用者與瀏覽器進行任何互動都不能得到反饋,只有等到任務結束,才能得到瀏覽器的響應。

我們來看一個典型的場景,來自文章:React的新引擎—React Fiber是什麼?

這個例子會在頁面中建立一個輸入框,一個按鈕,一個 BlockList 元件。BlockList 元件會根據 NUMBER_OF_BLOCK 數值渲染出對應數量的數字顯示框,數字顯示框顯示點選按鈕的次數。

例項

在這個例子中,我們可以設定 NUMBER_OF_BLOCK 的值為 100000。這時候點選按鈕,觸發 setState,頁面開始更新。此時點選輸入框,輸入一些字串,比如 “hi,react”。可以看到:頁面沒有任何響應。等待 7s 之後,輸入框中突然出現了之前輸入的 “hireact”。同時, BlockList 元件也更新了。

顯而易見,這樣的使用者體驗並不好。

瀏覽器主執行緒在這 7s 的 performance 如下圖所示:

performance 圖示

黃色部分是 JavaScript 執行時間,也是 React 佔用主執行緒時間; 紫色部分是瀏覽器重新計算 DOM Tree 的時間; 綠色部分是瀏覽器繪製頁面的時間。

這三種任務,總共佔用瀏覽器主執行緒 7s,此時間內瀏覽器無法與使用者互動。主要是黃色部分執行時間較長,佔用了 6s,即 React 較長時間佔用主執行緒,導致主執行緒無法響應使用者輸入。這就是一個典型的例子。

React 效能升級——React Fiber

React 核心團隊很早之前就預知效能風險的存在,並且持續探索可解決的方式。基於瀏覽器對 requestIdleCallback 和 requestAnimationFrame 這兩個API 的支援,React 團隊實現新的排程策略 -- Fiber reconcile。

更多關於 Fiber 的內容同樣推薦文章:React的新引擎—React Fiber是什麼? 文章中又在應用 React Fiber 的場景下,重複剛才的例子,不會再出現頁面卡頓,互動自然而順暢。

瀏覽器主執行緒的 performance 如下圖所示:

performance

可以看到:在黃色 JavaScript 執行過程中,也就是 React 佔用瀏覽器主執行緒期間,瀏覽器在也在重新計算 DOM Tree,並且進行重繪。只管來看,黃色和紫色等互相交替,同時頁面截圖顯示,使用者輸入得以及時響應。簡單說,在 React 佔用瀏覽器主執行緒期間,瀏覽器也在與使用者互動。這顯然是“更好的效能”表現。

以上是 React 應用第一種方法:“將耗時高的任務分段”,達到了效能突破。下面我們再來看另一種“民間”做法,應用 Web worker。

React 結合 Web worker

關於 Web worker 的概念此文不再贅述,大家可以訪問 MDN 地址進行了解。我們聚焦思考點:如果讓 React 接入 Web worker 的話,切入點在哪裡,該如何實施?

總所周知,標準的 React 應用由兩部分構成:

  • React core:負責絕大部分複雜的 Virtual DOM 計算;

  • React-Dom:負責與瀏覽器真實 DOM 互動來展示內容。

那麼答案很簡單,我們嘗試在 Web worker 中執行 React Virtual DOM 的相關計算。即將 React core 部分移入 Web worker 執行緒中。

確實有人提出了這樣的想法,請參考 React 倉庫 第 #3092 號 Issue,這也吸引來了 Dan Abramov 的討論。雖然這樣的提案被拒絕,但這並不妨礙我們讓 React 結合 worker 做試驗。

Talk is cheap, show me the code, and demo: 讀者可以訪問這裡,該網站分別用原生 React 和接入 Web worker 版 React 實現了兩個應用,並對比其效能表現。關於程式碼部分,感興趣的同學可以私信我。

最終結論:只有當大量的節點發生變化的時,Web worker 提升渲染效能才會有一些效果。當節點數量非常少的時候,接入 Web worker 的效能可能是負收益。我認為這是由於 worker 執行緒和主執行緒之間的通訊成本所致。

這麼看,Web worker 版本的 React 仍有效能提升空間,我簡單總結如下:

  • 因為 worker 執行緒和主執行緒在使用 postMessage 通訊時,效能成本較大,我們可以採用 batching 思想減少通訊的次數。

如果在每次 DOM 需要改變時,都呼叫 postMessage 通知主執行緒,不是特別明智。所以可以用 batching 思想,將 worker 執行緒中計算出來的 DOM 待更新內容進行收集,再統一傳送。這樣一來,batching 的粒度就很有意思了。如果我們走極端,每次 batching 收集的變更都非常多,遲遲不向主執行緒傳送,那麼在一次 batching 時就給瀏覽器真正的渲染過程帶來了壓力,反而適得其反。

  • 使用 postMessage 傳遞訊息時,採用 transferable objects 進行資料負載
  • 關於 worker 版 syntheticEvent

原生 React 有一套事件系統,它在最頂層監聽所有的瀏覽器事件,之後將它們轉化為合成事件(syntheticEvent),傳遞給我們在 Virtual DOM 上定義的事件監聽者。

對於我們的 Web worker,由於 worker 執行緒不能直接操作 DOM,也就不能監聽瀏覽器事件。因此所有事件同樣都在主執行緒中處理,轉化為虛擬事件再傳遞給 worker 執行緒進行釋出,也就意味著所有關於建立虛擬事件的操作還是都在主執行緒中進行,一個可能改善的方案可以考慮直接將原始事件傳遞給 worker,由 worker 來生成模擬事件並冒泡傳遞。

關於 React 結合 worker 還有很多值得深挖的內容,比如:事件處理方面 preventDefault 和 stopPropogation 的同步性保障(worker 執行緒和主執行緒通訊是非同步的);使用 multiple worker(一個以上 worker)進行探究等。如果讀者有興趣,我會專門寫篇文章介紹。

Redux 和 Web worker

既然 React 可以接入 Web worker,那麼 Redux 當然也能借鑑這樣的思想,將 Redux 中 reducer 複雜的純計算過程放在 worker 執行緒裡,是不是一個很好的思路?

我使用 “N-皇后問題” 模擬大型計算,除了這個極其耗時的演算法,頁面中還執行這麼幾個模組,來實現頻繁更新 DOM 的渲染邏輯:

  • 一個實時每 16 毫秒,顯示計數(每秒增加 1)的 blinker 模組;

  • 一個定時每 500 毫秒,更新背景顏色的 counter 模組;

  • 一個永久往復運動的 slider 模組;

  • 一個每 16 毫秒翻轉 5 度的 spinner 模組

如圖:

image.png

這些模組都定時頻繁地更新 DOM 樣式,進行渲染。正常情況下,在 JavaScript 主執行緒進行 N-皇后計算時,這些渲染過程都將被卡頓。

如果將 N-皇后計算放置到 worker 執行緒,我們會發現 demo 展現了令人驚訝的效能提升,完全絲滑毫無卡頓。如上圖,左半部分為正常版本,不出意外出現了頁面卡頓,右側是接入 worker 之後的應用。

在實現層面,藉助 Redux 庫的 enchancer 設計,完成了抽象封裝。 一個 store enhancer,實際上就是一個 curry 化的高階函式,這和 React 中的高階元件的概念很相似,同時也類似我們更加熟悉的中介軟體。其實參考 Redux 原始碼,會發現 Redux 原始碼中 applyMiddleware 方法的執行結果就是一個 store enhancer。

那麼為什麼不選擇中介軟體,而是使用 enhancer 來實現呢?這個 Redux worker demo 所採用的公共庫設計思路非常有趣,關於神奇的 Redux 高階內容不再展開,感興趣的讀者可以在我新出版的書中找到相應內容。這也就到了廣告時間。。。


《React 狀態管理與同構實戰》這本書由我和前端知名技術大佬顏海鏡合力打磨,凝結了我們在學習、實踐 React 框架過程中的積累和心得。**除了 React 框架使用介紹以外,著重剖析了狀態管理以及服務端渲染同構應用方面的內容。**同時吸取了社群大量優秀思想,進行歸納比對。

本書受到百度公司副總裁沈抖、百度資深前端工程師董睿,以及知名 JavaScript 語言專家阮一峰、Node.js 佈道者狼叔、Flarum 中文社群創始人 justjavac、新浪移動前端技術專家小爝、百度資深前端工程師顧軼靈等前端圈眾多專家大咖的聯合力薦。

有興趣的讀者可以點選這裡,瞭解詳情。也可以掃描下面的二維碼購買。再次感謝各位的支援與鼓勵!懇請各位批評指正!

React 狀態管理與同構實戰

React 狀態管理與同構實戰

最後,前端學習永無止境,希望和每一位技術愛好者共同進步,大家可以在知乎找到我!

Happy coding!

相關文章