源码阅读:Vue.nextTick()

0.065字数 733阅读 585

1. 知识储备

在阅读源代码之前请按顺序阅读这些文章/视频:
Vue.js:异步更新队列
从浏览器多进程到JS单线程,JS运行机制最全面的一次梳理
Philip Roberts: What the heck is the event loop anyway? | JSConf EU 2014
MDN:MutationObserver
MDN:MessageChannel
Tasks, microtasks, queues and schedules
Vue.js 升级踩坑小记(可省略,但是这篇文章给我的收获还是很大的)

2. 知识点小结:

这里只做一个最简单粗暴的知识点小结,不包含任何解释。
宏任务(macrotask)
主代码块,setTimeout,setInterval, setImmediate,MessageChannel,postMessage

微任务(microtask)
promise,MutationObserver

任务执行顺序以及渲染的执行
macrotask -> microtask -> 渲染 -> macrotask -> microtask -> 渲染 -> ......

3. Vue如何实现 .nextTick()

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

异步队列很明显提高了性能,但是如果我们想要在DOM更新之后做点什么,可能就有点麻烦了(详情看这),因为主代码块在微任务列表之前,而Vue是在微任务或者下一个宏任务中才更新DOM的,这时候就需要使用.nextTick()了。

Vue.js 2.5之前,几乎都是用 microtask 来模拟 Node.js 的.nextTick()

  1. 浏览器是否支持Promise?是则使用Promise,否则进行下一步
  2. 浏览器是否支持MutationObserver,是则使用MutationObserver,否则进行下一步
  3. setTimeout (此时是一个macrotask)

Vue.js 2.5之后,默认使用 microtask ,在DOM事件强制使用 macrotask:

  1. 先确定使用 macrotask 时用哪个API,优先级为:
    setImmediate -> MessageChannel ->setTimeout
  2. 确定使用 microtask 时用哪个API,优先级为:Promise -> macroTimerFunc(和macrotask一致)
  3. 判断是否使用 macrotask ,是则调用macroTimerFunc,否则调用 microTimerFunc
  4. DOM事件默认会包裹一层函数来强制其使用 macrotask

4. 正题

Vue.js 2.5之前,.nextTick()放在env.js中,使用Promise, MutationObserver, setTimeout来实现异步队列:

// env.js

/* @flow */
/* globals MutationObserver */

import { noop } from 'shared/util'

// 能否使用 __proto__?
export const hasProto = '__proto__' in {}

// 浏览器环境检测,和本文无关,可忽略
export const inBrowser = typeof window !== 'undefined'
export const UA = inBrowser && window.navigator.userAgent.toLowerCase()
export const isIE = UA && /msie|trident/.test(UA)
export const isIE9 = UA && UA.indexOf('msie 9.0') > 0
export const isEdge = UA && UA.indexOf('edge/') > 0
export const isAndroid = UA && UA.indexOf('android') > 0
export const isIOS = UA && /iphone|ipad|ipod|ios/.test(UA)

// this needs to be lazy-evaled because vue may be required before
// vue-server-renderer can set VUE_ENV
let _isServer
export const isServerRendering = () => {
  if (_isServer === undefined) {
    /* istanbul ignore if */
    if (!inBrowser && typeof global !== 'undefined') {
      // detect presence of vue-server-renderer and avoid
      // Webpack shimming the process
      _isServer = global['process'].env.VUE_ENV === 'server'
    } else {
      _isServer = false
    }
  }
  return _isServer
}

export const devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__

/* istanbul ignore next */
function isNative (Ctor: Function): boolean {
  return /native code/.test(Ctor.toString())
}


