【譯】光速 React

zhe.zhang發表於2017-05-16

光速 React

Vixlet 團隊優化效能的經驗教訓

【譯】光速 React

在過去一年多,我們 Vixlet 的 web 團隊已經著手於一個激動人心的專案:將我們的整個 web 應用遷移到 React + Redux 架構。對於整個團隊來說,這是不斷增長的機遇,而在遷移過程中,我們一路風雨兼程。

因為我們的 web-app 可能有非常大的 feed 檢視,包括成百上千的媒體、文字、視訊、連結元素,我們花了相當多的時間尋找能充分利用 React 效能的方法。在這裡,我們將分享我們這一路學到的一些經驗教訓。

宣告下面講的做法和方法更適用於我們具體應用的效能需求。然而,像所有的開發者建議的那樣,最重要的是要考慮到你的應用程式和團隊的實際需求。React 是一個開箱即用的框架,所以你可能不需要像我們一樣細緻地優化效能。話雖如此,我們還是希望你能在這篇文章裡找到一些有用的資訊。

基本原理

【譯】光速 React

向更大的世界邁出第一步。

render() 函式

一般來說,要儘可能少地在 render 函式中做操作。如果非要做一些複雜操作或者計算,也許你可以考慮使用一個 memoized 函式以便於快取那些重複的結果。可以看看 Lodash.memoize,這是一個開箱即用的記憶函式。

反過來講,避免在元件的 state 上儲存一些容易計算的值也很重要。舉個例子,如果 props 同時包含 firstNamelastName,沒必要在 state 上存一個 fullName,因為它可以很容易通過提供的 props 來獲取。如果一個值可以通過簡單的字串拼接或基本的算數運算從 props 派生出來,那麼沒理由將這些值包含在元件的 state 上。

Prop 和 Reconciliation

重要的是要記住,只要 props(或 state)的值不等於之前的值,React 就會觸發重新渲染。如果 props 或者 state 包含一個物件或者陣列,巢狀值中的任何改變也會觸發重新渲染。考慮到這一點,你需要注意在每次渲染的生命週期中,建立一個新的 props 或者 state 都可能無意中導致了效能下降。
PS:譯者對這段保留意見,物件或者陣列只要引用不變,是不會觸發rerender的,是我翻譯有誤還是原文的錯誤?

例子: 函式繫結的問題

/*
給 prop 傳入一個行內繫結的函式(包括 ES6 箭頭函式)實質上是在每次父元件 render 時傳入一個新的函式。
*/
render() {
  return (
    <div>
      <a onClick={ () => this.doSomething() }>Bad</a>
      <a onClick={ this.doSomething.bind( this ) }>Bad</a>
    </div>
  );
}


/*
應該在建構函式中處理函式繫結並且將已經繫結好的函式作為 prop 的值
*/

constructor( props ) {
  this.doSomething = this.doSomething.bind( this );
  //or
  this.doSomething = (...args) => this.doSomething(...args);
}
render() {
  return (
    <div>
      <a onClick={ this.doSomething }>Good</a>
    </div>
  );
}複製程式碼

例子: 物件或陣列字面量

/*
物件或者陣列字面量在功能上來看是呼叫了 Object.create() 和 new Array()。這意味如果給 prop 傳遞了物件字面量或者陣列字面量。每次render 時 React 會將他們作為一個新的值。這在處理 Radium 或者行內樣式時通常是有問題的。
*/

/* Bad */
// 每次渲染時都會為 style 新建一個物件字面量
render() {
  return <div style={ { backgroundColor: 'red' } }/>
}

/* Good */
// 在元件外宣告
const style = { backgroundColor: 'red' };

render() {
  return <div style={ style }/>
}複製程式碼

例子 : 注意兜底值字面量

/*
有時我們會在 render 函式中建立一個兜底的值來避免 undefined 報錯。在這些情況下,最好在元件外建立一個兜底的常量而不是建立一個新的字面量。
/*
/* Bad */
render() {
  let thingys = [];
  // 如果 this.props.thingys 沒有被定義,一個新的陣列字面量會被建立
  if( this.props.thingys ) {
    thingys = this.props.thingys;
  }

  return <ThingyHandler thingys={ thingys }/>
}

/* Bad */
render() {
  // 這在功能上和前一個例子一樣
  return <ThingyHandler thingys={ this.props.thingys || [] }/>
}

