探究 vue 不同版本下 nextTick 底层实现

193 阅读8分钟

下面这一段代码它的操作很简单:

页面渲染了一个复选框,当点击复选框框时阻止 click 事件的默认行为,并在 click 的回调的将对复选框的 checked 值置为 true,从而改变复选框的 checked 状态。

无奖竞猜:大家可以先猜一猜一下这个复选框最终会不会被勾选上呢?

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <!-- import CSS -->
  <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
</head>
<body>
  <div id="app">
    <input
      type="checkbox"
      :checked="checked"
      @click="handleClick"> {{ checked }}
  </div>
</body>
  <!-- import Vue before Element -->
  <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
  <!-- import JavaScript -->
  <script src="https://unpkg.com/element-ui/lib/index.js"></script>
  <script>
    new Vue({
      el: '#app',
      data: function() {
        return {
          checked: false
        };
      },
      methods: {
        handleClick ($event) {
          $event.preventDefault()
          this.checked = true
        }
      }
    })
  </script>
</html>

事实上同样的操作在 vue@2.5.17 版本与 vue@2.6.14 版本下表现并不一致:

2.5版本:

nexttick2.5.gif

2.6版本:

nexttick2.6.gif

可以看到,2.5 版本下复选框的勾选状态与 checked 的值是同步的;但在 2.6 版本下,checked 值虽然改变了,复选框的选中状态并没有发生变化;

源码分析

可以看到,2.5 版本下复选框的勾选状态与 checked 的值是同步的;但在 2.6 版本下,checked 值虽然改变了,复选框的选中状态并没有发生变化;

那么是什么原因导致的同一段代码在两个版本的 vue 下表现不一致呢?

我们来一起分析一下:

2.5 版本

  1. 首先是复选框勾选状态被改变,handleClick 回调被触发,checked 的值被改变:

    methods: {
        handleClick () {
            this.checked = true
        }
    }
    
  2. 由于 checked 是个响应式数据,它的值被更新会导致 Object.defineProperty 上的 set 方法被触发,在这里会调用相关依赖的 notify 方法,通知依赖数据源被改变

    // src\core\observer\index.js
    set: function reactiveSetter (newVal) {
          const value = getter ? getter.call(obj) : val
          /* eslint-disable no-self-compare */
          if (newVal === value || (newVal !== newVal && value !== value)) {
            return
          }
          /* eslint-enable no-self-compare */
          if (process.env.NODE_ENV !== 'production' && customSetter) {
            customSetter()
          }
          if (setter) {
            setter.call(obj, newVal)
          } else {
            val = newVal
          }
          childOb = !shallow && observe(newVal)
          // 调用相关依赖的 notify 方法,通知依赖数据源被改变
          dep.notify()
        }
    
  3. 而 notify 方法会遍历调用 sub 上的 update 方法,这里的 sub 其实就是我们的 watcher 观察者;

    // src\core\observer\dep.js
    notify () {
        const subs = this.subs.slice()
        for (let i = 0, l = subs.length; i < l; i++) {
          // 遍历调用 sub 上的 update 方法
          subs[i].update()
        }
      }
    
  4. 接下来会命中 Watcher 实例上的 update() 方法,这个方法可以简单理解为:当 watcher 依赖的数据源发生改变时,就会执行这个方法,做一些组件重新渲染前的准备工作;

    // src/core/observer/watcher.js
    export default class Watcher {
      // ......(省略)
      update () {
        if (this.lazy) {
          this.dirty = true
        } else if (this.sync) {
          this.run()
        } else {
          debugger
          // 在这里又调用了 queueWatcher 这一方法
          queueWatcher(this)
        }
      }
      // ......(省略)
    }
    ​
    

    而在 update() 中又调用了 queueWatcher 这一方法,下面来看一下 queueWatcher 的实现:

  5. queueWatcher 这个函数使用了一个 queue 即先进先出的队列,并将我们刚刚传入的 watcher 实例 push 进队列,最后在 nextTick 的时候,执行 flushSchedulerQueue 函数;

    // src/core/observer/scheduler.js
    export function queueWatcher (watcher: Watcher) {
      debugger
      const id = watcher.id
      if (has[id] == null) {
        has[id] = true
        if (!flushing) {
          queue.push(watcher)
        } else {
          let i = queue.length - 1
          while (i > index && queue[i].id > watcher.id) {
            i--
          }
          queue.splice(i + 1, 0, watcher)
        }
        // queue the flush
        if (!waiting) {
          waiting = true
          // 在 nextTick 的时候,执行 flushSchedulerQueue 函数
          // 我们接着看一下 flushSchedulerQueue 和 nextTick 的实现
          nextTick(flushSchedulerQueue)
        }
      }
    }
    

    flushSchedulerQueue 这个函数主要做的就是循环 queue 这个队列,并依次调用我们推入队列里的 watcher 实例上的 run() 方法,去重新渲染组件:

    // src/core/observer/scheduler.js
    function flushSchedulerQueue () {
      flushing = true
      let watcher, id
      queue.sort((a, b) => a.id - b.id)
      for (index = 0; index < queue.length; index++) {
        watcher = queue[index]
        id = watcher.id
        has[id] = null
        debugger
        // 循环 queue,依次调用 queue 里的 watcher 实例上的 run() 方法
        watcher.run()
      }
      // ......(省略)
    }
    
  6. 那么 nextTick 函数又在这里起到了什么作用呢?

    实际上 vue 并不会立即去执行 flushSchedulerQueue 函数从而重新渲染组件,而是在 nextTick 方法中将刚刚传入的 flushSchedulerQueue 以回调函数的形式推入 callbacks 数组;

    // src/core/util/next-tick.js
    
    // nextTick 将刚刚传入的 flushSchedulerQueue 作为回调函数,放入 callbacks 数组中
    // 并在下一个 tick 依次执行 flushSchedulerQueue 函数;
    // 我们刚刚说到 flushSchedulerQueue 函数的主要作用就是调用 watcher 实例上的 run() 方法,去重新渲染组件
    // 由此可证,vue 组件的重新渲染是在下一个 tick 执行的
    export function nextTick (cb?: Function, ctx?: Object) {
      debugger
      let _resolve
      // 将刚刚传入的 flushSchedulerQueue 作为回调函数,放入 callbacks 数组中
      callbacks.push(() => {
        if (cb) {
          try {
            cb.call(ctx)
          } catch (e) {
            handleError(e, ctx, 'nextTick')
          }
        } else if (_resolve) {
          _resolve(ctx)
        }
      })
      // ......(省略)
    }
    

    那么 callbacks 里的函数又是什么时候被调用的呢?我们接着往下看:

    下面的代码会去判断是使用宏任务还是微任务,如果是宏任务调用 macroTimerFunc 方法,否则调用 microTimerFunc 方法;

    这两个方法做的就是:以微任务 / 宏任务的形式在下一个 tick 执行 callbacks 数组里的回调函数,也就是执行 flushSchedulerQueue;

    我们前面说到 flushSchedulerQueue 函数的主要作用就是调用 watcher 实例上的 run() 方法,去重新渲染组件;由此可知,vue 组件的重新渲染也是在下一个 tick 执行的:

    // src/core/util/next-tick.js
    
    export function nextTick (cb?: Function, ctx?: Object) {
      // ......(省略)
      if (!pending) {
        pending = true
        // 判断是使用宏任务还是微任务
        if (useMacroTask) {
          // 2.5.17版本下,会命中这里的逻辑
          // macroTimerFunc 是使用 MessageChannel 实现的宏任务
          // macroTimerFunc/microTimerFunc 这两个函数被调用
          // 就会去执行 callbacks 数组里的回调函数,也就是执行 flushSchedulerQueue
          // flushSchedulerQueue 被执行,最终就会调用 watcher 上的 run() 方法,重新渲染组件
          macroTimerFunc()
        } else {
          microTimerFunc()
        }
      }
      // ......(省略)
    }
    

    接下来我们来具体看一下 macroTimerFunc 方法和 microTimerFunc 方法的实现:

    macroTimerFunc 的实现会先判断当前环境是否支持 setImmediate,如果不支持再去检测是否支持 MessageChannel,如果还不支持最后就会降级为 setTimeout;

    而 microTimerFunc 的实现,则会先判断当前环境是否支持 Promise,如果不支持的话直接使用 macroTimerFunc;

    // src/core/util/next-tick.js
    
    let microTimerFunc
    let macroTimerFunc
    let useMacroTask = false
    
    // macroTimerFunc 先判断当前环境是否支持 setImmediate
    if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
      macroTimerFunc = () => {
        setImmediate(flushCallbacks)
      }
    } else if (typeof MessageChannel !== 'undefined' && (
      isNative(MessageChannel) ||
      // PhantomJS
      MessageChannel.toString() === '[object MessageChannelConstructor]'
    )) {
      // macroTimerFunc 如果不支持 setImmediate 再检测是否支持 MessageChannel
      const channel = new MessageChannel()
      const port = channel.port2
      channel.port1.onmessage = flushCallbacks
      macroTimerFunc = () => {
        port.postMessage(1)
      }
    } else {
      // 如果 setImmediate 和 MessageChannel 都不支持最后就会降级为 setTimeout;
      macroTimerFunc = () => {
        setTimeout(flushCallbacks, 0)
      }
    }
    // microTimerFunc 判断是否支持  Promise
    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve()
      microTimerFunc = () => {
        p.then(flushCallbacks)
        if (isIOS) setTimeout(noop)
      }
    } else {
      // 不支持的话直接使用 macroTimerFunc
      microTimerFunc = macroTimerFunc
    }
    

    在 macroTimerFunc / microTimerFunc 方法中,又调用了 flushCallbacks 这一方法,我们之前 push 进 callback 的函数就是在这里被调用

  7. 此外 next-tick 文件中还暴露了一个 withMacroTask 方法,这个方法是用来给 v-on 之类的 DOM 操作事件做一层包装,确保回调命中的是宏任务的逻辑:

    // src/core/util/next-tick.js
    
    export function withMacroTask (fn: Function): Function {
      return fn._withTask || (fn._withTask = function () {
        useMacroTask = true
        const res = fn.apply(null, arguments)
        useMacroTask = false
        return res
      })
    }
    

    因此对于我们的 change 回调,会强制走 macroTimerFunc 的逻辑,在 chrome 它是一个通过 MessageChannel 实现的宏任务;

    从下图调试工具截图中也可以看到 flushCallbacks 函数被调用时,this 指向了 MessagePort

