Vue源码系列(五):异步更新机制和nextTick原理🔥🔥

4,079 阅读4分钟

您说什么、希望什么、期待什么、想什么都不重要,只有你做了什么才算数  

                                                                              ------博恩.崔西

前言

这是一个Vue源码系列文章,建议从第一篇文章 Vue源码系列(一):Vue源码解读的正确姿势 开始阅读。 文章是我个人学习源码的一个历程,这边分享出来希望对大家有所帮助。

本文要讲解的是Vue异步更新机制和nextTick原理。

大家都知道,异步更新和响应式原理一样,都是Vue的核心之一。在我们使用Vue的过程中,基本大部分的 watcher 更新都需要经过异步更新的处理。而 nextTick 则是异步更新的核心。

接下来我将从源码入手,然后再以例子讲解,让小伙伴们都能弄懂这Vue的一个核心原理。

源码解析

源码解析我们需要找一个入口,那入口又是什么呢??

在上上篇文章 Vue源码系列(三):数据响应式原理🔥🔥 中咱们说了vue的数据响应式原理,其中在 dep.js(也就是“代码块8”)中,我们说过一个方法:notify(),当时我对他的解析是:通知更新。它就是本篇文章的入口,不记得的可以回顾一下上上篇文章。

接下来从入口 notify() 方法,开始本章的源码解读。

dep.js

代码块 1
// src/core/observer/dep.js

...

// 遍历dep的所有watcher 然后执行他们的update 
notify () {
  // 获取dep所收集的所有watcher
  const subs = this.subs.slice()
  // 在非生产环境并且同步执行的时候  做排序处理
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    subs.sort((a, b) => a.id - b.id)
  }
  // 遍历所有watcher,并执行他们各自的update()方法
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

...

watcher.js

接下来看看 watcher 调用的 update() 方法具体做了什么。

代码块 2
// src/core/observer/watcher.js

...

// 更新
update () {
  if (this.lazy) {
    // 懒执行走这里,比如:computed()
    this.dirty = true
  } else if (this.sync) {
    // 同步执行 更新视图 执行下面的run函数
    this.run()
  } else {
    // 异步推送 进入更新队列 将watcher放到观察者队列中 具体实现查看【代码块 3】
    queueWatcher(this)
  }
}

// 更新视图
run () {
  if (this.active) {
    // 调用get方法
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      // 更新旧值为新值
      const oldValue = this.value
      this.value = value
      if (this.user) {
        const info = `callback for watcher "${this.expression}"`
        invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
      } else {
        // 渲染watcher
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

...

scheduler.js

继续看看将watcher放到观察者队列中的 queueWatcher 方法

【代码块 3// /src/core/observer/scheduler.js

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  /** 
   * 当我们调用某个watcher的callback之前会先将它在has中的标记置为null 
   * 
   * 注意 这里是==而不是=== 
   * 如果has[id]不存在,则has[id]为undefined,undefined==null结果为true 
   * 如果has[id]存在且为null,则为true 
   * 如果has[id]存在且为true,则为false 
   * 这个if表示,如果这个watcher尚未被 flush 则 return 
   */
   
  // 做一个判重 如果 watcher 已经存在,则不会重复进入队列
  if (has[id] == null) {
    // 再次把watcher置为true 说明已经在队列中了 保证只有一个watcher,避免重复
    has[id] = true
    if (!flushing) {
      // 如果没有在刷新队列中,则将watcher push入队列中
      queue.push(watcher)
    } else {
      // 如果在刷新队列中 则根据当前 watcher.id 遍历
      // 这个循环其实是在处理边界情况。 即:在watcher队列更新过程中,用户再次更新了队列中的某个watcher
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      // 倒序查找,找到小于自己的 则将自己插入下一个位置
      queue.splice(i + 1, 0, watcher)
    }
    if (!waiting) {
      // 通过waiting 保证nextTick只执行一次
      waiting = true
      // 在非生产环境并且同步执行的时候 刷新调度队列
      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      // 最终会把所有更新动作(flushSchedulerQueue)放入nextTick中,推入到异步队列中执行
      nextTick(flushSchedulerQueue)
    }
  }
}

next-tick.js

最后就是看看nextTick具体做了什么。

【代码块 4// src/core/util/next-tick.js

// 引入一些需要的方法
import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'
// 是否使用微任务
export let isUsingMicroTask = false
// 其实就是需要处理的事件队列
const callbacks = []
// 如果已经有timerFunc推送到任务队列中 则不再推送
let pending = false

// 最终执行nextTick方法传进来的回调函数(执行事件队列中的事件)
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 遍历callbacks,执行存储在其中的flushSchedulerQueue函数
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
/**
 * 这里先翻译一下源码中注释的大概意思, 以下是对源码注释的一个翻译:
 *
 * Here we have async deferring wrappers using microtasks.
 * 这里我们使用微任务的异步延迟包装器
 * In 2.5 we used (macro) tasks (in combination with microtasks).
 * 在 2.5 版本我们使用宏任务和微任务相结合
 * However, it has subtle problems when state is changed right before repaint 
 * 然而,当状态在重新绘制之前更改时,有一些微妙的问题
 * Also, using (macro) tasks in event handler would cause some weird behaviors
 * 此外,在事件处理程序中使用宏任务会导致一些无法回避的奇怪行为
 * So we now use microtasks everywhere, again.
 * 所以我们现在重新使用微任务(microtasks)
 * A major drawback of this tradeoff is that there are some scenarios where microtasks have too high a priority and fire in between supposedly sequential events or even between bubbling of the same event 
 * 这种权衡的一个主要缺点是,在某些情况下,微任务的优先级太高,在假定的连续事件之间,甚至在同一事件的冒泡之间触发
 *
 */


// 设置一个函数指针,将该指针添加到任务队列,待主线程任务执行完毕后, 再将任务队列中的 timerFunc 函数添加到执行栈中执行
let timerFunc
/**
 * 这边其实是一个“优雅降级”处理
 * 执行的优先顺序为 promise => MutationObserver => setImmediate => setTimeout 
 */
// 首先 判断是否原生支持Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
// 其次 判断是否原生支持MutationObserver
// MutationObserver: 该方法提供了监视对DOM树所做更改的能力
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  // 如果支持MutationObserver 用MutationObserver执行flushCallbacks
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
// 再次 判断是否原生支持setImmediate 
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    // 如果原生支持setImmediate  用setImmediate执行flushCallbacks
    // 该方法用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数。
    setImmediate(flushCallbacks)
  }
// 最后 都不支持的情况下 则使用setTimeout来兜底
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}


// 将回调函数cb包装成一个箭头函数push到事件队列callbacks中
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 向事件队列中添加箭头函数作为参数,并且使用callbacks存储包装好的cb函数
  callbacks.push(() => {
    if (cb) {
      // try catch 包装回调函数,是为了错误捕获
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 在事件队列执行(flushCallbacks调用)的时候pending才设为false
  // pending 为 flushCallbacks 被添加到队列里到尚未执行这段时间
  if (!pending) {
    pending = true
    timerFunc()
  }
  
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

源码解析小结

到此就是vue异步更新源码和nextTick的源码解析。咱们稍作总结一下:

  • 异步更新机制是vue的核心原理之一,主要运用的是浏览器的异步任务队列。(这里之后会出一个Event Loop的文章,之后文章OK以后再把链接贴上来)
  • 数据更新以后调用 dep.js 中的 notify() 遍历 dep 的所有 watcher,然后执行他们的 update()
  • 然后 watcher.js 中的 update()watcher 放到观察者队列中,
  • 最后 执行 scheduler.js 中的 queueWatcher() 最终会把所有更新动作(flushSchedulerQueue)放入 nextTick中,并推入到异步队列中执行。 在回调中,对 queueWatcher() 中的 watcher 进行排序,然后执行对应的DOM更新

Vue的数据更新为什么要使用异步呢?

以下面的代码为例,展开说明

<template>
  <div>
    <h1 ref="h1" @click="handleMessage">{{ message }}</h1>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        message: '异步更新'
      }
    },
    methods: {
      handleMessage() {
        this.message = 'nextTick'
        const _data = this.$refs['h1'].innerHTML
        console.log(_data)
      }
    },
  }
</script>

<style lang="scss" scoped></style>

上面的代码本意是想在点击 h1 标签的时候更新 h1 的内容为 nextTick。 但是在实际过程中我们会发现,这么简单的逻辑既然实现不了,打印出来以后既然不是我们想象的结果。如图↓

img1.gif 从上图可以看出,在第一次点击的时候,我们打印出来的还是原来的值 异步更新 而不是 nextTick。在第二次点击的时候才会变成 nextTick。那么如果我们想在第一次点击的时候就更新做一些别的事,就会无法实现。
那么为什么会出现这种情况呢?
这就是因为vue对dom的更新时异步的。当vue观察到data中的数据变化时,就会缓存在一个事件循环中,只有等js的引擎清空了这个事件队列中的缓存以后,在下一个事件的时候才会去渲染。
所以咱们打印的时候是打印不到想要的结果的。那么想要打印到我们想要的结果怎么办呢?其实也很简单,加一个 nextTick 就OK。上代码↓

<template>
  <div>
    <h1 ref="h1" @click="handleMessage">{{ message }}</h1>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        message: '异步更新'
      }
    },
    methods: {
      handleMessage() {
        this.message = 'nextTick'
        this.$nextTick(()=>{
          const _data = this.$refs['h1'].innerHTML
          console.log(_data)
        })
      }
    },
  }
</script>

<style lang="scss" scoped></style>

效果如下↓

img2.gif 那么问题来了,Vue的数据更新为什么要使用异步呢?看着没啥用的感觉。接下来再看一个例子 🌰

<template>
  <div>
    <h1 ref="h1" @click="handleMessage">{{ value }}</h1>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        value: 0
      }
    },
    methods: {
      handleMessage() {
        for(var i=0; i<=10; i++ ) {
          this.value = i
          console.log(this.value)
        }
      }
    },
  }
</script>

<style lang="scss" scoped></style>

一样的当我们点击 h1 的时候看一下效果↓

img3.gif 如图,在我们正常的想法中 this.value 从0依次变到10,视图也会从0依次变到10,结果视图只是从0直接变为10,中间并没有过渡。这就是Vue的数据更新使用异步的原因:
Vue每一次的更新都会渲染整个组件,如果是同步的话,一旦修改了data属性,便会触发对应的watcher,然后调用对应watcher下的update方法更新视图,这样就会造成渲染太过频繁。而异步更新则Vue会在本轮数据更新后,再去异步更新视图,这样就能大大的优化性能。



本文到此也就结束了,希望对大家有所帮助。同时看到这里了也希望XDM献上免费小爱心,不胜感激🙏 🙏 🙏



Vue2.x系列的源码文章也就到这篇文章为止,基本常规的vue源码面试题也都差不多了,之后会继续Vue3.x的系列文章,希望大家继续多多支持🙏 🙏 🙏 。