/* Good */

// 在元件外部宣告
const NO_THINGYS = [];

render() {
  return <ThingyHandler thingys={ this.props.thingys || NO_THINGYS }/>
}複製程式碼

儘可能的保持 Props(和 State)簡單和精簡

理想情況下,傳遞給元件的 props 應該是它直接需要的。為了將值傳給子元件而將一個大的、複雜的物件或者很多獨立的 props 傳遞給一個元件會導致很多不必要的元件渲染(並且會增加開發複雜性)。

在 Vixlet,我們使用 Redux 作為狀態容器,所以在我們看來,最理想的是方案在元件層次結構的每一個層級中使用 react-reduxconnect() 函式直接從 store 上獲取資料。connect 函式的效能很好,並且使用它的開銷也非常小。

元件方法

由於元件方法是為元件的每個例項建立的,如果可能的話,使用 helper/util 模組的純函式或者靜態類方法。尤其在渲染大量元件的應用中會有明顯的區別。

進階

【譯】光速 React

在我看來檢視的變化是邪惡的!

shouldComponentUpdate()

React 有一個生命週期函式 shouldComponentUpdate()。這個方法可以根據當前的和下一次的 props 和 state 來通知這個 React 元件是否應該被重新渲染。

然而使用這個方法有一個問題,開發者必須考慮到需要觸發重新渲染的每一種情況。這會導致邏輯複雜,一般來說,會非常痛苦。如果非常需要,你可以使用一個自定義的 shouldComponentUpdate() 方法,但是很多情況下有更好的選擇。

React.PureComponent

React 從 v15 開始會包含一個 PureComponent 類,它可以被用來構建元件。React.PureComponent 宣告瞭它自己的 shouldComponentUpdate() 方法,它自動對當前的和下一次的 props 和 state 做一次淺對比。有關淺對比的更多資訊,請參考這個 Stack Overflow:

stackoverflow.com/questions/3…

在大多數情況下,React.PureComponent 是比 React.Component 更好的選擇。在建立新元件時,首先嚐試將其構建為純元件,只有元件的功能需要時才使用 React.Component

更多資訊,請查閱相關文件 React.PureComponent

元件效能分析(在 Chrome 裡)

在新版本的 Chrome 裡,timeline 工具裡有一個額外的內建功能可以顯示哪些 React 元件正在渲染以及他們花費的時間。要啟用此功能,將 ?react_perf 作為要測試的 URL 的查詢字串。React 渲染時間軸資料將位於 User Timing 部分。

更多相關資訊,請查閱官方文件:Profiling Components with Chrome Timeline

有用的工具: why-did-you-update

這是一個很棒的 NPM 包,他們給 React 新增補丁,當一個元件觸發了不必要的重新渲染時,它會在控制檯輸出一個 console 提示。

注意: 這個模組在初始化時可以通過一個過濾器匹配特定的想要優化的元件,否則你的命令列可能會被垃圾資訊填滿,並且可能你的瀏覽器會掛起或者崩潰,查閱 why-did-you-update 文件獲取更多詳細資訊。

常見效能陷阱

【譯】光速 React

setTimeout() 和 setInterval()

在 React 元件中使用 setTimeout() 或者 setInterval() 要十分小心。幾乎總是有更好的選擇,例如 'resize' 和 'scroll' 事件(注意:有關注意事項請參閱下一節)。

如果你需要使用 setTimeout()setInterval(),你必須遵守下面兩條建議

不要設定過短的時間間隔。

當心那些小於 100 ms 的定時器,他們很可能是沒意義的。如果確實需要一個更短的時間,可以使用 window.requestAnimationFrame()

保留對這些函式的引用,並且在 unmount 時取消或者銷燬他們。

setTimeout()setInterval() 都返回一個延遲函式的引用,並且需要的時候可以取消它們。由於這些函式是在全域性作用域執行的,他們不在乎你的元件是否存在,這會導致報錯甚至程式卡死。

注意: 對 window.requestAnimationFrame() 來說也是如此

解決這個問題最簡答的方法是使用 react-timeout 這個 NPM 包,它提供了一個可以自動處理上述內容的高階元件。它將 setTimeout/setInterval 等功能新增到包裝組建的 props 上。(特別感謝 Vixlet 的開發人員 Carl Pillot 提供這個方法)