// 相关代码在这里
export const nextTick = (function () {
  const callbacks = [] // 存放回调函数
  let pending = false // 是否有异步队列(callbacks)正在等待执行
  let timerFunc // 处理异步队列的函数(Promise,MutationObserver,setTimeout)

  function nextTickHandler () { // 清空callbacks列表,执行callback列表中的函数
    pending = false // 表示没有异步队列在等待了
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  // nextTick利用了微任务队列,微任务队列可以用原生的Promise或者MutationObserver来实现
  // MutationObserver被广泛支持,但是在iOS >= 9.3.3上会有严重的bug。
  // 因此优先使用Promise
  /* istanbul ignore if */
  if (typeof Promise !== 'undefined' && isNative(Promise)) { // 用Promise把回调函数推入微任务队列
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
      p.then(nextTickHandler).catch(logError)
      // 在UIWebViews中虽然Promise.then没有完全break,但是会陷入一个很奇怪的状态
      //回调函数都被推入微任务队列中,但是在浏览器处理别的任务(比如timer)之前队列不会被清空。
      // 因此添加一个空的timer来强制清空微任务队列。
      if (isIOS) setTimeout(noop)
    }
  } else if (typeof MutationObserver !== 'undefined' && ( 
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    // Promise不能用则用MutationObserver,MutationObserver也属于微任务
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter)) // 创建一个看不见的文本节点,让MutationObserver来监听它
    observer.observe(textNode, {
      characterData: true
    })
    timerFunc = () => {
      counter = (counter + 1) % 2
      textNode.data = String(counter)
    }
  } else { // Promise和MutationObserver都不能用
    // 用setTimeout代替,setTimeout为宏任务
    /* istanbul ignore next */
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

  return function queueNextTick (cb?: Function, ctx?: Object) { // 添加回调函数,调用VUe.nextTick即调用这个函数,注意到可以传入一个对象做为该函数的上下文!
    let _resolve
    callbacks.push(() => { // 包裹传入的函数,绑定其上下文,并push到callbacks中
      if (cb) cb.call(ctx)
      if (_resolve) _resolve(ctx)
    })
    if (!pending) { // 如果没有异步队列在等待执行,那么处理当前的异步队列
      pending = true
      timerFunc()
    }
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(resolve => {
        _resolve = resolve
      })
    }
  }
})()

let _Set
/* istanbul ignore if */
if (typeof Set !== 'undefined' && isNative(Set)) { // 浏览器支持Set
  // use native Set when available.
  _Set = Set
} else { // 浏览器不支持Set
  // a non-standard Set polyfill that only works with primitive keys.
  _Set = class Set {
    set: Object;
    constructor () {
      this.set = Object.create(null)
    }
    has (key: string | number) { // set[key]是否存在
      return this.set[key] === true
    }
    add (key: string | number) { // 添加一个元素
      this.set[key] = true
    }
    clear () { // 清空对象内所有元素
      this.set = Object.create(null)
    }
  }
}

export { _Set }

 
Vue.js 2.5+nestTick单独成一个文件了:next-tick.js:

// next-tick.js

/* @flow */
/* globals MessageChannel */

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

const callbacks = [] // 存储回调函数
let pending = false // 当前是否有异步队列在等待执行?

function flushCallbacks () { // 执行任务队列中的回调函数
  pending = false // pending为false,表示异步队列已经被清空
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

// 在<2.4 的版本,nextTick几乎都是使用microtasks来实现
// 但这会导致一些问题(下面讲)
// 所以 2.5+ 默认使用microtasks,但某些场景下会强制使用 macrotasks(比如,v-on绑定的事件)
let microTimerFunc
let macroTimerFunc
let useMacroTask = false // 是否使用macrotask来处理nextTick?默认为否

// 决定macrotask的实现。
//  在技术上 setImmediate 是最理想的,但是它只能在IE中使用。
// 让回调函数始终排队在 同一个事件循环中触发的DOM事件 之后的唯一polyfill就是使用MessageChannel
/* istanbul ignore if */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { // 优先使用 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 { // 不支持 setImmediate 和 MessageChannel 时用 setTimeout 代替
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 决定MicroTask的实现。
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // 在UIWebViews中虽然Promise.then没有完全break,但是会陷入一个很奇怪的状态
    // 回调函数都被推入微任务队列中,但是在浏览器处理别的任务(比如timer)之前不会执行这些任务
    // 因此添加一个空的timer来强制清空微任务队列。
    if (isIOS) setTimeout(noop)
  }
} else {
  // 不支持Promise则用macrotask代替
  // MutationObeserver因为兼容性问题被抛弃了
  microTimerFunc = macroTimerFunc
}


// 包裹一个函数,强制其使用macrotask
// 默认会给每一个DOM事件的回调函数调用withMacroTask 
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true // 使用macrotask
    const res = fn.apply(null, arguments)
    useMacroTask = false // 状态重新设置为false,不然其他回调函数也会用macrotask
    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
    })
  }
}

5. 为什么默认使用 microtask 的一点补充

(摘自知乎,原链接:Vue 中如何使用 MutationObserver 做批量处理?

根据HTML Standard,在每个 task 运行完以后,UI 都会重渲染(知识点小结那块有说到任务执行顺序以及什么时候渲染),那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。(当然,浏览器实现有不少不一致的地方,上面 Jake 那篇文章里已经有提到。)

推荐阅读更多精彩内容