Vue2 源码简介

异步更新队列与 nextTick 原理

理解 Vue2 为什么异步更新 DOM,掌握 watcher 队列去重与 nextTick 的实现原理。

🎯 引言

你在写 Vue 代码时有没有遇到过这样的困惑:连续改了 10 次数据,页面却只更新了一次?或者刚改完数据马上去读 DOM,拿到的还是旧内容?

学完这篇文章,你能够解释 Vue2 为什么要异步更新 DOM,理解 watcher 队列是如何去重和调度的,并掌握 this.$nextTick 的原理和典型用法,知道修改数据后想立刻操作更新后的 DOM 该怎么办。


🧱 问题引入:为什么不能每次都同步渲染

先看一个例子:

App.vue
<template>
    <div>{{ count }}</div>
</template>

<script>
export default {
    data() {
        return { count: 0 };
    },
    mounted() {
        for (let i = 0; i < 10; i++) {
            this.count++;
        }
    },
};
</script>

循环里 count 变了 10 次。如果 Vue 每次数据变化都立刻重新渲染,就要执行 10 次"生成虚拟 DOM、Diff、打补丁",前 9 次的结果用户根本看不到,纯属浪费。

Vue 的做法是:数据变化时先不渲染,把需要更新的 Watcher 放进一个队列,等到当前事件循环快结束时,统一执行一次渲染。 这样 10 次修改合并成 1 次 DOM 更新,性能开销大幅降低。

这就像一个快递柜:每来一个包裹就派一个快递员跑一趟太浪费,不如攒一批,到点统一派送一次。


⚙️ queueWatcher:入队与去重

数据变化会触发 Watcher 的 update() 方法。同步 Watcher(如计算属性)会直接重新求值,而渲染 Watcher 走的是 queueWatcher,进入异步队列。

简化自 src/core/observer/scheduler.js

const queue = [];
const has = {}; // 记录已入队的 watcher id
let waiting = false;

function queueWatcher(watcher) {
    const id = watcher.id;

    // 按 id 去重:同一个 watcher 一次事件循环只入队一次
    if (has[id] == null) {
        has[id] = true;
        queue.push(watcher);

        // 一次事件循环只注册一次 flush
        if (!waiting) {
            waiting = true;
            nextTick(flushSchedulerQueue);
        }
    }
}

这段代码有三个关键设计

  • has 对象去重count 改 10 次,触发的是同一个渲染 Watcher,has[id] 保证它只入队一次。这就是"改 10 次只更新一次"的直接原因。
  • waiting 标志:保证一次事件循环里只调用一次 nextTick(flushSchedulerQueue),避免重复注册刷新任务。
  • nextTick 延迟执行:把队列刷新推迟到当前同步代码执行完之后,让这一轮的所有数据修改都能攒进队列。

🛠 flushSchedulerQueue:统一刷新

下一次事件循环到来时,flushSchedulerQueue 开始工作。简化自 src/core/observer/scheduler.js

function flushSchedulerQueue() {
    // 按 id 从小到大排序
    queue.sort((a, b) => a.id - b.id);

    for (let i = 0; i < queue.length; i++) {
        const watcher = queue[i];
        has[watcher.id] = null;
        watcher.run(); // 重新渲染并打补丁
    }

    // 重置状态,等待下一轮
    queue.length = 0;
    waiting = false;
}

两个细节值得注意:

  • 为什么要排序:Watcher 的 id 是递增的,创建越早 id 越小。父组件的渲染 Watcher 先于子组件创建,排序保证父组件先更新、子组件后更新,避免子组件被重复刷新。
  • watcher.run():真正执行渲染的地方,内部会调用 vm._update(vm._render()),走我们在 Snabbdom 课程 里学过的虚拟 DOM Diff 流程。

所有 Watcher 跑完之后,Vue 还会在这里统一调用各个组件的 updated 生命周期钩子。所以在 updated 里读到的 DOM 一定是最新的。


⚡ nextTick 实现:微任务优先

现在轮到最后一块拼图:nextTick 是怎么"推迟执行"的?简化自 src/core/util/next-tick.js

const callbacks = [];
let pending = false;
let timerFunc;

function nextTick(cb) {
    callbacks.push(cb);

    if (!pending) {
        pending = true;
        timerFunc();
    }
}

function flushCallbacks() {
    pending = false;
    const cbs = callbacks.slice(0);
    callbacks.length = 0;
    for (let i = 0; i < cbs.length; i++) {
        cbs[i]();
    }
}

结构很简单:callbacks 数组收集所有回调,pending 保证一次事件循环只启动一次定时器,到点后由 flushCallbacks 逐个执行。

timerFunc 的选择是一条优雅降级链,Vue 按环境能力依次尝试:

// 1. 优先使用 Promise(微任务)
if (typeof Promise !== 'undefined') {
    const p = Promise.resolve();
    timerFunc = () => {
        p.then(flushCallbacks);
    };
}
// 2. 退化为 MutationObserver(微任务)
else if (typeof MutationObserver !== 'undefined') {
    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);
    };
}
// 3. 退化为 setImmediate(宏任务,仅 IE 和 Node 支持)
else if (typeof setImmediate !== 'undefined') {
    timerFunc = () => {
        setImmediate(flushCallbacks);
    };
}
// 4. 兜底 setTimeout(宏任务)
else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0);
    };
}

为什么优先微任务? 微任务在当前调用栈结束后、浏览器渲染前执行;宏任务要等下一个事件循环,中间可能多经历一次渲染。用微任务执行刷新,能让 DOM 更新尽早发生,减少中间状态被用户看到的机会。在现代浏览器里,timerFunc 走的就是 Promise.then 这一档。


💡 this.$nextTick 的典型用法

this.$nextTick(cb) 就是把 cb 放进同一个 callbacks 数组,等队列刷新完成后执行。典型场景是:修改数据后,需要立刻读取更新后的 DOM

App.vue
<template>
    <div>
        <ul ref="list">
            <li v-for="item in list" :key="item">{{ item }}</li>
        </ul>
        <button @click="addItem">添加一项</button>
    </div>
</template>

<script>
export default {
    data() {
        return { list: ['苹果', '香蕉'] };
    },
    methods: {
        addItem() {
            this.list.push('橙子');

            // 同步读:列表高度还没更新,拿到的是旧高度
            console.log('同步读取:', this.$refs.list.offsetHeight);

            this.$nextTick(() => {
                // 异步读:DOM 已更新,拿到的是新高度
                console.log('nextTick 读取:', this.$refs.list.offsetHeight);
            });
        },
    },
};
</script>

本地运行环境:Node.js v22,安装 vue@2.7.16

npm install vue@2.7.16
$nextTick 不是让代码"立刻执行",而是"等 DOM 更新完再执行"。如果你只是想延迟执行代码、不涉及读 DOM,用它没有意义。
直接操作 DOM 的场景其实大多有替代方案:控制显隐用 v-if / v-show,样式变化用 :class / :style 绑定。只有读取尺寸、对接第三方 DOM 库这类非碰 DOM 不可的场景,才需要 $nextTick

🧾 小节总结

  • Vue2 数据变化不立即渲染,而是把 Watcher 放入队列,合并到下一次事件循环统一执行。
  • queueWatcherhas 对象按 id 去重,同一个 Watcher 一轮只入队一次;waiting 标志保证只注册一次刷新。
  • flushSchedulerQueue 按 id 排序执行(父组件先于子组件),逐个 watcher.run(),随后调用 updated 钩子。
  • nextTickcallbacks 数组 + pending 标志收集回调,定时器按 Promise → MutationObserver → setImmediate → setTimeout 降级。
  • 优先微任务是因为它在当前调用栈结束后、渲染前执行,能让更新尽早生效。
  • 修改数据后要操作更新后的 DOM,用 this.$nextTick(cb);能不用 DOM 的场景尽量用数据绑定代替。

❓ 知识问答

Q1:nextTick 是微任务还是宏任务?

A:不固定,取决于运行环境。现代浏览器支持 Promise,nextTick 走微任务;老环境会按降级链退化为 MutationObserver(微任务)、setImmediate 或 setTimeout(宏任务)。Vue 优先选微任务,让回调在渲染前尽早执行。

Q2:为什么改 10 次数据,DOM 只更新一次?

A:10 次修改触发的是同一个渲染 Watcher,queueWatcher 里的 has 对象按 watcher.id 去重,它只入队一次,队列刷新时自然只渲染一次。

Q3:同步修改数据后立刻读 DOM,为什么拿到的是旧值?

A:渲染是异步的,数据修改只是把 Watcher 放进了队列,真正的 DOM 更新要等 flushSchedulerQueue 在下一次事件循环执行。同步代码里 DOM 还没更新,所以读到旧值。

Q4:flushSchedulerQueue 为什么要给队列排序?

A:排序按 Watcher id 从小到大执行,id 越小创建越早。父组件的渲染 Watcher 先于子组件创建,排序保证父先更新、子后更新,避免子组件被父组件更新牵连后再重复刷新一次。

Q5:需要直接操作 DOM 的场景有哪些替代方案?

A:多数场景可以用数据绑定替代:显隐用 v-if / v-show,样式用 :class / :style,文本用插值或 v-text。只有读取元素尺寸、初始化图表或地图这类第三方 DOM 库时,才真正需要在 $nextTick 里操作 DOM。


🧪 小练习

写一个计数器组件:点击按钮后 count 连续 +1 三次,分别在同步代码和 $nextTick 回调里打印 DOM 中的文本内容,观察两次打印的差异,并解释原因。

Counter.vue
<template>
    <div>
        <p ref="text">{{ count }}</p>
        <button @click="addThree">连加三次</button>
    </div>
</template>

<script>
export default {
    data() {
        return { count: 0 };
    },
    methods: {
        addThree() {
            this.count++;
            this.count++;
            this.count++;

            // 请在这里编写代码:同步打印 this.$refs.text.textContent

            // 请在这里编写代码:在 $nextTick 回调里打印 this.$refs.text.textContent
        },
    },
};
</script>

🎉 恭喜你已经掌握 Vue2 异步更新队列与 nextTick 的原理啦!理解了"watcher 去重入队、微任务统一刷新"这套机制,再看 Vue 的各种更新行为就都讲得通了。