Vue中nextTick的底层原理

前言

知其然且知其所以然,Vue 作为目前最为主流的前端 MVVM 模型框架之一(实际并没有完全遵循 MVVM 模型),在熟练使用的基础上,去深入理解其实现原理是非常有意义的一件事情。

阅读 Vue 源码就是一个很好的学习方式,不仅可以让我们帮助我们更快解决工作中遇到的问题,也能借鉴优秀源码的经验,学习高手发现问题、思考问题、解决问题的思路,学习怎么写出规范又好维护的高质量代码。

一、异步更新队列

Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 Watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环**“tick”**中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。

例如,当你设置 vm.someData = ‘new value’,该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环 “tick” 中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:

<div id="example">{{message}}</div>
var vm = new Vue({
    el: '#example',
    data: {
        message: '123'
    }
});
vm.message = 'new message'; // 更改数据
vm.$el.textContent === 'new message'; // false
Vue.nextTick(function () {
    vm.$el.textContent === 'new message'; // true
});

在组件内使用 vm.$nextTick() 实例方法特别方便,因为它不需要全局 Vue,并且回调函数中的 this 将自动绑定到当前的 Vue 实例上:

Vue.component('example', {
    template: '<span>{{ message }}</span>',
    data: function () {
        return {
            message: '未更新'
        };
    },
    methods: {
        updateMessage: function () {
            this.message = '已更新';
            console.log(this.$el.textContent); // => '未更新'
            this.$nextTick(function () {
                console.log(this.$el.textContent); // => '已更新'
            });
        }
    }
});

因为 $nextTick() 返回一个 Promise 对象,所以你可以使用新的 ES2017 async/await 语法完成相同的事情:

methods: {
        updateMessage: async function () {
            this.message = '已更新';
            console.log(this.$el.textContent); // => '未更新'
            await this.$nextTick();
            console.log(this.$el.textContent); // => '已更新'
        }
    }

nextTick接收一个回调函数作为参数,并将这个回调函数延迟到DOM更新后才执行;nextTick 是在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用nextTick,则可以在回调中获取更新后的 DOM

使用场景:想要操作 基于最新数据的生成DOM 时,就将这个操作放在 nextTick 的回调中;

二、前置知识

nextTick 函数的作用可以理解为异步执行传入的函数,这里先介绍一下什么是异步执行,从 JS 运行机制说起。

2.1 JS 运行机制

JS 的执行是单线程的,所谓的单线程就是事件任务要排队执行,前一个任务结束,才会执行后一个任务,这就是同步任务,为了避免前一个任务执行了很长时间还没结束,那下一个任务就不能执行的情况,引入了异步任务的概念。JS 运行机制简单来说可以按以下几个步骤。

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个任务队列(task queue)。只要异步任务有了运行结果,会把其回调函数作为一个任务添加到任务队列中。
  3. 一旦执行栈中的所有同步任务执行完毕,就会读取任务队列,看看里面有那些任务,将其添加到执行栈,开始执行。
  4. 主线程不断重复上面的第 3 步。也就是常说的事件循环(Event Loop)。
    请添加图片描述

2.2 异步任务的类型

nextTick 函数异步执行传入的函数,是一个异步任务。异步任务分为两种类型。

主线程的执行过程就是一个 tick,而所有的异步任务都是通过任务队列来一一执行。任务队列中存放的是一个个的任务(task)。规范中规定 task 分为两大类,分别是宏任务(macro task)和微任务 (micro task),并且每个 macro task 结束后,都要清空所有的 micro task。

用一段代码形象介绍 task的执行顺序。

