React 的效能優化(一)當 PureComponent 遇上 ImmutableJS

有贊前端發表於2017-09-29

一、痛點

在我們的印象中,React 好像就意味著元件化、高效能,我們永遠只需要關心資料整體,兩次資料之間的 UI 如何變化,則完全交給 React Virtual DomDiff 演算法 去做。以至於我們很隨意的去操縱資料,基本優化shouldComponentUpdate 也懶得去寫,畢竟不寫也能正確渲染。但隨著應用體積越來越大,會發現頁面好像有點變慢了,特別是元件巢狀比較多,資料結構比較複雜的情況下,隨便改變一個表單項,或者對列表做一個篩選都要耗時 100ms 以上,這個時候我們就需要優化了!當然如果沒有遇到效能瓶頸,完全不用擔心,過早優化是邪惡的。這裡我們總結一個很簡單的方案來讓 React 應用效能發揮到極致。在下面一部分,我們先回顧一下一些背景知識,包括:JavaScript 變數型別和 React 渲染機制,如果你是老鳥可以直接跳過。

二、一些背景知識的回顧

1. 變數型別

JavaScript的變數型別有兩類:

  • 基本型別:6 種基本資料型別, UndefinedNullBooleanNumberStringSymbol
  • 引用型別:統稱為 Object 型別,細分為:Object 型別、 Array 型別、 Date 型別、 RegExp 型別、 Function 型別等。

舉個例子:

let p1 = { name: 'neo' };
let p2 = p1;
p2.name = 'dave';
console.log(p1.name); // dave複製程式碼

在引用型別裡,宣告一個 p1 的物件,把 p1 賦值給 p2 ,此時賦的其實是該物件的在堆中的地址,而不是堆中的資料,也就是兩個變數指向的是同一個儲存空間,後面 p2.name 改變後,也就影響到了 p1。雖然這樣做可以節約記憶體,但當應用複雜後,就需要很小心的運算元據了,因為一不注意修改一個變數的值可能就影響到了另外一個變數。如果我們想要讓他們不互相影響,就需要拷貝出一份一模一樣的資料,拷貝又分淺拷貝與深拷貝,淺拷貝只會拷貝第一層的資料,深拷貝則會遞迴所有層級都拷貝一份,比較消耗效能。

2. React

React 中,每次 setStateVirtual DOM 會計算出前後兩次虛擬 DOM 物件的區別,再去修改真實需要修改的 DOM 。由於 js 計算速度很快,而操作真實 DOM 相對比較慢,Virtual DOM 避免了沒必要的真實 DOM 操作,所以 React 效能很好。但隨著應用複雜度的提升, DOM 樹越來越複雜,大量的對比操作也會影響效能。比如一個 Table 元件,修改其中一行 Tr 元件的某一個欄位, setState 後,其他所有行 Tr 元件也都會執行一次 render 函式,這其實是不必要的。我們可以通過 shouldComponentUpdate 函式決定是否更新元件。大部分時候我們是可以知道哪些元件是不會變的,根本就沒必要去計算那一部分虛擬 DOM

三、 PureComponent

React15.3 中新加了一個類PureComponent,前身是 PureRenderMixin ,和 Component 基本一樣,只不過會在 render 之前幫元件自動執行一次shallowEqual(淺比較),來決定是否更新元件,淺比較類似於淺複製,只會比較第一層。使用 PureComponent 相當於省去了寫 shouldComponentUpdate 函式,當元件更新時,如果元件的 propsstate

  1. 引用和第一層資料都沒發生改變, render 方法就不會觸發,這是我們需要達到的效果。
  2. 雖然第一層資料沒變,但引用變了,就會造成虛擬 DOM 計算的浪費。
  3. 第一層資料改變,但引用沒變,會造成不渲染,所以需要很小心的運算元據。

四、 Immutable.js

Immutable.jsFacebook2014 年出的永續性資料結構的庫,永續性指的是資料一旦建立,就不能再被更改,任何修改或新增刪除操作都會返回一個新的 Immutable 物件。可以讓我們更容易的去處理快取、回退、資料變化檢測等問題,簡化開發。並且提供了大量的類似原生 JS 的方法,還有 Lazy Operation 的特性,完全的函數語言程式設計。

import { Map } from "immutable";
const map1 = Map({ a: { aa: 1 }, b: 2, c: 3 });
const map2 = map1.set('b', 50);
map1 !== map2; // true
map1.get('b'); // 2
map2.get('b'); // 50
map1.get('a') === map2.get('a'); // true複製程式碼

可以看到,修改 map1 的屬性返回 map2,他們並不是指向同一儲存空間,map1 宣告瞭只有,所有的操作都不會改變它。

ImmutableJS 提供了大量的方法去更新、刪除、新增資料,極大的方便了我們操縱資料。除此之外,還提供了原生型別與 ImmutableJS 型別判斷與轉換方法:

import { fromJS, isImmutable } from "immutable";
const obj = fromJS({
  a: 'test',
  b: [1, 2, 4]
}); // 支援混合型別
isImmutable(obj); // true
obj.size(); // 2
const obj1 = obj.toJS(); // 轉換成原生 `js` 型別複製程式碼

ImmutableJS 最大的兩個特性就是: immutable data structures(永續性資料結構)與 structural sharing(結構共享),永續性資料結構保證資料一旦建立就不能修改,使用舊資料建立新資料時,舊資料也不會改變,不會像原生 js 那樣新資料的操作會影響舊資料。而結構共享是指沒有改變的資料共用一個引用,這樣既減少了深拷貝的效能消耗,也減少了記憶體。比如下圖:

tree
tree

左邊是舊值,右邊是新值,我需要改變左邊紅色節點的值,生成的新值改變了紅色節點到根節點路徑之間的所有節點,也就是所有青色節點的值,舊值沒有任何改變,其他使用它的地方並不會受影響,而超過一大半的藍色節點還是和舊值共享的。在 ImmutableJS 內部,構造了一種特殊的資料結構,把原生的值結合一系列的私有屬性,建立成 ImmutableJS 型別,每次改變值,先會通過私有屬性的輔助檢測,然後改變對應的需要改變的私有屬性和真實值,最後生成一個新的值,中間會有很多的優化,所以效能會很高。

五、 案例

首先我們看看只使用 React 的情況下,應用效能為什麼會被浪費,程式碼地址:github.com/wulv/fe-exa… ,這個案例使用 create-react-app,檢測工具使用 chrome 外掛:React Perf。執行

git clone https://github.com/wulv/fe-example.git
cd fe-example/react-table
yarn
yarn start複製程式碼

可以開啟頁面,開始記錄,然後隨便對一列資料進行修改,結束記錄,可以看到我們僅修改了一行資料,但在 Print Wasted 那一項裡,渲染 Tr 元件浪費了5次:

react-table
react-table

無論是新增,刪除操作,都會浪費 n-1render ,因為 App 元件的整個 state 改變了,所有的元件都會重新渲染一次,最後對比出需要真實 DOM 的操作。我們把 Table 元件和 Tr 繼承的 Component 改成 PureComponent ,那麼, Tr 元件每次更新都會進行一次 shallowEqual 比較,在記錄一次,會發現修改操作沒有了浪費,然而這個時候新增和刪除操作卻無效了,分析一下新增的操作是:

 add = () => {
    const  { data } = this.state;
    data.push(dataGenerate())
    this.setState({
      data
    })
  }複製程式碼

data.push 並沒有改變 data 的引用,所以 PureComponentshallowEqual 直接返回了 true ,不去 render 了。這並不是我們想要的,所以如果使用 Component 必定帶來效能浪費,使用 PureComponent 又必須保證元件需要更新時,propsstate 返回一個新引用,否則不會更新 UI

這個時候, ImmutableJS 就可以顯示出它的威力了,因為它可以保證每次修改返回一個新的 Object,我們看看修改後的例子:程式碼地址:github.com/wulv/fe-exa… ,執行上面例子同樣的操作,可以看到:

react-immutablejs
react-immutablejs

