setState:这个API设计到底怎么样

setState:这个API设计到底怎么样

最近,长发飘飘的Eric Elliott发推喷setState,但是他的观点招来很多反对的声音,甚至是React团队成员的反对声音,于是Eric在Medium上发文继续喷setState,详细内容可以看这里

setState() Gate

说一个东西是好是坏,每个人可以有自己的观点,咱们也不用继续争论,但是Eric在文中有一句话我觉得应该毫无争议的。

If people frequently get confused about an API, it could be an opportunity to improve that API, or at least improve the documentation.

翻译过来就是这句话。

如果一个API要是总把人搞糊涂,那就应该改进这个API,至少要应该改进文档。

谁也不想用一个不爽的API,对不对?

今天我也来说说setState这个API到底怎么样,是不是合理,是不是有可以改进之处。

React抽象来说,就是一个公式

UI=f(state)

我们把最终绘制出来的UI当做一个函数f运行的结果,f就是React和我们基于React写得代码,而f的输入参数就是state

作为React管理state的一个重要方法,setState肯定非常重要,如果只是简单用法,也不会有任何问题,但是如果用得深,就会发现很……尴尬。

我刚开始接触React的时候,就意识到React相当于一个jQuery的替代品,但是就像单独依靠jQuery难以管理大型项目,所以也需要给配合使用的MVC框架找一个替代品,我选择的替代品是Redux,我很早就将React和Redux配合使用;现在,回过头来看看React的setState,发现坑真的不少,不禁感叹自己还是挺走运的。


对setState用得深了,就容易犯错,所以我们开门见山先把理解setState的关键点列出来。

  1. setState不会立刻改变React组件中state的值;
  2. setState通过引发一次组件的更新过程来引发重新绘制;
  3. 多次setState函数调用产生的效果会合并。

这几个关键点其实是相互关联的,一个一个说吧。

setState不会立刻改变React组件中state的值

在React中,一个组件中要读取当前状态用是访问this.state,但是更新状态却是用this.setState,不是直接在this.state上修改,为什么呢?

//读取状态
const count = this.state.count

//更新状态
this.setState({count: count + 1});

//无意义
this.state.count = count + 1;

因为this.state说到底只是一个对象,单纯去修改一个对象的值是没有意义的,去驱动UI的更新才是有意义的,想想看,如果只是改了this.state这个对象,但是没有让React组件重新绘制一遍,那有什么用?你可以尝试在代码中直接修改this.state的值,会发现的确能够改变状态,但是却不会引发重新渲染。


所以,需要用一个函数去更改状态,这个函数就是setState,当setState被调用时,能驱动组件的更新过程,引发componentDidUpdate、render等一系列函数的调用。

当然,如果使用Object的setter功能,实际上也可以通过对this.state对象的直接修改来实现setState一样的功能,但是,如果React真的这么设计的话,我敢肯定,那样的API设计会更让人晕头转向,因为不管是谁,第一眼也看不出来修改一个this.state对象居然会引发重新渲染的副作用。

这么看来,React提供setState这个API是一个挺合理的决定。

因为setState并不会立刻修改this.state的值,所以下面的code可能产生很不直观的结果。

function incrementMultiple() {
  this.setState({count: this.state.count + 1});
  this.setState({count: this.state.count + 1});
  this.setState({count: this.state.count + 1});
}

直观上来看,当上面的incrementMultiple函数被调用时,组件状态的count值被增加了3次,每次增加1,那最后count被增加了3,但是,实际上的结果只给state增加了1。

原因并不复杂,就是因为调用this.setState时,并没有立即更改this.state,所以this.setState只是在反复设置同一个值而已,上面的code等同下面这样。

function incrementMultiple() {
  const currentCount = this.state.count;
  this.setState({count: currentCount + 1});
  this.setState({count: currentCount + 1});
  this.setState({count: currentCount + 1});
}

currentCount就是一个快照结果,重复地给count设置同一个值,不要说重复3次,哪怕重复一万次,得到的结果也只是增加1而已。

既然this.setState不会立即修改this.state的值,那在什么时候修改this.state的值呢?这就要说一下React的更新生命周期。

setState通过引发一次组件的更新过程来引发重新绘制

setState调用引起的React的更新生命周期函数4个函数(比修改prop引发的生命周期少一个componentWillReceiveProps函数),这4个函数依次被调用。

  • shouldComponentUpdate
  • componentWillUpdate
  • render
  • componentDidUpdate

当shouldComponentUpdate函数被调用的时候,this.state没有得到更新。

当componentWillUpdate函数被调用的时候,this.state依然没有得到更新。

直到render函数被调用的时候,this.state才得到更新。

(或者,当shouldComponentUpdate函数返回false,这时候更新过程就被中断了,render函数也不会被调用了,这时候React不会放弃掉对this.state的更新的,所以虽然不调用render,依然会更新this.state。)

如果你没兴趣去记住React的生命周期(虽然你应该记住),那就可以简单认为,直到下一次render函数调用时(或者下一次shouldComponentUpdate返回false时)才得到更新的this.state。

不管你喜欢不喜欢,反正this.state就是不会再this.setState调用之后立刻更新。

多次setState函数调用产生的效果会合并

比如下面的代码。

function updateName() {
  this.setState({FirstName: 'Morgan'});
  this.setState({LastName: 'Cheng'});
}

连续调用了两次this.setState,但是只会引发一次更新生命周期,不是两次,因为React会将多个this.setState产生的修改放在一个队列里,缓一缓,攒在一起,觉得差不多了再引发一次更新过程。

在每次更新过程中,会把积攒的setState结果合并,做一个merge的动作,所以上面的代码相当于这样。

function updateName() {
  this.setState({FirstName: 'Morgan', LastName: 'Cheng'});
}

如果每一个this.setState都引发一个更新过程的话,那就太浪费了!

对于开发者而言,也可以放心多次调用this.setState,每一次只要关注当前修改的那一个字段就行,反正其他字段会合并保留,丢不掉。

所以,合并多次this.setState调用更改的状态这个API设计决定也不错。

总结一下,setState最招骂的就是不会立即修改this.state。

如果有可能的话,怎么改进这个API呢?

首先setState肯定还是不能立刻更新this.state,不然React整个概念就被推翻了,所以能做的只是用一个更清楚的方式表达,让开发者不会误以为this.state会被this.setState立即更新,好像也没有特别好的改进方法。

不过,最近一个this.setState函数的隐藏功能进入了大家的视野,那就是:原来this.setState可以接受一个函数作为参数啊!

这真的是React游戏世界中的一个大彩蛋。

函数式的setState用法

如果传递给this.setState的参数不是一个对象而是一个函数,那游戏规则就变了。


这个函数会接收到两个参数,第一个是当前的state值,第二个是当前的props,这个函数应该返回一个对象,这个对象代表想要对this.state的更改,换句话说,之前你想给this.setState传递什么对象参数,在这种函数里就返回什么对象,不过,计算这个对象的方法有些改变,不再依赖于this.state,而是依赖于输入参数state。

比如,对于上面增加state上count的例子,可以这么写一个函数。

function increment(state, props) {
  return {count: state.count + 1};
}

可以看到,同样是把状态中的count加1,但是状态的来源不是this.state,而是输入参数state。

对应incrementMultiple的函数就是这么写。

function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState(increment);
}

对于多次调用函数式setState的情况,React会保证调用每次increment时,state都已经合并了之前的状态修改结果。

简单说,加入当前this.state.count的值是0,第一次调用this.setState(increment),传给increment的state参数是0,第二调用时,state参数是1,第三次调用是,参数是2,最终incrementMultiple的效果,真的就是让this.state.count变成了3,这个函数incrementMultiple终于实至名归。

值得一提的是,在increment函数被调用时,this.state并没有被改变,依然,要等到render函数被重新执行时(或者shouldComponentUpdate函数返回false之后)才被改变。

让setState接受一个函数的API设计很棒!因为这符合函数式编程的思想,让开发者写出没有副作用的函数,我们的increment函数并不去修改组件状态,只是把“希望的状态改变”返回给React,维护状态这些苦力活完全交给React去做。

正因为流程的控制权交给了React,所以React才能协调多个setState调用的关系。

让我们再往前推进一步,试着如果把两种setState的用法混用,那会有什么效果?

我们把incrementMultiple改成这样。

function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState({count: this.state.count + 1});
  this.setState(increment);
}

在几个函数式setState调用中插入一个传统式setState调用(嗯,我们姑且这么称呼以前的setState使用方式),最后得到的结果是让this.state.count增加了2,而不是增加4。

原因也很简单,因为React会依次合并所有setState产生的效果,虽然前两个函数式setState调用产生的效果是count加2,但是半路杀出一个传统式setState调用,一下子强行把积攒的效果清空,用count加1取代。

这么看来,传统式setState的存在,会把函数式setState拖下水啊!只要有一个传统式的setState调用,就把其他函数式setState调用给害了。

如果说setState这儿API将来如何改进,也许就该完全采用函数为参数的调用方法,废止对象为参数的调用方法。

当然,React近期肯定不会有这样的惊世骇俗的改变,但是大家可以先尝试函数式setState用法,这才是setState的未来。

【更新】后续讨论 setState为什么不会同步更新组件状态 - 知乎专栏

欢迎关注这个专栏 进击的React - 知乎专栏

编辑于 2017-05-16 12:39