如果你不想引入這個依賴,並且希望自行解決此問題,你可以使用以下的方法:

// 如何正確取消 timeouts/intervals

compnentDidMount() {
 this._timeoutId = setTimeout( this.doFutureStuff, 1000 );
 this._intervalId = setInterval( this.doStuffRepeatedly, 5000 );
}
componentWillUnmount() {
 /*
   高階提示:如果操作已經完成,或者值未被定義,這些函式也不會報錯
 */
 clearTimeout( this._timeoutId );
 clearInterval( this._intervalId );
}複製程式碼

如果你使用 requestAnimationFrame() 執行的一個動畫迴圈,可以使用一個非常相似的解決方案,當前程式碼要有一點小的修改:

// 如何確保我們的動畫迴圈在元件消除時結束

componentDidMount() {
  this.startLoop();
}

componentWillUnmount() {
  this.stopLoop();
}

startLoop() {
  if( !this._frameId ) {
    this._frameId = window.requestAnimationFrame( this.loop );
  }
}

loop() {
  // 在這裡執行迴圈工作
  this.theoreticalComponentAnimationFunction()

  // 設定迴圈的下一次迭代
  this.frameId = window.requestAnimationFrame( this.loop )
}

stopLoop() {
  window.cancelAnimationFrame( this._frameId );
  // 注意: 不用擔心迴圈已經被取消
  // cancelAnimationFrame() 不會丟擲異常
}複製程式碼

未去抖頻繁觸發的事件

某些常見的事件可能會非常頻繁的觸發,例如 scrollresize。去抖這些事件是明智的,特別是如果事件處理程式執行的不僅僅是基本功能。

Lodash 有 _.debounce 方法。在 NPM 上還有一個獨立的 debounce 包.

“但是我真的需要立即反饋 scroll/resize 或者別的事件”

我發現一種可以處理這些事件並且以高效能的方式進行響應的方法,那就是在第一次事件觸發時啟動 requestAnimationFrame() 迴圈。然後可以使用 [debounce()](https://lodash.com/docs#debounce) 方法並且將 trailing 這個配置項設為 true這意味著該功能只在頻繁觸發的事件流結束後觸發)來取消對值的監聽,看看下面這個例子。

class ScrollMonitor extends React.Component {
  constructor() {
    this.handleScrollStart = this.startWatching.bind( this );
    this.handleScrollEnd = debounce(
      this.stopWatching.bind( this ),
      100,
      { leading: false, trailing: true } );
  }

  componentDidMount() {
    window.addEventListener( 'scroll', this.handleScrollStart );
    window.addEventListener( 'scroll', this.handleScrollEnd );
  }

  componentWillUnmount() {
    window.removeEventListener( 'scroll', this.handleScrollStart );
    window.removeEventListener( 'scroll', this.handleScrollEnd );

    //確保元件銷燬後結束迴圈
    this.stopWatching();
  }

  // 如果迴圈未開始,啟動它
  startWatching() {
    if( !this._watchFrame ) {
      this.watchLoop();
    }
  }

  // 取消下一次迭代
  stopWatching() {
    window.cancelAnimationFrame( this._watchFrame );
  }

  // 保持動畫的執行直到結束
  watchLoop() {
    this.doThingYouWantToWatchForExampleScrollPositionOrWhatever()

    this._watchFrame = window.requestAnimationFrame( this.watchLoop )
  }

}複製程式碼

密集CPU任務執行緒阻塞

某些任務一直是 CPU 密集型的,因此可能會導致主渲染執行緒的阻塞。舉幾個例子,比如非常複雜的數學計算,迭代非常大的陣列,使用 File api 進行檔案讀寫,利用 <canvas> 對圖片進行編碼解碼。

在這些情況下,如果有可能最好使用 Web Worker 將這些功能移到另一個執行緒上,這樣我們的主渲染執行緒可以保持順滑。

相關閱讀

MDN 文章: Using Web Workers

MDN 文件: Worker API

結語

我們希望上述建議對您能有所幫助。如果沒有 Vixlet 團隊的偉大工作和研究,上述的提示和程式設計技巧是不可能產出的。他們真的是我曾經合作過的最棒的團隊之一。

在你的 React 的征途中保持學習和練習,願原力與你同在!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章