「源码解读」Vue2.x | nextTick原理

401 阅读2分钟

事件循环

同步任务

指的是在主线程中排队执行,没有被挂起的任务。只有一个任务执行完,才能执行下一个任务。

异步任务

指的是被主线程挂起,并被推入异步队列中的任务。通常以回调函数的形式,再次回到主线程。

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.thenMutationObserversetImmediate,如果环境不支持将会使用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.thenMutationObserversetImmediate,如果环境都不支持则使用setTimeout(fn, 0)

首先定义一个callbacks来存储回调函数。pending是一个标记位,代表一个等待状态。

const callbacks = [];
let pending = false;

setTimeouttask中创建一个事件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源码中,还会有很多方法来判断当前环境是否支持原生PromiseMutationObserver等的方法。源码地址

模拟异步更新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方法里执行所有Watcherrun方法。

function flushSchedulerQueue() {
    queue.forEach(watcher => {
        const id = watcher.id;
        has[id] = null;
        watcher.run();
    })
    waiting = false;
}

以上就是Vue中实现,异步更新DOM的大致思路。相信读完之后,对理解Vue源码会有很大的帮助。