从 setState promise 化的探讨 体会 React 团队设计思想

一直很好奇的一个问题,今天看到了不错的文章,记录学习下
更新于: 2023-04-01 20:11:21

前言

  • setState 是同步还是异步?
  • 如果是异步,怎么让它同步?
  • 为什么要这样设计?
  • 为什么不改成 promise 的方式?

基本概念

React 的理念之一是 UI=f(data),修改 data 即驱动 UI 变化,那么怎么修改呢?React 提供了一个 API —— setState(类组件的修改方法),下面是官网的解释:

  1. setState 会将 state 排入队列 (enqueue)
  2. 并不总是立即更新组件。它会批量推迟更新
  3. 为了消除隐患,请使用 componentDidUpdate 或者 setState 的回调函数(setState(updater, callback)),这两种方式都可以保证在应用更新后触发
setState() 将对组件 state 的更新排入队列,并通知 React 需要使用更新后的 state 重新渲染此组件及其子组件。这是用于更新用户界面以响应事件处理器和处理服务器数据的主要方式为了更好的感知性能,React 会延迟调用它,然后通过一次传递更新多个组件。React 并不会保证 state 的变更会立即生效

setState() 并不总是立即更新组件。它会批量推迟更新。这使得在调用 setState() 后立即读取 this.state 成为了隐患。为了消除隐患,请使用 componentDidUpdate 或者 setState 的回调函数(setState(updater, callback)),这两种方式都可以保证在应用更新后触发

除非 shouldComponentUpdate() 返回 false,否则 setState() 将始终执行重新渲染操作。如果可变对象被使用,且无法在 shouldComponentUpdate() 中实现条件渲染,那么仅在新旧状态不一致调用 setState()可以避免不必要的重新渲染

setState使用

  • 用法1: updater(obj)
  • 用法2: updater(callback)
setState(updater, [callback])

// 用法1: 例如:this.setState({count: 2})

// 用法2: 例如
// this.setState((state, props) => {
//   return {counter: state.counter + props.step};
// });

setState vs componentDidUpdate

为什么会拿这2个做比较,其实上文中提到,setState 出现的异步问题,可以通过 componentDidUpdate (或者 shouldComponentUpdate )解决掉

解释这个问题的原文在这里: https://stackoverflow.com/questions/56501409/what-is-the-advantage-of-using-componentdidupdate-over-the-setstate-callback

stackoverflow 有人问过,也有人回答过:

  • 一致的逻辑:这个我理解就是代码的一致性,美观度
  • 批量更新:因为 componentDidUpdate 是每次
  • 什么时候 setState 会比较好?
    • 当外部代码需要等待状态更新时,如 Promise

setState 的特性

  • 批处理: 同一周期内对多个 setState 进行处理,会被首先合并为1次,
  • 另外 setState 一定会引发更新过程,但不一定会引发 render 被执行,因为 shouldCompomentUpdate 可以返回 false
如果在同一周期内对多个 setState 进行处理,例如,在同一周期内多次设置商品数据,相当于:
this.setState({count: state.count + 1});
this.setState({count: state.count + 1});
this.setState({count: state.count + 1});
// === 
Object.assign(
  count,
  {quantity: state.quantity + 1},
  {quantity: state.quantity + 1},
  ...
)
后调的 setState 将覆盖同一周期内先调用 setState 的值
setState(stateChange[, callback])
setState((state, props) => stateChange[, callback])
setState 必引发更新过程,但不一定会引发 render 被执行,因为 shouldCompomentUpdate 可以返回 false

批处理引发的问题

state.count = 0;
this.setState({count: state.count + 1}); 
this.setState({count: state.count + 1}); 
this.setState({count: state.count + 1}); 
// state.count === 1,不是 3
import React from "react";

export class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = { value: 0, count: 0 };
  }

  componentDidUpdate(prevProps, prevState) {
    console.log("componentDidUpdate: " + this.state.value);
  }

  onClick = () => {
    // aric: 一次 click,简称为一个执行周期;这种写法,基本就是只执行最后一次了
    // 最终的state =
    // Object.assign(
    //   { count: state.count + 2 },
    //   { count: state.count + 3 },
    //   { count: state.count + 4 }
    // );

    const state = this.state;
    this.setState({ count: state.count + 2 });
    this.setState({ count: state.count + 3 });
    this.setState({ count: state.count + 4 });
    console.log(this.state.count);
  };

  render() {
    return <button onClick={this.onClick}>{this.state.value}</button>;
  }
}

// Log to console
console.log("Hello console");

为什么要 setState,而不是直接 this.state.xx = oo?

因为 setState 做的事情不仅仅只是修改了 this.state 的值,另外最重要的是它会触发 React 的更新机制,会进行diff,然后将 patch 部分更新到真实 dom 里

如果你直接 this.state.xx = oo 的话,state 的值确实会改,但是它不会驱动 React 重渲染。setState 能帮助我们更新视图,引发 shouldComponentUpdate、render 等一系列函数的调用。至于批处理,React 会将 setState 的效果放入队列中,在事件结束之后产生一次重新渲染,为的就是把 Virtual DOM 和 DOM 树操作降到最小,用于提高性能

当调用 setState 后,React 的 生命周期函数 会依次顺序执行

  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • render
  • getSnapshotBeforeUpdate
  • componentDidUpdate

问题3:那为什么会出现异步的情况呢?(为什么这么设计?)

因为性能优化。假如每次 setState 都要更新数据,更新过程就要走五个生命周期,走完一轮生命周期再拿 render 函数的结果去做 diff 对比和更新真实 DOM,会很耗时间。所以将每次调用都放一起做一次性处理,能降低对 DOM 的操作,提高应用性能

那如何在表现出异步的函数里可以准确拿到更新后的 state 呢?

  • 回调函数里
  • 或者可以直接给 state 传递函数来表现出同步的情况
onHandleClick() {
    this.setState(
        {
            count: this.state.count + 1,
        },
        () => {
            console.log("点击之后的回调", this.state.count); // 最新值
        }
    );
}
this.setState(state => {
	console.log("函数模式", state.count);
	return { count: state.count + 1 };
});

三种渲染模式

  • legacy 模式:ReactDOM.render(<App />, rootNode) 。这是当前 React app 使用的方式。当前没有计划删除本模式,但是这个模式可能不支持新功能
  • blocking 模式:ReactDOM.createBlockingRoot(rootNode).render(<App />) 。目前正在实验中,作为迁移到 concurrent 模式的第一个步骤
  • concurrent 模式 :ReactDOM.createRoot(rootNode).render(<App />)。目前再实验中,未来稳定之后,打算作为 React 的模式开发模式。这个模式开启了所有的新功能
    • 拥有不同的优先级,更新的过程可以被打断
  1. 在 legacy 模式下,在 React 的 setState 函数实现中,会根据一个变量 isBatchingUpdates 判断是直接更新 this.state 还是放到队列中回头再说,而 isBatchingUpdates 默认是 false,也就表示 setState 会同步更新 this.state,但是,有一个函数 batchedUpdates,这个函数会把 isBatchingUpdates 修改为 true,而当 React 在调用事件处理函数之前就会调用这个 batchedUpdates,造成的后果,就是由 React 控制的事件处理过程 setState 不会同步更新 this.state
  2. 像 addEventListener 绑定的原生事件、setTimeout/setInterval 会走同步,除此之外,也就是 React 控制的事件处理 setState 会异步
  3. 而 concurrent 模式都是异步,这也是未来 React 18 的默认模式

总结下关键知识点

  • setState 不会立即改变 React 组件中 state 的值
  • setState 通过引发一次组件的更新过程来引发重新绘制
  • 多次 setState 函数调用产生的效果会合并(批处理)

setState 是同步还是异步?

setState是同步还是异步
  • 代码同步,渲染看模式
    • legacy 模式,非原生事件、setTimeout/setInterval 的情况下为异步;addEventListener 绑定原生事件、setTimeout/setInterval 时会同步
    • concurrent 模式:异步

第 2642 Issue 解读和深入分析

一切的探究始于 React 第 #2642 号 issue: Make setState return a promise,上面关于 count 连续 +3 大家已经有所了解。接下来我举一个真正在生产开发中的例子,方便大家理解讨论。

我们现在开发一个可编辑的 table,需求是:

  • 当用户敲下“回车”,光标将会进入下一行(调用 setState 进行光标移动);
  • 如果用户当前已经在最后一行,那么敲下回车时,第一步将先创建一个新行(调用 setState 创建新的最后一行)
  • 在新行创建之后,再去新的最后一行进行光标聚焦(调用 setState 进行光标移动)。

利用 promise 改造 setState

探索 React 源码,完成 setState promise 化的改造

首先找到源码中关于 setState 定义的地方,它在 react/src/isomorphic/modern/class/ReactBaseClasses.js 这个目录下:

ReactComponent.prototype.setState = function(partialState, callback) {
  invariant(
    typeof partialState === 'object' ||
      typeof partialState === 'function' ||
      partialState == null,
    'setState(...): takes an object of state variables to update or a ' +
      'function which returns an object of state variables.',
  );
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};

利用 promise 改造的版本

ReactComponent.prototype.setState = function(partialState, callback) {
   invariant(
     typeof partialState === 'object' ||
       typeof partialState === 'function' ||
       partialState == null,
      'setState(...): takes an object of state variables to update or a ' +
        'function which returns an object of state variables.',
    );
 +  let callbackPromise;
 +  if (!callback) {
 +    class Deferred {
 +      constructor() {
 +        this.promise = new Promise((resolve, reject) => {
 +          this.reject = reject;
 +          this.resolve = resolve;
 +        });
 +      }
 +    }
 +    callbackPromise = new Deferred();
 +    callback = () => {
 +      callbackPromise.resolve();
 +    };
 +  }
    this.updater.enqueueSetState(this, partialState, callback, 'setState');
 +
 +  if (callbackPromise) {
 +    return callbackPromise.promise;
 +  }
  }; 

官方并不认可这个方案

盲目的扩充 setState 接口只会是一个短视的行为。

很遗憾,答案是否定的。我们来从 React 设计思想上,和 React 官方团队的回应上,了解一下否决理由。

sebmarkbage(Facebook 工程师,React 核心开发者)认为:解决异步带来的困扰方案其实很多。比如,我们可以在合适的生命周期 hook 函数中完成相关逻辑。在这个场景里,就是在行组件的 componentDidMount 里调用 focus,自然就完成了自动聚焦。

此外,还有一个方法:新的 refs 接口设计支持接收一个回调函数,当其子组件挂载时,这个回调函数就会相应触发。

实现一个 setStateAsync

仅是提供一个实现,但并不推荐:

This is really dirty, it will promisify the current context functions that includes reacts and your own. only use it when your lazy and not in production.

  • Mixin
  • Or 继承
  • 原生 Promise 实现
import Promise from "bluebird";

export default {
  componentWillMount() {
    this.setStateAsync = Promise.promisify(this.setState);
  },
};
import React from "react";
import Promise from "bluebird";

// ES6 class - have not tested this example (wrote it in the comment here) but should work
export default class Test extends React.Component {
  constructor() {
    super();
    this.setStateAsync = Promise.promisify(this.setState);
  }
}
class AbstractComponent extends React.Component {
    setStatePromise(newState) {
        return new Promise((resolve) => {
            this.setState(newState, () => {
                resolve();
            });
        });
    }
}

一个Mixin的实现

  • 亮点: User.propto 可写,但不可读
// mixin
let sayHiMixin = {
  sayHi() {
    alert(`Hello ${this.name}`);
  },
  sayBye() {
    alert(`Bye ${this.name}`);
  }
};

// usage:
class User {
  constructor(name) {
    this.name = name;
  }
}

// copy the methods
Object.assign(User.prototype, sayHiMixin);

// now User can say hi
new User("Dude").sayHi(); // Hello Dude!

最后总结

对此,Redux 原作者 Dan Abramov 也发表了自己的看法。他认为,以他的经验来看,任何需要使用 setState 第二个参数 callback 的场景,都可以使用生命周期函数 componentDidUpdate (and/or componentDidMount) 来复写。

In my experience, whenever I'm tempted to use setState callback, I can achieve the same by overriding componentDidUpdate (and/or componentDidMount).

另外,在一些极端场景下,如果开发者确实需要同步的处理方式,比如如果我想在某 DOM 元素挂载到屏幕之前做一些操作,promises 这种方案便不可行。因为 Promises 总是异步的。反过来,如果 setState 支持这两种不同的方式,那么似乎也是完全没有必要而多余的。

在社区,确实很多第三方库渐渐地接受使用 promises 风格,但是这些库解决的问题往往都是强异步性的,比如文件读取、网络操作等等。 React 似乎没有必要增加这么一个 confusing 的特性。

另外,如果每个 setState 都返回一个 promises,也会带来性能影响:对于 React 来说,setState 将必然产生一个 callback,这些 callbacks 需要合理储存,以便在合适时间来触发。

总结一下,解决 setState 异步带来的问题,有很多方式能够完美优雅地解决。在这种情况下,直接让 setState 返回 promise 是画蛇添足的。另外,这样也会引起性能问题等等。

我个人认为,这样的思路很好,但是难免有些 Overengineering。

参考