Vue - The Good Parts: nextTick

avatar
@滴滴出行

前言

nextTick 在 Vue 中是一个很出名的工具函数,我们在实际运用的时候也经常会用到,那么它实际上到底有什么样的作用,Vue 中又是如何设计的,我们在日常中有什么场景是可以借鉴的。

我们以 Vue 最新的 v2.6.14 版本来分析,链接 github.com/vuejs/vue/b…

正文分析

What

nextTick 是个什么东西,参考 Vue 2 的官方 API 文档:cn.vuejs.org/v2/api/#Vue…

image2021-6-9_11-32-53.png

可以看出是执行一个回调函数,我们这里可以成为一个任务,那在 Vue 中文档已经讲明白了,在下次 DOM 更新循环结束后执行这个任务(回调),这样你就可以取到更新后的 DOM 了。

How

先来看下 nextTick 全部的代码,把flow相关去掉,加上我们自己的注释:

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
 
// 是否使用的是 MicroTask,如 Promise MutationObserver
// 如果浏览器不支持 则会使用 MacroTask setImmediate setTimeout
// 相关进一步知识可以参考 浏览器 eventloop 相关文章
export let isUsingMicroTask = false
// 储存所有的 callback 队列,可以认为是一个个任务
const callbacks = []
// 是否在等待执行
let pending = false
// 依次执行任务队列,循环 & 执行
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
 
// 实现异步的函数,从名字上看下一个 tick,即一个 timer
let timerFunc
 
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 原生 Promise 异步
  const p = Promise.resolve()
  timerFunc = () => {
    // 利用 promise.then 实现,一个 micro task 之后执行 flushCallbacks
    p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // 降级使用 MutationObserver
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    // 触发textNode的改变,进而触发MutationObserver的回调执行 flushCallbacks
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    // 直接利用 setImmediate
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    // 经典的 setTimeout
    setTimeout(flushCallbacks, 0)
  }
}
 
