Skip to content
文章目录

requestAnimationFrame与nextTick与事件循环

requestAnimationFrame 是微任务还是宏任务?

  • requestAnimationFrame 和 requestIdleCallback 是和宏任务性质一样的任务,只是他们的执行时机不同而已。也有人说它们既不是宏任务也不是微任务,其实我们不必纠结这个,我们所要做的就是知道他们的执行时机就好。
  • 浏览器在每一轮 Event Loop 事件循环中不一定会去重新渲染屏幕,会根据浏览器刷新率以及页面性能或是否后台运行等因素判断的,浏览器的每一帧是比较固定的,会尽量保持 60Hz 的刷新率运行,每一帧中间可能会进行多轮事件循环。
  • requestAnimationFrame 回调的执行与 task 和 microtask 无关,而是与浏览器是否渲染相关联的。它是在浏览器渲染前,在微任务执行后执行。
  • requestIdleCallback 是在浏览器渲染后有空闲时间时执行,如果 requestIdleCallback 设置了第二个参数 timeout,则会在超时后的下一帧强制执行。

nextTick 是做什么的?

将回调延迟到下次 DOM 更新循环之后执行

vue2 的 nextTick 实现原理

出于兼容性考虑,依次判断浏览器是否支持,选择使用对应 api

Promise -> MuationObserver -> setImmediate -> setTimeout

优先选择微任务,如果微任务都不支持,则选择宏任务

vue3 的 nextTick 实现原理

抛弃了兼容性,直接使用 Promise,来实现 nextTick

nextTick的源码可以看出,nextTick本质就是创建了一个微任务(不考虑setTimeout),将其回调推入微任务队列。vue中一个事件循环中的所有dom更新操作也是一个微任务,两者属于同一优先级,执行先后只于入队的先后有关,换句话说,如果你先写了nextTick,再写赋值语句(在此之前没有触发dom更新的操作),那在nextTick中获取的可就不是更新后的dom

vue
<template>
  <div class="demo">
    <p class="name">{{ name }}</p>
    <button @click="modify">修改</button>
  </div>
</template>
<script lang="ts" setup>
const name = ref('111')

const modify = () => {
  name.value = '222' // 关键的赋值语句,如果注释掉,结果就大不一样了
  nextTick(() => {
    const text = document.querySelector<HTMLElement>('.name').innerText
    console.log(text)
  })
  name.value = '333'
}
</script>

如上代码,如果注释掉 name.value = "2222",虽然 nextTick 语句下面也有赋值操作:name.value = "3333";,但由于 nextTick 先进入微任务队列,所以回调先于 dom 更新执行,所以是获取的 dom 仍旧是旧的更新前的 dom

图例

为啥会是这种执行结果?

vue 更新其实是一个数组里面存在更新函数和 nextTick 函数,而更新函数在前面,
nextTick 函数则在数组的最后面,
但是 vue 会把所有的更新去重,最后只有一个更新函数


一个事件循环宏任务中响应式数据修改所触发的更新不会马上去执行更新函数,
而是把更新函数缓冲到一个更新队列中,同时创建一个微任务,去消费更新队列;
nextTick 也会创建一个微任务,去执行它的回调;两个都是微任务,
所以执行先后顺序就和创建的先后顺序有关了。

源码补充

js
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

//  上面三行与核心代码关系不大,了解即可
//  noop 表示一个无操作空函数,用作函数默认值,防止传入 undefined 导致报错
//  handleError 错误处理函数
//  isIE, isIOS, isNative 环境判断函数,
//  isNative 判断某个属性或方法是否原生支持,如果不支持或通过第三方实现支持都会返回 false

export let isUsingMicroTask = false // 标记 nextTick 最终是否以微任务执行

const callbacks = [] // 存放调用 nextTick 时传入的回调函数
let pending = false // 标记是否已经向任务队列中添加了一个任务,如果已经添加了就不能再添加了
// 当向任务队列中添加了任务时,将 pending 置为 true,当任务被执行时将 pending 置为 false
//

// 声明 nextTick 函数,接收一个回调函数和一个执行上下文作为参数
// 回调的 this 自动绑定到调用它的实例上
export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  // 将传入的回调函数存放到数组中,后面会遍历执行其中的回调
  callbacks.push(() => {
    if (cb) {
      // 对传入的回调进行 try catch 错误捕获
      try {
        cb.call(ctx)
      } catch (e) {
        // 进行统一的错误处理
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })

  // 如果当前没有在 pending 的回调,
  // 就执行 timeFunc 函数选择当前环境优先支持的异步方法
  if (!pending) {
    pending = true
    timerFunc()
  }

  // 如果没有传入回调,并且当前环境支持 promise,就返回一个 promise
  // 在返回的这个 promise.then 中 DOM 已经更新好了,
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

// 判断当前环境优先支持的异步方法,优先选择微任务
// 优先级:Promise---> MutationObserver---> setImmediate---> setTimeout
// setTimeout 可能产生一个 4ms 的延迟,而 setImmediate 会在主线程执行完后立刻执行
// setImmediate 在 IE10 和 node 中支持

// 当在同一轮事件循环中多次调用 nextTick 时 ,timerFunc 只会执行一次

let timerFunc
// 判断当前环境是否原生支持 promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 支持 promise
  const p = Promise.resolve()
  timerFunc = () => {
    // 用 promise.then 把 flushCallbacks 函数包裹成一个异步微任务
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
    // 这里的 setTimeout 是用来强制刷新微任务队列的
    // 因为在 ios 下 promise.then 后面没有宏任务的话,微任务队列不会刷新
  }
  // 标记当前 nextTick 使用的微任务
  isUsingMicroTask = true

  // 如果不支持 promise,就判断是否支持 MutationObserver
  // 不是IE环境,并且原生支持 MutationObserver,那也是一个微任务
} else if (
  !isIE &&
  typeof MutationObserver !== 'undefined' &&
  (isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
  let counter = 1
  // new 一个 MutationObserver 类
  const observer = new MutationObserver(flushCallbacks)
  // 创建一个文本节点
  const textNode = document.createTextNode(String(counter))
  // 监听这个文本节点,当数据发生变化就执行 flushCallbacks
  observer.observe(textNode, { characterData: true })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter) // 数据更新
  }
  isUsingMicroTask = true // 标记当前 nextTick 使用的微任务

  // 判断当前环境是否原生支持 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 以上三种都不支持就选择 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 如果多次调用 nextTick,会依次执行上面的方法,将 nextTick 的回调放在 callbacks 数组中
// 最后通过 flushCallbacks 函数遍历 callbacks 数组的拷贝并执行其中的回调
function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0) // 拷贝一份 callbacks
  callbacks.length = 0 // 清空 callbacks
  for (let i = 0; i < copies.length; i++) {
    // 遍历执行传入的回调
    copies[i]()
  }
}

// 为什么要拷贝一份 callbacks

// 用 callbacks.slice(0) 将 callbacks 拷贝出来一份,
// 是因为考虑到在 nextTick 回调中可能还会调用 nextTick 的情况,
// 如果在 nextTick 回调中又调用了一次 nextTick,则又会向 callbacks 中添加回调,
// 而 nextTick 回调中的 nextTick 应该放在下一轮执行,
// 否则就可能出现一直循环的情况,
// 所以需要将 callbacks 复制一份出来然后清空,再遍历备份列表执行回调

JS 引擎

js 引擎包括 parser、解释器、gc 再加一个 JIT 编译器这几部分。

  • parser: 负责把 javascript 源码转成 AST
  • interperter:解释器, 负责转换 AST 成字节码,并解释执行
  • JIT compiler:对执行时的热点函数进行编译,把字节码转成机器码,之后可以直接执行机器码
  • gc(garbage collector):垃圾回收器,清理堆内存中不再使用的对象

渲染引擎

渲染时会把 html、css 分别用 parser 解析成 dom 和 cssom,然后合并到一起,并计算布局样式成绝对的坐标,生成渲染树,之后把渲染树的内容复制到显存就可以由显卡来完成渲染。

每一次渲染流程叫做一帧,浏览器会有一个帧率(比如一秒 60 帧)来刷新。

如何结合 JS 引擎和渲染引擎

不管是 JS 引擎、还是渲染引擎,都比较傻(纯粹),JS 引擎只会不断执行 JS 代码,渲染引擎也是只会布局和渲染。但是要完成一个完整的网页应用,这两者都需要。 怎么综合两者呢?

因为 javascript 最开始只是被设计用来做表单处理,那么就不会有特别大的计算量,就没有采用多线程架构,而是在一个线程内进行 dom 操作和逻辑计算,渲染和 JS 执行相互阻塞。(后来加了 web worker,但不是主流)

我们知道,JS 引擎只知道执行 JS,渲染引擎只知道渲染,它们两个并不知道彼此,该怎么配合呢?

答案就是 event loop。

浏览器的 event loop

任务队列

Js 中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(microtasks)。宏任务队列可以有多个,微任务队列只有一个

宏任务: script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering, Ajax,DOM事件

微任务: process.nextTick (node.js中进程相关的对象), Promise, Object.observer, MutationObserver

宏任务(task):就是 JS 内部(任务队列里)的任务,严格按照时间顺序压栈和执行。如 setTimeOut、setInverter、setImmediate 、 MessageChannel 等

微任务(Microtask ):通常来说就是需要在当前 任务 执行结束后立即执行的任务,例如需要对一系列的任务做出回应,或者是需要异步的执行任务而又不需要分配一个新的 任务 ,这样便可以减小一点性能的开销。

在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;之后再取 macrotask 任务,周而复始,直至两个队列的任务都取完。

这两篇博文可以仔细拜读/理解/记忆下

JS 微任务和宏任务(面试题常用)

Event loop 和 JS 引擎、渲染引擎的关系(精致版)

check 机制

浏览器里面执行一个 JS 任务就是一个 event loop,每个 loop 结束会检查下是否需要渲染,是否需要处理 worker 的消息,通过这种每次 loop 结束都 check 的方式来综合渲染、JS 执行、worker 等,让它们都能在一个线程内得到执行(渲染其实是在别的线程,但是会和 JS 线程相互阻塞)。

这样就解决了渲染、JS 执行、worker 这三者的调度问题。

但是这样有没有问题?

我们会在任务队列中不断的放新的任务,这样如果有更高优的任务是不是要等所有任务都执行完才能被执行。如果是“急事”呢?

所以这样还不行,要给 event loop 加上“急事”处理的快速通道,这就是微任务 micro tasks

micro tasks

任务还是每次取一个执行,执行完检查下要不要渲染,处理下 worker 消息,但是也给高优先级的“急事”加入了插队机制,会在执行完任务之后,把所有的急事(micro task)全部处理完。

这样,event loop 貌似就挺完美的了,每次都会检查是否要渲染,也能更快的处理 JS 的“急事”。

requestAnimationFrame

JS 执行完,开始渲染之前会有一个生命周期,就是 requestAnimationFrame,在这里面做一些计算最合适了,能保证一定是在渲染之前做的计算。

如果有人问 requestAnimationFrame 是宏任务还是微任务,就可以告诉他:requestAnimationFrame 是每次 loop 结束发现需要渲染,在渲染之前执行的一个回调函数,不是宏微任务。

event loop 的问题

虽然后面加入了 worker,但是主流的方式还是 JS 计算和渲染相互阻塞,这样就导致了一个问题:

每一帧的计算和渲染是有固定频率的,如果 JS 执行时间过长,超过了一帧的刷新时间,那么就会导致渲染延迟,甚至掉帧(因为上一帧的数据还没渲染到界面就被覆盖成新的数据了),给用户的感受就是“界面卡了”。

什么情况会导致帧刷新拖延甚至帧数据被覆盖(丢帧)呢?每个 loop 在 check 渲染之前的每一个阶段都有可能,也就是 task、microtask、requestAnimationFrame、requestIdleCallback 都有可能导致阻塞了 check,这样等到了 check 的时候发现要渲染了,再去渲染的时候就晚了。

所以主线程 JS 代码不要做太多的计算(不像安卓会很自然的起一个线程来做),要做拆分,这也是为啥 ui 框架要做计算的 fiber 化,就是因为处理交互的时候,不能让计算阻塞了渲染,要递归改循环,通过链表来做计算的暂停恢复。

