Immutable 操作在 React 中的實踐

螞蟻金服資料體驗技術發表於2018-05-07

作者簡介 Amy 螞蟻金服·資料體驗技術團隊

最近在需求開發的過程中,踩了很多因為更新引用資料但是頁面不重新渲染的坑,所以對這塊的內容總結了一下。

在談及 Immutable 資料之前,我們先來聊聊 React 元件是怎麼渲染更新的。

React 元件的更新方式

state 的直接改變

React 元件的更新是由狀元件態改變引起,這裡的狀態一般指元件內的 state 物件,當某個元件的 state 發生改變時,元件在更新的時候將會經歷如下過程:

  • shouldComponentUpdate
  • componentWillUpdate
  • render()
  • componentDidUpdate

state 的更新一般是通過在元件內部執行 this.setState 操作, 但是 setState 是一個非同步操作,它只是執行將要修改的狀態放在一個執行佇列中,React 會出於效能考慮,把多個 setState 的操作合併成一次進行執行。

props 的改變

除了 state 會導致元件更新外,外部傳進來的 props 也會使元件更新,但是這種是當子元件直接使用父元件的 props 來渲染, 例如:

render(){
	return <span>{this.props.text}</span>
}

複製程式碼

當 props 更新時,子元件將會渲染更新,其執行順序如下:

  • componentWillReceiveProps (nextProps)
  • static getDerivedStateFromProps()
  • shouldComponentUpdate
  • componentWillUpdate
  • render
  • getSnapshotBeforeUpdate()
  • componentDidUpdate

示例程式碼 根據示例中的輸出顯示,React 元件的生命週期的執行順序可以一目瞭然了。

state 的間接改變

還有一種就是將 props 轉換成 state 來渲染元件的,這時候如果 props 更新了,要使元件重新渲染,就需要在 componentWillReceiveProps 生命週期中將最新的 props 賦值給 state,例如:

class Example extends React.PureComponent {
    constructor(props) {
        super(props);
        this.state = {
            text: props.text
        };
    }
    componentWillReceiveProps(nextProps) {
        this.setState({text: nextProps.text});
    }
    render() {
        return <div>{this.state.text}</div>
    }
}
複製程式碼

這種情況的更新也是 setState 的一種變種形式,只是 state 的來源不同。

React 的元件更新過程

當某個 React 元件發生更新時(state 或者 props 發生改變),React 將會根據新的狀態構建一棵新的 Virtual DOM 樹,然後使用 diff 演算法將這個 Virtual DOM 和 之前的 Virtual DOM 進行對比,如果不同則重新渲染。React 會在渲染之前會先呼叫 shouldComponentUpdate 這個函式是否需要重新渲染,整個鏈路的原始碼分析可參照這裡,React 中 shouldComponentUpdate 函式的預設返回值是 true,所以元件中的任何一個位置發生改變了,元件中其他不變的部分也會重新渲染。

當一個元件渲染的機構很簡單的時候,這種因為某個狀態改變引起整個元件改變的影響可能不大,但是當元件渲染很複雜的時候,比如一個很多節點的樹形元件,當更改某一個葉子節點的狀態時,整個樹形都會重新渲染,即使是那些狀態沒有更新的節點,這在某種程度上耗費了效能,導致整個元件的渲染和更新速度變慢,從而影響使用者體驗。

PureComponent 的淺比較

基於上面提到的效能問題,所以 React 又推出了 PureComponent, 和它有類似功能的是 PureRenderMixin 外掛,PureRenderMixin 外掛實現了 shouldComponentUpdate 方法, 該方法主要是執行了一次淺比較,程式碼如下:

function shallowCompare(instance, nextProps, nextState) {
  return (
    !shallowEqual(instance.props, nextProps) ||
    !shallowEqual(instance.state, nextState)
  );
}
複製程式碼

PureComponent 判斷是否需要更新的邏輯和 PureRenderMixin 外掛一樣,原始碼如下:

 if (this._compositeType === CompositeTypes.PureClass) {
      shouldUpdate =
        !shallowEqual(prevProps, nextProps) ||
        !shallowEqual(inst.state, nextState);
 }

複製程式碼

利用上述兩種方法雖然可以避免沒有改變的元素髮生不必要的重新渲染,但是使用上面的這種淺比較還是會帶來一些問題:

假如傳給某個元件的 props 的資料結構如下所示:

const data = {
   list: [{
      name: 'aaa',
      sex: 'man'
   },{
   	   name: 'bbb',
   	   sex: 'woman'
   }],
   status: true,
}

複製程式碼

由於上述的 data 資料是一個引用型別,當更改了其中的某一欄位,並期望在改變之後元件可以重新渲染的時候,發現使用 PureComponent 的時候,發現元件並沒有重新渲染,因為更改後的資料和修改前的資料使用的同一個記憶體,所有比較的結果永遠都是 false, 導致元件並沒有重新渲染。

解決問題的幾種方式

要解決上面這個問題,就要考慮怎麼實現更新後的引用資料和原資料指向的記憶體不一致,也就是使用Immutable資料,下面列舉自己總結的幾種方法;

使用 lodash 的深拷貝

這種方式的實現程式碼如下:

import _ from "lodash";

 const data = {
	  list: [{
	    name: 'aaa',
	    sex: 'man'
	  }, {
	    name: 'bbb',
	    sex: 'woman'
	  }],
	  status: true,
 }
 const newData = _.cloneDeepWith(data);
 shallowEqual(data, newData) //false
 
 //更改其中的某個欄位再比較
  newData.list[0].name = 'ccc';
  shallowEqual(data.list, newData.list)  //false

複製程式碼

這種方式就是先深拷貝複雜型別,然後更改其中的某項值,這樣兩者使用的是不同的引用地址,自然在比較的時候返回的就是 false,但是有一個缺點是這種深拷貝的實現會耗費很多記憶體。

使用 JSON.stringify()

這種方式相當於一種黑魔法了,使用方式如下:


  const data = {
    list: [{
      name: 'aaa',
      sex: 'man'
    }, {
      name: 'bbb',
      sex: 'woman'
    }],
    status: true,
    c: function(){
      console.log('aaa')
    }
  }
 
 const newData = JSON.parse(JSON.stringify(data))
 shallowEqual(data, newData) //false
 
  //更改其中的某個欄位再比較
  newData.list[0].name = 'ccc';
  shallowEqual(data.list, newData.list)  //false
複製程式碼

這種方式其實就是深拷貝的一種變種形式,它的缺點除了和上面那種一樣之外,還有兩點就是如果你的物件裡有函式,函式無法被拷貝下來,同時也無法拷貝 copyObj 物件原型鏈上的屬性和方法

使用 Object 解構

Object 解構是 ES6 語法,先上一段程式碼分析一下:

  const data = {
    list: [{
      name: 'aaa',
      sex: 'man'
    }, {
      name: 'bbb',
      sex: 'woman'
    }],
    status: true,
  }
  
  const newData =  {...data};
  console.log(shallowEqual(data, newData));  //false
  
  console.log(shallowEqual(data, newData));  //true
  //新增一個欄位
  newData.status = false;
  console.log(shallowEqual(data, newData));  //false
  //修改複雜型別的某個欄位
  newData.list[0].name = 'abbb';
  console.log(shallowEqual(data, newData));  //true
複製程式碼

通過上面的測試可以發現: 當修改資料中的簡單型別的變數的時候,使用解構是可以解決問題的,但是當修改其中的複雜型別的時候就不能檢測到(曾經踩過一個大坑)。

因為解構在經過 babel 編譯後是 Object.assign(), 但是它是一個淺拷貝,用圖來表示如下:

images | left

這種方式的缺點顯而易見了,對於複雜型別的資料無法檢測到其更新。

使用第三方庫

業界提供了一些庫來解決這個問題,比如 immutability-helper , immutable 或者immutability-helper-x

immutability-helper

一個基於 Array 和 Object 操作的庫,就一個檔案但是使用起來很方便。例如上面的例子就可以寫成下面這種:

   import update from 'immutability-helper';
    
    const data = {
    list: [{
      name: 'aaa',
      sex: 'man'
    }, {
      name: 'bbb',
      sex: 'woman'
    }],
    status: true,
  }
  
   const newData = update(data, { list: { 0: { name: { $set: "bbb" } } } });
   console.log(this.shallowEqual(data, newData));  //false

   //當只發生如下改變時
   const newData = update(data,{status:{$set: false}});
   console.log(this.shallowEqual(data, newData));  //false
   console.log(this.shallowEqual(data.list, newData.list));  //true
複製程式碼

同時可以發現當只改變 data 中的 status 欄位時,比較前後兩者的引用欄位,發現是共享記憶體的,這在一定程度上節省了記憶體的消耗。而且 API 都是熟知的一些對 Array 和 Object 操作,比較容易上手。

immutable

相比於 immutability-helper, immutable 則要強大許多,但是與此同時,也增加了學習的成本,因為需要學習新的 API,由於沒怎麼用過,在此不再贅述,具體知識點可移步這裡

immutability-helper-x

最後推薦下另一個開源庫immutability-helper-x,API更好用哦~可以將

const newData = update(data, { list: { 0: { name: { $set: "bbb" } } } });
複製程式碼

簡化為可讀性更強的

const newData = update.$set(data, 'list.0.name', "bbb");
或者
const newData = update.$set(data, ['list', '0', 'name'], "bbb");
複製程式碼

寫在最後

在 React 專案中,還是最好使用 immutable 資料,這樣可以避免很多渾然不知的 bug。 以上只是個人在實際開發中的一些總結和積累,如有闡述得不對的地方歡迎拍磚~

對我們團隊感興趣的可以關注專欄,關注github或者傳送簡歷至'tao.qit####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~

原文地址:github.com/ProtoTeam/b…

相關文章