侧边栏壁纸
博主头像
M酷博主等级

一帆风顺 ⛵️⛵️⛵️

  • 累计撰写 45 篇文章
  • 累计创建 40 个标签
  • 累计收到 464 条评论

目 录CONTENT

文章目录

《Vue 源码系列》之 nextTick 源码

M酷
2021-10-04 / 2 评论 / 26 点赞 / 3,971 阅读 / 4,874 字 / 正在检测是否收录...
广告 广告

每天进步一点点,今天我们一起来看看 nextTick 的源码。

概念

根据官网解释,nextTick 是下次 DOM 更新循环结束之后的回调,我们可以通过它拿到更新后的 DOM ,并做一些操作。

nextTick 有2个,一个是全局的 Vue.nextTick(callback),一个是实例上的 this.$nextTick(callback),它们的作用都是一样的,只是作用的范围不一样而已。

参数

  • 对于全局的 nextTick ,有2个参数,回调函数上下文对象Vue.nextTick(callback, context)

  • 对于实例方法 $nextTick,只有1个回调函数this.$nextTick(callback)

两者的参数都是非必填的,在没有提供回调且在支持 Promise 的环境中,会返回一个 Promise

用法

使用方法很简单,无非如下 3 种:

1、一般用法

Vue.nextTick(()=>{
  // DOM 更新后的逻辑
})
this.$nextTick(()=>{
  // DOM 更新后的逻辑
})

2、结合 async/await ,更加简洁

async getData(){
  await this.$nextTick();
  // DOM 更新后的逻辑
}

3、作为 Promise 使用

this.$nextTick().then(()=>{
  // DOM 更新后的逻辑,不传回调函数,默认会返回一个 Promise
})

异步更新队列

在看源码之前,我们先来了解一下什么是 Vue 的异步更新队列

可能你还没有注意到,Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中1次,最终只做1次Dom 更新,这极大的减少了性能开销。

在下一个的事件循环 tick 中,Vue 刷新队列并执行实际 (已去重的) 工作。

Vue 在内部对异步队列尝试使用原生的 Promise.thenMutationObserversetImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。

事件循环

另一个与 nextTick 关系密切的就是 JS 的 事件循环机制

我们都知道 JS 是单线程语言,一次只能做一件事。为了提升事件处理效率,加入了事件循环机制。

一个简单的事件循环流程图如下:

eventLoop

事件分类

  • 同步任务(一切同步的操作)

  • 异步任务

    • 宏任务(如 setTimeout、setInterval、setImmediate、I/O等)
    • 微任务(如 Promise.then、MutationObserver、process.nextTick等)

主线程会优先执行同步任务,当所有同步任务执行完毕后,才会从任务队列里取出异步任务执行。

这里只需要知道以下几点即可:

  • 每个宏任务都对应一个微任务队列,宏任务执行完后会立刻执行对应的微任务(当前循环);
  • 微任务执行完后,会做更新一次UI,然后继续下一轮循环;
  • 微任务的优先级很高,很适合做异步流程控制。

源码

首先,nextTick 源码位于 /src/core/util/next-tick.js ,整体代码很少,我们一起看一下。

初始变量

一开始定义了 如下 3 个变量:

  1. isUsingMicroTask :是否使用微任务
  2. callbacks :回调函数
  3. pending :是否正在处理
export let isUsingMicroTask = false
const callbacks = []
let pending = false

nextTick

接下来,我们先看 nextTick 方法的代码部分:

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

看起来很简单是不是?

  1. 首先定义一个 _resolve 的标识用来判断回调是否结束;
  2. 然后就是使用 callbacks 收集传入的回调函数,并对其做了异常处理;
  3. 然后会进入 timerFunc 这个方法,我们先不看;
  4. 最后,如果没有传回调函数并且当前环境支持 Promise,它会返回一个 Promise 对象,对应用法里的 Promise 方式使用。

timerFunc

在上面,我们看到 nextTick 中间的一个环节调用了 timerFunc 这个方法,他究竟做了什么呢?

我们可以搜索该方法,发现这个方法有多处赋值,整体代码如下(官方注释已翻译):

let timerFunc
// 虽然 MutationObserver 支持度比 Promise 更高,但它在iOS系统( >= 9.3.3)中,触发 touch 事件时会有严重 bug,
// 多次触发甚至会导致无响应,所以优先使用 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // <微任务> 使用原生 Promise
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 但在部分IOS系统里,微任务队列不会及时清空,所以手动加个计时器清一下
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // <微任务> 不支持原生 Promise 则用 MutationObserver 代替,但是它在 IE11 中不可靠
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // <宏任务> 不支持 MutationObserver 则用 setImmediate 代替,虽然是宏任务,但效率优于 setTimeout
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // <宏任务>都不支持就只能用 setTimeout 代替
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

这个一个明显的分支判断,我们分析一下流程

  • 最开始会判断当前环境是否原生支持 Promise ,支持的话优先用 Promise
  • 不支持 Promise 那就看是否支持 MutationObserver
  • 不支持 MutationObserver 那就看是否支持 setImmediate
  • 以上都不支持,就只能用 setTimeout

在使用 PromiseMutationObserverisUsingMicroTask 会标记为 true,代表使用微任务来做异步流程控制。

timerFunc 的作用,只是为了执行回调函数而已,只是在使用不同方法时,需要加入一些特定的处理。

比如使用 MutationObserver 时,会通过监听一个文本节点的内容变化来触发清空操作,充分利用了微任务的高优先级特性。

总之,nextTick 会优先使用微任务(Promise、MutationObserver),不支持相应方法采用宏任务(setImmediate、setTimeout)代替。

flushCallbacks

这个就是清空回调函数队列的方法,它会执行 callbacks 数组里的所有回调并清空它。

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

通过源码里的大片注释,我们发现 nextTick 是个让作者很头痛的东西,在 2.5 版本的 vue 里,为了兼容性考虑,使用 宏任务 + 微任务 的方案来处理,相关的 issue 很多,2.6 版本才统一优先使用 微任务

// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).

总结

  • Vue 中的Dom更新是异步的(基于事件循环),会等待所有数据都操作完毕后统一更新 UI;

  • 在 Vue 2.6+ 中,优先使用 微任务 Promise 来做异步流程控制,实在不行才使用 宏任务 setTimeout 来控制

    具体降级流程:
    Promise > MutationObserver > SetImmediate > SetTimeout

    Vue 2.5 版本中则是:
    SetImmediate > MessageChannel > SetTimeout

  • 微任务有很高的优先级,处于宏任务结束后,UI渲染之前,是做异步流程控制的最佳选择,特别是 Promise

好啦👌,相信到这里,你们已经对 nextTick 的原理有了一个更深入的认识,如果有对事件循环 EventLoop 不了解的,可以参考文末的相关文章。

参考

一次弄懂EventLoop

你真的理解nextTick吗?

26
广告 广告

评论区