除了 JS 代码本身要注意之外,如果浏览器能够提供 API 就是在每帧间隔来执行,那样岂不是就不会阻塞了,所以后来有了 requestIdeCallback。

requestIdleCallback

requestIdleCallback 会在每次 check 结束发现距离下一帧的刷新还有时间,就执行一下这个。如果时间不够,就下一帧再说。

如果每一帧都没时间呢,那也不行,所以提供了 timeout 的参数可以指定最长的等待时间,如果一直没时间执行这个逻辑,那么就算拖延了帧渲染也要执行。

这个 api 对于前端框架来说太需要了,框架就是希望计算不阻塞渲染,也就是在每一帧的间隔时间(idle 时间)做计算,但是这个 api 毕竟是最近加的,有兼容问题,所以 react 自己实现了类似 idle callback 的 fiber 机制,在执行逻辑之前判断一下离下一帧刷新还有多久,来判断是否执行逻辑。

nextTick 与 promise then 的顺序问题

打印: 1 2 promise 3

打印: 1 promise 2 3

vue 中的$nextTick 不是也用 Promise().resolve().then()做的吗?都是微任务,为什么在第一张图中, '2'比'Promise'先被打印出来?

当你传入回调函数时 vue 实际上是将回调推入一个队列等待下次更新时机时一起调用,然后因为你第一个例子中修改了 data 的 msg,所以会触发 vue 的 update 逻辑,也就触发了 nextTick,你可以看下 vue 的调用栈

可以看到 update 后就紧接着执行了 nextTick 来执行推入的回调函数,所以才会出现第一个例子中的那样,如果你改成 this.$nextTick().then(...)这才是 promise 的形式,这样输出才会符合你所预期的,也就是第二个例子那样

看上图的调用栈,更改数据出发 update 之后一直到 timerFunc,而这个 timerFunc 是 Vue 一开始就准备好的一个异步函数,当浏览器支持 Promise 时 timerFunc 定义如下:

js
function nextTick(cb, ctx) {
  var _resolve
  // 包装cb回调函数,如果没有回调则返回一个promise实例
  callbacks.push(function () {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(function (resolve) {
      _resolve = resolve
    })
  }
}

nextTick 方法主要做了两件事:

  • 把回调函数添加到 callbacks 回调队列中
  • 执行 timerFunc 函数
js
var callbacks = []
var pending = false

function flushCallbacks() {
  pending = false
  var copies = callbacks.slice(0)
  callbacks.length = 0
  for (var i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
var timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve()
  timerFunc = function () {
    p.then(flushCallbacks)
    if (isIOS) {
      setTimeout(noop)
    }
  }
}
// 省略优雅降级使用setTimeout的部分代码

这段代码是 Vue 初始化或者叫安装时就已经准备好的,p 的确是一个 promise,但它早于你在 mounted 中创建的 promise,所以当执行 timerFunc 时会先执行 nextTick,再执行你的 promise,如果你用的是 this.$nextTick().then(...)那么 vue 会返回一个新的 promise,那么你的 2 就如预期一样在 promise 之后打印。

当执行到 this.msg = 'end'时,触发了 msg 属性的 setter,setter 中执行 dep.notify()派发更新,触发 Watcherupdate 方法,最终会执行 nextTick(flushSchedulerQueue)(这里可以在第一张图的 nextTick 回调中加个断点就执行函数调用栈了)。

也就是说,执行完 this.msg='end'的时候,会触发一次 nextTick,继而执行 timerFunc 方法,然后会把 p.then(flushCallbacks)推到微任务中,然后再接着执行 Promise.resolve().then(...),再继续走,后面的 this.$nextTick(...) 只是把回调函数收集到了 callbacks

因此第一张图中 2 是比 promise!先执行。

相关资源

requestAnimationFrame 和 requestIdleCallback 是宏任务还是微任务

Event loop 和 JS 引擎、渲染引擎的关系(精致版)

神奇的 nextTick 一定能获取到最新的 dom 么?

nextTick

nextTick 的使用和原理(面试题)

Vue 的 nextTick 与 promise then 的顺序问题

JS 微任务和宏任务(面试题常用)