zl程序教程

您现在的位置是:首页 >  前端

当前栏目

关于Vue中$nextTick的作用及实现原理(Vue进阶)

Vue原理 实现 关于 作用 进阶 nextTick
2023-09-11 14:18:53 时间

Hello,大家好,最近找工作的路途依然艰难,但是要一直保持对技术的热爱!今天继续跟大家探索一下Vue进阶的内容,这次研究一下Vue的全局API中的$nextTick,这是一个很常用,也是经典的API。

PS: 哪位大佬公司还缺前端,能帮忙内推的,可以加下我联系方式,22届应届生,有6个多月实习经验,PC、小程序、App均有若干项目实践,base地点不限,一线城市均可。

$nextTick的作用

相信经常使用Vue框架开发项目的同学一定对这个API不陌生。如果有同学没有用过,我们拿一个小demo看一下使用过程。

<template>
	<div id="example">{{message}}</div>
</template>
<script>
    var vm = new Vue({
      el: '##example',
      data: {
        message: '123'
      }
    })
    vm.message = 'new message' // 更改数据
    console.log(vm.$el.innerHTML) // '123'
    Vue.nextTick(function () {
      console.log(vm.$el.innerHTML) // 'new message'
    })
</script>

这是一段非常简单的代码,我们首先动态渲染了一个message变量,它的初始值为'123',我们想将message的值修改为'new message'。但是,我们修改完message变量的值后,立刻输出它的DOM值,可以发现,message依然为'123'。但是从实际的渲染视图来看,message确实在视图上由'123'改为了'new message'

那这是怎么回事的,回想一下我们的代码,其实就是通过Vue框架做了一个最简单的DOM更新操作。既然涉及到了DOM更新,就需要了解Vue更新DOM的机制,Vue内部维护了一个虚拟DOM,我们进行常规的DOM操作,并不是立刻更新真实的DOM树,而是被Vue记录在了内部的虚拟DOM上,然后再统一进行更新,这个统一更新的操作是异步的,Vue内部维护了一个任务队列。

所有,为什么我们修改完DOM的值后,立刻输出这个DOM的值还是原来的值,因为此时这个DOM更新的操作被Vue记录了下来,存到了需要更新的任务队列里,等待更新。

那么怎么解决这个问题呢,Vue为我们提供了一个全局API$nextTick,它支持传入一个回调函数,只有当VueDOM操作更新结束之后,才会执行这个回调函数,所以,在传入的回调函数中输出DOM的值,一定是更新之后的结果。

那么,$nextTick是如何实现的呢?

$nextTick的实现原理

想要知道$nextTick的原理就要先弄明白,Vue是如何维护一个内部的任务队列来异步更新DOM的。

牵扯的知识点其实越来越多,我们首先要充分了解JS的运行机制。我们知道JS执行的单线程的,它能实现高效执行、不阻塞,是基于了事件循环的机制。

我简单概述一下事件循环的过程,首先JS中所有的代码先被分为了同步任务和异步任务,代码的执行有一个主的执行栈,同步代码从上到下依次执行,异步代码会被怼到任务队列里,任务队列中的异步代码再次被分为了宏任务和微任务,宏任务和微任务的执行原则就是,优先执行微任务队列的代码,微任务队列清空之后,再去执行宏任务队列,每执行完一个宏任务都要去清空一遍微任务队列(前提是有)。

上次宏任务和微任务的执行原则可以大概用以下代码概述。

