vue 2.x内部运行机制系列文章-异步更新策略及nextTick原理

643 阅读5分钟

通过前几篇文章,我们了解到在我们修改 data 后会依次执行setter -> Dep -> Watcher -> patch -> 视图的过程。

先看个例子

<template>
  <div>
    <div>{{number}}</div>
    <div @click="handleClick">click</div>
  </div>
</template>

export default {
    data () {
        return {
            number: 0
        };
    },
    methods: {
        handleClick () {
            for(let i = 0; i < 1000; i++) {
                this.number++;
            }
        }
    }
}

当我们点击click时,number会被增加1000次,那么按照我们之前的理解,会执行1000次setter -> Dep -> Watcher -> patch -> 视图的过程。显然这种方式是不可取的,那么vue中关于这种情况是怎么处理的呢?

vue在默认情况下,每次触发某个数据的 setter 方法后,对应的 Watcher 对象其实会被 push 进一个队列 queue 中,在下一个 tick 的时候将这个队列 queue 全部拿出来 runWatcher 对象的一个方法,用来触发 patch 操作) 一遍

我们再看一下watcherupdate方法

 update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

这里有个queueWatcher方法,通过这个方法就把当前的watcher对象 push 进一个队列 queue 中,在下一个 tick 的时候将这个队列 queue 全部拿出来 run

那么,什么是nextTick呢?

nextTick

众所周知,Event Loop分为宏任务(macro task)以及微任务( micro task),不管执行宏任务还是微任务,完成后都会进入下一个tick,并在两个tick之间执行UI渲染。

vue.js在 microtask(或是 macro task)中创建一个事件,目的是在当前调用栈执行完毕以后(不一定立即),即UI渲染完毕后,才会去执行这个事件.

Vue.js 实现了一个 nextTick 函数,传入一个 cb ,这个 cb 会被存储到一个队列中,在下一个 tick 时触发队列中的所有 cb 事件。

let callbacks = []; // 用来存放回调函数的队列,全局对象
let pending = false; //  异步锁,默认开启

function flushCallbacks () {
 // 重置异步锁(开启,以便再次调用nextTick执行)
  pending = false
  // 复制一份callbacks出来,为了防止nextTick里调用另一个nextTick
  const copies = callbacks.slice(0)
  callbacks.length = 0 // 清空callbacks,以便下一次nextTick的cb
  for (let i = 0; i < copies.length; i++) {
    // 执行每一项回调
    copies[i]()
  }
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 将回调推入callbacks队列
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 如果异步锁是开启状态
  if (!pending) {
    // 关闭异步锁,等待异步方法执行完毕
    pending = true
    /**异步方法,会根据不同的浏览器来选择不同的方法,这里理解为一个类似于setTimeout的异步方
    法即可,当所有同步方法执行完成后会执行这个方法,这个方法内部会执行flushCallbacks方法**/
    timerFunc() 
  }
}

简单说一下几个变量或方法

  • callbacks:数组用来存储回调函数cb
  • pending: 我在这里称作异步锁。默认情况下,这个锁是开启状态(pending=false),表示异步方法还未开始执行或者刚刚执行完毕,当pending=true时,表示正在等待所有同步方法执行完毕马上执行异步方法
  • timerFunc: 异步方法,会根据不同的浏览器来选择不同的方法,这里理解为一个类似于setTimeout的异步方法即可,当所有同步方法执行完成后会执行这个方法,这个方法内部会执行flushCallbacks方法

当我们执行nextTick时,这时候会把传入的这个cbpushcallbacks的队列中并将pending设为true,等待所有的同步方法后,执行timerFunc方法,这个方法内部会执行flushCallbacks方法,将callbacks里的所有cb依次执行,并重置异步锁,以便下次调用nextTick

我们注意到在flushCallbacks方法里,有这么一句话

 const copies = callbacks.slice(0)

为什么?这是为了防止nextTick里调用另一个nextTick,假设不copy一份callbacks出来,那么嵌套两层执行nextTick时,会一次性将两次nextTickcb全部执行完,这显然不符合我们的要求。

ok,到现在为止,我们已经了解了nextTick的实现原理,那么,我们再返回来看看,我们的update()方法。

异步更新策略

 update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }

我们来看看,这个queueWatcher发生了什么?

let has: { [key: number]: ?true } = {}

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 如果queue里不存在当前的watcher
  if (has[id] == null) {
    has[id] = true // 表示该watcher已存在
    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
      nextTick(flushSchedulerQueue)
    }
  }
}

// 省略部分源码,只保留核心部分
function flushSchedulerQueue () {
  let watcher, id
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index] //取出当前watcher
    id = watcher.id // watcher的id
    has[id] = null  // 将has[id]置为空
    watcher.run() // 执行run方法
  }
  
  waiting  = false;
}


简单介绍几个变量和方法

  • has:一种{ [key: number]: ?true }map结构的数据,用来表示当前的watcher是否已经推入到当前队列中。
  • waiting: 是一个标记位,标记是否已经向nextTick 传递了 flushSchedulerQueue 方法
  • flushSchedulerQueue:用来执行队列里的每一个watcher的run()方法。

整个update的过程就是:

首先判断当前watcher是否存在于queue里,如果不存在,则push到队列中,否则则什么都不执行。 之后再将flushSchedulerQueue方法,作为回调传入nextTick()方法,再所有同步方法执行完成后,再将queue中所有watcher的run方法依次执行。这个过程就是我们说的异步更新策略。

ok,再来看看我们开始的例子

<template>
 <div>
   <div>{{number}}</div>
   <div @click="handleClick">click</div>
 </div>
</template>

export default {
   data () {
       return {
           number: 0
       };
   },
   methods: {
       handleClick () {
           for(let i = 0; i < 1000; i++) {
               this.number++;
           }
       }
   }
}

当我们执行this.number++时,触发过程就变为setter -> Dep -> Watcher -> update -> queueWatcher -> nextTick(flushSchedulerQueue) -> run -> patch -> 视图

当我们执行run的时候,这时候number已经变为1000,这时候视图只更新1次。