Immutable 操作在 React 中的实践

avatar
数据可视化 @蚂蚁集团

作者简介 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…