blog
blog copied to clipboard
Vue源码探秘(nextTick)
引言
前两节我带大家分别分析了依赖收集
和派发更新
。在上一节的最后,提到了nextTick
,这一节我们一起来看下nextTick
的源码实现。
为什么要异步更新
我们先思考一个问题:Vue
为什么要引入异步更新队列这一概念?
其实这块,在上一节有提到。如果渲染 Watcher
的回调是同步执行的,那执行流程是这样子的:在修改一个属性值的时候,会触发它的 setter
,然后就马上去触发渲染 Watcher
的回调,引起页面的重新渲染,乍看好像没有什么问题。
但是我们在实际开发中经常会在一个函数内修改多个属性,也就是说这些属性是同时或者说几乎同时被修改了,那实际上渲染 Watcher
完全可以等同一时间点的所有属性都修改完了再去执行回调重新渲染。
而这对于同步 Watcher
是无法实现的,有几个属性修改它就会被触发渲染页面几次,显然这会造成严重的性能问题。
这也就是引入异步更新队列的意义所在,在数据修改时,不会直接触发 Watcher
的回调,而是先把它放入一个异步队列中,并且我们可以通过相关逻辑控制当有多个属性同时修改时同个 Watcher
不会被重复添加到队列中。
因为有 nextTick
的存在,Watcher
的回调是异步执行的,所以它会一直等待同一时刻被修改的属性的 setter
都触发完,相关 Watcher
都添加入队列中后(也就是同步代码执行完后),才会触发渲染 Watcher
重新渲染,这样页面只需要重新渲染一次,性能也得到了很大的提升。
js 运行机制
由于 nextTick
涉及到了 macrotask
和 microtask
的概念。而这又与js
的运行机制有关,所以这里我们有必要先大概了解一下js
的运行机制,也有助于我们理解 nextTick
。
单线程
js
是一门单线程语言,或者说它只有一个主线程。
为什么
js
不能是多线程呢,这是因为js
是一种与浏览器交互的语言。假设js
是多线程,然后同时有多个线程操作一个DOM
节点,那浏览器要以哪个线程为准呢,这显然会造成错乱。
js 引擎
一个典型的浏览器会有图形引擎和一个 js
引擎。js
引擎是一个专门处理 js
脚本的虚拟机,比如 Chrome
的 js
引擎 V8
。
执行上下文和执行栈
js
在执行一个函数的时候,会创建这个函数的执行上下文,并将执行上下文压入执行栈。当前执行上下文在执行栈的栈顶,当执行完后会弹出栈。
事件循环和任务队列
浏览器的
Event loop
和Node
的Event loop
是两个概念。具体可以参考https://segmentfault.com/a/1190000013861128
这里我主要说下在浏览器的Event loop
中的microtask
和macrotask
。
实际上异步任务有两种,分别是微任务 microtask
和宏任务 macrotask
。像是 Promises
、MutationObserver
还有 node.js
中的 process.nextTick
都属于微任务;而 setInterval
、setTimeout
、setImmediate
、requestAnimationFrame
、DOM
事件回调则属于宏任务。因此相对的任务队列也分为微任务队列
和宏任务队列
。
那 microtask
和 macrotask
的区别在哪里呢?当主线程处于闲置状态时,会先去微任务队列看是否有事件需要执行,有的话会执行完微任务队列的所有事件直到微任务队列为空。然后再去宏任务队列看,有的话则取出第一位的事件执行。
也就是说在每一次事件循环中,只会提取一个 macrotask
出来执行,而 microtask
会一直提取,直到微任务队列清空,并且 microtask
先于 macrotask
执行。
同样举一个例子来帮助理解:
setTimeout(() => {
console.log(1);
}, 0);
new Promise((resolve) => {
console.log(2);
resolve(3);
}).then((res) => {
console.log(res);
});
console.log(4);
setTimeout(() => {
console.log(5);
}, 0);
上述例子的输出结果是 2、4、3、1、5 。因为 Promise
属于 microtask
,setTimeout
属于 macrotask
,会先输出 3 再输出 1、5 。
nextTick
nextTick
除了有提供给内部使用的接口,也有暴露给外部使用的接口。从官方文档我们可以了解到,外部接口有 vm.$nextTick
和 Vue.nextTick
。其中 vm.$nextTick
定义在 src/core/instance/render.js
文件中:
// src/core/instance/render.js
export function renderMixin(Vue: Class<Component>) {
// ...
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this);
};
// ...
}
而 Vue.nextTick
定义在 src/core/global-api/index.js
文件中:
// src/core/global-api/index.js
export function initGlobalAPI(Vue: GlobalAPI) {
// ...
Vue.nextTick = nextTick;
// ...
}
这两个外部接口实际上都是调用了内部的 nextTick
接口,和 nextTick
相关的代码都定义在 src/core/util/next-tick.js
文件中:
// src/core/util/next-tick.js
/* @flow */
/* globals MutationObserver */
import { noop } from "shared/util";
import { handleError } from "./error";
import { isIE, isIOS, isNative } from "./env";
export let isUsingMicroTask = false;
const callbacks = [];
let pending = false;
function flushCallbacks() {
// ...
}
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== "undefined" && isNative(Promise)) {
const p = Promise.resolve();
timerFunc = () => {
p.then(flushCallbacks);
if (isIOS) setTimeout(noop);
};
isUsingMicroTask = true;
} else if (
!isIE &&
typeof MutationObserver !== "undefined" &&
(isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
// Use MutationObserver where native Promise is not available,
// e.g. PhantomJS, iOS7, Android 4.4
// (#6466 MutationObserver is unreliable in IE11)
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);
};
isUsingMicroTask = true;
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
// Fallback to setImmediate.
// Technically it leverages the (macro) task queue,
// but it is still a better choice than setTimeout.
timerFunc = () => {
setImmediate(flushCallbacks);
};
} else {
// Fallback to setTimeout.
timerFunc = () => {
setTimeout(flushCallbacks, 0);
};
}
export function nextTick(cb?: Function, ctx?: Object) {
// ...
}
代码刚开始定义的几个变量和flushCallbacks
函数的具体作用,我会在后面介绍。
接着定义了 timerFunc
,它的作用就是异步执行 flushCallbacks
函数。这里 timerFunc
通过一系列的判断来确定最适合的取值。
来看下timerFunc
的取值逻辑:
- 我们知道异步任务有两种,其中
microtask
要优于macrotask
,所以优先选择Promise
。因此这里先判断浏览器是否支持Promise
。 - 如果不支持再考虑
macrotask
。对于macrotask
会先后判断浏览器是否支持MutationObserver
和setImmediate
。 - 如果都不支持就只能使用
setTimeout
。这也从侧面展示出了macrotask
中setTimeout
的性能是最差的。
我们再来看 nextTick
函数的具体逻辑:
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;
timerFunc();
}
// $flow-disable-line
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve;
});
}
}
nextTick
函数会创建一个匿名函数 push 到 callbacks
中。
接着 if (!pending)
语句中 pending
作用显然是让 if
语句的逻辑只执行一次,而它其实就代表 callbacks
中是否有事件在等待执行。
if
语句的逻辑也很简单,就是执行 timerFunc
函数,也就是异步执行 flushCallbacks
函数。现在我们回过头来看 flushCallbacks
函数的定义:
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
flushCallbacks
函数的主要逻辑就是将 pending
置为 false
以及清空 callbacks
数组,然后遍历 callbacks
数组,执行里面的每一个函数。
我们回到 nextTick
函数,来看最后一段代码:
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve;
});
}
这里 if
对应的情况是我们调用 nextTick
函数时没有传入回调函数并且浏览器支持 Promise
,那么就会返回一个 Promise
实例,并且将 resolve
赋值给 _resolve
。我们再来看前面的代码:
let _resolve;
callbacks.push(() => {
if (cb) {
// ...
} else if (_resolve) {
_resolve(ctx);
}
});
当我们执行 callbacks
的函数时,发现没有 cb
而有 _resolve
时就会执行之前返回的 Promise
对象的 resolve
函数。
总结
这一节我们学习了 nextTick
的实现原理,nextTick
会把要执行的回调保存到一个队列 callbacks
中,然后把遍历 callbacks
执行回调作为一个异步任务,所以会在执行栈为空时也就是同步代码执行完后才会执行这些回调。
同时结合上一节派发更新的内容,我们就明白了数据发生改变后,页面重新渲染是一个异步的过程。
关于这个pending
一直有点困惑 ,我的理解如下:
比如我在某个数据更新之后写了两个nextTick
执行函数
this.$nextTick(() => {
console.log('执行第一个nextTick')
})
this.$nextTick(() => {
console.log('执行第二个nextTick')
})
按照这个里的pending
的逻辑的话是:
执行第一个nexttick的时候 ,pending就变成true, 然后执行flushCallbacks
,假设程序比较简单的话,当前callback
只有一个
() => {
console.log('执行第一个nextTick')
}
按照任务队列的逻辑,其实还不会触发callback中的函数执行,假设callback[0]是异步的promise
,所以先不会执行,而是等
然后第二个nexttick
触发之后再收集了,重复上面的逻辑再进行,然后再排队执行微任务?
是正确的?