在我之前的文章《手写vue -实现数据响应式、数据双向绑定和事件监听》的结尾处,提到了许多不足:主要是数据响应式驱动式视图更新的效率问题。而Vue高效的秘诀就是拥有一套批量、异步的更新策略。
在我们日常使用Vue时,应该都有用到过$nextTick()或nextTick()方法。
我们知道,$nextTick()的作用是,在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用 $nextTick,则可以在回调中获取更新后的 DOM。
那么,“在下次 DOM 更新循环结束之后执行延迟回调”这句话是什么意思呢?Vue内部是如何实现的?利用的什么原理呢?
其实,$nextTick()是一个异步方法,Vue批量异步更新策略是内部基于“浏览器的事件循环机制event loop”实现的。
为了方便我们能更好的理解Vue批量异步更新策略,我们先了解一下事件循环机制。
浏览器为了协调事件处理、脚本执行、网络请求和渲染等任务而制定的工作机制。
代表一个个离散的、独立的工作单元。浏览器完成一个宏任务,在下一个宏任务执行开始前,会对页面进行重新渲染。
主要包括创建文档对象、解析HTML、执行主线JS代码以及各种事件如页面加载、输入、网络事件和定时器等。
比如常见的:setTimeout()、setInteval()和xhr异步请求等。
微任务是更小的任务,是在当前宏任务执行结束后立即执行的任务。如果存在微任务,浏览器会清空微任务之后再重新渲染。微任务的例子有 Promise 回调函数、DOM变化等。
我们先看一道常见的JS面试题:
// 写出下面代码的输出顺序
console.log('script start'); // ①
setTimeout(function () {
console.log('setTimeout'); // ②
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1'); // ③
})
.then(function () {
console.log('promise2'); // ④
});
console.log('script end'); // ⑤
这段代码的执行过程就能很好的说明“事件循环机制”。我们先说下答案吧:
script start
script end
promise1
promise2
setTimeout
首先,在这段代码中,由上面对于事件循环机制的概念我们知道:
1)代码肯定是从上至下放入到Call Stack调用栈中执行的;
2)其中console.log(‘script start’)和console.log(‘script end’)是执行主线上的同步代码,会被作为同一宏任务,加入到宏任务队列;
3)setTimeout()属于宏任务,会被放入到宏任务队列中;
4)promise()会被放入到微任务队列中。
所以,这段代码进入调用栈Call Stack的顺序和执行顺序是这样的:
1)console.log(‘script start’)作为一个宏任务的一部分,首先进入到调用栈并执行输出script start;
2)setTimeout()属于宏任务,会被放入到宏任务队列中,当第一个宏任务执行完,且间隔中的微任务也执行完才执行。
3)接着promise()会被放入到微任务队列中,等到第一个宏任务执行完,第二个宏任务执行前才放入调用栈执行;
4)执行第一个宏任务中的console.log(‘script end’)输出script end,第一个宏任务结束;
5)微任务队列中的promise()进入到调用栈,并执行输出promise1和promise2,微任务队列已清空;
6)宏任务队列中的第二个宏任务setTimeout()进入调用栈并执行输出script end。
文字说明可能有点儿绕,大家可以结合一下例子事件循环机制-例子说明里的动画手动走一遍看看,应该很容易理解。
说完事件循环机制的原理,接下来我们看下在Vue2中是如何借助这一机制实现批量异步更新策略的。
只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。
一个组件实例对应一个Watcher。如果同一个 watcher 被多次触发,只会被推入到队列中一次。去重对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列执行实际工作。
Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 或 setImmediate ,如果执行环境都不支持,则会采用 setTimeout 代替。
从《vue2源码解析(一) - new Vue()的初始化过程》中我们知道,触发视图更新的是Watcher的update()方法,所以,异步更新操作应该是从这里开始的。
当响应式数据发生改变时,会触发key对应的set()方法,接着调用dep.notify()通知更新,最后触发视图更新的是Watcher的update()方法,所以,异步更新操作应该是从这里开始的。
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
queueWatcher(this)就是Watcher加入微任务队列的方法。那么,这里面具体做了什么呢?
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let 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
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
将Watcher放入queue数组中,每个Watcher会根据ID判断只加入一次。
queue数组主要是用于在flushSchedulerQueue()遍历执行,即执行更新操作。
flushSchedulerQueue()的作用是清空微任务队列,即遍历执行queue数组中的Watcher的update()。但在这里还并未执行flushSchedulerQueue(),将来会在两个宏任务执行间隙中执行。
若队列不在刷新状态且是非等待状态则把调用nextTick(flushSchedulerQueue)将flushSchedulerQueue放入到callbacks数组中。
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
})
}
}
将cb(即flushSchedulerQueue)作为子元素保存到callbacks数组中。callbacks数组将作为一个微任务,将来放入微任务队列中。也就是说,将所有待执行更新的Watcher整合成一个数组对象(微任务),将来放到微任务队列中。
接着调用timerFunc()异步执行更新。
注意:若是手动调用nextTick(),将会往callbacks中增加一个元素。
后面会有实例进行解析。
timerFunc()函数的作用是:异步遍历执行callbacks中的更新方法。
它定义是根据不同运行环境去生成的,异步方式的优先级是:
Promise > MutationObserver > setImmediate > setTimeout
前三个都是微任务级别的异步方法,setTimeout是宏任务级别的异步方法。
修改响应式数据时,将Watcher放入了微服务队列中(callbacks),会在两个宏任务执行间隙去调用timerFunc() => flushCallbacks() => Watcher.run()进行更新。
<html>
<head>
<title>Vue源码剖析title>
<script src="../../dist/vue.js">script>
head>
<body>
<div id="demo">
<h1>异步更新h1>
<p id="p1">{
{foo}}p>
div>
<script>
// 创建实例
const app = new Vue({
el: '#demo',
data: {
foo: '0' },
mounted() {
this.foo = Math.random()
console.log('1:' + this.foo);
this.foo = Math.random()
console.log('2:' + this.foo);
this.foo = Math.random()
console.log('3:' + this.foo);
// 下面的3个输出的值是多少?
console.log('p1.innerHTML:' + p1.innerHTML)
Promise.resolve().then(() => {
console.log('promise p1.innerHTML:' + p1.innerHTML)
})
this.$nextTick(() => {
console.log('p1.innerHTML:' + p1.innerHTML)
})
}
});
script>
body>
html>
结合上面的内容分析执行过程: