引言:
前面核心篇说过Vue 运行时的核心主要包括数据初始化、数据更新、异步队列、DOM渲染这几个部分,理解异步队列是理解数据更新非常重要的一部分,本文讲一下Vue的异步队列的思路以及实现原理,顺带讲一下 Vue 的 $nextTick。
一、Vue的异步队列是什么?
要弄懂这个概念首先看一个例子:
<div id="example"><div>{{ words }}</div><input type="button" @click="clickHanler" value="click"/> </div>var vm = new Vue({el:"#example",data: {name: "Devin",greetings: "Hello"},computed: {words: function(){return this.greetings + ' ' + this.name + '!'}},methods: {clickHanler(){this.name = 'Devinn';this.name = 'Devinnzhang';this.greetings = 'Morning';}}});
由前面的分析可以知道,此时 Vue 中创建了两个 watcher,一个是渲染 watcher,负责渲染模板,一个是 computed watcher 负责计算属性。当点击 clickHandler 的时候数据发生变化会通知道两个 watcher ,watcher进行更新。这里的 watcher 更新会有两个问题:
1、渲染watcher 和 computed watcher 同时订阅了变化的数据,哪一个先执行,执行顺序是怎么样的?
2、在一个事件循环中 name 的变化触发了两次,greetings 触发了一次,对应两个 watcher 一共执行了几次?DOM渲染了几次?
可见,在数据更新的阶段,需要一个管理多个 watcher 进行更新的角色,这个角色需要保证 watcher 能够按照正确的顺序执行并且尽可能的减少执行次数(对应到渲染 watcher就是DOM渲染),Vue中的这个角色就是异步队列。
下面讲一下实现原理。
二、异步队列实现原理
Vue的异步队列是在数据更新时开启的,那么从数据更新的逻辑开始看:
/*** Define a reactive property on an Object.*/function defineReactive$$1 (obj,key,val,customSetter,shallow) {var dep = new Dep();var property = Object.getOwnPropertyDescriptor(obj, key);if (property && property.configurable === false) {return}// cater for pre-defined getter/settersvar getter = property && property.get;var setter = property && property.set;if ((!getter || setter) && arguments.length === 2) {val = obj[key];}var childOb = !shallow && observe(val);Object.defineProperty(obj, key, {enumerable: true,configurable: true,get: function reactiveGetter () {var value = getter ? getter.call(obj) : val;if (Dep.target) {dep.depend();if (childOb) {childOb.dep.depend();if (Array.isArray(value)) {dependArray(value);}}}return value},set: function reactiveSetter (newVal) {var value = getter ? getter.call(obj) : val;/* eslint-disable no-self-compare */if (newVal === value || (newVal !== newVal && value !== value)) {return}/* eslint-enable no-self-compare */if (customSetter) {customSetter();}// #7981: for accessor properties without setterif (getter && !setter) { return }if (setter) {setter.call(obj, newVal);} else {val = newVal;}childOb = !shallow && observe(newVal);dep.notify();}});}
Dep.prototype.notify = function notify () {// stabilize the subscriber list firstvar subs = this.subs.slice();if (!config.async) {// subs aren't sorted in scheduler if not running async// we need to sort them now to make sure they fire in correct// ordersubs.sort(function (a, b) { return a.id - b.id; });}for (var i = 0, l = subs.length; i < l; i++) { subs[i].update(); //watcher.update() }};
当数据发生变化时,会调用 dep.notity() 进而通知订阅的 watcher 进行 更新。下面进入正题,看一下 watcher 更新的逻辑:
/*** Subscriber interface.* Will be called when a dependency changes.*/Watcher.prototype.update = function update () {/* istanbul ignore else */if (this.lazy) {this.dirty = true;} else if (this.sync) {this.run();} else {queueWatcher(this);}};
可以看到 update 中 watcher 并没有立即执行( 同步的除外),而是调用了 queueWatcher (将更新的 watcher 加入到了一个队列中),看下 queueWatcher 的实现:
/*** Push a watcher into the watcher queue.* Jobs with duplicate IDs will be skipped unless it's* pushed when the queue is being flushed.*/function queueWatcher (watcher) {var id = watcher.id;console.log('watcherId='+ id + 'exporession=' + watcher.expression);if (has[id] == null) {//console.log('watcherId='+ id + 'exporession=' + watcher.expression);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.var i = queue.length - 1;while (i > index && queue[i].id > watcher.id) {i--;}queue.splice(i + 1, 0, watcher);}// queue the flushif (!waiting) {waiting = true;if (!config.async) {flushSchedulerQueue();return}nextTick(flushSchedulerQueue);}}}
这里的 queueWatcher 做了两个事:
1、将 watcher 压入队列,重复的 watcher 知会被压入一次,这样在一个事件循环中 触发了多次的 watcher 只会被压入队列一次。如例子中异步队列中只有一个 渲染 watcher 和一个computed watcher;
2、调用 nextTick(flushSchedulerQueue) 对队列中的 watcher 进行异步执行, nextTick 实现异步,flushSchedulerQueue 对 watcher 进行遍历执行。
看一下 nextTick 的实现:
var callbacks = [];
---
function nextTick (cb, ctx) { //cb === flushSchedulerQueuevar _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();}// $flow-disable-lineif (!cb && typeof Promise !== 'undefined') {return new Promise(function (resolve) {_resolve = resolve;})}}
nextTick 首先会将想要执行的函数 放在 callback 数组中,之后调用 timerFunc() 开启异步线程对 push 到数组中的 函数一一执行
var timerFunc;if (typeof Promise !== 'undefined' && isNative(Promise)) {var p = Promise.resolve();timerFunc = function () {p.then(flushCallbacks);if (isIOS) { setTimeout(noop); }};isUsingMicroTask = true;} else if (!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) ||// PhantomJS and iOS 7.xMutationObserver.toString() === '[object MutationObserverConstructor]')) {// Use MutationObserver where native Promise is not available,// e.g. PhantomJS, iOS7, Android 4.4// (#6466 MutationObserver is unreliable in IE11)var counter = 1;var observer = new MutationObserver(flushCallbacks);var textNode = document.createTextNode(String(counter));observer.observe(textNode, {characterData: true});timerFunc = function () {counter = (counter + 1) % 2;textNode.data = String(counter);};isUsingMicroTask = true;} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {// Fallback to setImmediate.// Techinically it leverages the (macro) task queue,// but it is still a better choice than setTimeout.timerFunc = function () {setImmediate(flushCallbacks);};} else {// Fallback to setTimeout.timerFunc = function () {setTimeout(flushCallbacks, 0);};}
前面是一些利用 Promise、MutationObserver (microtask),后面是利用 setImmediate、setTimeout (macrotask)的兼容实现,这里都是异步,理解上直接看最后 setTimeout 就可以了
timerFunc = function () {setTimeout(flushCallbacks, 0);}; ---var pending = false;function flushCallbacks () {pending = false;var copies = callbacks.slice(0);callbacks.length = 0;for (var i = 0; i < copies.length; i++) {copies[i]();}}
可以看到异步调用就是执行前面通过 nextTick 放入 callbacks 中的函数,Vue 中就是 flushSchedulerQueue。为什么说 Vue中呢?跟 $nextTick 接口有些关系,先看完 flushSchedulerQueue 再讲。
/*** Flush both queues and run the watchers.*/function flushSchedulerQueue () {currentFlushTimestamp = getNow();flushing = true;var watcher, id;// Sort queue before flush.// This ensures that:// 1. Components are updated from parent to child. (because parent is always// created before the child)// 2. A component's user watchers are run before its render watcher (because// user watchers are created before the render watcher)// 3. If a component is destroyed during a parent component's watcher run,// its watchers can be skipped.queue.sort(function (a, b) { return a.id - b.id; });// do not cache length because more watchers might be pushed// as we run existing watchersfor (index = 0; index < queue.length; index++) { watcher = queue[index];if (watcher.before) {watcher.before();}id = watcher.id;has[id] = null;watcher.run();// in dev build, check and stop circular updates.if (has[id] != null) {circular[id] = (circular[id] || 0) + 1;if (circular[id] > MAX_UPDATE_COUNT) { //MAX_UPDATE_COUNT === 100 最大循环次数为100warn('You may have an infinite update loop ' + (watcher.user? ("in watcher with expression \"" + (watcher.expression) + "\""): "in a component render function."),watcher.vm);break}}}// keep copies of post queues before resetting statevar activatedQueue = activatedChildren.slice();var updatedQueue = queue.slice();resetSchedulerState();// call component updated and activated hooks callActivatedHooks(activatedQueue);callUpdatedHooks(updatedQueue);// devtool hook/* istanbul ignore if */if (devtools && config.devtools) {devtools.emit('flush');}}
flushSchedulerQueue 中首先会将队列中所有的 watcher 按照 id 进行排序,之后再遍历队列依次执行其中的 watcher,排序的原因是要保证 watcher 按照正确的顺序执行(watcher 之间的数据是可能存在依赖关系的,所以有执行的先后顺讯,可以看下 watcher 的初始化顺序)。此时的 flushSchedulerQueue 已经通过 nextTick(flushSchedulerQueue ) 变成了异步执行,这样做的目的是在一个事件循环(clickHandler)中让 flushSchedulerQueue 只执行一次,避免多次执行、渲染。
以上就是异步队列的基本上实现原理。
ps:补充一下前面说的 nextTick
首先 nextTick 中 callBacks 是支持多个 cb 的,由于 queueWatcher 的调用,第一个 cb 就是 flushSchedulerQueue 了,并且在 queueWatcher 中 flushSchedulerQueue 没有执行完是不允许再添加入 flushSchedulerQueue 的,所以有且只有一个 flushSchedulerQueue 在异步调用中,调用完之后才会执行下一个 cb。
Vue中开了一个接口 叫 $nextTick,通过在 $nextTick中传入 cb,就可以等待DOM渲染后执行cb 操作,$nextTick 就是这里的 nextTick 函数了,之所以传入的 cb 是在DOM渲染后执行就是因而已经执行了 flushSchedulerQueue 完成了 watcher 的执行、DOM的渲染。在 nextTick 中等待的执行就是这样:
[flushSchedulerQueue , cb1, cb2, ...]
三、异步队列设计的亮点:
异步队列我认为比较精妙的设计有两个部分:
第一、异步执行解决同一个事件循环多次渲染的难题,简单却极其有效;
第二、多个事件循环通过重复压入的方式解决队列中已执行过,但需要重新更新的 watcher 部分,保证数据更新的完整和正确性。改动了一下上面的例子理解一下:
<div id="example"><div>{{ words }}</div><input type="button" @click="clickHanler" value="click"/> </div>var i = 0;var vm = new Vue({el:"#example",data: {name: "Mr",greetings: "Hello"},computed: {words: function(){return this.greetings + ' ' + this.name + '!'}},methods: {clickHanler(){this.name = 'Mr_' + i;this.greetings = 'Morning';}}});
例子中每次点击都会触发异步队列中 computed watcher 和渲染 watcher 的更新。由于更新是异步的,那么当我多次连续点击的时候会有一种可能,就是异步队列在前面的遍历执行中已经执行了队列中的部分 watcher ,比如 computed watcher,后续的点击又需要更新这个 watcher,这时候改怎么办?
Vue中用重复压入队列的方式解决的这个问题,就是如果已经执行过,那么在对列剩下的部分中再压入一次,这样需要更新的 watcher 就会在当前执行 watcher 的下一个来执行。
[ 1, 2, 3, 4, 5 ] [ 1, 2, 1, 2, 3, 4, 5 ] // 1,2 已执行过,此时会再次压入队列
逻辑实现在 queueWatcher 中
function queueWatcher (watcher) {var id = watcher.id;console.log('watcherId='+ id + 'exporession=' + watcher.expression);if (has[id] == null) { //待执行状态//console.log('watcherId='+ id + 'exporession=' + watcher.expression);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. var i = queue.length - 1;while (i > index && queue[i].id > watcher.id) { //index: 执行到 watcher 的indexi--;}queue.splice(i + 1, 0, watcher);}// queue the flushif (!waiting) {waiting = true;if (!config.async) {flushSchedulerQueue();return}nextTick(flushSchedulerQueue);}}}
总结:
本文从数据更新的角度讲了一下Vue的异步队列设计和实现原理,主要的过程是在更新的过程通过 queueWatcher 将各个 watcher 放入队列中,之后统一通过 nextTick(flushSchedulerQueue) 异步对队列中的 watcher 进行更新。总得来说,异步队列主要解决了数据更新中多次触发、多次渲染的问题,其中单一事件循环通过异步的方式来解决,多次事件循环通过重复压入队列的方式来保证数据更新的正确性和完整性。最后如果需要等待DOM的更新或者当前数据更新完毕后执行某些逻辑可以调用 $nextTick来实现。