likes
comments
collection
share

探究 $nextTick 的实现原理

作者站长头像
站长
· 阅读数 12

前言

Vue 实现响应式并不是数据发生变化之后 DOM 立即变化,而是按一定的策略进行 DOM 的更新。

Vue在修改数据后,视图不会立刻更新,而是等同一事件循环中的所有数据变化完成之后,再统一进行视图更新。

所以如果你用一个for循环来动态改变数据100次,实际上它只会应用最后一次改变,如果没有这种机制,DOM就进行 100 次的重绘,这固然是一个很大的开销。

而这里就涉及 Vue 一个重要的概念:异步更新队列

如果想要在修改数据之后立即获取更新后的 DOM 可以使用 $nextTick。

今天就来看看 $nextTick 是如何实现的吧!!!

JS 运行机制 (Event Loop)

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

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

这里主线程的执行过程就是一个tick,而所有的异步结果都是通过任务队列来调度。

Loop 分为宏任务和微任务,无论是执行宏任务还是微任务,完成后都会进入到一下tick,并在两个tick之间进行UI渲染。

由于Vue DOM更新是异步执行的,即修改数据时,视图不会立即更新,而是会监听数据变化,并缓存在同一事件循环中,等同一数据循环中的所有数据变化完成之后,再统一进行视图更新。为了确保得到更新后的DOM,所以设置了 Vue.nextTick()方法。

异步执行的任务又分为宏任务、微任务

$nextTick

是Vue的核心方法之一,官方文档解释如下:

在下次DOM更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的DOM。

nextTick 原理

Vue是异步执行dom更新的,一旦观察到数据变化,Vue就会开启一个队列,然后把在同一个事件循环 (event loop) 当中观察到数据变化的 watcher 推送进这个队列。如果这个watcher被触发多次,只会被推送到队列一次。这种缓冲行为可以有效的去掉重复数据造成的不必要的计算和Dom操作。而在下一个事件循环时,Vue会清空队列,并进行必要的DOM更新。

当你设置 vm.someData = ‘new value’,DOM 并不会马上更新,而是在异步队列被清除,也就是下一个事件循环开始时执行更新时才会进行必要的DOM更新。如果此时你想要根据更新的 DOM 状态去做某些事情,就会出现问题。为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用 Vue.nextTick(callback) 。这样回调函数在 DOM 更新完成后就会调用。

源码浅析

nextTick 的实现单独有一个JS文件来维护它,在src/core/util/next-tick.js中。

nextTick 源码主要分为两块:能力检测和根据能力检测以不同方式执行回调队列。

watcher 触发 nextTick

当数据发生变化时 dep 通知 watcher 执行 update 更新

/**
 * 根据 watcher 配置项,决定接下来怎么走,一般是 queueWatcher
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    // 懒执行时走这里,比如 computed
    // 将 dirty 置为 true,可以让 computedGetter 执行时重新计算 computed 回调函数的执行结果
    this.dirty = true
  } else if (this.sync) {
    // 同步执行,在使用 vm.$watch 或者 watch 选项时可以传一个 sync 选项,
    // 当为 true 时在数据更新时该 watcher 就不走异步更新队列,直接执行 this.run 
    // 方法进行更新
    // 这个属性在官方文档中没有出现
    this.run()
  } else {
    // 更新时一般都这里,将 watcher 放入 watcher 队列
    queueWatcher(this)
  }
}

queueWatcher

/**
 * 将 watcher 放入 watcher 队列 
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 如果 watcher 已经存在,则跳过,不会重复入队
  if (has[id] == null) {
    // 缓存 watcher.id,用于判断 watcher 是否已经入队
    has[id] = true
    if (!flushing) {
      // 当前没有处于刷新队列状态,watcher 直接入队
      queue.push(watcher)
    } else {
      // 已经在刷新队列了
      // 从队列末尾开始倒序遍历,根据当前 watcher.id 找到它大于的 watcher.id 的位置,然后将自己插入到该位置之后的下一个位置
      // 即将当前 watcher 放入已排序的队列中,且队列仍是有序的
      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 = false 表示当前浏览器的异步任务队列没有 flushSchedulerQueue 
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        // 直接去刷新 watcher 调度队列 
        // 一般不会走这儿,Vue 默认是异步执行,如果改为同步执行,性能会大打折扣
        flushSchedulerQueue()
        return
      }
      /**
       *   熟悉的 nextTick => vm.$nextTick、Vue.nextTick
       *   1、将 回调函数(flushSchedulerQueue) 放入 callbacks 数组
       *   2、通过 pending 控制向浏览器任务队列中添加 flushCallbacks 函数
       */
      nextTick(flushSchedulerQueue)
    }
  }
}


watcher 更新时 watcher 依次入队,避免 watcher 重复入队

$nextTick 本质是 promise

