使用 React Profiler 來觀察 React Web App 的渲染狀況並進行效能優化

React Profiler 是個好東西,真希望人人都會


BotBonnie 是由我們「邦妮科技」開發的一套用來設計聊天機器人的 Web App,具有視覺化的介面大幅降低入門門檻,即使沒有程式技術的背景,也能無痛製作聊天機器人。

在 BotBonnie 平台上,聊天機器人的對話流程是以模組為單位組合而成,每個模組可以包含一個或多個機器人吐出的訊息內容。透過視覺化的介面,用戶可以使用滑鼠以拖拉的方式來編排模組,也可以針對每個模組的內容進行編輯。


由於網頁前台的使用者操作與資料結構相較於一般的靜態網頁複雜,因此使用 React(v16.9)作為前端開發框架,並且搭配 Redux(v4.0) 來管理資料與狀態。

對於小型的聊天機器人,畫面上的模組數量並不多,用戶在操作平台時的體驗是非常流暢的,但對於比較大型的聊天機器人,對話流程就會比較複雜,畫面上的模組數量也會比較多,用戶在拖拉或編輯模組時就會明顯感受到畫面出現停頓的現象。用戶輸入文字時,畫面會停頓一下,輸入的文字才接著突然出現

使用 React Profiler 觀察渲染狀況

React 在 v16.5 開始支援 React Profiler,我們可以使用 React Profiler 來收集 React Component Tree 的渲染資訊以便找到效能瓶頸,在開發模式下只要在 Chrome 瀏覽器的開發人員工具中就可以找到 Profiler 的 tab 來使用 React Profiler。

記錄渲染資訊

按下左上角的「Start profiling」按鈕就可以開始記錄,接著在網頁上進行需要被觀察的操作行為,最後再按「Stop profiling」按鈕就可以結束記錄。

解讀渲染資訊

右上角的長條圖可以看到整個記錄過程中 React 總共更新了幾次 DOM tree,而每一個直條就代表一次更新(Commit),然後直條的顏色/長度代表該次更新花費的時間,越長/黃的直條表示該次更新花費的時間越長,越短/藍的則相反。

當我們點擊任一個直條,詳細的資訊會顯示在右邊的 Commit Information 中,包含該次更新發生的時間(Commited at)、更新花費的時間(Render duration)等等。

我們可以進一步透過下方的(倒)火焰圖查看所有 Component 在該次更新時的狀況。每一個橫條為一個 Component,若為灰色表示這個 Component 在該次更新沒有重新渲染;若不是灰色則表示有重新渲染,而越接近黃色代表重新渲染所花費的時間越久,越接近藍色則反之。

點擊任一個橫條,我們同樣可以在右邊看到每一次重新渲染發生的時間點和花費的時間,甚至還能夠知道造成 Component 重新渲染的原因(例如是哪些 props 改變了?或是哪些 state 改變了?還是 parent 渲染造成的? )

進行效能優化

透過 React Profiler 找到效能瓶頸(花最多時間進行渲染的 Component)便可以進行優化。大部分不必要的 re-render 發生在「PureComponent」或是「被包在 React.memo 中的 Function Component」,以上兩種 Component 在每次接收新的 state 或 props 會進行 shallow compare,只要任一 state 或 props 改變,就會觸發 re-render。

以下列出一些可能造成不必要的 re-render 的情況,以及優化的做法:

props 傳入 inline function

使用 inline function 作為 props 傳入,那麼在父元件每次 render 時都會建立一個新的 function,就會造成以上兩種的子元件 re-render,例如:

// 父元件為 class component
class Page extends React.Component {
  render() {
    return <Button onClick={() => {
      this.setState({ isClicked: true })
    }} />
  }
}

// 父元件為 function component
const Page = () => {
  return <Button onClick={() => {
    setIsClicked(true)
  }} />
}

可以分別用 class method 和 React.useCallback 來改寫以進行優化:

// 父元件為 class component
class Page extends React.Component {
  handleClick = () => {
    this.setState({ isClicked: true })
  }
  
  render() {
    return <Button onClick={this.handleClick} />
  }
}

// 父元件為 function component
const Page = () => {
  const handleClick = React.useCallback(() => {
    setIsClicked(true)
  }, [])
  
  return <Button onClick={handleClick} />
}