// 主实现
export function nextTick (cb, ctx) {
  let _resolve
  // 往 callbacks 队列中添加一个一个任务
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 如果不是在等待中,即上一轮的callbacks任务队列已经执行完毕
  // 那么就进入等待状态,重新进入新一轮的等待下一个timer然后执行新一轮存下来的callbacks任务队列
  if (!pending) {
    pending = true
    timerFunc()
  }
  // nextTick的另一种用法,nextTick().then()
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

我们可以看出来,代码虽然不多,但是处理的情况还是很多的,也有很多兼容性的处理。如果我们来翻译下,nextTick 最核心的实现就是:拿一个队列存储所有要执行的任务,在下一个tick(异步)执行这些任务

那根据这个核心实现,在不考虑兼容性和异常的情况下,我们可以实现一个极简版本的 nextTick:

let pending = false
const tasks = []
const flushCallbacks = () => {
  pending = false
  tasks.forEach(task => task())
  tasks.length = 0
}
 
const p = Promise.resolve()
const timerFunc = () => {
  p.then(flushCallbacks)
}
 
function nextTick(task) {
  tasks.push(task)
  if (!pending) {
    pending = true
    timerFunc()
  }
}

短短20行,但是功能很核心也很强大,我们可以像这样使用:

const task1 = () => console.log('1')
const task2 = () => console.log('2')
 
console.log('before')
nextTick(task1)
nextTick(task2)
console.log('after')
 
// 运行的结果:before after 1 2

这个时候,相信你已经更进一步理解了 nextTick:将需要异步执行的任务收集起来在下一个 tick 依次执行他们。

Why

那为什么需要 nextTick 呢,我们不能直接执行这些任务吗?在 Vue 中的话,官网也给到了大家答案,详情 cn.vuejs.org/v2/guide/re…

如果简化来理解的话就是:为了更好的性能,将更新 DOM 操作存放在异步更新队列中,在下一个 tick 统一进行更新 DOM 操作。

试想下,如果我们每更新一次数据,Vue 就需要去更新一次 DOM 操作的话,得有多卡顿,因为日常我们处理逻辑一定是这样的:

const data = {
  title: 'hello',
  desc: 'world'
}
this.msg = data.title
this.context = data.desc

这个还是一个局部场景,更别想说,我们的整个 Vue 应用的数据更新,DOM 更新了。

所以 Vue 中就采用了异步更新队列这种方式来进行优化,也就是依赖上边我们分析的 nextTick 所做的最核心的事情。

总结

nextTick 之中,我们可以从其中学到什么或者我们可以进一步了解什么呢?

队列

看出这里边对于队列的操作(当然,用数组模拟的,本质是一样的):队列里添加任务,执行队列里的任务,清空队列。

队列是一个我们十分常用的数据结构,上边所提到的 eventloop,你会发现和 nextTick 本质是一样的,只是变得更复杂了,存在多个队列的情况,需要处理。

异步

我们知道了部分 timerFunc 的实现,相对应的也就是我们需要知道,哪些 API 的操作是异步的,以及是哪种异步处理(MacroTask、MicroTask),他们之间有什么差异和使用的影响,我们遇到异步场景的时候应该如何去选择。

还有一个点,这里用到了降级的方案 setTimeout,传的第二个参数是 0,那么这个时候的效果是啥样的;setTimeout 还可以有其他的什么用法,到底可以有几个参数,返回值是啥类型的,什么时候需要我们手工去 clearTimeout。

相对应的延伸,就是大名鼎鼎的 eventloop 相关知识,也需要去区分浏览器环境和 Node.js 环境。

异步和队列碰撞在一起,可以有很多火花。

我们有很多时候时候都需要处理异步任务,而对于这些任务的处理,最合适的数据结构就是队列了,例如大名鼎鼎的 async 库 github.com/caolan/asyn… 简直就是把异步玩到了极致,里边有很多很好的实现思路以及技巧,感兴趣的也是可以深入了解的。

我们的现实需求也一样,例如,在小程序场景中,不能超出10个的并发请求,超出的请求会被取消掉,所以我们需要对请求进行封装一层,在mpx中是封装为了mpx-fetch,而且我们还要求了高低优先级两种请求,这种情况,就需要我们借助于队列来实现我们的需求。

数组循环

flushCallbacks 中,我们看到了一个技巧,正常我们自己的简单实现中,是直接便利 callbacks 然后执行的,而 Vue 中则不是,他是复制了一份新的,然后循环执行的。

这么做的原因,其实是考虑了一种特殊情况,如果某一个 callback 执行的时候,又一次调用了 nextTick,进而更新了 callbacks,那这个时候的执行就不是我们所期望的了。所以需要先拷贝一份原有的,即使在 cb 中更新了 callbacks 也不影响我们的循环和执行,符合预期。

这是一个很严谨的地方,我们在实际场景中,也要有这种思考和意识。

同时这个问题还可以有很多的延伸,针对于数组循环,正向循环和逆向循环有啥区别吗,是不是都一样;以及我们用 for 循环和用数组本身的 forEach 会有啥不一样吗;还有 for 循环的终止条件,我们写 i < array.lengthconst len = array.length; i < len 有啥区别没有?

Promise

Promise 是一个很好的东西,相当有用,我们需要深入理解并使用它。这里有一个比较有意思的一个点是 nextTick 的返回值处理,应用到了一个技巧:外部如何更新 Promise 的状态,即你所看到的 _resolve 这个变量的作用。

Promise,一个各大厂基本都在考察的,Promise有哪些规范,包含哪些定义,哪些API,如何实现一个 Promise。

希望你去深入学习和理解它,做到精通 Promise!

其他小Tips

  • isNative 的处理,他是如何判断的
  • pending,防重
  • 错误如何处理
  • 如何考虑兼容和降级

滴滴前端技术团队的团队号已经上线,我们也同步了一定的招聘信息,我们也会持续增加更多职位,有兴趣的同学可以一起聊聊。