this.$nextTick 的使用及原理

158 阅读3分钟

基本使用

nextTick函数接收一个回调函数作为参数,它的作用是将回调延迟到DOM更新并渲染到页面上之后执行。当更新了状态(数据)后需要对新DOM做一些操作,但是这时我们其实获取不到更新后的DOM(具体原因后面会讲)。

<template>
    <div>
       <div ref="next">{{ message }}</div>
       <button @click="change">按钮</button>
    </div>
</template>
<script>
export default {
    name: "NextTick",
    data() {
        return {
            message: "你好",
        }
    },
    methods: {
        change() {
            this.message = "changed"; //更新和渲染DOM的回调被推送到微任务队列中
            console.log(this.$refs.next.innerHTML); // 你好   // 同步代码
            this.$nextTick(() => { // 回调被推送到微任务队列中
                // 对更新后的DOM进行操作
                 console.log(this.$refs.next.innerHTML); // changed
            });
            console.log(this.$refs.next.innerHTML); // 你好   // 同步代码
        }
    }
 }
 </script>

在 Vue 中,当状态发生变化时,watcher 会得到通知,然后触发 虚拟 DOM 的渲染流程。而 watcher 触发渲染这个操作并不是同步的, 而是异步的。Vue 中有一个队列,每当需要渲染时,会将 watcher 推送到这个队列中,在下一次事件循环中再让 watcher 触发渲染的 流程。

为什么 Vue 使用异步更新队列?

从 Vue 2.0开始使用虚拟DOM进行渲染,变化侦测的通知只发送到组件,组件内用到的所有状态的变化都会通知到同一个 watcher ,然后虚拟DOM会对整个组件进行“比对(diff)”并 更改DOM。也就是说,如果在同一轮事件循环中有两个数据发生了变化,那么组件的 watcher 会收到两份通知,从而进行两次渲 染。事实上,并不需要渲染两次,虚拟DOM会对整个组件进行渲染,所以只需要等所有状态都修改完毕后,一次性将整个组件的 DOM渲染到最新即可。 要解决这个问题,Vue 的实现方式是将收到通知的watcher 实例添加到队列中缓存起来,并且在添加到队列之前检查其中是否已经存在相同的watcher ,只有不存在时,才将watcher 实例添加到队列中。然后在下一次事件循环(event loop)中, Vue.js会让队列中的watcher 触发渲染流程并清空队列。这样就可以保证即便在同一事件循环中有两个状态发生改变, watcher 最后也只执行一次渲染流程。

“下次DOM更新周期”的意思其实是下次微任务执行时更新DOM。而 vm.nextTick其实是将回调添加到微任务中。只有在特殊情况下才会降级成宏任务,默认会添加到微任务中。因此,如果使用vm.nextTick 其实是将回调添加到微任务 中。只有在特殊情况下才会降级成宏任务,默认会添加到微任务中。 因此,如果使用vm.nextTick 来获取更新后的DOM,则需要注意顺序的问题。因为不论是更新DOM的回调还是使用 vm.$nextTick 注册的回调,都是向微任务队列中添加任务,所以哪个任务先添加到队列中,就先执行哪个任务。

注意:事实上,更新DOM的回调也是使用vm.$nextTick 来注册到微任务中的

$nextTick 实现原理

import { nextTick } from '../util/index' 

Vue.prototype.$nextTick = function (fn) { 
    return nextTick(fn, this) 
}

由于vm.nextTick会将回调添加到任务队列中延迟执行,所以在回调执行前,如果反复调用vm.nextTick 会将回调添加到任务队列中延迟执行,所 以在回调执行前,如果反复调用vm.nextTick ,Vue.js并不 会反复将回调添加到任务队列中,只会向任务队列中添加一个任 务。此外,Vue.js内部有一个列表用来存储vm.nextTick参数中提供的回调。在一轮事件循环中,vm.nextTick 参 数中提供的回调。在一轮事件循环中,vm.nextTick 只会向 任务队列添加一个任务,多次使用vm.$nextTick 只会将回调 添加到回调列表中缓存起来。当任务触发时,依次执行列表中的 所有回调并清空列表。其代码如下:

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]() 
    } 
} 

let microTimerFunc 
const p = Promise.resolve() 
microTimerFunc = () => { 
    p.then(flushCallbacks) 
} 

export function nextTick (cb, ctx) { 
    callbacks.push(() => { 
        if (cb) { 
            cb.call(ctx) 
        } 
    }) 
   if (!pending) { 
       pending = true 
       microTimerFunc() 
   } 
} 

// 测试一下 
nextTick(function () { 
    console.log(this.name) // Berwin 34 
 }, {name: 'Berwin'})

在上面代码中,我们通过数组callbacks 来存储用户注册的回调,声明了变量pending 来标记是否已经向任务队列中添加了一个任务。每当向任务队列中插入任务时,将pending 设置为 true ,每当任务被执行时将pending 设置为false ,这样就可以通过pending 的值来判断是否需要向任务队列中添加任务。 上面我们还声明了函数flushCallbacks ,它就是我们所说的被注册的那个任务。当这个函数被触发时,会将callbacks 中的所有函数依次执行,然后清空callbacks ,并将pending 设置为false 。也就是说,一轮事件循环中flushCallbacks只会执行一次。接下来声明了microTimerFunc 函数,它的作用是使用 Promise.then 将flushCallbacks 添加到微任务队列中。上面的准备工作完成后,当我们执行nextTick 函数注册回调时,首先将回调函数添加到callbacks 中,然后使用pending 判断是否需要向任务队列中新增任务。下面我们从执行的角度回顾nextTick 的流程。首先,当 nextTick 被调用时,会将回调函数添加到callbacks 中。如果此时是本轮事件循环第一次使用nextTick ,那么需要向任务队列中添加任务。因此,我们使用microTimerFunc 函数封装 Promise.then 的作用就是将任务添加到微任务队列中。如果 不是本轮事件循环中第一次调用nextTick ,也就是说,此时任务队列中已经被添加了一个执行回调列表的任务,那么我们就不需要执行microTimerFunc 向任务队列中添加重复的任务,因为被添加到任务队列中的任务只需要执行一次,就可以将本轮事件循环中使用nextTick 方法注册的回调都依次执行一遍。下图给出了nextTick 的内部注册流程和执行流程。

image.png

在Vue 2.4版本之前,nextTick 方法在任何地方都使用微任务,但是微任务的优先级太高,在某些场景下可能会出现问题。所以Vue.js提供了在特殊场合下可以强制使用宏任务的方法。具体实现如下:

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]() 
    } 
} 
let microTimerFunc 
let macroTimerFunc = function () {...} 
// 新增代码 
let useMacroTask = false 
const p = Promise.resolve() 
microTimerFunc = () => { 
    p.then(flushCallbacks) 
}
// 新增代码 
export function withMacroTask (fn) {
    return fn._withTask || (fn._withTask = function () { 
        useMacroTask = true 
        const res = fn.apply(null, arguments) 
        useMacroTask = false 
        return res 
    }) 
} 
export function nextTick (cb, ctx) { 
    callbacks.push(() => { 
        if (cb) { 
            cb.call(ctx) 
        } 
    }) 
    if (!pending) { 
        pending = true 
        // 修改代码 
        if (useMacroTask) { 
            macroTimerFunc() 
        } else { 
            microTimerFunc() 
        } 
    } 
}

在上述代码中,新增了withMacroTask 函数,它的作用是给回调函数做一层包装,保证在整个回调函数执行过程中,如果修改了状态(数据),那么更新DOM的操作会被推到宏任务队列中。也就是说,更新DOM的执行时间会晚于回调函数的执行时间。下面用点击事件举例。假设点击事件的回调使用了 withMacroTask 进行包装,那么在点击事件被触发时,如果回调中修改了数据,那么这个修改数据的操作所触发的更新DOM的操 作会被添加到宏任务队列中。因为我们在nextTick 中新增了判断语句,当useMacroTask 为true 时,则使用 macroTimerFunc 注册事件。因此,withMacroTask 的实现逻辑很简单,先将变量 useMacroTask 设置为true ,然后执行回调,如果这时候回调中修改了数据(触发了更新DOM的操作),而useMacroTask 是true ,那么更新DOM的操作会被推送到宏任务队列中。当回调执行完毕后,将useMacroTask 恢复为false 。

说明:更新DOM的回调也是使用nextTick 将任务添加到任 务队列中。