image-20220728210750564.png

  1. 接着 vue 会对新旧虚拟节点进行比较,对 dom 元素的 attrs、class、props 等进行更新;对于我们这里的例子,就是去更新 dom 的 props,也就是 checked 属性;

  2. 在 updateDOMProps 方法中对 input 元素的 checked 属性进行更新,由于我们 this.checked = true 已经生效,因此 input 元素的 checked 属性最终为更新为 true。

2.6 版本

对于 2.6 版本的 vue,前面的派发更新逻辑与 2.5 版本 vue 基本一致,但在对 nextTick 的实现上有一些差别,我们来看一下 2.6 版本的 nextTick:

// src/core/util/next-tick.js

export function nextTick (cb?: Function, ctx?: Object) {
  // ......(省略)
  if (!pending) {
    pending = true
    debugger
    timerFunc()
  }
  // ......(省略)
}
// src/core/util/next-tick.js

let timerFunc

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  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)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

可以看到,代码中 withMacroTask 的逻辑被剔除了,而 timerFunc 的降级顺序依次为:

Promise(微任务) => MutationObserver(微任务) => setImmediate(宏任务)=> setTimeout (宏任务)

也就是说,对于 v-on 绑定的回调,2.6 版本是用 Promise 微任务来实现的;

在调试工具中也可以看到,timerFunc 的实现指向了 46 行,也就是通过 Promise 来实现。

image-20220728220105650.png

image-20220728220201270.png