如此一來即使父元件 re-render 時,handleClick 都還是同一個 function,再作為 props 傳入就不會造成不必要的 re-render 而達到優化效能的目的。

props 傳入 React element

如果使用 React element 作為 props 傳入,就和 inline function 一樣,在父元件每次 render 時也都會建立一個新的 React element,同樣就會造成以上兩種的子元件 re-render,例如:

// 父元件為 class component
class Page extends React.Component {
  render() {
    return <Button content={<p>Click me!</p>} />
  }
}

// 父元件為 function component
const Page = () => {
  return <Button content={<p>Click me!</p>} />
}

以上兩種情況可以分別用 class field 和 React.useMemo 來改寫以進行優化:

// 父元件為 class component
class Page extends React.Component {
  content = <p>Click me!</p>

  render() {
    return <Button content={this.content} />
  }
}

// 父元件為 function component
const Page = () => {
  const content = React.useMemo(() => <p>Click me!</p>, [])

  return <Button content={content} />
}

props 傳入 NaN

如果計算某個結果得到 NaN 作為 props 傳入,在父元件下次 render 時還是計算得到 NaN,由於 NaN 和 NaN 並不相等(NaN !== NaN),所以同樣就會造成以上兩種的子元件 re-render,例如:

const Page = () => {
  // Math.max 其中一個參數如果是 undefined,就會回傳 NaN
  const max = Math.max(1, 2, undefined)

  return <Button text={max} />
}

可以透過 Number.isNaN 來判斷是否為 NaN,若為 NaN 則設為 false,如此一來即使重複計算得到 NaN,就都會改為傳入 false,就不會造成不必要的 re-render:

const Page = () => {
  const max = Math.max(1, 2, undefined)

  return <Button text={Number.isNaN(max) ? false : max} />
}

優化結果

我們使用 React Profiler 觀測「使用者輸入文字」的行為來作為測量依據,此行為大致上會經過以下歷程(包含使用者的操作與程式的執行):

  1. 使用者點擊文字輸入框
  2. 使用者輸入文字
  3. 使用者停止輸入文字
  4. Component dispatched redux action
  5. Redux reducer calculated next state
  6. Component re-rendered

並且產生 16 次 Commit。

優化前

每一次的 Commit 的資訊如下(按照時間先後順序由左至右):

總共花費約 1347.9 毫秒(約 1.35 秒)來更新 DOM tree。

優化後

每一次的 Commit 的資訊如下,可以看到紅框標起來的部分明顯大幅下降:

優化後更新 DOM tree 花費總時間大約為 235.4 毫秒,相較於優化前的 1347.9 毫秒只佔了 17.46%,相當於提升了 5.7 倍的效能!


React 框架底層使用 Virtual DOM 機制來避免頻繁的操作 DOM tree ,並且透過 Reconciliation 演算法有效率的找出需要更新的 DOM elements,即使如此在操作非常廣或深的 DOM tree 時還是有可能遇到效能瓶頸。

作為工具類型的 BotBonnie 平台,為了提供用戶絕佳的使用者體驗,除了平台的介面設計與功能操作,我們也非常重視平台操作的效能,雖然在著手進行效能觀測與優化前,也不是有百分之百的把握能夠提升多少的效能,但經過工程師間的討論並且實(ㄅㄚˇ)際(ㄕㄡˇ)行(ㄋㄨㄥˋ)動(ㄗㄤ),總算是得到不錯的結果,覺得這次的經驗難得因此整理出這篇文章和各位分享,希望同樣也能幫助到閱讀本文章的各位,歡迎留言討論與指教!

另外,我們 BotBonnie 正在大力招募工程師,有興趣的朋友歡迎來聊聊喔!
徵才資訊👉https://www.104.com.tw/company/1a2x6bkk2j


參考資料:

  1. https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#reading-performance-data
  2. https://reactjs.org/docs/reconciliation.html

謝謝您的閱讀,喜歡這篇文章嗎?
- 按「★讚」,給予我們愛的鼓勵 
- 分享或轉發,將實用資訊分享給更多人
官方網站 / 粉絲專頁 / 交流社團 / 教學手冊

發表迴響

這個網站採用 Akismet 服務減少垃圾留言。進一步瞭解 Akismet 如何處理網站訪客的留言資料