从合成事件分析setState()是同步还是异步

1,041 阅读6分钟

对于Vue框架,只要侦听到数据变化将开启一个队列,Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。所以 Vue 优先使用微任务,也就是在本轮事件循坏的末尾更新 DOM,在浏览器不支持微任务时采用宏任务,也就是下一次事件循坏来更新 DOM。那么setState()是同步还是异步的呢?如果是异步的那是宏任务还是微任务?下面通过看几段代码

handleClick = () => {
        console.log('开始运行')
        this.setState({}, () => {
            console.log('更新')
        })
        console.log('结束运行')
}
// 开始运行
// 结束运行
// 更新

这么看,似乎 setState 是一个异步函数,如果是一个异步函数,那么它是宏任务还是微任务呢?

handleClick = () => {
        console.log('开始运行')
        Promise.resolve().then(() => {
            console.log('微任务');
        })
        this.setState({}, () => {
            console.log('更新')
        })
        console.log('结束运行')
}
// 开始运行
// 结束运行
// 更新
// 微任务

setState 执行时机居然在 Promise.resolve().then() 之前,首先它绝不是一个宏任务,如果是微任务,由于 Promise.resolve().then() 微任务的注册时机在 setState() 之前,所以执行时机也应该在 setState() 之前,但是结果却相反,只有 setState() 是同步函数时执行时机在 Promise.resolve().then() 之前,所以 setState() 是同步函数,那岂不是和第一段代码得出的结论相反?

主要原因就是在 React 合成事件中,所有的 setState 操作会先缓存到一个队列中,等同步代码执行完之后才会取出之前缓存的 setState 队列进行一次计算,进行更新操作,整个过程依然是同步的,类似于下面的伪代码:

const queue = [];
const setState = (fn) => {
    queue.push(fn)
}
handleClick = () => {
    console.log('开始运行')
    Promise.resolve().then(() => {
        console.log('微任务');
    })
    setState(() => {
        console.log('更新')
    })
    console.log('结束运行')
    queue.forEach((fn) => {
        fn();
    )
}

合成事件

React合成事件是React 模拟原生DOM事件所有能力 的一个事件对象,React事件系统背后的动机如下:

  1. 合成事件符合W3C规范,在底层抹平了不同浏览器之间的差异。向开发者暴露了统一、稳定的、并且和DOM原生事件相同的事件接口。让开发者不再关注底层兼容问题,可以专注于业务逻辑开发。
  2. 自定义事件系统,让React掌握了事件处理的主动权,方便React对事件的中心化管控,比如批量更新,事件池。

React 事件系统的核心由三部分组成

  1. 事件合成
  2. 事件绑定
  3. 事件触发

事件合成

在初始化阶段,React 构建合成事件和原生事件的映射关系以及合成事件和事件处理插件映射关系,也就是在这一阶段处理两种映射关系。

  • 合成事件和原生事件的映射关系
{
   onClick: ['click'], 
   onClickCapture: ['click'],
   onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
   onMouseEnter: ['mouseout', 'mouseover'],
}
  • 合成事件和事件处理插件映射关系
{
    onClick: SimpleEventPlugin,
    onClickCapture: SimpleEventPlugin,
    onChange: ChangeEventPlugin,
    onChangeCapture: ChangeEventPlugin,
}

事件绑定

React 遍历元素(只遍历元素类型为HostComponent的元素(dom元素),如果是自定义元素忽略)props的时候如果发现是React合成事件,比如onClick,找到对应的原生事件,进行真正的事件绑定,绑定在 document 上,dispatchEvent 为统一的事件处理函数,大部分事件都按照冒泡逻辑处理,只有几个特殊事件比如 scorll,focus,blur等是在事件捕获阶段发生,例如如果遇到元素绑定了 onChange 事件,那么在 document 上将会这样绑定:

document.addEventListener('click', dispatchEvent, false)
document.addEventListener('focus', dispatchEvent, true)

需要注意的是,即使在元素上绑定 onClickCapture 最终也是以冒泡的形式绑定在 document 上,和 onClick 绑定的形式一样,事件处理函数的执行顺序会在 dispatchEvent 函数内处理,在下面的事件触发里详细说明。

如果有多个元素绑定了同一类事件,在 document 上只会绑定一次,每个绑定事件的事件源和事件执行会在 dispatchEvent 函数内处理。

// 统一绑定为 document.addEventListener('click', dispatchEvent, false)
<div onClick={handleClick1}>
    <div onClickCapture={handleClick2}></div>
</div>

事件触发

事件触发执行的就是绑定在 document 上的事件处理函数 dispatchEvent 函数,legacy模式下的批量更新就在这个函数里,首先将批量更新开关 isBatchingEventUpdates 设为 true, 然后根据合成事件类型,找到对应的事件插件,执行事件插件里的函数 extractEventsextractEvents 可以作为整个事件系统核心函数,它做的事情主要如下:

  1. 在事件插件函数里首先形成 React 事件独有的合成事件源对象,这个对象,保存了整个事件的信息,将作为参数传递给真正的事件处理函数(handerClick)。
  2. 然后声明事件执行队列(数组),按照冒泡和捕获逻辑,从事件源开始逐渐向上,查找dom元素为HostComponent的类型,收集上面的 React 合成事件,例如 onClick / onClickCapture ,对于冒泡阶段的事件(onClick),将 push 到执行队列后面,对于捕获阶段的事件(onClickCapture),将 unShift到执行队列的前面。
  3. 最后将事件执行队列,保存到React事件源对象上,然后依次取出事件队列里面的事件执行,模拟出事件的冒泡与捕获。

举个例子比如如下

handerClick = () => console.log(1) 
handerClick1 = () => console.log(2) 
handerClick2 = () => console.log(3) 
handerClick3= () => console.log(4) 
render(){ 
  return 
      (<div onClick={ this.handerClick2 } onClickCapture={this.handerClick3} > 
          <button onClick={ this.handerClick } onClickCapture={ this.handerClick1 } className="button" >点击</button> 
      </div> )
  }

打印 // 4 2 1 3

看到这里我们应该知道上述函数打印顺序为什么了吧,首先遍历 button 遇到了onClickCapture ,将 handerClick1 放到了数组最前面,然后又把onClick对应handerClick的放到数组的最后面,形成的结构是[ handerClick1 , handerClick ] , 然后向上遍历,遇到了div,将onClickCapture对应的handerClick3放在了数组前面,将onClick对应的handerClick2 放在了数组后面,形成的结构 [ handerClick3,handerClick1,handerClick,handerClick2 ] 然后依次执行数组里的函数,所以执行的顺序就是 4 2 1 3。

执行顺序.png

最后事件队列里的函数执行完后事件源对象被清空,批量更新开关 isBatchingEventUpdates 设为 false。整个过程是同步代码。

事件池

我们来看如下一段代码

handerClick = (e) => { 
    console.log(e.target) // button 
    setTimeout(()=>{ 
        console.log(e.target) // null 
    },0) 
}

对于一次点击事件的处理函数,在正常的函数执行上下文中打印e.target就指向了dom元素,但是在setTimeout中打印却是null,如果这不是 React 合成事件,两次打印的应该是一样的,但是为什么两次打印不一样呢?因为在React采取了一个事件池的概念,每次我们用的事件源对象,在事件函数执行之后事件源对象释放到事件池中这样的好处是每次我们不必再创建事件源对象,可以从事件池中取出一个事件源对象进行复用,在事件处理函数执行完毕后,会释放事件源到事件池中,清空属性。调用 e.persist() 会阻止将事件源对象释放到事件池中。

handerClick = (e) => { 
    console.log(e.target) // button 
    e.persist()
    setTimeout(()=>{ 
        console.log(e.target) // button
    },0) 
}

知道了合成事件内部执行机制后我们再看一个例子应该就很容易解释了

state={number:0} 
handerClick = () =>{ 
    this.setState({number: this.state.number + 1 }) 
    console.log(this.state.number) //0 
    this.setState({number: this.state.number + 1 }) 
    console.log(this.state.number) //0 
    setTimeout(()=>{ 
        this.setState({number: this.state.number + 1 }) 
        console.log(this.state.number) //2 
        this.setState({number: this.state.number + 1 }) 
        console.log(this.state.number)// 3 
    }) 
}

上面的例子中,handerClick 执行时批量更新开关被设为了 true,所以会开启批量更新,等 setTimeout 上面的四行代码执行完之后,事件执行函数已执行完毕,批量更新开关被设为了 false,所以当 执行 setTimeout 里的代码时不会开启批量更新。