Vue源码解析之 nextTick

890 阅读5分钟

本文为原创文章,未获授权禁止转载,侵权必究!

本篇是 Vue 源码解析系列第 10 篇,关注专栏

前言

我们在 派发更新 文章中了解到,在执行 update 方法时,会执 nextTick(flushSchedulerQueue) 这段逻辑。而 nextTick 是 Vue 的核心实现,在介绍之前,我们需先了解下 JS 的运行机制。

JS 运行机制

JS 执行是单线程的,它基于事件循环。事件循环大致分为以下几个步骤:

  • 所有同步任务都在主线上执行,形成一个执行栈
  • 主线程外,存在一个任务队列 Event Queue ,每当有一个异步任务执行,都会插入该队列
  • 执行栈 中所有同步任务执行完毕后会读取 任务队列,并依次执行
  • 主线程会重复上述三步骤

另外,任务队列中 task 又分为 macro task 宏任务 和 micro task 微任务。在浏览器环境中,常见的 macro tasksetTimeoutpostMessagesetImmediate ;常见的 micro taskMutationObseverPromise.then。而每个 macro task 执行完毕后,会执行所有的 micro task

nextTick 实现

nextTick 定义在 src/core/util/next-tick.js 文件中:

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

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 遍历执行传入的 cb
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false

// Determine (macro) task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
// 判断是否支持原生 setImmediate
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && ( // 判断是否支持原生 MessageChannel
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  // 都不支持 执行 setTimeout
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
// 判断是否支持 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    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)
  }
} else {
  // fallback to macro
  // 不支持 直接指向 宏任务
  microTimerFunc = macroTimerFunc
}

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a (macro) task instead of a microtask.
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 不直接执行 cb回调,确保同一个 tick 执行多次 nextTick 不会开启多个异步任务
  // 把这些异步任务 压成一个同步任务 下一次 tick 中执行 flushCallbacks 函数 遍历执行回调
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      // 调用nextTick 未传cb,直接nextTick.then(() => {})
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  // nextTick.then(() => {}) 场景
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

而我们常见的 vm.$nextTick 和 全局 Vue.nextTick(),被定义在 src/core/instance/render.jssrc/core/global-api/index.js 中:

// src/core/instance/render.js
export function renderMixin(Vue: Class<Component>) {
  // install runtime convenience helpers
  installRenderHelpers(Vue.prototype)

  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }
  // 省略
}

// src/core/global-api/index.js
Vue.nextTick = nextTick

next-tick.js 逻辑先定义 microTimerFunc 微任务函数 和 macroTimerFunc 宏任务函数两个变量。 对于 macro task 实现,会先判断是否支持原生 setImmediate,不支持再去判断是否支持原生 MessageChannel ,如果都不支持,最后执行 setTimeout 0。对于 micro task 的实现,会先检测浏览器是否支持原生 Promise ,不支持直接指向 macro task 的实现。

另外,next-tick.js 还暴露了两个函数 nextTickwithMacroTaskwithMacroTask 函数它是对函数做一层包装,确保函数执行过程中对数据任意的修改,触发变化执行 nextTick 的时强制走 macroTimerFunc

我们重点关注下 nextTick 函数,在 派发更新 文章中,我们在执行 nextTick(flushSchedulerQueue) 所用到的函数。该函数会把传入的回调函数插入到 callbacks 数组,之后根据 useMacroTask 条件执行 macroTimerFunc 还是 microTimerFunc,而它们都会在下一个 tick(事件循环) 执行 flushCallbacksflushCallbacks 函数主要对 callbacks 遍历,然后执行相应的回调函数。

这里使用 callbacks 而不是直接在 nextTick 中执行回调函数的原因是保证在同一个 tick (每一个事件循环) 内多次执行 nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。

// 下一次 tick 执行 flushCallbacks 函数 遍历执行每个回调
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

最后 nextTick 还有一段逻辑:

if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}

该逻辑是调用 nextTick 不传入回调函数 cb 时,提供一个 Promise 化的调用,当 _resolve 函数执行,就会跳到 then 的逻辑中。

nextTick().then(() => {})

nextTick 应用场景及过程分析:

<div id="app">
    <p ref="msg">{{msg}}</p>
    <button @click="handleClick">点击</button>
</div>
<script src="../dist/vue.js"></script>
<script>
const app = new Vue({
    el: '#app',
    data() {
        return {
            msg: 'hello world',
        }
    },
    methods: {
        handleClick() {
            this.msg = 'hello vue'
            console.log('sync:', this.$refs.msg.innerText)
            this.$nextTick(() => {
                console.log('nextTick:', this.$refs.msg.innerText)
            })
            this.$nextTick().then(() => {
                console.log('nextTick with promise:', this.$refs.msg.innerText)
            })
        }
    }
})
</script>

// 输出结果
sync: hello world
nextTick: hello vue
nextTick with promise: hello vue

// 过程分析
1. 点击按钮 msg 值变更 
2. 触发 setter 函数,调用 watcher 的 update 方法,最终执行 nextTick(flushSchedulerQueue)
3. 之后会把 flushSchedulerQueue 及 两个 nextTick 的回调都插入到 callbacks 中
4. 在下一个 tick 事件循环中会执行 flushCallbacks 函数,遍历调用每个回调函数并输出结果

// 注意点
由于第二个 nextTick 未传 cb 回调,而是直接 then 调用,根据 nextTick 函数中最后逻辑
if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}

此时 _resolve 为 then 传入的回调,即
() => {
    console.log('nextTick with promise:', this.$refs.msg.innerText)
}

在下一个 tick 会调用 flushCallbacks 函数,遍历 callbacks 依次执行每个 回调函数,此时 callbacks 存在三个回调
let _resolve
callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
})

callbacks 前两个回调函数分别是 flushSchedulerQueue 和第一个 nextTick 传入的 cb,
由于第二个 nextTick 未传入回调函数,根据上文此时 _resolve 为 then 传入的 cb
那么在执行 callbacks 第三个回调函数时,直接走 else 逻辑,即:
else if (_resolve) {
  _resolve(ctx)
}

之后整个过程执行完毕,并输出结果

总结

  • nextTick 是把要执行的任务推入到一个队列中,在下一个 tick 同步执行。
  • 数据的变化到 DOM 的重新渲染是一个异步过程,通过上文分析,数据变化会触发 setter 函数,调用 watcher 的 update 方法,最终会执行 nextTick(flushSchedulerQueue) 。但是 watchersflush 是在 nextTick 后,所以重新渲染是异步的。这也是为什么我们调用服务端接口获取数据,对数据修改后 DOM 变化,必须在 nextTick 后执行。

参考

Vue.js 技术揭秘

Vue 源码解析系列

  1. Vue源码解析之 源码调试
  2. Vue源码解析之 编译
  3. Vue源码解析之 数据驱动
  4. Vue源码解析之 组件化
  5. Vue源码解析之 合并配置
  6. Vue源码解析之 生命周期
  7. Vue源码解析之 响应式对象
  8. Vue源码解析之 依赖收集
  9. Vue源码解析之 派发更新
  10. Vue源码解析之 nextTick