从一个 bug 探究 React 里面 state 的更新机制

github的地址 欢迎 star!
最近同事遇到一个 react 组件延迟渲染的问题,最后发现是由于对状态的更新理解不到位导致。具体问题描述:

问题产生

// 在 parent.js 组件里面有这样的函数以及一个 Child 的子组件
// 点击按钮触发 roleId 改变,发起异步请求请求后端得到数据 data

<!-- parent.js-->
this.state.data 来自于 ajax 请求返回后的数据
<Child
    roleId={this.state.roleId}
    data={this.state.data}
/>



<!-- Child.js-->

class Child extends React.Component {
    constructor(props){
        super(props);
        this.state = {
            data:null
        }
    }
    
    componentWillMount() {
        this.setState({
            data: this.props.data
        })
    }
    
    componentWillReceiveProps(nextProps) {
        if(nextProps.roleId !==this.props.roleId){
            this.setState({
                data: nextProps.data
            })
        }
    }
    
    ...
    render() {
        return (
        <div>
        {
            this.state.data.map(val=>(<div>{val.name}</div>)) 
        }
        </div>
    }
    
}
复制代码

上面的代码乍一看没有问题,但是当改变 roleId 的时候,会发现 data 里面的值没有改变,而且发现改变 roleId 时候,data 改变是上一次的结果,存在延迟(异步)。可以通过 chrome 里面的 react 插件查看值的改变是有先后顺序。

结论,因为 data 是在 roleId 改变之后异步产生,componentWillReceiveProps 在初始化 render 的时候不会执行,它会在 Component 接受到新的状态(Props)时被触发(只要父组件重新 render 就会触发这个函数),也就是说 roleId 改变的这个时候,data 并没有改变。

在 react 尽量使用纯组件,避免 props 变成 state 再进行处理。这里就对 state 的更新进行浅析。

探究一下 react 里面 state 的更新策略

react 里面 state 的更新不能通过直接给它赋值的方式改变(保证单向数据流)

<!--错误-->
this.state.data = 1
// 绝对不要直接修改this.state,这不仅是一种低效做法,而且很有可能会被之后的操作替换


this.setState({  
  count: this.state.count + 1
});
this.setState({  
  count: this.state.count + 1  // 这里也是错误 获取不到上面得到的最新的count,最终结果可能只显示加了一次1
});

复制代码

通过 setState 来进行更新,而通过 setState 连续更新有几种方式:

  1. 通过 setState 函数第二个参数,它是 state 更新完毕的回调函数

setState(nextState, callback) //  callback 里面就能拿到 nextState 中更新的状态值

// 例子
this.setState({  
  count: this.state.count + 1
}, () => {
  this.setState({
    count: this.state.count + 1
  });
});

复制代码
  1. 函数方式

nextState 也可以是一个 function,称为状态计算函数,结构为 function(state, props) => newState。这个函数会将每次更新加入队列中,执行时通过当前的 state 和 props 来获取新的 state。那么上面的例子就可以这样写

this.setState((state, props) => {  
  return {count: state.count + 1};
});
this.setState((state, props) => {  
  return {count: state.count + 1};
});
复制代码
  1. 通过 react 生命周期函数 把需要在 setState 更新之后进行的逻辑放在一个合适的生命周期 hook 函数中,比如 componentDidMount 或者 componentDidUpdate 也当然可以解决问题。也就是说 count 第一次 +1 之后,出发 componentDidUpdate 生命周期 hook,第二次 count +1 操作直接放在 componentDidUpdate 函数里面就好啦。

官网里面说 State Updates May Be Asynchronous,说明 setState 的更新并不是都是异步的,具体我们可以看一下这个列子:

setState 方法与包含在其中的执行是一个很复杂的过程,从 React 最初的版本到现在,也有无数次的修改。它的工作除了要更动 this.state 之外,还要负责触发重新渲染,这里面要经过 React 核心 diff 算法,最终才能决定是否要进行重渲染,以及如何渲染。而且为了批次与效能的理由,多个 setState 呼叫有可能在执行过程中还需要被合并,所以它被设计以延时的来进行执行是相当合理的。

setState 通过一个队列机制实现 state 更新。setState 调用时,将需要更新的 state 合并后放入状态队列,而不会立刻更新 this.state 的(队列机制可以高效的批量更新 state)。

  1. 当直接修改 this.state 的值,该 state 不会被放在状态队列里面,下次调用 setState 并对状态队列进行合并,就会忽略之前直接被修改的 state,造成无法预知的错误。
  2. 避免频繁地重复更新 state

具体的源码实现如下:

<!--ReactBaseClasses.js-->
function Component(props, context, updater) {
  this.props = props;
  this.context = context;
  // If a component has string refs, we will assign a different object later.
  this.refs = emptyObject;
  // We initialize the default updater but the real one gets injected by the
  // renderer.
  this.updater = updater || ReactNoopUpdateQueue;
}

Component.prototype.setState = function(partialState, callback) {
  ...
  this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
//setState里调用this.updater的一个方法


<!--ReactFiberClassComponent.js-->

const classComponentUpdater = {
  ...
  enqueueSetState(inst, payload, callback) {
    const fiber = ReactInstanceMap.get(inst);
    const currentTime = requestCurrentTime();
    const expirationTime = computeExpirationForFiber(currentTime, fiber);

    const update = createUpdate(expirationTime);
    update.payload = payload;
    if (callback !== undefined && callback !== null) {
      if (__DEV__) {
        warnOnInvalidCallback(callback, 'setState');
      }
      update.callback = callback;
    }

    enqueueUpdate(fiber, update, expirationTime);  // 加入更新队列
    scheduleWork(fiber, expirationTime);  // 开始安排更新工作
  },
  ...
};


<!--ReactFiberScheduler.js-->
// requestWork is called by the scheduler whenever a root receives an update.
// It's up to the renderer to call renderRoot at some point in the future.
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
  addRootToSchedule(root, expirationTime);

  if (isRendering) {
    // Prevent reentrancy. Remaining work will be scheduled at the end of
    // the currently rendering batch.
    return;
  }

  if (isBatchingUpdates) {
    // Flush work at the end of the batch.
    if (isUnbatchingUpdates) {
      // ...unless we're inside unbatchedUpdates, in which case we should
      // flush it now.
      nextFlushedRoot = root;
      nextFlushedExpirationTime = Sync;
      performWorkOnRoot(root, Sync, false);
    }
    return;
  }

  // TODO: Get rid of Sync and use current time?
  if (expirationTime === Sync) {
    performSyncWork();
  } else {
    scheduleCallbackWithExpiration(expirationTime);
  }
}



<!-- ReactFiberScheduler.js-->
...
// TODO: Batching should be implemented at the renderer level, not inside
// the reconciler.
function batchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
  const previousIsBatchingUpdates = isBatchingUpdates;
  isBatchingUpdates = true;
  try {
    return fn(a);
  } finally {
    isBatchingUpdates = previousIsBatchingUpdates;
    if (!isBatchingUpdates && !isRendering) {
      performSyncWork();
    }
  }
}
...

<!--ReactDOMEventListener.js -->
...
export function dispatchEvent(
  topLevelType: DOMTopLevelEventType,
  nativeEvent: AnyNativeEvent,
) {
  if (!_enabled) {
    return;
  }

  const nativeEventTarget = getEventTarget(nativeEvent);
  let targetInst = getClosestInstanceFromNode(nativeEventTarget);
  if (
    targetInst !== null &&
    typeof targetInst.tag === 'number' &&
    !isFiberMounted(targetInst)
  ) {
    // If we get an event (ex: img onload) before committing that
    // component's mount, ignore it for now (that is, treat it as if it was an
    // event on a non-React tree). We might also consider queueing events and
    // dispatching them after the mount.
    targetInst = null;
  }

  const bookKeeping = getTopLevelCallbackBookKeeping(
    topLevelType,
    nativeEvent,
    targetInst,
  );

  try {
    // Event queue being processed in the same cycle allows
    // `preventDefault`.
    batchedUpdates(handleTopLevel, bookKeeping);
  } finally {
    releaseTopLevelCallbackBookKeeping(bookKeeping);
  }
}
...


复制代码

以上实际代码相对复杂,用一段伪代码表示:

function interactiveUpdates(callback) {
    isBatchingUpdates = true;  // 先把合成更新标识符设为真
    
    // 执行事件的回调函数,如果里面有调用到 setState
    // 则会发生上面所说的情况,先把更新加入更新队列
    // 再先返回不执行更新
    callback();  
    
    isBatchingUpdates = false;
    performSyncWork();  // 开始更新
}

复制代码

总结更新策略

在 React 的 setState 函数实现中,会根据一个变量 isBatchingUpdates 判断是直接更新 this.state 还是放到队列中回头再说,而 isBatchingUpdates 默认是 false,也就表示 setState 会同步更新 this.state,但是,有一个函数 batchedUpdates,这个函数会把 isBatchingUpdates 修改为 true,而当 React 在调用事件处理函数之前就会调用这个 batchedUpdates,造成的后果,

就是由 React 控制的事件处理过程 setState 不会同步更新 this.state;

在 React 控制之外的情况, setState 会同步更新 this.state!

控制之外,指的是绕过 React 通过 addEventListener 直接添加的事件处理函数,还有通过 setTimeout/setInterval 产生的异步调用。 具体可以查看 JS Bin

关于 setState 实现 promise 化

以上情况说明一般来说 setState 是异步更新,便会想到用 promise 来进行包装:

function setStatePromise(that, newState) {
    return new Promise((resolve) => {
        that.setState(newState, () => {
            resolve();
        });
    });
}

复制代码

关于 setState 的未来 -- 函数式的 setState

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

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

// 对于多次调用函数式 setState 的情况,React 会保证调用每次 increment 时,state 都已经合并了之前的状态修改结果。
function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState(increment);
}

// 加入当前 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,大家可能会想到混用的情况:

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

// 最后得到的结果是让this.state.count增加了2,而不是增加4。
复制代码

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

说了这么多,最后总结一下 setState 的关键点:

  1. setState 不会立刻改变 React 组件中 state 的值;
  2. setState 通过引发一次组件的更新过程来引发重新绘制;
  3. 多次 setState 函数调用产生的效果会合并。
不过博客里面没有对源码里面重要的一些概念进行梳理,以及一些关键点解读,react 的设计哲学等等,请允许下次分享

如果有错误或者不严谨的地方,请务必给予指正,十分感谢!

参考:

  1. www.zhihu.com/question/66…
  2. juejin.im/post/5a39de…
  3. juejin.im/entry/58b52…
  4. reactjs.org/docs/state-…
  5. 《深入 React 技术栈》
  6. react 中 setState promise 化的探究
  7. setState 何时同步更新状态
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值