Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React - setState 学习笔记 #2

Open
jtwang7 opened this issue May 20, 2021 · 3 comments
Open

React - setState 学习笔记 #2

jtwang7 opened this issue May 20, 2021 · 3 comments

Comments

@jtwang7
Copy link
Owner

jtwang7 commented May 20, 2021

React - setState 学习笔记

参考文章:

行文结构遵循参考文章排版。

setState用法

setState(updater[, callback])

  • updater :
    • updater is Object: setState(stateChange[, callback])
    • updater is Function: setState((state, props) => stateChange[, callback])
  • callback:state 更新后调用的回调函数。

setState执行流程

执行顺序:
setState -> render -> componentDidUpdate -> setState-callback

setState 不能保证同步。

注意,此处用词是不能保证同步,即 setState 可能是同步的,也可能是异步的,这取决于 setState 的调用场景。

setState异步执行

setState 在合成事件处理程序(React 内部对原生事件处理程序封装后的事件)以及生命周期中的更新是异步的。
updater 会被放入到队列中,这就导致 setState 执行完后不会立即更新 state,触发重新渲染。多次 setState 的调用只会产生一次批量更新,触发一次重新渲染。

setState 异步执行,实际上是将 updater 推入一个队列维护,然后寻找一个”合适的时机“,批量处理(清空)这个队列中的所有 updater。

  • Q:合适的时机是什么时候?

setState 的”异步“与 JS 中常说的异步性质不同。setState 的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是 setState 合并对象的操作被安排在了所有同步操作之后,导致在同步操作中没法立马拿到更新后的值,形式了所谓的“异步”。

setState同步执行

setState 在原生的事件处理函数以及异步回调函数中是同步执行的。
同步的 setState 执行后会立即执行 render()、componentDidUpdate(),再执行下一个 setState。即遵循 setState 的执行流程。

未来

摘录自React的核心成员 - Dan Abramov 的回答
Currently (React 16 and earlier), only updates inside React event handlers are batched by default. There is an unstable API to force batching outside of event handlers for rare cases when you need it.
In future versions (probably React 17 and later), React will batch all updates by default so you won't have to think about this. As always, we will announce any changes about this on the React blog and in the release notes.

大致意思就是,未来 React 计划将 setState 统一默认为异步,批量更新。

@jtwang7
Copy link
Owner Author

jtwang7 commented May 20, 2021

函数式 setState

参考文章:

行文结构遵循参考文章排版。

首先我们加深一下对 setState 工作原理的理解:
setState 接收两个参数,第一个参数的”最终结果“是一个对象,该对象可以直接传入,也可以通过函数返回,第二个参数接收一个回调,其在 state 对象更新之后执行。
setState 更新 state 是通过对象合并的方式实现的,setState 通过将接收的对象合并到 state 来更新或设置 state。

函数式 setState:其实就是 setState 第一参数传递函数时的写法。

setState((state, props) => stateChange[, callback])

函数式 setState 接收一个函数作为第一参数,该函数接受组件的"先前 state"(previous state)和"当前的 props"(current props),它用于计算并返回"下一个 state"(next state)。

React 为什么致力于推广函数式 setState ?

React 一直致力于在 JavaScript 中推广函数式编程。在 setState 的使用上,也明确推荐传递一个函数作为第一参数,而不是直接传递对象。原因如下:

  1. setState 更新状态可能是异步的(上述提及了发生 setState 异步调用的场景)

出于性能的考虑,React 会维护队列来批处理多个 setState。若直接传递对象作为 setState 的第一参数,在调用该 setState 时,React 会将传递给 setState() 的对象推入到一个队列中而不是立即合并到当前状态,推入队列的对象会在”某一时刻“被 setState 合并,以此更新组件的状态,随后利用合并得到的新 state 对象调用 render 进行组件的重新渲染(可以看到,批处理使得 setState 和重新渲染只执行了一次)。

setState 决定合并 state 对象的时刻是什么时候?(一直很困惑...)

setState 合并类似于 ES6 的 Object.assign(),它将所有被推入队列中的 updater 对象按推入顺序排列,在合并的时候,源对象中的 key&value 会被合并到目标对象中,当 key 相同时,后一个 key&value 就会覆盖前一个。

Object.assign(
  // 按推入顺序排列
  {},
  {score: state.score   1},
  {score: state.score   1},
  {score: state.score   1},
)

如上例所示,对象合并后,由于合并过程中键值对重复会覆盖的特点,最终结果实际上仍是执行了一次 setState({score: state.score 1})

如果我们传入一个函数作为 setState 的第一参数,最终结果与上例会有区别吗?答案是肯定的。
首先 setState 会将 updater(这里是函数)放入队列,以下用代码模拟该过程。

const updateQueue = [
  (state) => ({score : state.score   1}),
  (state) => ({score : state.score   1}),
  (state) => ({score : state.score   1})
];

然后在某一时刻会发生对象合并,合并的顺序遵循 updater 推入队列的数据,可以理解为将 updater 逐一从队列(先进先出)弹入到合并函数内(此处用 Object.assign 模拟)。

Object.assign(
  // 按推入顺序排列
  {},
  (state) => ({score : state.score   1}),
  (state) => ({score : state.score   1}),
  (state) => ({score : state.score   1})
)

为了得到参与合并的源对象,程序会按照顺序调用执行回调函数,这就保证了下一个回调函数内使用的 state 是上一次回调更新后的 state。

  1. setState 实现了声明状态更改与组件类的分离

函数式 setState 真正强大的功能在于,其可以在组件类之外声明状态更新逻辑。然后在组件类中调用它。

// outside your component class
function increaseScore (state, props) {
  return {score : state.score   1}
}
class User{
  ...
// inside your component class
  handleIncreaseScore () {
    // 将逻辑部分分离出 class 类
    this.setState( increaseScore )
  }
  ...
}

除此之外你还可以将相关的状态更改逻辑整合到一起,然后通过模块化导入的方式引用。

// 将逻辑通过模块化引入
import {increaseScore} from "../stateChanges";
class User{
  ...
  // inside your component class
  handleIncreaseScore () {
    this.setState( increaseScore)
  }
  ...
}\
  1. 函数式 setState 使得我们可以传递其他额外的参数用于计算下一个 state
function multiplyBy(number) {
  return function increaseScore (state, props) {
    return {score : state.score   number}
  }
}
class User{
  ...
// inside your component class
  handleIncreaseScore () {
    // 现在可以传递不同的 number 来计算下一个 state 了
    this.setState( multiplyBy(5) )
  }
  ...
}

总结

尽量使用函数式 setState:

  1. 可有效避免因 setState 异步更新所导致的一系列问题。使用函数式 setState 可以保证得到当前调用前一刻的最新 state 状态。
  2. setState 实现了状态更改的逻辑部分与组件类的分离。
  3. 函数式 setState 使得我们可以传递其他额外的参数用于计算下一个 state。即我们除了可以获得 prevState 外,还可以自定义传入额外的参数。

@jtwang7
Copy link
Owner Author

jtwang7 commented May 20, 2021

setState 的合并时机

参考文章:

前言

看过很多关于 setState 执行机制的文章,它们都提及了 setState 的异步场景,我也从中学习到了 setState 状态批处理的一些思想。但这些文章也都不约而同地避开了 setState 究竟是何时完成对象合并的这一问题。跟随上述文章,实现一个简单的异步 setState,你将解开这一疑惑。

异步 setState

setState 的批处理优化

同步的 setState 有一个明显的问题在于:每次调用setState都会触发更新并马上触发一次渲染,即便每次更新的状态键值对是相同的值。

同步 setState 简单实现如下:

// 接收新的状态对象
setState( stateChange ) {
    // 合并到目标对象
    Object.assign( this.state, stateChange );
    // 组件渲染
    renderComponent( this );
}

因此,React 针对 setState 做了一些优化:React会将多个setState的调用合并成一个来执行,这意味着当调用setState时,state并不会立即更新,而是通过维护队列,存储 updater ,然后再在”合适的时机“清空队列,实现对象的合并和组件的渲染。

异步 setState 实现要求

  • 异步更新state,将短时间内的多个setState合并成一个
  • 实现 updater 的两种形态:接受一个对象,或接受一个返回对象的回调函数。

setState 队列

为合并多个 stateChange,我们需要一个队列来保存每次 setState 接收的 stateChange,然后在一段时间后,清空这个队列并渲染组件。

// 声明队列
const queue = [];
function enqueueSetState( stateChange, component ) {
    // 每一次 setState 执行实际是将接收的 stateChange 推入队列存放
    // 此处还接收了 component,主要用于后续获取前一个 state
    queue.push( {
        stateChange,
        component
    } );
}

此时 setState 可以写为:

setState( stateChange) {
    enqueueSetState( stateChange, this );
}

为什么选用队列这一数据结构?
队列特点是“先进先出”,使用队列可以保证 stateChange 在合并过程中的顺序是正确的。

清空队列

我们已经实现了将 stateChange 推入队列,此外我们要需要实现一个函数,用于将队列内的 stateChange 逐一取出并最终合并成一个对象(即清空队列并返回新的 state 对象)。

function flush() {
    let item;
    // 遍历队列
    while( item = setStateQueue.shift() ) {
        const { stateChange, component } = item;

        // 如果没有prevState,则将当前的state作为初始的prevState
        // component 就是当前的组件实例,从组件实例上获取 state 属性
        if ( !component.prevState ) {
            component.prevState = Object.assign( {}, component.state );
        }

        // 如果 stateChange 是一个方法,也就是setState的第二种形式,则执行该方法,然后将返回结果与目标对象合并
        if ( typeof stateChange === 'function' ) {
            Object.assign( component.state, stateChange( component.prevState, component.props ) );
        } else {
            // 如果stateChange是一个对象,则直接合并到setState中
            Object.assign( component.state, stateChange );
        }

        // 获取最新的 state
        component.prevState = component.state;
    }
}

组件渲染

至此我们只实现了 state 的一套更新流程,setState 在 state 状态更新完成后还需要触发 render 渲染组件。渲染组件需要与遍历清空 stateChange 队列分开进行,因为同一个组件可能会多次添加到队列中,我们需要另一个队列保存所有组件,不同之处是,这个队列内不会有重复的组件。
我们修改以下 enqueueSetState 函数,让其能够维护 stateChange 队列和组件队列,其中保证组件队列内容是唯一的。

// stateChange 队列
const queue = [];
// 组件队列
const renderQueue = [];
function enqueueSetState( stateChange, component ) {
    queue.push( {
        stateChange,
        component
    } );
    // 如果 renderQueue 里没有当前组件,则添加到队列中,及避免重复添加相同组件
    if ( !renderQueue.some( item => item === component ) ) {
        renderQueue.push( component );
    }
}

同时在 flush 清空队列的方法中,我们加入遍历渲染 renderQueue 队列的逻辑,保证 renderQueue 队列清空,所有组件都重新渲染。

function flush() {
    let item, component;
    while( item = queue.shift() ) {
        // ...
    }
    // 渲染每一个组件
    while( component = renderQueue.shift() ) {
        renderComponent( component );
    }
}

★ 重点:何时执行 flush 方法(合并对象并重新渲染组件)

我们已经实现了 stateChange 和对应组件的存储,同时也实现了这两个存储队列的清空过程。在队列的存储过程触发时机比较明显,即当我们组件调用 setState 时触发。

将 stateChange 和调用 setState 的组件推入队列的行为,我们可以看作是同步的,且在 setState 调用时立即执行。

当存储过程完成后,我们需要合并一段时间内所有的setState,也就是在一段时间后才执行flush方法来清空队列,关键是这个“一段时间“怎么由程序自行决定。

  • 一个比较好的方法是利用 js 的事件循环机制(Event Loop),将 flush 排到所有同步任务之后执行。

可将函数延迟执行的方法有:Promise.resolve().then(fn)setTimeout(fn, 0) 等,它们共同点都是将异步函数放入任务队列中,等待主线程的同步函数执行完毕后再从队列中取出函数执行。

定义一个延迟执行函数的方法

function defer( fn ) {
    return Promise.resolve().then( fn );
}

将 flush 函数通过该延迟方法调用,使其在队列 push 完成后执行。

function enqueueSetState( stateChange, component ) {
    // 空队列时调用 defer(flush),此时 flush 被推入事件循环的任务队列,而不是立即执行
    if ( queue.length === 0 ) {
        defer( flush );
    }
    // 同步代码被推入主线程并执行
    queue.push( {
        stateChange,
        component
    } );
    if ( !renderQueue.some( item => item === component ) ) {
        renderQueue.push( component );
    }
    // 所有同步代码执行完毕后,再从任务队列调用 flush 执行 state 更新以及组件渲染。
}

总结

一个 React 组件调用 setState 后的一系列过程:

  1. React 组件(函数)执行内部的同步代码
  2. React 碰到 setState 并调用,立即推入接收的 updater 和当前调用组件到不同的维护队列
  3. 不会执行更新 state 和渲染 render 的操作,跳出 setState 继续执行 React 组件函数体内的同步代码

此时 setState 实际上已经执行完毕了,state 更新和 render 调用已经被排入了事件循环的任务队列,只是等待一个时机 —— ”React 组件函数体内同步代码全部执行完毕后“,将任务从队列中取出,并在主线程执行 flush。

  1. 若又遇到 setState,则重复步骤 2、3,不同点在于它不会重复添加当前组件实例,也不会执行 defer(flush) 重复将 flush 推入事件循环的任务队列(因为有 if ( queue.length === 0 ) 这条语句)。
  2. 同步代码执行完毕,执行 flush 方法,更新 state,得到新的 state 对象后,执行 render() 渲染虚拟 DOM 树。
  3. render 结束后,执行后续的生命周期,最后执行 setState 的第二个参数(callback)。

同步代码 -> setState -> 同步代码 -> setState -> ... -> (更新 state) -> render -> 生命周期函数 -> setState-callback

若 setState 被放在异步代码中执行,那么 setState 内部的更新就是以“同步”姿态执行了。

@jtwang7
Copy link
Owner Author

jtwang7 commented May 21, 2021

setState 下的正确 state 调用

  • 若我们期望基于上一个 state 计算下一个 state 值时,可以通过”函数式 setState“ 的方式。
setState((prevState, props) => stateChange[, callback])

在 updater (第一个参数)为函数时,其接收上一个 state 状态以及当前 props 作为参数。

  • 若我们期望获取正确的 state 值时,分为两种情况:
    • 在 render 中使用 state。
    • 在生命周期中使用 state,一般为 componentDidUpdate。

因为 state 的合并更新在 render 以及生命周期之前进行,所以在上述场景中,均能正确获得 state,不需要做额外的处理。

注意

  • 避免在 render 或者生命周期以外的地方使用 state,因为你无法保证 state 是否被更新(尽管存在同步 state 的使用场景,你也应该避免使用)。

state 的使用雷区:React 组件的函数体区域(除生命周期以外,return React 元素之前的区域),该部分为同步代码的主要执行区域。

  • 避免在 render 或者生命周期中使用 setState。setState 调用后,必然会出现 render 和生命周期函数(除非你人为设置跳过该阶段),这就导致在 render 或生命周期中使用 setState 又会触发 render 或生命周期,然后接着触发 setState ...,很明显程序死循环了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant