skip to content
usubeni fantasy logo Usubeni Fantasy

Vue 的异步更新机制

/ 10 min read

This Post is Available In: CN EN ES JA

这篇文章主要介绍的就是 Vue 异步更新相关的原理。本篇的核心理解起来没有响应式原理难,重点就是两个字队列

异步更新

为什么需要异步更新?

this.a = 1;
this.b = 2;
this.c = 3;
this.a = 5;
this.a = 6;

想想嘛,根据前一篇解析的 Vue 响应式原理,响应式数据在赋值时运行 setter,会触发视图更新,但是就像上面例子中连续赋值的场景可以说十分常见了,在 React 倒是可以写在一个 setState 里,但是 Vue 没有这种方法。

总不能每次更新数据都立即给你刷新一次页面,如果每次赋值都跑一趟 renderpatch,那整个页面不是卡得不行吗?

解决这个问题的关键就是先把需要运行的更新函数都存入队列(而且,在加入队列时,相同的函数不会重复加入),再异步运行队列。

相关变量

在正式分析 Vue 的异步队列原理之前,先看看实现队列使用的这些变量:

var circular = {}; // 开发环境检测循环引用
var queue = []; // watcher 队列
var activatedChildren = [];
var has = {}; // 是否已经加入队列
var waiting = false; // 可以理解为是否在等待 flushing
var flushing = false; // 是否已经开始处理队列
var index = 0; // 队列当前运行到哪里

你也许十分好奇,为什么会有 waiting 和 flushing 这么相似的两个变量呢?

开始我也很疑惑,是否正在 flushing,是否等待 flushing(waiting),虽然还是有细微的区别:

  • 在 waiting 时,异步任务未开始
  • 在 flushing 时,已经确定开始队列任务

开始时间有细微不同,但是他们重置的时机(resetSchedulerState)是一致的,似乎是没有分两个变量的必要,所以我暂时把这理解成可以让代码更自文档吧。

说回队列,这个系统存在着两条队列。既然上篇说到 queueWatcher 会将需要更新的 watcher 放入队列,下次一并更新。现在就先讲 watcher 的队列,具体分析 queueWatcher 到底进行了怎样的操作。

queueWatcher

function queueWatcher(watcher) {
var id = watcher.id;
if (has[id] == null) {
has[id] = true; // 标记 watcher 已添加到队列
if (!flushing) {
// 队列还没开始更新,直接推入队列即可
queue.push(watcher);
} else {
// 队列正在处理中时,Vue 的做法是直接把新的 watcher 插到运行到的最新位置
// 这蕴含着隐藏逻辑
// 已经在前面运行过的 watcher 这时候会立即再在下一个运行
var 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 = true;
nextTick(flushSchedulerQueue);
}
}
}

最后的 nextTick(flushSchedulerQueue) 就是把运行 watcher 队列放入 nextTick 队列中(虽然没有传参,但是 flushSchedulerQueue 可以读取 queueWatcher 处理的 queue)。

短短的这一句函数调用,连接了两个重点:flushSchedulerQueue 是处理 watcher 队列的核心,而 nextTick 是 Vue 异步渲染的核心。

flushSchedulerQueue

根据官方注释,queue.sort 对队列进行排序,是为了保证:

  • 父组件先于子组件
  • 用户定义的 watcher 函数先于渲染 watcher
  • 如果组件在父组件被 destroy,可以跳过这个 watcher(通过 watcher 的 active 属性)
function flushSchedulerQueue() {
flushing = true;
var watcher, id;
queue.sort(function (a, b) {
return a.id - b.id;
});
// queue 可变长,queue.length 不能缓存
for (index = 0; index < queue.length; index++) {
watcher = queue[index];
id = watcher.id;
has[id] = null; // 已经处理的 watcher 从 has 去除
watcher.run();
}
// keep copies of post queues before resetting state
var activatedQueue = activatedChildren.slice();
var updatedQueue = queue.slice();
resetSchedulerState(); // 重置队列相关变量
callActivatedHooks(activatedQueue);
callUpdatedHooks(updatedQueue);
}

简单来说 flushSchedulerQueue 就是用于处理 queueWatcher 编排好的 queue

这里提出一个简单的实践问题,在 watch 某个值的回调函数中,可以访问最新的 DOM 吗?

答案就藏在上面的代码中:在 flushSchedulerQueue 函数对 queue 的循环处理中,queue 仍然会接受 queueWatcher push 的 watcher,这主要用于用户自定的 watch 函数或组件间触发的更新,就不安排到下一个循环了,直接插入 queue 按顺序更新。

所以,大家不要以为 watch 一个值的变化,在回调函数里就能拿到最新的 DOM,因为他们是运行在同一 tick 的(而且组件渲染函数因为 sort 总运行在最后)。

nextTick

var nextTick = (function () {
var callbacks = [];
var pending = false;
var timerFunc;
function nextTickHandler() {
pending = false;
var copies = callbacks.slice(0);
callbacks.length = 0;
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}
if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
timerFunc = function () {
setImmediate(nextTickHandler);
};
} else if (
typeof MessageChannel !== "undefined" &&
(isNative(MessageChannel) ||
// PhantomJS
MessageChannel.toString() === "[object MessageChannelConstructor]")
) {
var channel = new MessageChannel();
var port = channel.port2;
channel.port1.onmessage = nextTickHandler;
timerFunc = function () {
port.postMessage(1);
};
} else if (typeof Promise !== "undefined" && isNative(Promise)) {
// use microtask in non-DOM environments, e.g. Weex
var p = Promise.resolve();
timerFunc = function () {
p.then(nextTickHandler);
};
} else {
// fallback to setTimeout
timerFunc = function () {
setTimeout(nextTickHandler, 0);
};
}
return function queueNextTick(cb, ctx) {
var _resolve;
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, "nextTick");
}
} else if (_resolve) {
_resolve(ctx);
}
});
if (!pending) {
pending = true;
timerFunc();
}
if (!cb && typeof Promise !== "undefined") {
return new Promise(function (resolve, reject) {
_resolve = resolve;
});
}
};
})();

先解释一下 Vue 制造异步运行的方法吧——

An asynchronous deferring mechanism. In pre 2.4, we used to use microtasks (Promise/MutationObserver) but microtasks actually has too high a priority and fires in between supposedly sequential events (e.g. #4521, #6690) or even between bubbling of the same event (#6566). Technically setImmediate should be the ideal choice, but it’s not available everywhere; and the only polyfill that consistently queues the callback after all DOM events triggered in the same loop is by using MessageChannel.

根据官方注释,最开始 Vue 使用 Promise 等微任务更新队列,但是发现更新时机不太对,在不该插入的时间点插入更新,后来进行了修改。

这里 Vue 提供了 4 种制造异步调用的方式:

  • setImmediate
  • MessageChannel,IE 10 或以上均可使用,十分意外,不查不知道,MessageChannel 的可用范围居然比 Promise 还大
  • Promise,注释写用于 Weex
  • setTimeout 0,IE 10 以下

看回整个 nextTick 归根到底是什么?

从代码中看到这是一个立即执行函数,并返回一个 queueNextTick 函数。所以实际上我们使用的是 queueNextTick(cb, ctx)。(不过其实我也不是很懂为什么这里要用闭包包裹 callbackstimerFunc 等变量而不直接放到外面)

nextTick(flushSchedulerQueue) 情况下,flushSchedulerQueue 就是回调函数 cb。

  • 首先 cb 会被推入 callbacks,这是异步运行的队列,和 watcher 队列不是一回事
  • 根据 !pending 的条件,pending 时会直接跳过 timerFunc 运行,直至上一次 nextTickHandler 完成

再顺带一提,我们平常用的 $nextTick 其实跟 nextTick 函数就是一个东西:

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

总结

两个队列:

  • queueWatcher 的 queue
  • nextTick 的 callbacks

queue 是比较自由的,方便在更新组件时及时新增需要修改的组件。

虽然 callbacks 看起来是个队列,也是个数组,但是在 waitingtrue 时并不会往 callbacks 塞东西,callbacks 应该是一直保持只有一个函数在排队的状态。(原理上应该是这样,但是不知道如何验证,如果有错误请指正)

流程:

  • 异步更新的作用是防止重复运行更新函数,使运行更流畅
  • setter 触发 watcher 后,回调函数不会立即运行,而是通过 queueWatcher 函数把 watcher 加入队列 queue
  • 在空闲时,queueWatcher 函数运行 nextTick(flushSchedulerQueue),把 queue 放到下一个 tick 运行
  • flushSchedulerQueue 函数运行 watcher 队列的函数
  • nextTick 就跟平常用的 $nextTick 一样,就是把队列运行放到下一 tick
  • Vue 制造 next tick 的方法有 4 种:setImmediate、MessageChannel、Promise、setTimeout
评论组件加载中……