阅读 151

vue源码分析-14-dom异步更新机制(nextTick)

前言

我们通过响应式原理的分析得知,当数据改变之后,界面会跟着响应发生变化,Vue内部会再次执行_update方法,生成新的Vnode,比较新旧Vnode,然后进行更新dom的操作。

我们这里先定义同步更新 和 异步更新

同步更新:当数据发生变化后,立即执行_update方法,进行 生成新的vnode->dom更新的过程。

异步更新:数据发生变化后,不会立即响应数据的变化,进行 生成新的vnode->dom更新的过程。更新的过程会被推送到异步队列中,延迟执行。

为什么需要异步更新

我们思考一个问题,为什么需要异步更新机制呢?

我们以如下场景为例。

代码入下

<body>
    <div id="app">
        <p>{{sum}}</p>
        <button @click="click">计算</button>
    </div>
</body>
<script>
     let data = {
         sum: 0,
         message: "hello vue",
     }
     var app = new Vue({
         el: "#app",
         data: data,
         methods: {
             click () {
                 this.sum += 10;
                 this.sum *= this.sum
             }
         },
     })
</script>
复制代码

如图所示

image

功能就是点击按钮,一开始sum = 0, 先计算 sum += 10; 再将sum放大十倍。

这里我们看到sum被重新赋值了两次,那么根据响应式原理分析得知,会调用两次sum的setter方法。

如何更新界面,就要分情况分析了。

如果是同步更新,那么就会调用两次更新操作,进行两次的 生成vnode->更新dom 的过程。

如果是异步更新,在第一次调用sum的setter方法的时候,把更新的过程推送到一个异步队列中,当同步代码执行完毕之后, 执行所有异步更新方法, 这时候sum已经等于100了,再根据这个数据去渲染界面,只会进行一次 生成vnode->更新dom 的过程。

上述示例说明了异步更新可以减少 重复生成vnode->dom更新的过程,上述事例只改变了两次数据影响不大,那如果用for循环是改变了千次万次数据,那么同步更新界面就会执行千次万次的 生成vnode->更新dom,异常消耗cpu资源导致界面卡顿等一系列问题。 所以综上分析得知,异步更新可以提升渲染效率。

vue异步更新实现

通过之前的数据响应式原理分析得知,当数据发生变化后,会调用setter方法,setter方法中执行了dep.notify()通知所有的Watcher(监听者,每个组件一个)更新界面。

Watcher的update方法,如果是同步更新,就会走run方法,如果是异步更新就会执行queueWatcher(this)方法,run方法没什么好说的,执行run方法会直接再次调用传入Watcher构造方法的_updater方法,由于是更新,所以就会进行 重新生成vnode -> dom diff -> dom更新 的过程。

update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      // 通常情况会走异步队列更新
      queueWatcher(this)
    }
  }
复制代码

接下来我们重点分析queueWatcher(this)方法。

通过源码分析可得知,queueWatcher(this)的主要逻辑就是将当前的Watcher保存到一个队列中,我们把这个队列queue称之为异步队列。

在方法的最后调用了nextTick(flushSchedulerQueue)方法

那么nextTick和flushSchedulerQueue是什么呢?

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 这里用一个has的哈希表存储已经存在与异步队列中等待下一次更新的Watcher(观察者)
  // 如果队列中已经存在了,那就不重复添加了
  if (has[id] == null) {
    has[id] = true
    // flushing是指是否正在执行异步队列中的更新方法
    // 如果没有正在执行异步队列中的更新方法
    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.
      // 否则如果有正在执行的异步队列(每次是队列里的方法一批量执行的)
      // 从后往前找到那个 同id的 Watcher将其替换掉
      // (因为组件更新的过程中也会更改数据,将Watcher推到异步队列)
      // 如果没找到,那么将会放到queue的末尾
      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) {
      // waitting表示正在更新
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      // 将flushSchedulerQueue加入到callbacks中
      // 将更新的回调推送至callbacks中
      nextTick(flushSchedulerQueue)
    }
  }
}
复制代码

我们先看flushSchedulerQueue方法,源代码就不贴了

flushSchedulerQueue方法主要就是遍历queue中保存的Watcher,然后调用Watcher的run方法,run方法可以更新界面。

也就是是说nextTick(flushSchedulerQueue)传入参数flushSchedulerQueue是一个可以执行异步队列中所有Watcher的更新操作。

接下来我们分析一下nextTick方法,nextTick方法根据名称可以翻译为下一帧,我们暂且猜到nextTick方法可以将flushSchedulerQueue更新函数放到下一帧去执行。

精简化后的nextTick方法如下,可以看到,vue维护了一个callbacks全局变量,这个callbacks保存了等待执行的回掉方法,默认情况下保存了flushSchedulerQueue更新界面的方法。

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 将cb保存至callbacks队列中
  callbacks.push(() => {
    cb.call(ctx)
  })
  // 如果当前空闲
  if (!pending) {
    // 当前忙
    pending = true
    // 执行callbacks中所有的回掉方法
    timerFunc()

  }
}
复制代码

那么保存在callbacks中的方法是何时执行的呢?这就要分析timerFunc()这个函数了。

我们通过源码得知 timerFunc这个函数会经过一系列判断被赋值,如果当前 环境支持Promise那么timerFunc就是一个执行promise.then的操作,否则就是一个执行setTimeout的操作,调用的方法都是flushCallbacks

简化后的源码如下

let timerFunc


if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    // 异步执行flushCallbacks
    // 就是将callback中保存的callback函数都执行掉
    p.then(flushCallbacks)
  }
  isUsingMicroTask = true
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
复制代码

flushCallbacks方法就是执行callbacks中保存的所有函数 ,callbacks中保存了flushSchedulerQueue刷新函数或者用户自定义的函数。

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
复制代码

通过以上分析得知 timerFunc方法执行以后,会将callback中保存的回掉函数推送到由浏览器维护的异步队列执行,因为promise.then中的方法将放到微任务队列中,settimeout中的方法会放到宏任务队列中,微任务和宏任务都是由执行环境维护的异步任务队列(如果对js异步执行的原理不明白的,请先了解js的同步和异步执行机制)

总之,我们的更新渲染函数都会被放到callbacks中等待同步代码执行完以后,再执行。

总结

当数据变化以后,监听数据的监听者Watcher会被保存在一个queue队列中, 此时callback[] 中有一个方法flushSchedulerQueue可以将保存在queue中全部的监听者Watcher都更新(dom更新)。但是数据改变以后,并不会立即调用callback[]中的方法,因为callback中方法的执行会在 promise.then 或者 settimeout 中。因为这些方法都是异步执行的。 所以当我们的数据发生变化以后, 并不会立即更新界面,而是等待同步代码执行完毕以后,才会异步的更新dom。 这也就是我们在同步代码中获取dom的时候,并不能获取到更新后的dom的原因,所以官方提供了$nextTick方法,在dom更新之后再 执行传入的回掉方法呢。