將 React 應用優化到 60fps

將 React 應用優化到 60fps翻譯自React at 60fps,從屬於Web 前端入門與工程實踐。

作為 DOM 的抽象,React 自然也遵循了著名的抽象漏洞定理(詳見2016-我的前端之路:工具化與工程化),引入 React 導致了在應用本身的性能消耗之外勢必會增加額外的性能損耗。Dan Abramov 在 Twitter 上提到,React 並不能保證性能優於原生的 DOM 實現,但是它能夠幫助大量的普通開發者構建大型應用的同時不必在初期就耗費大量的精力在性能優化上,在大部分用戶交互界面上 React 已經能夠幫我們進行合理的優化了。但是在應用開發的過程,特別是最後的細節優化階段中,我們需要著眼於部分性能瓶頸頁面,正確地認識這種限制的緣由以及相對應的處理方案。本文即是作者在構建自己的大型應用中經驗的總結。

避免過早優化

無論你在做的是啥應用,注意要避免如驚弓之鳥般過早優化。換言之,在你真實的發現某些性能問題之前不要為了優化而優化,在 React 中,如果我們進行過多的冗餘優化拆分操作反而會造成奇怪的 Bug。正常的性能優化過程應該包含以下幾個步驟:

  • 確定發現存在性能的缺陷

  • 使用 DevTools 來解析發現瓶頸所在

  • 嘗試使用優化技巧解決這些問題

  • 測試是否確實有性能提升

  • 重複第二步

React 15.4 中引入了新的性能評測工具,可以方便地與 Chrome DevTools 集成使用,從而大大簡化我們性能定位地困難。

需要使用 shouldComponentUpdate 嗎?

相信幾乎每個 React 開發者都會熟悉組件生命周期中的 shouldComponentUpdate,React 會根據該函數返回的布爾值來判斷是否需要重渲染該組件。在我最初用 React 的那段時間,我天真的以為 React 會智能地幫我們在 Props 與 State 沒有改變的時候取消組件重渲染,不過事實證明只要你調用了setState或者傳入了不同的 Props 的時候,React 就會重渲染組件。而重載這個shouldComponentUpdate方法可能是最簡單的組件優化方式了,不過這種方式仍然存在某些不足或者副作用。譬如當你在某個高階組件中重載了該方法之後,儘管你只是希望不重渲染該組件,而實際上 React 可能會依賴於該組件的shouldComponentUpdate返回值而取消對組件樹中的整個子組件的重渲染。基於該函數最著名的實現當屬shouldPureComponentUpdate,該重載方式會淺層比較當前的與未來的 State 以及 Props 的差異,這種方式的缺陷如下:

  • 它並沒有深層比較兩個對象,不過如果它真的進行了深層比較,該操作會變得異常緩慢,這也就是使用不可變數據結構的原因。

  • 如果傳入的某個 Props 是某個回調函數,那麼該函數會一直返回True。

  • 比較檢測本身也是有性能損耗的,應用中過多的冗餘比較反而會降低性能。

總結而言,在實際應用開發中,建議的重載shouldComponentUpdate應該適用於以下類型的組件:

  • 使用簡單 Props 的純組件

  • 葉子組件或者在組件樹中較深位置的組件

不過還是需要強調的是,無論你是選擇重載shouldComponentUpdate函數還是使用pure HoC這樣的模式,還是首先應該找出那個拖慢整個應用性能的組件。

將高性能消耗的代碼放置到較高階組件中

如果在你的渲染函數中存在著部分性能消耗較高的計算代碼,那麼建議是將這部分代碼儘可能地放置到較高階的組件中,或者使用memorizing(reselect)的方式來減少重複調用或者計算。在我構建status.postmarkapp.com/網站的過程中,我主要通過以下的方式優化整體性能:

  • 將可視化數據與覆蓋層信息抽取出來放置到獨立的組件中。

  • 將執行大量數據轉換的代碼移出到容器組件中。

  • 僅僅對於可視化組件與覆蓋層組件覆寫shouldComponentUpdate函數。

  • 使用不可變數據結構(Immutable)來降低比較帶來的性能消耗

我發現最常見的降低應用性能的原因就是用戶輸入引發的 DOM 操作,譬如用戶滾動或者滑鼠移動的響應,都會大幅度的降低應用的幀數。這些事件往往都會以較高地頻次觸發,如果你打算監聽並且響應任何用戶細小的動作,那麼估計你的應用離崩潰不遠了。我們通常會使用debounce模式來避免頻繁地觸發響應,不過這種模式也會讓用戶覺得應用不是那麼靈活響應,這裡我們再討論下應該以怎樣的方式來解決這個性能問題。

同步滾動組件

為了更好地解釋這個問題,我構建了某個同步滾動的組件來演示這個問題,其效果如下所示:

該組件的主要職能在於保持左右兩個滾動面板的一致性(就好像常見的MarkDown預覽),而因為兩個面板的內容高度不一致,因此兩個面板需要以不同的速度進行滾動。

不要濫用 this.setState

React 應用開發中最常見的某個錯誤就是對於this.setState函數的使用,我們不應該將render()函數中用不到的狀態放置到this.state對象中。下面我們來看下第一個版本的滾動面板的實現:

class ScrollPane extends React.Component { componentDidUpdate() { // Each time we get new props we set the // new scrollTop position on the DOM element this.el.scrollTop = this.props.scrollTop } render() { <div ref={(el) => {this.el = el}}> }}class ScrollContainer extends React.Component { constructor() { super() this.leftPane = null this.rightPane = null this.state = { leftPaneScrollTop: 0, rightPaneScrollTop: 0 } } handleLeftScroll = (evt) => { // Calculate new scrollTop positions // for left and right panes based on // DOM nodes and evt.target.scrollTop const leftPaneScrollTop = … const rightPaneScrollTop = … // Don"t do this since this will re-render everything // on each `scroll` event! this.setState({ leftPaneScrollTop, rightPaneScrollTop }) } render() { return ( <div> <ScrollPane ref={(el) => {this.leftPane = el}} onScroll={this.handleScroll} scrollTop={this.state.leftPaneScrollTop} > <ExpensiveComponent /> </ScrollPane> <ScrollPane ref={(el) => {this.rightPane = el}} onScroll={this.handleScroll} scrollTop={this.state.rightPaneScrollTop} > <ExpensiveComponent /> </ScrollPane> </div> ) }}

在這個版本的實現中,我們將所有的狀態放置到了this.state中,此時問題就在於每次你調用this.setState來設置組件狀態時,React 會重渲染整個組件樹。另外,我們是否真的有必要將scrollTop的值以 Props 的方式傳遞到子組件中?我們可以先將滾動高度從組件狀態對象中提取出來:

handleScroll = (evt) => { // Calculate new scrollTop positions // for left and right panes based on // DOM nodes and evt.target.scrollTop this.leftPaneScrollTop = … this.rightPaneScrollTop = …}

將滾動高度作為類成員屬性就不會觸發重渲染,不過此時我們應該如何更新兄弟組件的滾動位置呢?這裡的建議是直接進行 DOM 操作。雖然這種方式看起來有點破壞 React 聲明式組件的特性,不過筆者在前文中也提到過,聲明式的特性與 DOM 操作並不相衝突。我們可以使用 Context(雖然貌似這個也不建議使用)來操作子組件而避免直接操作子組件的命令式代碼,從而保證其他組件仍然保持純粹的聲明式。代碼如下:

export default class ScrollPane extends Component { static contextTypes = { registerPane: PropTypes.func.isRequired, unregisterPane: PropTypes.func.isRequired }; componentDidMount() { this.context.registerPane(this.el) } componentWillUnmount() { this.context.unregisterPane(this.el) } render() { return ( <div ref={(el) => { this.el = el }}> {this.props.children} </div> ) }}export default class ScrollContainer extends Component { static childContextTypes = { registerPane: PropTypes.func, unregisterPane: PropTypes.func } getChildContext() { return { registerPane: this.registerPane, unregisterPane: this.unregisterPane } } panes = [] registerPane = (node) => { if (!this.findPane(node)) { this.addEvents(node) this.panes.push(node) } } unregisterPane = (node) => { if (this.findPane(node)) { this.removeEvents(node) this.panes.splice(this.panes.indexOf(node), 1) } } addEvents = (node) => { node.onscroll = this.handlePaneScroll.bind(this, node) } removeEvents = (node) => { node.onscroll = null } findPane = node => this.panes.find(pane => pane === node) handlePaneScroll = (node) => { window.requestAnimationFrame(() => { // Calculate new scrollTop positions // for left and right panes based on // DOM nodes and evt.target.scrollTop // and set it directly on DOM nodes this.panes.forEach((pane) => { pane.scrollTop = … }) }) } render() { return ( <div> <ScrollPane> <ExpensiveComponent /> </ScrollPane> <ScrollPane> <ExpensiveComponent /> </ScrollPane> </div> ) }}

在上述實踐中,ScrollContainer組件實現了register/unregister方法用來添加或者刪除面板以及註冊 DOM 監聽事件。而ScrollPane組件僅用來在掛載時註冊,在卸載時註銷。每次面板觸發onScroll事件的時候,回調函數會獲得新的滾動高度然後自動為其他面板設置scrollTop位置值。可以在這裡查看源代碼,並且這種方式也用於了 React Native 的 Animated。


推薦閱讀:

基於 Webpack 的應用包體尺寸優化
React 實現一個漂亮的 Table
React Conf 2017 不能錯過的大起底——Day 1!
解析 Redux 源碼

TAG:React | 前端性能优化 |