新增,刪除,修改操作,沒有一次浪費。沒有浪費的原因是所有的子元件都使用了 PureComponentImmutableJS 保證修改操作返回一個新引用,並且只修改需要修改的節點(PureComponent 可以渲染出新的改動),其他的節點引用保持不變(PureComponent 直接不渲染)。可以看出, PureComponentImmutableJS 簡直是天生一對啊,如果結合 redux ,那就更加完美了。因為 reduxreducer 必須每次返回一個新的引用,有時候我們必須使用 clone 或者 assign 等操作來確保返回新引用,使用 ImmutanleJS 天然保證了這一點,根本就不需要 lodash 等函式庫了,比如我使用redux + immutable + react-router + express 寫了一個稍微複雜點的例子:github.com/wulv/fe-exa… pageIndexstore 的狀態是:

{
  loading: false,
  tableData: [{
    "name": "gyu3w0oa5zggkanciclhm2t9",
    "age": 64,
    "height": 121,
    "width": 71,
    "hobby": {
      "movie": {
        "name": "zrah6zrvm9e512qt4typhkt9",
        "director": "t1c69z1vd4em1lh747dp9zfr"
      }
    }
  }],
  totle: 0
}複製程式碼

如果我需要快速修改 width 的值為90,比較一下使用深拷貝、 Object.assignImmutableJS 三種方式的區別:

// payload = { name: 'gyu3w0oa5zggkanciclhm2t9', width: 90 }
// 1. 使用深拷貝
 updateWidth(state, payload) {
    const newState = deepClone(state);
    return newState.tableData.map(item => {
      if (tem.name === payload.name) {
        item.width = payload.width;
      }
      return item;
    });
  }
// 2. 使用Object.assign
 updateWidth(state, payload) {
    return Object.assign({}, state, {
      tableData: state.state.map(item => {
        if (item.name === payload.name) {
          return Object.assign({}, item, { width: payload.width });
        }
        return item;
      })
    })
  }
// 3. 使用ImmutableJS
 updateWidth(state, payload) {
  return state.update('tableData', list => list.update(
      list.findIndex((item) => item.get('name') === payload.name),
    item => item.set('width', payload.width)));
  }複製程式碼

使用深拷貝是一個昂貴的操作,而且引用都改變了,必然造成 re-render, 而 Object.assign 會淺複製第一層,雖然不會造成 re-render,但淺複製把其他的屬性也都複製了一次,在這裡也是很沒有必要的,只有使用 ImmutableJS 完美的完成了修改,並且程式碼也最少。

六、 優勢與不足

可以看出, ImmutableJS 結合 PureComponent 可以很大程度的減少應用 re-render 的次數,可以大量的提高效能。但還是有一些不足的地方:

  1. 獲取元件屬性必須用 getgetIn 操作(除了 Record 型別),這樣和原生的.操作比起來就麻煩多了,如果元件之前已經寫好了,還需要大量的修改。
  2. ImmutableJS 庫體積比較大,大概56k,開啟 gzip 壓縮後16k。
  3. 學習成本。
  4. 難以除錯,在 redux-logger 裡面需要在 stateTransformer 配置裡執行 state.toJS()

七、 最佳實踐

其實,重要的是程式設計者需要有效能優化的意識,熟悉 js 引用型別的特性,瞭解事情的本質比會使用某個框架或庫更加重要。用其他的方法也是完全可以達到 ImmutableJS 的效果,比如新增資料可以使用解構操作符的方式:

 add = () => {
    const  { data } = this.state;
    this.setState({
      data: [...data, dataGenerate()]
    })
  }複製程式碼

只不過如果資料巢狀比較深,寫起來還是比較麻煩。以下有一些小技巧:

  1. 還有兩個輕量庫可以實現不可變資料結構:seamless-immutable或者immutability-helper,只不過原理完全不一樣,效率也沒那麼高。
  2. 避免大量使用 toJS 操作,這樣會浪費效能。
  3. 不要將簡單的 JavaScript 物件與 Immutable.JS 混合
  4. 結合 redux 的時候,要使用import { combineReducers } from 'redux-immutablejs';,因為 reduxcombineReducers 期望 state 是一個純淨的 js 物件。
  5. 儘量將 state 設計成扁平狀的。
  6. 展示元件不要使用 Immutable 資料結構。
  7. 不要在 render 函式裡一個 PureComponent 元件的 props 使用 bind(this) 或者 style={ { width: '100px' } },因為 shallowEqual 一定會對比不通過。

八、 參考連結

本文首發於有贊技術部落格

相關文章