for (macroTask of macroTaskQueue) {
    // 1. 处理当前的宏任务
    handleMacroTask();

    // 2. 处理对应的所有微任务
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在浏览器环境中,常见的宏任务和微任务有:

  • 宏任务(macro task)有setTimeoutMessageChannelpostMessagesetImmediate
  • 微任务(micro task)有MutationObseverPromise.then

了解了关于JS的事件循环后,回到Vue中,Vue内部其实也需要维护一个异步更新的任务队列,那么最好的办法就是借鉴JS原生提供的能力。

Vue在内部对异步队列尝试使用原生的Promise.thenMutationObserversetImmediate,如果执行环境不支持,则会采用setTimeout代替。

宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,使用宏任务;但是,各种宏任务之间也有效率的不同,需要根据浏览器的支持情况,使用不同的宏任务。

我们看一下Vue源码中关于这部分功能的实现:
位置 src/core/util/next-tick.js

let microTimerFunc
let macroTimerFunc
let useMacroTask = false

/* 对于宏任务(macro task) */
// 检测是否支持原生 setImmediate(高版本 IE 和 Edge 支持)
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    macroTimerFunc = () => {
        setImmediate(flushCallbacks)
    }
}
// 检测是否支持原生的 MessageChannel
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)
    }
}
// 都不支持的情况下,使用setTimeout
else {
    macroTimerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

/* 对于微任务(micro task) */
// 检测浏览器是否原生支持 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    microTimerFunc = () => {
        p.then(flushCallbacks)
    }
}
// 不支持的话直接指向 macro task 的实现。
else {
    // fallback to macro
    microTimerFunc = macroTimerFunc
}

首先声明了两个变量: microTimerFuncmacroTimerFunc ,它们分别对应的是 micro task 的函数和 macro task 的函数。对于 macro task 的实现,优先检测是否支持原生 setImmediate,这是一个高版本 IEEdge 才支持的特性,不支持的话再去检测是否支持原生的 MessageChannel,如果也不支持的话就会降级为 setTimeout;而对于 micro task 的实现,则检测浏览器是否原生支持 Promise,不支持的话直接指向 macro task 的实现。

OK,有了以上的铺垫,我们的主角$nextTick来了,以下是它的核心代码。

const callbacks = []   // 回调队列
let pending = false    // 异步锁

// 执行队列中的每一个回调
function flushCallbacks () {
    pending = false     // 重置异步锁
    // 防止出现nextTick中包含nextTick时出现问题,在执行回调函数队列前,提前复制备份并清空回调函数队列
    const copies = callbacks.slice(0)
    callbacks.length = 0
    // 执行回调函数队列
    for (let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

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()
        }
    }
    // 如果没有提供回调,并且支持Promise,返回一个Promise
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

首先,先来看 nextTick函数,该函数的主要逻辑是:先把传入的回调函数 cb 推入 回调队列callbacks 数组,同时在接收第一个回调函数时,执行能力检测中对应的异步方法(异步方法中调用了回调函数队列)。最后一次性地根据 useMacroTask 条件执行 macroTimerFunc 或者是 microTimerFunc,而它们都会在下一个 tick 执行 flushCallbacksflushCallbacks 的逻辑非常简单,对 callbacks 遍历,然后执行相应的回调函数。

nextTick 函数最后还有一段逻辑:

if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}

这是当 nextTick 不传 cb 参数的时候,提供一个 Promise 化的调用,比如:

nextTick().then(() => {})

_resolve 函数执行,就会跳到 then 的逻辑中。

这里有两个问题需要注意:

  1. 如何保证只在接收第一个回调函数时执行异步方法?

nextTick源码中使用了一个异步锁的概念,即接收第一个回调函数时,先关上锁,执行异步方法。此时,浏览器处于等待执行完同步代码就执行异步代码的情况。

  1. 执行 flushCallbacks 函数时为什么需要备份回调函数队列?执行的也是备份的回调函数队列?

因为,会出现这么一种情况:nextTick 的回调函数中还使用 nextTick。如果 flushCallbacks 不做特殊处理,直接循环执行回调函数,会导致里面nextTick 中的回调函数会进入回调队列。

以上就是对 nextTick 的源码分析,我们了解到数据的变化到 DOM 的重新渲染是一个异步过程,发生在下一个 tick。当我们在实际开发中,比如从服务端接口去获取数据的时候,数据做了修改,如果我们的某些方法去依赖了数据修改后的 DOM 变化,我们就必须在 nextTick 后执行。

总结

今天带着大家探索了一下Vue中的一个全局API $nextTick的基本用法和实现原理,牵扯出了很多的知识点,Vue的虚拟DOMVue更新DOM的机制、JS的事件循环,跟随Vue的源码和大家共同学习一下。

QQ: 505417246
WX: 18331092918
公众号: Code程序人生
B站账号: LuckyRay123
个人博客: http://rayblog.ltd/
欢迎关注我的各类账号, 持续更新优质前端内容