export function nextTick(cb? Function, ctx: Object) {
    let _resolve
    // cb 即为传入的 flushSchedulerQueue函数,会统一处理压入callbacks数组
    // callbacks也就是异步操作队列
    callbacks.push(() => {
        if(cb) {
            try {
                cb.call(ctx)  // 调用 flushSchedulerQueue 函数,将其放入 callbacks异步队列
            } catch(e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    
    // pending 为false 说明本轮事件循环中没有执行过timerFunc()
    if(!pending) {
        pending = true
        timerFunc()
    }
    
    
    // 当不传入 cb 参数时,提供一个promise化的调用 
    if(!cb && typeof Promise !== 'undefined') {
      // 如nextTick().then(() => {})
      // 当_resolve执行时,就会跳转到then逻辑中
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

next-tick.js 对外暴露了 nextTick 这个参数,所以每次调用 Vue.nextTick 时:

会把传入的回调函数 cb 压入 callbacks 数组执行 timerFunc 函数,延迟调用 flushCallbacks 函数遍历执行 callbacks 数组中的所有函数

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

MutationObserver

先简单介绍下 MutationObserver:MO 是 HTML5 中的 API,用于监视 DOM 变动的接口,它可以用于监控任何 DOM 节点的变更如子节点的删除、属性的修改、文本内容修改等。

调用过程是要先给它绑定回调,得到 MO 实例,这个回调会在 MO 实例监听到变动时触发。 这里 MO 的回调是放在microtask中执行的。

// 创建MO实例
const observer = new MutationObserver(callback)

const textNode = '想要监听的Don节点'

observer.observe(textNode, {
  characterData: true // 说明监听文本内容的修改
})

timerFunc

由于宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,再使用宏任务。

源码路径位于:src\core\util\next-tick.js



/**
 * flushCallbacks 做了三件事:
 *   1、将 pending 置为 false  表示下一个 flushCallbacks() 可以进入浏览器异步任务队列
 *   2、清空 callbacks 数组
 *   3、执行 callbacks 数组中的所有函数(比如 flushSchedulerQueue、用户调用 nextTick 传递的回调函数)
 */

// 对callbacks进行遍历,然后执行相应的回调函数
function flushCallbacks () {
    pending = false
    // 这里拷贝的原因是:
    // 有的cb 执行过程中又会往 callbacks 中加入内容
    // 比如 $nextTick的回调函数里还有$nextTick
    // 后者的应该放到下一轮的nextTick 中执行
    // 所以拷贝一份当前的,遍历执行完当前的即可,避免无休止的执行下去
    const copies = callbcks.slice(0)
    callbacks.length = 0 // callbacks里面的函数
    for(let i = 0; i < copies.length; i++) {
        copies[i]() // 执行 callbacks 数组中的每一个函数
    }
}

let timerFunc // 异步执行函数 用于异步延迟调用 flushCallbacks 函数

// 优先使用 Promise  首选 Promise.resolve().then()
if(typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
   // 首选 Promise.resolve().then()
    timerFunc = () => {
        // 在 微任务队列 中放入 flushCallbacks 函数 
        p.then(flushCallbacks)
        
        // IOS 的UIWebView, Promise.then 回调被推入 microTask 队列,但是队列可能不会如期执行
        // 因此,添加一个空计时器强制执行 microTask
        if(isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
} else if(!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString === '[object MutationObserverConstructor]')) {
    // 当 原生 Promise 不可用时,使用 原生 MutationObserver
    let counter = 1
    // 创建MO实例,监听到DOM变动后会执行回调flushCallbacks
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true // 设置true 表示观察目标的改变
    })
    
    // 每次执行timerFunc 都会让文本节点的内容在 0/1之间切换
    // 切换之后将新值复制到 MO 观测的文本节点上
    // 节点内容变化会触发回调
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter) // 触发回调
    }
    isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
   // setImmediate 宏任务队列,  setImmediate 性能优于 setTimeout 将flushCallbacks 放入浏览器的异步任务队列
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

关键在于timeFunc(),该函数起到延迟执行的作用。

nextTick 的目的就是产生一个回调函数加入 task 或者 microtask 中,当前栈执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。

timeFunc() 有四种实现:

  • promise:

如果浏览器支持Promise,那么就用 Promise.then 的方式来延迟函数调用,Promise.then() 可以将函数延迟到当前函数调用栈最末端,也就是函数调用栈最后调用该函数。从而做到延迟

  • MutationObserver

MutationObserver 是H5 新加的一个功能,其功能是监听 DOM 节点的变动,在所有 DOM 变动完成后,执行回调函数。

  • setImmediate、setTimeout

利用 setImmediate、setTimeout 的延迟原理,setTimeout(func, 0)会将func函数延迟到下一次函数调用栈的开始,也就是当前函数执行完毕后再执行该函数,因此完成了延迟功能。

**而 setImmediate 性能优于 setTimeout **

延迟调用优先级如下:

Promise.then > MutationObserver > setImmediate > setTimeout

为什么优先使用 microtack

JS 的 event loop 执行时会区分 task 和 microtask,引擎在每个 task 执行完毕,从队列中取下一个 task 来执行之前,会先执行完所有 microtask 队列中的 microtask。

setTimeout 回调会被分配到一个新的 task 中执行,而 Promise 的 resolver、MutationObserver 的回调都会被安排到一个新的 microtask 中执行,会比 setTimeout 产生的 task 先执行。

要创建一个新的 microtask,优先使用 Promise,如果浏览器不支持,再尝试 MutationObserver。

实在不行,只能用 setTimeout 创建 task 了。

为啥要用 microtask? 根据 HTML Standard,在每个 task 运行完以后,UI 都会重渲染,那么在 microtask 中就完成数据更新,当前 task 结束就可以得到最新的 UI 了。

反之如果新建一个 task 来做数据更新,那么渲染就会进行两次。

flushCallbacks

依次执行callbacks中的函数


/**
 * 做了三件事:
 *   1、将 pending 置为 false  表示下一个 flushCallbacks() 可以进入浏览器异步任务队列
 *   2、清空 callbacks 数组
 *   3、执行 callbacks 数组中的所有函数(比如 flushSchedulerQueue、用户调用 nextTick 传递的回调函数)
 */
function flushCallbacks () {
  // 微任务队列中只能有一个 flushCallbacks()
  pending = false
  const copies = callbacks.slice(0) // 清空 callbacks 数组
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

callbacks 异步队列中存放了 flushSchedulerQueue 函数,

在 flashcallbacks() 刷新队列的过程中依次执行 callbacks 数组中的 flushSchedulerQueue 函数

flushSchedulerQueue

刷新队列,保证了 watcher 的父子关系顺序,依次执行 watcher 的 run 方法

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  //  flushing = true 表示 watcher 队列正在被刷新
  flushing = true
  let watcher, id

  // 确保 watcher 的先后顺序
  // watcher 队列排序  watcher 的id 由小到大有序递增  保证组件更新顺序  
  // 父组件的 watcher 先于子组件的 watcher 更新
  // 用户 watcher 先于 渲染 watcher 更新
  // 父组件的 watcher 在执行时 子组件的 watcher 销毁 可以跳过子组件的执行 
  queue.sort((a, b) => a.id - b.id)

  //  for 循环遍历 watcher 队列  依次执行 watcher 的 run 方法
  // queue.length 动态计算 确保是最新的队列
  for (index = 0; index < queue.length; index++) {
    // 拿出当前索引的 watcher
    watcher = queue[index]
    // 首先执行 brfore 钩子  执行beforeUpdate()
    if (watcher.before) {
      watcher.before() // watcher 实例化时 传入的 before 方法
    }
    // 清空缓存 表示当前 watcher 已经被执行  当该 watcher 再次入队时可以进入
    id = watcher.id
    has[id] = null
    // 执行 watcher 的run() 
    watcher.run()
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

flushSchedulerQueue() 主要负责依次执行 queue 数组中每个 watcher 的 run 方法,从而进入更新阶段。

刷新队列,由 flushCallbacks 函数负责调用,主要做了如下两件事:

  1. 更新 flushing 为 ture,表示正在刷新队列,在此期间往队列中 push 新的 watcher 时需要特殊处理(将其放在队列的合适位置)
  2. 按照队列中的 watcher.id 从小到大排序,保证先创建的 watcher 先执行,也配合 第一步
  3. 遍历 watcher 队列,依次执行 watcher.before、watcher.run,并清除缓存的 watcher

$nextTick 的应用场景

1.在Vue生命周期的created()钩子函数进行的DOM操作一定要放在nextTick的回调函数中。

原因是created()钩子函数执行时DOM其实并未进行渲染。

2.在数据变化后要执行的某个操作,而这个操作需要使用随数据改变而改变的 DOM 结构的时候,这个操作应该放在nextTick()的回调函数中。

因为:Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变,如果同一个 watcher 被多次触发,只会被推入到队列中一次。

总结

使用nextTick()是为了可以获取更新后的DOM 。

触发时机:在同一事件循环中的数据变化后,DOM完成更新,立即执行nextTick()的回调。

同一事件循环中的代码执行完毕 -> DOM 更新 -> nextTick callback触发

Vue 的 nextTick 是如何实现

  • 将传递的回调函数放入 callbacks 全局数组中

  • 调用 timerFunc 函数,在浏览器的异步队列中放入刷新callbacks(flashcallbacks ) 函数 ,延迟执行 (根据运行环境判断将flashcallbacks() 放入宏任务或者是微任务队列, 使得 flashcallbacks 被延迟调用)

  • 事件循环到了微任务或者宏任务,依次遍历执行 callbacks 数组中的所有函数

参考

  1. $nextTick的作用和使用场景
  2. Vue 源码解读(4)—— 异步更新
  3. 温故而知新,浅入 Vue nextTick 底层原理