vue2源码解析(三) - Vue的批量异步更新策略与$nextTick

Vue2的异步更新策略与$nextTick源码解析

  • 前言
  • 一、事件循环机制
    • 1.概念解释
      • 1.1 事件循环Event Loop
      • 1.2 宏任务Task
      • 1.3 微任务MicroTask
    • 2. 案例解析
  • 二、Vue2的批量异步更新策略
    • 1. 概念解释
      • 1.1 异步
      • 1.2 批量
      • 1.3 异步策略
    • 2. 源码分析
      • 2.1 思维导图
      • 2.2 Watcher何时加入微任务队列?
      • 2.3 如何加入微任务队列?
        • 2.3.1 Watcher去重
        • 2.3.2 生成微服务队列数组
        • 2.3.3 定义微任务的执行方式
      • 3. 批量异步更新
  • 三、实例分析

前言

在我之前的文章《手写vue -实现数据响应式、数据双向绑定和事件监听》的结尾处,提到了许多不足:主要是数据响应式驱动式视图更新的效率问题。而Vue高效的秘诀就是拥有一套批量、异步的更新策略。

在我们日常使用Vue时,应该都有用到过$nextTick()或nextTick()方法。
我们知道,$nextTick()的作用是,在下次 DOM 更新循环结束之后执行延迟回调,在修改数据之后使用 $nextTick,则可以在回调中获取更新后的 DOM

那么,“在下次 DOM 更新循环结束之后执行延迟回调”这句话是什么意思呢?Vue内部是如何实现的?利用的什么原理呢?

其实,$nextTick()是一个异步方法,Vue批量异步更新策略是内部基于“浏览器的事件循环机制event loop”实现的。

一、事件循环机制

为了方便我们能更好的理解Vue批量异步更新策略,我们先了解一下事件循环机制。

1.概念解释

vue2源码解析(三) - Vue的批量异步更新策略与$nextTick_第1张图片

1.1 事件循环Event Loop

浏览器为了协调事件处理、脚本执行、网络请求和渲染等任务而制定的工作机制。

1.2 宏任务Task

代表一个个离散的、独立的工作单元。浏览器完成一个宏任务,在下一个宏任务执行开始前,会对页面进行重新渲染。
主要包括创建文档对象、解析HTML、执行主线JS代码以及各种事件如页面加载、输入、网络事件和定时器等。
比如常见的:setTimeout()、setInteval()和xhr异步请求等。

1.3 微任务MicroTask

微任务是更小的任务,是在当前宏任务执行结束后立即执行的任务。如果存在微任务,浏览器会清空微任务之后再重新渲染。微任务的例子有 Promise 回调函数、DOM变化等。

2. 案例解析

我们先看一道常见的JS面试题:

// 写出下面代码的输出顺序
console.log('script start');	// ①

setTimeout(function () {
     	
  console.log('setTimeout');	// ②
}, 0);

Promise.resolve()
  .then(function () {
     
    console.log('promise1');	// ③
  })
  .then(function () {
     
    console.log('promise2');	// ④
  });

console.log('script end');	// ⑤

这段代码的执行过程就能很好的说明“事件循环机制”。我们先说下答案吧:

script start
script end
promise1
promise2
setTimeout

首先,在这段代码中,由上面对于事件循环机制的概念我们知道:
1)代码肯定是从上至下放入到Call Stack调用栈中执行的;
2)其中console.log(‘script start’)和console.log(‘script end’)是执行主线上的同步代码,会被作为同一宏任务,加入到宏任务队列;
3)setTimeout()属于宏任务,会被放入到宏任务队列中;
4)promise()会被放入到微任务队列中。

所以,这段代码进入调用栈Call Stack的顺序和执行顺序是这样的:
1)console.log(‘script start’)作为一个宏任务的一部分,首先进入到调用栈并执行输出script start;
2)setTimeout()属于宏任务,会被放入到宏任务队列中,当第一个宏任务执行完,且间隔中的微任务也执行完才执行。
3)接着promise()会被放入到微任务队列中,等到第一个宏任务执行完,第二个宏任务执行前才放入调用栈执行;
4)执行第一个宏任务中的console.log(‘script end’)输出script end,第一个宏任务结束;
5)微任务队列中的promise()进入到调用栈,并执行输出promise1和promise2,微任务队列已清空;
6)宏任务队列中的第二个宏任务setTimeout()进入调用栈并执行输出script end。

文字说明可能有点儿绕,大家可以结合一下例子事件循环机制-例子说明里的动画手动走一遍看看,应该很容易理解。

二、Vue2的批量异步更新策略

说完事件循环机制的原理,接下来我们看下在Vue2中是如何借助这一机制实现批量异步更新策略的。

1. 概念解释

vue2源码解析(三) - Vue的批量异步更新策略与$nextTick_第2张图片

1.1 异步

只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。

1.2 批量

一个组件实例对应一个Watcher。如果同一个 watcher 被多次触发,只会被推入到队列中一次。去重对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列执行实际工作。

1.3 异步策略

Vue 在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 或 setImmediate ,如果执行环境都不支持,则会采用 setTimeout 代替。

2. 源码分析

从《vue2源码解析(一) - new Vue()的初始化过程》中我们知道,触发视图更新的是Watcher的update()方法,所以,异步更新操作应该是从这里开始的。

2.1 思维导图

Vue2批量异步更新思维导图

2.2 Watcher何时加入微任务队列?

当响应式数据发生改变时,会触发key对应的set()方法,接着调用dep.notify()通知更新,最后触发视图更新的是Watcher的update()方法,所以,异步更新操作应该是从这里开始的。

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

queueWatcher(this)就是Watcher加入微任务队列的方法。那么,这里面具体做了什么呢?

2.3 如何加入微任务队列?

2.3.1 Watcher去重

export function queueWatcher (watcher: Watcher) {
     
  const id = watcher.id
  if (has[id] == null) {
     
    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.
      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

      if (process.env.NODE_ENV !== 'production' && !config.async) {
     
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

将Watcher放入queue数组中,每个Watcher会根据ID判断只加入一次。

queue数组主要是用于在flushSchedulerQueue()遍历执行,即执行更新操作。
flushSchedulerQueue()的作用是清空微任务队列,即遍历执行queue数组中的Watcher的update()。但在这里还并未执行flushSchedulerQueue(),将来会在两个宏任务执行间隙中执行。

若队列不在刷新状态且是非等待状态则把调用nextTick(flushSchedulerQueue)将flushSchedulerQueue放入到callbacks数组中。

2.3.2 生成微服务队列数组

export function nextTick (cb?: Function, ctx?: Object) {
     
  let _resolve
  callbacks.push(() => {
     
    if (cb) {
     
      try {
     
        cb.call(ctx)
      } catch (e) {
     
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
     
      _resolve(ctx)
    }
  })
  if (!pending) {
     
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
     
    return new Promise(resolve => {
     
      _resolve = resolve
    })
  }
}

将cb(即flushSchedulerQueue)作为子元素保存到callbacks数组中。callbacks数组将作为一个微任务,将来放入微任务队列中。也就是说,将所有待执行更新的Watcher整合成一个数组对象(微任务),将来放到微任务队列中。

接着调用timerFunc()异步执行更新。

注意:若是手动调用nextTick(),将会往callbacks中增加一个元素。
后面会有实例进行解析。

2.3.3 定义微任务的执行方式

timerFunc()函数的作用是:异步遍历执行callbacks中的更新方法。
它定义是根据不同运行环境去生成的,异步方式的优先级是:
Promise > MutationObserver > setImmediate > setTimeout

前三个都是微任务级别的异步方法,setTimeout是宏任务级别的异步方法。

3. 批量异步更新

修改响应式数据时,将Watcher放入了微服务队列中(callbacks),会在两个宏任务执行间隙去调用timerFunc() => flushCallbacks() => Watcher.run()进行更新。

三、实例分析


<html>
<head>
    <title>Vue源码剖析title>
    <script src="../../dist/vue.js">script>
head>
<body>
    <div id="demo">
        <h1>异步更新h1>
        <p id="p1">{
    {foo}}p>
    div>
    <script>
        // 创建实例
        const app = new Vue({
      
            el: '#demo',
            data: {
       foo: '0' },
            mounted() {
      
                this.foo = Math.random()
                console.log('1:' + this.foo); 
                this.foo = Math.random()
                console.log('2:' + this.foo);
                this.foo = Math.random()
                console.log('3:' + this.foo);
                // 下面的3个输出的值是多少?
                console.log('p1.innerHTML:' + p1.innerHTML)
                Promise.resolve().then(() => {
      
                    console.log('promise p1.innerHTML:' + p1.innerHTML)
                })
                this.$nextTick(() => {
      
                    console.log('p1.innerHTML:' + p1.innerHTML) 
                })
            }
        });
    script>
body>
html>

结合上面的内容分析执行过程:

  1. 前面三次修改数据的操作this.foo = Math.random(),都是在执行主线JS代码,是同步代码,会作为一个宏任务加入到宏任务队列,并放入Call Stack调用栈中执行,分别输出三次值。
  2. 执行这三次this.foo = Math.random()时,都会触发set() => dep.notify() => update() => queueWatcher()加入微任务队列,但是只会加入一次,所以微任务callbacks中只有一个Watcher的更新方法。我们假设这个微任务为ms1。但是此时,微任务ms1还未放入调用栈中,因为上一个宏任务还未执行结束,微任务ms1还未执行,dom还未更新。也就是说,第25行输出的值还是初始化定义的值0。
  3. 接着Promise也是一个微任务,会加入都微任务队列中,我们假设它为微任务ms2。ms2也还未放入调用栈中,因为上一个宏任务还未执行结束。
  4. 接着this.$nextTick()的调用其实是会往callbacks数组中追加了一个元素,也就是说,这里的操作只是往微任务ms1中增加了一个操作。
  5. 到这里,第一个宏任务已经执行结束了,开始将微任务队列中微任务ms1和ms2逐个放入调用栈中执行。
  6. 微任务ms1进入调用栈开始执行,此时Vue内部已经完成了dom渲染,所以,第30行输出的是第三次this.foo = Math.random()的值。注意:这里是先执行了第30行的代码,因为它属于微任务ms1!
  7. 微任务ms1执行结束后,接着微任务ms2进入调用栈开始执行第27行代码,输出的也是第三次this.foo = Math.random()的值。

你可能感兴趣的:(#,Vue2源码解析,vue,队列,js)