现在我们知道了在 2.5 版本和 2.6 版本下最主要差异,就在于 2.5 版本 v-on 回调使用宏任务,而 2.6 版本的 v-on 回调使用微任务。

那么这个差异又是如何影响组件渲染的呢?这其实与我们 事件循环 机制有关,我们一起来回顾一下事件循环的基本知识:

事件循环

  1. 首先,我们的 js 代码都是在执行栈上被执行的;

  2. 当代码在执行的过程中,遇上异步任务的回调、或者触发的一些操作,会被放在任务队列里;

  3. 任务队列的话又分为两种,宏任务队列和微任务队列;

    • 宏任务队列:scirpt、setTimeout、setImmediate、postMessage 等
    • 微任务队列:promise.then、MutationObsever
  4. 一般执行栈中最开始执行的都是我们整体的 script ,当我们的执行栈中的宏任务代码都被执行完毕以后,会去微任务队列中读取最早加入的任务,放入执行栈中执行,重复读取执行这个过程,一直到本次微任务队列清空;

  5. 这个时候如果是浏览器环境的话,浏览器可能会进行一些重新渲染的操作;

  6. 那么这样就完成了一次事件循环;

  7. 接着开启下一次事件循环,又会先从宏任务队列中读取一个任务执行,然后再去清空微任务队列。

过程分析

我简单分析了一下复选框被勾选后的一个大致流程:

在 2.5 版本下:

  1. 复选框被点击选中,接着我们的 click 回调被执行 $event.preventDefault() 方法被调用,告诉我们的浏览器需要阻止默认事件,也就是在 未来的某一时刻 将我们的复选框恢复到未选中的状态,而根据我们刚刚 debugger 观察, $event.preventDefault() 的执行时机是在下一个 tick 开始之前;
  2. 接下来 this.checked = !this.checked 语句执行,checked 值变为 true,由于 checked 值被绑定到了 input 元素上,因此我们需要对 input 元素进行更新;我们之前说过,出于性能考虑 vue 底层对 dom 的更新操作是异步的,因此这次更新会通过 nextTick 放入一个宏任务里,在下一个 tick 执行;
  3. 当前执行栈任务执行完毕,由于没有微任务,准备进入下一个 tick;此时 $event.preventDefault() 生效,复选框恢复未选中状态,input 元素的 checked 属性值变为 false;
  4. 下一个事件循环开始,在步骤 2 中被推入宏任务的 dom 更新操作被执行,vue 进行 patchVnode、updateDOMProps 等一系列操作;在 updateDOMProps 时,vue 会拿到 checked 值去对 input 元素上的 checked 属性做更新,由于 checked 值为 true,因此 input checked 属性也被设置为 true,表示在页面上就是复选框被重新勾选了。

在 2.6 版本下

  1. 复选框被点击选中,接着我们的 click 回调被执行 $event.preventDefault() 方法被调用,告诉我们的浏览器需要阻止默认事件,也就是在 未来的某一时刻 将我们的复选框恢复到未选中的状态;
  2. 接下来 this.checked = !this.checked 语句执行,checked 值变为 true,由于 checked 值被绑定到了 input 元素上,因此我们需要对 input 元素进行更新;我们之前说过,出于性能考虑 vue 底层对 dom 的更新操作是异步的,因此这次更新会通过 nextTick 放入微任务里
  3. 步骤 2 中被推入微任务的 dom 更新操作被执行,vue 进行 patchVnode、updateDOMProps 等一系列操作;在 updateDOMProps 时,vue 会拿到 checked 值去对 input 元素上的 checked 属性做更新,由于 checked 值为 true,因此 input checked 属性也被设置为 true,表示在页面上就是复选框被重新勾选了。
  4. 当前执行栈任务执行完毕,由于没有微任务,准备进入下一个 tick;此时 $event.preventDefault() 生效,复选框恢复未选中状态,input 元素的 checked 属性值变为 false;

可以看到造成影响的主要原因就是:2.6 版本的 nextTick 是用微任务实现,而 2.5 版本是用宏任务实现。

至此,我们就知道了 nextTick 对组件渲染结果的影响。