一般情况下,大家都只知道通过$nextTick函数来从DOM中获取到状态更新之后的值,但是通过这种方式真的一定可以获取到DOM的最新状态值吗?
$nextTick实现原理
Vue 2不同版本中对$nextTick函数的实现略有差异,但是基本原理还是一致的,这里以2.6.14版本源码进行分析。把相关代码汇聚如下,其中为简单起见异步实现以setTimeout为例
var callbacks = [];
var pending = false;
var timerFunc = function () {
setTimeout(flushCallbacks, 0);
};
function flushCallbacks () {
pending = false;
var copies = callbacks.slice(0);
callbacks.length = 0;
for (var i = 0; i < copies.length; i++) {
copies[i]();
}
}
function nextTick (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) {
_resolve = resolve;
})
}
}
源码中逻辑实现相当简洁清晰:
- 如果没传入回调函数,可以把nextTick函数当作异步函数来使用,这时候调用nextTick返回一个Promise
- 可以通过多次调用nextTick函数传入不同的回调函数,但是这些函数是以同步的方式执行
State更新到DOM更新原理
对于Vue框架,大家一般比较熟悉其响应式特性,即在state更新后会主动更新相应的DOM,这个流程并不需要开发者进行操作,通过自动化方式进行执行。
vm._watcher = new Watcher(vm, function () {
vm._update(vm._render(), hydrating)
}, noop)
上述这段代码来源于Vue 2.0.0版本,这个版本的render watcher定义比较清晰易于理解,其实2.6.14版本中也比较类似,只是考虑的情况会多一些。
将整个DOM的渲染抽象成一个watcher来响应相关state的变化,当所依赖的state有所变化后,就会触发render watcher的执行,因此也就更新了DOM
频繁的DOM更新比较耗时,那在Vue中对Watcher这块做了哪些方面的优化呢
Watcher执行优化
Vue 2版本中通过defineProperty来实现响应式,那么就从defineProperty开始看一下状态变更后,都经过哪些流程触发了Watcher执行。
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;
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
if (customSetter) {
customSetter();
}
if (getter && !setter) { return }
if (setter) {
setter.call(obj, newVal);
} else {
val = newVal;
}
childOb = !shallow && observe(newVal);
dep.notify();
}
});
当我们对一个属性赋值时就会调用属性对应的set方法,赋值之后会调用依赖对象的notify方法,也就是所谓的观察者模式中的通知方法
Dep.prototype.notify = function notify () {
//...
for (var i = 0, l = subs.length; i < l; i++) {
subs[i].update();
}
};
每个subs[i]就是一个Watcher对象,通过调用调用Watcher对象的update方法触发Watcher的更新
Watcher.prototype.update = function update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true;
} else if (this.sync) {
this.run();
} else {
queueWatcher(this);
}
};
针对不同的Watcher类型,有三种更新方式:
- lazy为true的情况,主要是针对computed属性,在下次需要更新computed属性时才会进行Watcher的更新
- sync为true时表示同步更新,也就是在对state赋值后,相应的Watcher会同步进行执行
- 把当前的Watcher对象放置到一个队列中,等待统一执行的时机,Render Watcher也属于这种Watcher类型
那么queueWatcher又做了哪些操作呢?
function queueWatcher (watcher) {
var id = watcher.id;
if (has[id] == null) {
has[id] = true;
if (!flushing) {
queue.push(watcher);
} else {
var i = queue.length - 1;
while (i > index && queue[i].id > watcher.id) {
i--;
}
queue.splice(i + 1, 0, watcher);
}
if (!waiting) {
waiting = true;
if (!config.async) {
flushSchedulerQueue();
return
}
nextTick(flushSchedulerQueue);
}
}
}
function flushSchedulerQueue () {
// ...
for (index = 0; index < queue.length; index++) {
// ...
watcher.run();
// ...
}
// ...
}
从源码中可以看出,queueWatcher主要做了三件事情:
- 对相同Watcher进行过滤,避免同一个Watcher重复执行
- 将Watcher放入一个全局queue对象中
- 在nextTick方法中放入一个flushSchefulerQueue方法,作为异步执行Watcher的回调函数
Watcher执行优化小结
通过以上源码分析可以看出,主要通过以下方式进行优化:
- 异步执行Watcher,避免阻塞正在执行的同步任务
- 过滤相同Watcher,在一个事件循环中一个Watcher只执行一次
Render Watcher与$nextTick异步顺序问题
由于render watcher与$nextTick都是异步执行的,因此两者的执行顺序就成了是否能够在$nextTick回调中获取到最新DOM状态的关键,相关demo可以参考jsfiddle.net/flyingbirdh…
其中主要逻辑应该就是这段简单的代码:
onAdd() {
this.$nextTick(() => {
console.log('current state:', this.txt);
const dom = document.getElementById('count');
console.log('dom value:', dom.innerText);
});
this.txt += 1;
},
期望效果是当点击按钮后,txt值会加1,输出的dom value值于最新的txt值保持一致; 实际是txt值加1,但是输出的dom value值却是上一个txt状态值。
这就是因为在书写事件响应函数时没有注意$nextTick的调用顺序时,造成$nextTick中的回调其实并没有能获取到最新的DOM状态。因为此时异步执行顺序变为: 先读取DOM状态,之后执行flushQueueWatcher函数,读取DOM状态在更新DOM状态(状态已经更新但是未进行render watcher更新)之前执行,因此也就无法获取到更新后的值。
总结
其实这种场景出现概率还是很低的,一般的开发习惯都是先更新状态,然后调用$nextTick进行DOM状态的读取,但是随着业务代码的迭代,还是有可能出现类似上述逻辑的代码,因此既然有出现的可能性是不是可以从框架层面避免呢?比如在nextTick(flushQueueWatcher)时将flushQueueWatcher放在callbacks头部,在每次执行异步队列时都先执行Watcher更新,是不是能更好的避免这个问题呢?