for (macroTask of macroTaskQueue) {
    handleMacroTask();
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在浏览器环境中, 常见的创建 macro task 的方法有

  • setTimeout、setInterval、postMessage、MessageChannel(队列优先于setTimeiout执行)
  • 网络请求IO
  • 页面交互:DOM、鼠标、键盘、滚动事件
  • 页面渲染
    常见的创建 micro task 的方法
  • Promise.then
  • MutationObserve
  • process.nexttick
    nextTick 函数要利用这些方法把通过参数 cb 传入的函数处理成异步任务。

三、nextTick 实现原理

将传入的回调函数包装成异步任务,异步任务又分微任务和宏任务,为了尽快执行所以优先选择微任务; nextTick 提供了四种异步方法 Promise.thenMutationObserversetImmediatesetTimeOut(fn,0)

3.1 Vue.nextTick 内部逻辑

在执行 initGlobalAPI(Vue) 初始化 Vue 全局 API 中,这么定义 Vue.nextTick :

function initGlobalAPI(Vue) {
    //...
    Vue.nextTick = nextTick;
}

可以看出是直接把 nextTick 函数赋值给 Vue.nextTick,就可以了,非常简单。

3.2 vm.$nextTick 内部逻辑

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

可以看出是vm.$nextTick内部也是调用 nextTick 函数。

3.3 源码解读

nextTick 的源码位于 src/core/util/next-tick.js nextTick 源码主要分为两块:

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

//  上面三行与核心代码关系不大,了解即可
//  noop 表示一个无操作空函数,用作函数默认值,防止传入 undefined 导致报错
//  handleError 错误处理函数
//  isIE, isIOS, isNative 环境判断函数,
//  isNative 判断是否原生支持,如果通过第三方实现支持也会返回 false

export let isUsingMicroTask = false     // nextTick 最终是否以微任务执行

const callbacks = []     // 存放调用 nextTick 时传入的回调函数
let pending = false     // 标识当前是否有 nextTick 在执行,同一时间只能有一个执行


// 声明 nextTick 函数,接收一个回调函数和一个执行上下文作为参数
export function nextTick(cb?: Function, ctx?: Object) {
    let _resolve
    // 将传入的回调函数存放到数组中,后面会遍历执行其中的回调
    callbacks.push(() => {
        if (cb) {   // 对传入的回调进行 try catch 错误捕获
            try {
                cb.call(ctx)
            } catch (e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    
    // 如果当前没有在 pending 的回调,就执行 timeFunc 函数选择当前环境优先支持的异步方法
    if (!pending) {
        pending = true
        timerFunc()
    }
    
    // 如果没有传入回调,并且当前环境支持 promise,就返回一个 promise
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

可以看到在 nextTick 函数中把通过参数 cb 传入的函数,做一下包装然后 push 到 callbacks 数组中。

然后用变量 pending 来保证执行一个事件循环中只执行一次 timerFunc()

最后执行 **if (!cb && typeof Promise !== ‘undefined’),**判断参数 cb不存在且浏览器支持 Promise,则返回一个 Promise 类实例化对象。例如 nextTick().then(() => {}),当 _resolve 函数执行,就会执行 then 的逻辑中。

来看一下 timerFunc 函数的定义,先只看用 Promise 创建一个异步执行的 timerFunc 函数 。

// 判断当前环境优先支持的异步方法,优先选择微任务
// 优先级:Promise---> MutationObserver---> setImmediate---> setTimeout
// setTimeOut 最小延迟也要4ms,而 setImmediate 会在主线程执行完后立刻执行
// setImmediate 在 IE10 和 node 中支持

// 多次调用 nextTick 时 ,timerFunc 只会执行一次

let timerFunc   
// 判断当前环境是否支持 promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {  // 支持 promise
    const p = Promise.resolve()
    timerFunc = () => {
    // 用 promise.then 把 flushCallbacks 函数包裹成一个异步微任务
        p.then(flushCallbacks)
        if (isIOS) setTimeout(noop)
    }
    // 标记当前 nextTick 使用的微任务
    isUsingMicroTask = true
    
    
    // 如果不支持 promise,就判断是否支持 MutationObserver
    // 不是IE环境,并且原生支持 MutationObserver,那也是一个微任务
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
    let counter = 1
    // new 一个 MutationObserver 类
    const observer = new MutationObserver(flushCallbacks) 
    // 创建一个文本节点
    const textNode = document.createTextNode(String(counter))   
    // 监听这个文本节点,当数据发生变化就执行 flushCallbacks 
    observer.observe(textNode, { characterData: true })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)  // 数据更新
    }
    isUsingMicroTask = true    // 标记当前 nextTick 使用的微任务
    
    
    // 判断当前环境是否原生支持 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => { setImmediate(flushCallbacks)  }
} else {

    // 以上三种都不支持就选择 setTimeout
    timerFunc = () => { setTimeout(flushCallbacks, 0) }
}

其中 isNative 方法是如何定义,代码如下。

function isNative(Ctor) {
    return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}

在其中发现 timerFunc 函数就是用各种异步执行的方法调用 flushCallbacks 函数。

来看一下 flushCallbacks 函数

// 如果多次调用 nextTick,会依次执行上面的方法,将 nextTick 的回调放在 callbacks 数组中
// 最后通过 flushCallbacks 函数遍历 callbacks 数组的拷贝并执行其中的回调
function flushCallbacks() {
    pending = false
    const copies = callbacks.slice(0)    // 拷贝一份
    callbacks.length = 0    // 清空 callbacks
    for (let i = 0; i < copies.length; i++) {    // 遍历执行传入的回调
        copies[i]()
    }
}

// 为什么要拷贝一份 callbacks

// callbacks.slice(0) 将 callbacks 拷贝出来一份,
// 是因为考虑到 nextTick 回调中可能还会调用 nextTick 的情况,
// 如果 nextTick 回调中又调用了一次 nextTick,则又会向 callbacks 中添加回调,
// nextTick 回调中的 nextTick 应该放在下一轮执行,
// 如果不将 callbacks 复制一份就可能一直循环

执行 pending = false 使下个事件循环中能 nextTick 函数中调用 timerFunc 函数。执行 var copies = callbacks.slice(0);callbacks.length = 0; 把要异步执行的函数集合 callbacks克隆到常量 copies,然后把 callbacks 清空。然后遍历 copies 执行每一项函数。回到 nextTick 中是把通过参数 cb 传入的函数包装后 push 到 callbacks 集合中。来看一下怎么包装的。

function() {
    if (cb) {
        try {
            cb.call(ctx);
        } catch (e) {
            handleError(e, ctx, 'nextTick');
        }
    } else if (_resolve) {
        _resolve(ctx);
    }
}

逻辑很简单。若参数 cb 有值。在 try 语句中执行 cb.call(ctx) ,参数 ctx 是传入函数的参数。 如果执行失败执行 handleError(e, ctx, ‘nextTick’)。

若参数 cb 没有值。执行 _resolve(ctx),因为在 nextTick 函数中如何参数 cb 没有值,会返回一个 Promise 类实例化对象,那么执行 _resolve(ctx),就会执行 then 的逻辑中。

到这里 nextTick 函数的主线逻辑就很清楚了。定义一个变量 callbacks,把通过参数 cb 传入的函数用一个函数包装一下,在这个中会执行传入的函数,及处理执行失败和参数 cb 不存在的场景,然后 添加到 callbacks。

调用 timerFunc 函数,在其中遍历 callbacks 执行每个函数,因为 timerFunc 是一个异步执行的函数,且定义一个变量 pending 来保证一个事件循环中只调用一次 timerFunc 函数。这样就实现了 nextTick 函数异步执行传入的函数的作用了。

那么其中的关键还是怎么定义 timerFunc 函数。因为在各浏览器下对创建异步执行函数的方法各不相同,要做兼容处理,下面来介绍一下各种方法。

3.4 为什么优先使用微任务:

按照上面事件循环的执行顺序,执行下一次宏任务之前会执行一次 UI 渲染,等待时长比微任务要多很多。所以在能使用微任务的时候优先使用微任务,不能使用微任务的时候才使用宏任务,优雅降级。

<think> 我们正在讨论 `nextTick` 在 Vue 中的使用,特别是结合提供的 `reload` 函数代码。让我们先回顾一下 `nextTick` 的作用,然后分析在提供的代码片段中如何使用它。 ### 1. `nextTick` 的作用 在 Vue 中,当你更改响应式数据时,DOM 更新是异步执行的。Vue 会收集同一事件循环中的所有数据变更,然后在下一个事件循环的“tick”中统一更新 DOM。`nextTick` 是一个全局方法,它允许你在 DOM 更新完成后执行回调函数。这意味着你可以在修改数据后立即使用 `nextTick` 来等待 DOM 更新完成,然后执行依赖于更新后 DOM 的操作。 ### 2. `nextTick` 的使用场景 常见的场景包括: - 在数据变化后操作 DOM 元素(例如,获取更新后的 DOM 元素尺寸或位置)。 - 在组件更新后执行某些操作(例如,滚动到某个位置)。 - 在创建或更新组件后,需要立即访问更新后的 DOM。 ### 3. 在引用[1]中的使用 在用户提供的引用[1]中,`nextTick` 被用于上传文件后的回调中: ```javascript const uploadCallbackHandler = () => { nextTick(() => { routerViewRef.value.reload(); getUseSpace(); }); }; ``` 这里,`nextTick` 确保在 DOM 更新后执行 `reload` 和 `getUseSpace` 方法。为什么要这样做?因为上传文件后,可能伴随着数据变化,而数据变化会触发 DOM 更新。如果直接调用 `reload`(可能会重新获取数据并更新 DOM)和 `getUseSpace`(可能依赖于更新后的 DOM 或数据),可能会因为 DOM 更新尚未完成而导致问题。使用 `nextTick` 可以确保这些操作在 DOM 更新后执行,从而避免潜在的问题。 ### 4. 结合 `const reload = (isReoload = false) => { ... }` 的分析 现在,考虑用户提供的 `reload` 函数: ```javascript const reload = (isReoload = false) => { _paginationRef.value?.reload(); }; ``` 这个函数调用了 `_paginationRef` 的 `reload` 方法,该方法可能会触发数据重新加载(例如,重新请求分页数据)并更新 DOM。 #### 在什么情况下需要在这个 `reload` 函数中使用 `nextTick`? 一般来说,在这个函数内部不需要使用 `nextTick`,因为它只是调用了组件的 `reload` 方法,而这个方法内部可能已经处理了异步更新。但是,如果你在调用 `reload` 之后需要立即执行一个依赖于更新后 DOM 的操作,那么你就应该使用 `nextTick`。 例如,假设你在调用 `reload` 后,需要立即获取更新后的某个 DOM 元素的高度: ```javascript reload(); // 触发数据重新加载和DOM更新 // 直接在这里获取DOM元素高度可能得到的是更新前的高度 nextTick(() => { // 这里可以获取更新后的高度 const height = document.getElementById('myElement').offsetHeight; }); ``` #### 在 `reload` 函数内部使用 `nextTick` 的情况 另一种情况是,如果你在 `reload` 函数内部执行了某些响应式数据的变化,然后需要等待这些变化更新到 DOM 后再执行某些操作,那么你可以在 `reload` 函数内部使用 `nextTick`。例如: ```javascript const reload = (isReoload = false) => { // 假设我们先修改了一个响应式数据 someReactiveData.value = newData; // 然后我们调用组件的reload方法 nextTick(() => { // 确保DOM已经更新后再执行reload _paginationRef.value?.reload(); // 或者在这里执行其他依赖于更新后DOM的操作 }); }; ``` 但这种情况较少见,因为通常组件的 `reload` 方法内部已经处理了异步更新。 ### 5. 总结 在用户提供的 `reload` 函数中,通常情况下不需要使用 `nextTick`,因为它只是触发组件的重新加载方法。但是,如果你在调用 `reload` 之后有需要等待 DOM 更新完成的操作,那么应该使用 `nextTick` 包裹这些操作。 在引用[1]中,`nextTick` 被用于确保在 DOM 更新后执行 `reload` 和 `getUseSpace`,这是因为上传回调可能发生在数据更新期间,使用 `nextTick` 可以确保操作在更新后执行,避免不一致。 ### 6. `nextTick` 的使用方法 在 Vue 3 中,`nextTick` 可以通过以下方式使用: - 从 `vue` 中导入:`import { nextTick } from 'vue';` - 作为全局 API 使用(在选项式 API 中,可以通过 `this.$nextTick` 使用,但在组合式 API 中推荐导入使用) 示例: ```javascript import { nextTick } from 'vue'; // 在组合式 API 中 nextTick(() => { // DOM 更新后执行 }); // 或者使用 async/await async function someMethod() { // 修改响应式数据 someReactiveData.value = 'new value'; await nextTick(); // 这里 DOM 已经更新 } ``` ### 7. 在用户代码情境下的应用建议 对于用户提供的 `reload` 函数,如果调用它之后需要立即执行一个依赖于重新加载后 DOM 的操作,那么应该在调用 `reload` 后使用 `nextTick` 包裹该操作。例如: ```javascript reload(); // 触发重新加载 nextTick(() => { // 在重新加载完成且DOM更新后执行的操作 scrollToBottom(); }); ``` 注意:如果 `_paginationRef.value.reload()` 是一个异步操作(例如,它返回一个 Promise),那么你可能需要等待这个 Promise 完成,而不仅仅是等待 DOM 更新。在这种情况下,使用 `nextTick` 是不够的,你应该等待异步操作完成。但根据代码,我们不知道 `reload` 方法是否是异步的,所以需要根据实际情况判断。 ### 相关问题 1.Vue 3 中,`nextTick` 和 `setTimeout(fn, 0)` 有什么区别? 2. 如何在 Vue 组件的选项式 API 中使用 `nextTick`? 3. 在组合式 API 中,`nextTick` 和 `watchEffect` 或 `watch` 有何异同? </think>### `nextTick` 在 Vue 中的使用方法及作用分析 `nextTick` 是 Vue 的核心异步 API,用于在 **DOM 更新周期后** 执行回调函数。结合您提供的代码情境,以下是详细解析: --- ### 一、`nextTick` 的核心作用 1. **解决异步更新问题** Vue 的数据驱动视图更新是异步的(参考[1]中的 `routerViewRef.value.reload()` 调用)。当修改响应式数据后,DOM 不会立即更新,而是进入一个队列。`nextTick` 确保回调在 **DOM 更新完成后执行**,避免操作过时的 DOM 元素。 2. **保证执行时机** 在引用[1]中: ```javascript nextTick(() => { routerViewRef.value.reload(); // 确保在 DOM 更新后触发刷新 getUseSpace(); // 保证获取最新布局信息 }); ``` 此处 `nextTick` 确保 `reload` 和 `getUseSpace` 能基于更新后的 DOM 执行[^1]。 3. **避免渲染阻塞** 同步代码执行可能阻塞渲染,`nextTick` 将操作推迟到浏览器渲染间隙,提升性能(类似微任务)。 --- ### 二、在 `reload` 函数中的应用场景分析 您提供的 `reload` 函数: ```javascript const reload = (isReoload = false) => { _paginationRef.value?.reload(); // 触发分页组件刷新 }; ``` #### 需要结合 `nextTick` 的典型场景: 1. **依赖 DOM 更新的后续操作** 若在触发 `reload()` 后需要立即操作更新后的 DOM 元素(如获取列表高度、滚动定位): ```javascript reload(); // 触发数据刷新 nextTick(() => { const listEl = document.getElementById("list"); listEl.scrollTop = 0; // 确保在数据渲染后滚动到顶部 }); ``` 2. **连续数据变更的批处理** 当同步修改多个响应式数据时,用 `nextTick` 合并操作: ```javascript data.value = fetchNewData(); // 数据变更 reload(); // 触发分页刷新 nextTick(() => { trackAnalytics(); // 在 DOM 更新后执行跟踪逻辑 }); ``` 3. **组件更新后的交互** 例如刷新后自动聚焦搜索框: ```javascript reload(); nextTick(() => { searchInputRef.value.focus(); // 确保组件已渲染 }); ``` --- ### 三、`nextTick` 使用方案 #### 在 Vue 3 中的两种用法: 1. **Composition API 写法** ```javascript import { nextTick } from 'vue'; const reload = async (isReoload = false) => { _paginationRef.value?.reload(); await nextTick(); // 等待 DOM 更新 console.log("DOM 已更新完成"); }; ``` 2. **Options API 写法**(参考引用[2]) ```javascript methods: { reload() { this.isRouterAlive = false; // 触发组件销毁 this.$nextTick(() => { // 等待销毁完成 this.isRouterAlive = true; // 重新创建组件 }); } } ``` --- ### 四、在您的代码中集成 `nextTick` 的示例 优化 `reload` 函数,确保在数据刷新后执行 DOM 相关操作: ```javascript import { nextTick } from 'vue'; const reload = async (isReoload = false) => { try { _paginationRef.value?.reload(); await nextTick(); // 等待分页组件渲染完成 // DOM 更新后的操作 if (isReoload) { scrollToTop(); // 滚动到顶部 showRefreshToast(); // 显示提示 } } catch (error) { console.error("刷新失败:", error); } }; ``` --- ### 关键区别:`nextTick` vs 强制刷新 | **方式** | **原理** | **性能影响** | **使用场景** | |----------------|------------------------------|--------------------|---------------------------| | `nextTick` | 等待 Vue 异步更新队列 | 几乎无开销 | DOM 操作、数据更新后逻辑 | | `router.reload()` | 销毁并重建整个组件(引用[2]) | 高开销(重新渲染) | 全局状态重置/路由级刷新 | | `location.reload()`| 整个页面重载 | 最高开销 | 非单页应用或紧急重置场景 | > 在您的 `reload` 函数中,`_paginationRef.value?.reload()` 属于 **组件级数据刷新**(高效)。若需 DOM 操作,应优先选 `nextTick` 而非路由刷新[^2]。 --- ### 相关问题 1.Vue 3 的 `setup()` 中,`nextTick` 和 `watchEffect` 的执行顺序有何差异? 2. 如何用 `nextTick` 解决表格数据更新后滚动条定位不准的问题? 3. `nextTick` 在服务端渲染 (SSR) 场景下会失效吗?该如何替代
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值