vue源码-nextTick

153 阅读6分钟

js 运行机制

理解 nextTick 关键在于理解 js 的事件循环机制

单线程

js 是单线程,所有任务只在一条主线程中执行,为什么 js 不采用多线程机制呢?因为 js 是在浏览器环境中与用户交互以及操作 dom 的,不能采用多线程,否则会有奇怪的问题,比如一条线程删除 dom,另一条线程却读取 dom ,这时浏览器应该以哪条线程为准?虽然后来新增了 web worker 可以开启多个线程,但是它主要是与主线程交互的,且不能操作 dom 还有诸多限制,并无改变 js 单线程本质

非阻塞

由于 js 是单线程的,任务只能一个个排队执行,但是有些操作很慢,如果等执行完成后再向下执行的话会大大影响效率,于是 js 引入了异步操作,任务可分为 同步任务 和 异步任务,同步任务是指前一个执行完才能执行下一个,异步任务是指执行时先挂起,然后继续向下执行,等到异步任务返回结果后才回头执行

事件循环

执行栈:同步任务会压入主线程执行,形成执行栈(栈结构先进后出)

任务队列:异步任务会压入任务队列排队等待(队列先进先出),任务队列又分为 宏队列微队列

以下操作会将任务压入宏队列,最常见的就是前两个啦

  • setTimeout
  • setInterval
  • setImmediate(相当于 setTimeout(fn, 0))
  • MessageChannel
  • postMessage

以下操作会将任务压入微队列,要注意 promise 属于同步任务,promise.then 才是异步任务,这个看下面的例子二就理解了

  • new Promise().then()
  • MutationObserver(html5 新特性)

所谓事件循环大致是

  1. js 执行时将同步任务压入执行栈,遇到异步任务则压入任务队列
  2. 先执行执行栈中的任务
  3. 等执行栈清空后,再读取微队列,将微队列的任务取出到执行栈执行
  4. 等微队列清空后,再读取宏队列,将宏队列的任务取出到执行栈执行
  5. 重复循环这个过程

当执行栈清空后,下一批任务会进入到执行栈,意味着一个 tick 开始

有点绕,看两个例子就理解了:

例子一

function a() {
  b();
  console.log('A');
}
function b() {
  console.log('B')
  setTimeout(function() {
      console.log('C');
  }, 2000)
}
a();

/*
输出:
B
A
C
*/

事件循环.gif

  1. a 方法入栈
  2. a 中调用了 b, b 方法入栈
  3. 栈结构先进后出,所以 b 会先执行,打印 “B”,遇到 setTimeout,立刻压到宏任务队列,等待两秒后压入执行栈队列
  4. b 执行完了,a 继续向下执行,打印 “A”
  5. 此时执行栈已经清空了,开始取出 setTimeout 中的任务,打印“C”

题外话:由此例子可知虽然 setTimeout 设置的时间是两秒,但是两秒后不一定就会执行,只是两秒后压入到执行栈队列,还得等待执行栈清空后再取出执行,此时时间可能已经超过两秒了

例子二

要注意 Promise 属于同步任务,Promise.then 才是异步任务

console.log('同步代码1');
setTimeout(() => {
    console.log('setTimeout')
}, 0)
new Promise((resolve) => {
  console.log('同步代码2')
  resolve()
}).then(() => {
    console.log('promise.then')
})
console.log('同步代码3');

同步任务先执行,然后到微队列的任务,再到宏队列的任务,所以最终结果如下:

"同步代码1"
"同步代码2"
"同步代码3"
"promise.then"
"setTimeout"

Vue nextTick

理解了事件循环,下面看下 Vue.nextTick

vue 文档中写道:Vue.nextTick([callback, context]) 是指在下次 DOM 更新循环结束之后执行延迟回调。

看一个例子

// 改变数据
vm.message = 'changed'

// 立刻读取 dom ,发现值还没改变,因为 DOM 根本就还没有更新
console.log(vm.$el.textContent) // 并不会得到'changed'

// nextTick 里面的代码会在 DOM 更新后执行
Vue.nextTick(function(){
    console.log(vm.$el.textContent) // 可以得到'changed'
})

为什么会这样?从 vue 源码的角度看下这个流程就懂了

vue 劫持了数据的 setter ,检测到 message 数据被修改后,会通知 watcher 执行更新,具体可以看vue源码 - 利用 Object.defineProperty 进行数据监测

vue 是异步更新 dom 的,也即 watcher 在更新 dom 时,并不是立刻更新的,而是将任务推到异步队列中,等到下一个 tick 再执行,关键源码如下:

const queue = [];

function queueWatcher(watcher: Watcher) {
  // 将当前 Watcher 添加到队列
  queue.push(watcher);
  // 将队列更新的回调通过 nexttick 推入异步队列
  nextTick(flushSchedulerQueue);
}

由上可知,修改 message后,立刻读取 dom 是读取不到最新数据的,因为执行 dom 更新的代码还在异步队列中排队等待执行呢。

而 nextTick 回调中则可以获取到最新的 dom ,原因是 nextTick 中的回调会被推入到异步队列中,此时 dom 更新的代码 和 读取最新 dom 的代码都在队列中,当下一个 tick 开始,队列中的任务会被取出来执行,此时你读取 dom 的代码是在更新 dom 之后,当然可以读取到最新值了

下面看下 nextTick 是如何将回调函数推入队列中的。

nextTick 实现原理

先说结论:其实就是将 nextTick 中的传入的回调函数,通过 宏方法微方法 压到 宏队列微队列 中,等到执行栈清空后,在下一个 tick 自然会到队列中将传入的函数取出来执行,这样就实现了在下一个 tick 执行传入的函数

下面看看源码

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
  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 */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  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 */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
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
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

Vue 首先定义了 宏方法macroTimerFunc 和 微方法microTimerFunc

宏方法的创建会先检测当前环境是否支持setImmediate,不支持的话再检测MessageChannel,还不支持就使用setTimeout(fn, 0),由上面事件循环的内容可知,这几个方法都是可以将任务压入到宏队列的,我觉得住部分代码可以使用门面模式解耦一下。

微方法则先检测是否支持 promise.then ,不支持的话直接把宏方法赋值给它(因为宏队列和微队列都能实现在下一个 tick 执行,所以直接把宏方法赋值给微方法也无所谓啦)

这样就优雅降级地定义好了宏方法和微方法

可以看到宏方法和微方法的回调函数其实是flushCallbacks,换句话说,这样就将flushCallbacks 压到宏队列或者微队列中啦

可以看到flushCallbacks 其实是循环执行callbacks 数组中的函数,callbacks 就是 nextTick 参数中传入的回调函数了

所以 nextTick 的流程是

  1. 将传入的函数 push 到callbacks 数组中
  2. 执行宏方法或微方法,将flushCallbacks 压入到相应队列中
  3. 执行栈清空后,下一个 tick 开始取出flushCallbacks 执行,flushCallbacks会循环执行callbacks 中的函数,callbacks 中的函数也就是 nextTick 传入的函数

这样就实现了在下一个 tick 执行 nextTick 传入的函数了