简单来说就是,被withMacroTask 包裹的函数所使用的所有 vm.nextTick方法都会将回调添加到宏任务队列中,其中包括状态被修改后触发的更新DOM的回调和用户自己使用vm.nextTick 方法都会将回调添加到宏任务队列中,其中包括状态被修改后触发的更新DOM的回调和用户自己使用 vm.nextTick 注册的回调等。 接下来,我们将介绍macroTimerFunc 是如何将回调添加到宏任务队列中的。前面我们介绍过几种属于宏任务的事件,Vue.js优先使用 setImmediate ,但是它存在兼容性问题,只能在IE中使用,所以使用MessageChannel 作为备选方案。如果浏览器也不支持MessageChannel ,那么最后会使用setTimeout 来将回调 添加到宏任务队列中。实现方式如下:

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { 
    macroTimerFunc = () => { 
        setImmediate(flushCallbacks) 
    } 
} else if (typeof MessageChannel !== 'undefined' 
        && (isNative(MessageChannel) 
        || MessageChannel.toString() === '[object MessageChannelConstructor]')) { 
    const channel = new MessageChannel() 
    const port = channel.port2 
    channel.port1.onmessage = flushCallbacks 
    macroTimerFunc = () => { 
        port.postMessage(1) 
    } 
} else { 
    macroTimerFunc = () => { 
        setTimeout(flushCallbacks, 0) 
    } 
}

可以看到,macroTimerFunc 被执行时,会将 flushCallbacks 添加到宏任务队列中。前面提到microTimerFunc 的实现原理是使用Promise.then ,但并不是所有浏览器都支持Promise ,当不支持时,会降级 成macroTimerFunc 。其实现方式如下:

if (typeof Promise !== 'undefined' && isNative(Promise)) { 
    const p = Promise.resolve() 
    microTimerFunc = () => { 
        p.then(flushCallbacks) 
    } 
} else { 
    microTimerFunc = macroTimerFunc 
}

首先判断浏览器是否支持Promise ,然后进行相应的处理即 可。官方文档中有这样一句话:如果没有提供回调且在支持Promise 的环境中,则返回一个Promise 。也就是说,可以这样使用 vm.$nextTick :

this.$nextTick().then(function () { // DOM更新了 })

要实现这个功能,我们只需要在nextTick 中进行判断,如果没有提供回调且当前环境支持Promise ,那么返回Promise ,并且在callbacks 中添加一个函数,当这个函数执行时,执行 Promise 的resolve 即可,代码如下:

export function nextTick (cb, ctx) { 
    // 新增代码 
    let _resolve 
    callbacks.push(() => { 
        if (cb) { 
            cb.call(ctx) 
        } else if (_resolve) { 
            // 新增代码 
            _resolve(ctx) 
        } 
    }) 
    if (!pending) { 
        pending = true 
        if (useMacroTask) { 
            macroTimerFunc() 
        } else { 
           microTimerFunc() 
        } 
    } 
    // 新增代码 
    if (!cb && typeof Promise !== 'undefined') { 
        return new Promise(resolve => { 
            _resolve = resolve 
        }) 
    } 
}

在上面的代码中,先在函数作用域中声明了变量 _resolve ,然后进行相应的处理。 最终完整的代码如下:

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]()
  }
}

let microTimerFunc
let macroTimerFunc
let useMacroTask = false

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (
  typeof MessageChannel !== 'undefined' &&
  (isNative(MessageChannel) ||
    MessageChannel.toString() === '[object MessageChannelConstructor]')
) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
  }
} else {
  microTimerFunc = macroTimerFunc
}

export function withMacroTask(fn) {
  return (
    fn._withTask ||
    (fn._withTask = function () {
      useMacroTask = true
      const res = fn.apply(null, arguments)
      useMacroTask = false
      return res
    })
  )
}

export function nextTick(cb, ctx) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      cb.call(ctx)
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise((resolve) => {
      _resolve = resolve
    })
  }
}

注意

修改数据的操作要在nextTick之前进行,因为更新渲染DOM和nextTick的回调都是被放到微任务队列中,而微任务队列中的任务是先进先执行,如果this.$nextTick在修改数据之前,nextTick的回调先进入微任务队列中,那么回调中获取的还是更新前的DOM。