事件循环
同步任务
指的是在主线程中排队执行,没有被挂起的任务。只有一个任务执行完,才能执行下一个任务。
异步任务
指的是被主线程挂起,并被推入异步队列中的任务。通常以回调函数的形式,再次回到主线程。
event loop
当执行栈中的所有任务执行完毕后,会去检查微任务队列中是否有事件存在,如果存在,依次执行微任务队列中事件的回调。然后取出宏任务队列中的一个事件,放到执行栈中执行,当执行栈中的所有任务都执行完毕后,再去检查微任务队列是否有事件,不断循环这个过程,就是事件循环。
执行栈
当执行一个方法的时候,JavaScript会生成一个与这个方法对应的执行环境(context),又叫执行上下文。然后会被添加到一个栈中,就是执行栈。
为什么使用nextTick
在Vue中,DOM的更新是异步的。也就是说对data里的数据做改变的时候,组件并不会立即重新渲染,而是将事件推入到一个异步队列中,在下一个事件循环中更新。
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue为什么要使用异步更新?假如有下面一种情况:
<template>
<div>
<div>{{ number }}</div>
<button @click="handleClick">click</button>
</div>
</template>
<script>
export default {
data() {
return {
number: 0
}
},
methods: {
handleClick() {
for (let i = 0; i < 1000; i++) {
this.number++;
}
}
}
}
</script>
以上代码中,当点击click按钮,将number增加1000次。如果不使用异步队列,组件会被重新渲染1000次。这极大造成了性能的浪费,而且效率极低。
Vue中使用异步更新队列。当检测到数据变化,Vue将会开启一个队列,缓冲在同一事件循环中发生的所有数据变更,如果一个渲染组件的回调函数被触发多次,只会被推入到队列一次。在下一个事件循环"tick"中,Vue执行队列中的回调函数。
Vue在内部尝试对异步队列使用原生的Promise.then、MutationObserver和setImmediate,如果环境不支持将会使用setTimeout(fn, 0)。
DOM的更新变成了异步,但是就是有需求要在数据变更之后,获取重新渲染后的DOM。怎么办呢,这时nextTick就隆重登场了。
nextTick
nextTick看名字就是知道,它一定是在事件循环下一个"tick"的时候要搞点事情。它的原理就是在DOM更新之后去执行回调函数。例如,在以第一个列子中:
var vm = new Vue({
el: '#example',
data: {
message: '123'
}
})
vm.message = 'new message' // 更改数据
vm.$el.textContent === 'new message' // false
Vue.nextTick(() => {
vm.$el.textContent === 'new message' // true
})
当然在组件内可以用vm.$nextTick,它和Vue.nextTick的区别就是,它的回调函数里的this已经绑定到了当前Vue的实例上了。
Vue.component('example', {
template: '<span>{{ message }}</span>',
data: function () {
return {
message: '未更新'
}
},
methods: {
updateMessage: function () {
this.message = '已更新'
console.log(this.$el.textContent) // => '未更新'
this.$nextTick(function () {
console.log(this.$el.textContent) // => '已更新'
})
}
}
})
因为nextTick返回一个Promise实例,所以可以使用ES2017 async/await。
await this.$nextTick()
console.log(this.$el.textContent) // => '已更新'
实现一个简易的nextTick
首先我们知道,nextTick接收一个回调函数,然后存储到队列,然后在下一个"tick"的时候执行队里的所有回调函数。
由于浏览器没有实现nextTick,在Vue源码里使用了Promise.then、MutationObserver和setImmediate,如果环境都不支持则使用setTimeout(fn, 0)。
首先定义一个callbacks来存储回调函数。pending是一个标记位,代表一个等待状态。
const callbacks = [];
let pending = false;
setTimeout在task中创建一个事件flushCallbacks。
function nextTick(cb) {
callbacks.push(cb);
if(!pending) {
pending = true;
setTimeout(flushCallbacks, 0);
}
}
flushCallbacks会在执行时,将callbacks中所有的cb一次执行。
function flushCallbacks() {
pending = false;
const copies = callbacks.slice(0);
callbacks.length = 0;
for (let i = 0; i < copies.length; i++) {
copies[i]();
}
}
在Vue.js源码中,还会有很多方法来判断当前环境是否支持原生Promise、MutationObserver等的方法。源码地址。
模拟异步更新DOM
上面说过了,如果同一个组件渲染函数触发多次,只会将事件推入队列一次。因为并不需要在下一个"tick"的时候执行多个同样的Watcher去修改界面。
为此,需要执行一个过滤操作,同一个的Watcher在同一个"tick"的时候应该只被执行一次,也就是说队列queue中不应该出现重复的Watcher对象。
使用id来区分相同或不同的Watcher。数据更新后Dep调用Watcher里的update方法。然后使用run方法来更新视图。
let uid = 0;
class Watcher {
constructor() {
this.id = ++uid;
}
update() {
queueWatcher(this);
}
run() {
console.log(`watcher${this.id}:视图更新了`);
}
}
然后在queueWatcher方法里,将watcher放入到任务队列里,在下一次"tick"的时候执行更新视图方法。
let queue = [];
let waiting = false;
let has = {};
function queueWatcher(this) {
if (has[id] == null) {
has[id] = true;
queue.push(watcher);
}
if (!waiting) {
nextTick(flushSchedulerQueue);
}
waiting = false;
}
在flushScheudlerQueue方法里执行所有Watcher的run方法。
function flushSchedulerQueue() {
queue.forEach(watcher => {
const id = watcher.id;
has[id] = null;
watcher.run();
})
waiting = false;
}
以上就是Vue中实现,异步更新DOM的大致思路。相信读完之后,对理解Vue源码会有很大的帮助。