前端性能的本質是什麼,你有真正了解嗎?

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

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

前端性能的本質是什麼,你有真正瞭解嗎?

性能問題本質

事實上,性能問題多種多樣:瓶頸可能出現在網絡傳輸過程,造成前端數據呈現延遲;也可能是移動 hybrid 應用中,wbview 容器帶來了瓶頸和限制。但是在分析性能問題時,經常逃不開一個概念——JavaScript 單線程。

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

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

前端性能的本質是什麼,你有真正瞭解嗎?

圖中的幾個關鍵角色:

Call Stack:調用棧,即 JavaScript 代碼執行的地方,Chrome 和 NodeJS 中對應 V8 引擎。遵循 LIFO(last-in-first-out)原則。當執行完當前所有任務時,棧為空,等待接收 Event Loop 中 next Tick 的任務。

Browser APIs:這是連接 JavaScript 代碼和瀏覽器內部的橋樑,使得 JavaScript 代碼可以通過 Browser APIs 操作 DOM,調用 setTimeout,AJAX 等。

Event queue: 每次通過 AJAX 或者 setTimeout 添加一個回調時,回調函數會加入到 Event queue 當中。

Job queue: 這是預留給 promise 且優先級較高的 queue,代表著“稍後執行這段代碼,但是在 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 週期執行,進而主線程就可以在子任務間隙當中執行 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 的性能瓶頸。

這並不難理解,因為 UI 渲染只是 JavaScript 調用瀏覽器的 APIs,這個過程對所有框架以及原生 JavaScript 來講是一樣的,都是黑盒執行,這一部分的性能消耗是且無法取巧的。

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

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

(http://www.infoq.com/cn/articles/what-the-new-engine-of-react)

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

前端性能的本質是什麼,你有真正瞭解嗎?

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

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

將瀏覽器主線程在這 7s 的 performance 如下圖所示:

前端性能的本質是什麼,你有真正瞭解嗎?

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

三種任務,佔用瀏覽器主線程 7s,此時間內瀏覽器無法與用戶交互。但是DOM 改變之後,瀏覽器重新計算 DOM Tree,重繪頁面是一個必不可少的階段(紫色綠色階段)。主要是黃色部分執行時間較長,佔用了 6 s,即 React 較長時間佔用主線程,導致主線程無法響應用戶輸入。

此處場景內容選自文章“React的新引擎—React Fiber是什麼?”

React 性能——React Fiber

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

在應用 React Fiber 的場景下,再重複剛才的例子。瀏覽器主線程的 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,這樣的提議遭到了 React 官方的禮貌回絕:

“Relay in a worker on the other hand seems very plausible.”

具體原因可以在此 issue 中找到,內容很多,也吸引來了 Dan Abramov 的現身說法,當然如果我是 React 庫的開發者,我也不會接受這樣的變動。不過這並不妨礙我們讓 React 結合 Worker 做試驗。

Talk is cheap, show me the code, and demo: 讀者可以訪問

http://web-perf.github.io/react-worker-dom/,

該網站分別用原生 React 和接入 Web Worker 版 React 實現了兩個應用,並對比其性能表現。

最終結論:不能絕對的說 Web Worker 可以對渲染速率有大幅度提升。只有當大量的節點發生變化的時,Web Worker 提升渲染性能才會有一些效果。實際上,當節點數量非常少的時候,Web Worker 的性能可能還不如 React 本身實現。這是由於 worker 線程和主線程之間的通信成本所致。

因此,Web Worker 版本的 React 仍有提升空間,我簡單總結如下:

• 因為 worker 線程和主線程在使用 postMessage 通信時,成本較大,我們可以採用 batching 思想減少通信的次數。

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

使用 postMessage 傳遞消息時,採用 transferable objects 進行數據負載

在 worker 和主線程之間,我想要傳遞的數據可能不是一個穩定的結構,因此,我需要制定一個公共的協議。使用 transferable objects 傳遞信息,能夠有效提高效率。更多內容參見社區文檔。

關於 Worker 版 syntheticEvent

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

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

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

Redux和Web Worker

既然 React 可以接入 Web Worker,狀態管理工具 Redux 當然也能借鑑這樣的思想,將 Redux 中 reducer 複雜的純計算過程放在 worker 線程裡,是不是一個很好的思路?

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

  • 一個實時每 16 毫秒,顯示計數(每秒增加 1)的 blinker 模塊;
  • 一個定時每 500 毫秒,更新背景顏色的 counter 模塊;
  • 一個永久往復運動的 slider 模塊;
  • 一個每 16 毫秒翻轉 5 度的 spinner 模塊
前端性能的本質是什麼,你有真正瞭解嗎?

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

如果將 N-皇后計算放置到 worker 線程,我們會發現 demo 展現了令人驚訝的性能提升,完全絲滑毫無卡頓。

如下圖,左邊為正常版本,不出意外出現了頁面卡頓,右側是介入 worker 之後的應用:

前端性能的本質是什麼,你有真正瞭解嗎?

在實現層面,藉助 Redux 庫的 enchancer 設計,完成了抽象封裝(類似中間件)。 一個 store enhancer,實際上就是一個顆粒化的高階函數,最終返回值是一個可以創建功能更加強大的 store 的函數 (enhanced store creator),這和 React 中的高階組件的概念很相似,同時也類似我們更加熟悉的中間件,其實參考 Redux 源碼,會發現 Redux 源碼中 applyMiddleware 方法,applyMiddleware(...middlewares) 的執行結果就是一個 store enhancer。

這些技術如何學習,有沒有免費資料?

對前端的技術,架構技術感興趣的同學關注我的頭條號,並在後臺私信發送關鍵字:“前端”即可獲取免費的架構師學習資料

知識體系已整理好(源碼,筆記,PPT,學習視頻),歡迎免費領取。還有面試視頻分享可以免費獲取。關注我,可以獲得沒有的架構經驗哦!!


分享到:


相關文章: