# setState到底是同步还是异步

setState 到底是同步还是异步?很多人可能都有这种经历, 面试的时候面试官给了你一段代码, 让你说出输出的内容, 比如这样:

  constructor(props) {
    super(props);
    this.state = {
      data: 'data'
    }
  }

  componentDidMount() {
    this.setState({
      data: 'did mount state'
    })

    console.log("did mount state ", this.state.data);
    // did mount state data

    setTimeout(() => {
      this.setState({
        data: 'setTimeout'
      })
  
      console.log("setTimeout ", this.state.data);
    })
  }

而这段代码的输出结果, 第一个 console.log 会输出 data , 而第二个 console.log 会输出 setTimeout .也就是第一次 setState 的时候, 它是异步的, 第二次 setState 的时候, 它又变成了同步的.是不是有点晕?不慌, 我们去源码中看看它到底干了什么.

# 结论

先把结论放到前面, 懒得看的同学可以直接看结论了.

只要你进入了 react 的调度流程, 那就是异步的.只要你没有进入 react 的调度流程, 那就是同步的.什么东西不会进入 react 的调度流程? setTimeout setInterval , 直接在 DOM 上绑定原生事件等.这些都不会走 React 的调度流程, 你在这种情况下调用 setState , 那这次 setState 就是同步的. 否则就是异步的.

setState 同步执行的情况下, DOM 也会被同步更新, 也就意味着如果你多次 setState , 会导致多次更新, 这是毫无意义并且浪费性能的.

# scheduleUpdateOnFiber

setState 被调用后最终会走到 scheduleUpdateOnFiber 这个函数里面来, 我们来看看这里面又做了什么:

function scheduleUpdateOnFiber(fiber, expirationTime) {
  checkForNestedUpdates();
  warnAboutRenderPhaseUpdatesInDEV(fiber);
  var root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);

  if (root === null) {
    warnAboutUpdateOnUnmountedFiberInDEV(fiber);
    return;
  }

  checkForInterruption(fiber, expirationTime);
  recordScheduleUpdate(); // TODO: computeExpirationForFiber also reads the priority. Pass the
  // priority as an argument to that function and this one.

  var priorityLevel = getCurrentPriorityLevel();

  if (expirationTime === Sync) {
    if ( // Check if we're inside unbatchedUpdates
    (executionContext & LegacyUnbatchedContext) !== NoContext && // Check if we're not already rendering
    (executionContext & (RenderContext | CommitContext)) === NoContext) {
      // Register pending interactions on the root to avoid losing traced interaction data.
      schedulePendingInteractions(root, expirationTime); // This is a legacy edge case. The initial mount of a ReactDOM.render-ed
      // root inside of batchedUpdates should be synchronous, but layout updates
      // should be deferred until the end of the batch.

      performSyncWorkOnRoot(root);
    } else {
      ensureRootIsScheduled(root);
      schedulePendingInteractions(root, expirationTime);

	  // 重点!!!!!!
      if (executionContext === NoContext) {
        // Flush the synchronous work now, unless we're already working or inside
        // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
        // scheduleCallbackForFiber to preserve the ability to schedule a callback
        // without immediately flushing it. We only do this for user-initiated
        // updates, to preserve historical behavior of legacy mode.
        flushSyncCallbackQueue();
      }
    }
  } else {
    ensureRootIsScheduled(root);
    schedulePendingInteractions(root, expirationTime);
  }

  if ((executionContext & DiscreteEventContext) !== NoContext && ( // Only updates at user-blocking priority or greater are considered
  // discrete, even inside a discrete event.
  priorityLevel === UserBlockingPriority$1 || priorityLevel === ImmediatePriority)) {
    // This is the result of a discrete event. Track the lowest priority
    // discrete update per root so we can flush them early, if needed.
    if (rootsWithPendingDiscreteUpdates === null) {
      rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]);
    } else {
      var lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root);

      if (lastDiscreteTime === undefined || lastDiscreteTime > expirationTime) {
        rootsWithPendingDiscreteUpdates.set(root, expirationTime);
      }
    }
  }
}

我们着重看这段代码:

if (executionContext === NoContext) {
  // Flush the synchronous work now, unless we're already working or inside
  // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
  // scheduleCallbackForFiber to preserve the ability to schedule a callback
  // without immediately flushing it. We only do this for user-initiated
  // updates, to preserve historical behavior of legacy mode.
  flushSyncCallbackQueue();
}

executionContext 代表了目前 react 所处的阶段, 而 NoContext 你可以理解为是 react 已经没活干了的状态.而 flushSyncCallbackQueue 里面就会去同步调用我们的 this.setState , 也就是说会同步更新我们的 state .所以, 我们知道了, 当 executionContextNoContext 的时候, 我们的 setState 就是同步的.那什么地方会改变 executionContext 的值呢?

我们随便找几个地方看看

function batchedEventUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= EventContext;
  ...省略
}

function batchedUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  ...省略
}

react 进入它自己的调度步骤时, 会给这个 executionContext 赋予不同的值, 表示不同的操作以及当前所处的状态, 而 executionContext 的初始值就是 NoContext , 所以只要你不进入 react 的调度流程, 这个值就是 NoContext , 那你的 setState 就是同步的.

# useState的setState

自从 raect 出了 hooks 之后, 函数组件也能有自己的状态, 那么如果我们调用它的 setState 也是和 this.setState 一样的效果吗?

对, 因为 useStateset 函数最终也会走到 scheduleUpdateOnFiber , 所以在这一点上和 this.setState 是没有区别的.

但是值得注意的是, 我们调用 this.setState 的时候, 它会自动帮我们做一个 state 的合并, 而 hook 则不会, 所以我们在使用的时候要着重注意这一点.

举个🌰

state = {
  data: 'data',
  data1: 'data1'
};

this.setState({ data: 'new data' });
console.log(state);
//{ data: 'new data',data1: 'data1' }

const [state, setState] = useState({ data: 'data', data1: 'data1' });
setState({ data: 'new data' });
console.log(state);
//{ data: 'new data' }

但是如果你自己去尝试在 function 组件的 setTimeout 中去调用 setState 之后, 打印 state , 你会发现他并没有改变, 这时你就会很疑惑, 为什么呢?这不是同步执行的吗?

这是因为一个闭包问题, 你拿到的还是上一个 state , 那打印出来的值自然是上一次的, 此时真正的 state 已经被改变了.那有没有其他的方法可以观察到 function 函数的同步行为?有, 我们下面再介绍.

# 案例分析

setTimeout 、原生事件内调用 setState 的操作确实比较少见, 但是下面这种写法一定很常见了.

  fetch = async () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve('fetch data');
      }, 300);
    })
  }

  componentDidMount() {
    (async () => {
      const data = await this.fetch();
      this.setState({data});
      console.log("data: ", this.state);
      // data: fetch data
    })()
  }

我们在 didMount 的时候发了一个请求, 然后再将结果 setState , 这时候我们用了 async/await 来进行处理.

这时候我们会发现其实 setState 也会变成同步了, 为什么呢?因为componentDidMount执行完毕后, 就已经退出了 react 的调度, 而我们请求的代码还没有执行完毕, 等结果请求回来以后 setState 才会执行.async 函数中 await 后面的代码其实是异步执行的.这和我们在 setTimeout 中执行 setState 其实是一个效果, 所以我们的 setState 就变成同步的了.

如果它变成同步会有什么坏处呢?我们来看看如果我们多次调用了 setState 会发生什么.

this.state = {
  data: 'init data',
}

componentDidMount() {
    setTimeout(() => {
      this.setState({data: 'data 1'});
      // console.log("dom value", document.querySelector('#state').innerHTML);
      this.setState({data: 'data 2'});
      // console.log("dom value", document.querySelector('#state').innerHTML);
      this.setState({data: 'data 3'});
      // console.log("dom value", document.querySelector('#state').innerHTML);
    }, 1000)
}

render() {
  return (
    <div id="state">
      {this.state.data}
    </div>
  );
}

这是在浏览器运行的结果

这样来看的话, 其实也并没有什么, 每次刷新后最终还是会显示 data 3 , 但是我们将代码中 console.log 的注释去掉, 再看看:

我们每次都能在 DOM 上拿到最新的 state , 这是因为 react 已经把 state 的修改同步更新了, 但是为什么界面没有显示出来?因为对浏览器来说, 渲染线程 和 js线程 是互斥的, react 代码运行时浏览器是没办法渲染的.所以实际上我们已经把 DOM 更新了, 但是 state 又被修改了, react 只好再做一次更新, 这样反复了三次, 最后 react 代码执行完毕后, 浏览器才把最终的结果渲染到了界面上.这也就意味着其实我们已经做了两次无用的更新.

我们把 setTimeout 去掉, 就会发现三次都输出了 init data , 因为此时的 setState 是异步的, 会把三次更新合并到一次去执行.

所以当 setState 变成同步的时候就要注意, 不要写出让 react 多次更新组件的代码, 这是毫无意义的.

而这里也回答了之前提出的问题, 如果我们想在 function 函数中观察到同步流程, 大家可以去试试当你在 setTimeoutsetState 之后, DOM 里面的内容会不会改变.

# 结语

react 已经帮助我们做了很多优化措施, 但是有时候也会因为代码不同的实现方式而导致 react 的性能优化失效, 相当于我们自己做了反优化.所以理解 react 的运行原理对我们日常开发确实是很有帮助的.