【Vue源码】从$nextTick 开始谈谈Vue的调度

1,371 阅读5分钟

本文涉及源码部分以 Vue2.x 作为分析模板,推荐了解浏览器事件循环、有一定Vue使用基础、对双向数据绑定、发布订阅和观察者模式有一定理解的同学进行阅读

前言

调度,这个词在各行各业都有很多涉及以及对应的设计体现。比如十字路口交通调度、厨房后厨备菜调度、日常工作的任务日程管理。那么我们首先可以根据上述事物建立一个抽象联系,那就是,调度可以看做是

将当前不急于实践的行为集中放到下一次执行,也就是异步操作

Part1: nextTick

说起 nextTick,大家可能会联想到 $nextTick, 他们区别是 nextTick 在框架内部导出,而 $nextTick 挂载在了Vue的原型上,让每个通过Vue构造函数生成的实例都能调用。

我们看看官方文档描述

将回调推迟到下一个 DOM 更新周期之后执行。在更改了一些数据以等待 DOM 更新后立即使用它。

1-1.使用

简单来说,next-tick 在代码中的调用方式,下面代码来自Vue官方文档

// 你可以这么用
import { nextTick } from 'vue'
const app = createApp({
  setup() {
    const message = ref('Hello!')
    const changeMessage = async newMessage => {
      message.value = newMessage
      await nextTick()
      console.log('Now DOM is updated')
    }
  }
})

// $nextTick也可以
createApp({
  // ...
  methods: {
    // ...
    example() {
      // 修改数据
      this.message = 'changed'
      // DOM 尚未更新
      this.$nextTick(function() {
        // DOM 现在更新了
        // `this` 被绑定到当前实例
        this.doSomethingElse()
      })
    }
  }
})

那么这两种调用有什么区别呢?

Vue 在渲染阶段,将 nextTick 挂到了框架实例上,所以我们可以在首次渲染之后去调用 $nextTick 方法处理。除了在引用方式上的些差别之外,两者在使用上并没有直接区别。Ps: 不要在nextTick的回调中写任何引发DOM改变的操作...容易陷入死循环

// 我们能够用 $nextTick 原因是这段代码
export function renderMixin (Vue: Class<Component>) {
//...
  Vue.prototype.$nextTick = function (fn: Function) { // 这里的 Vue 是 Vue 构造函数
    return nextTick(fn, this)
  }
//...
}

1-2.nextTick原理

讲完具体的使用我们从源码层看next-tick.

抛去对于各种边界情况的判断,我们直接把整个 next-tick.js 简化下面这段代码。

//next-tick.js 
// ...省略引入的部分
const callbacks = []
let pending = false

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

let timerFunc

const p = Promise.resolve()
timerFunc = () => {
  p.then(flushCallbacks)
}
// 省略对宿主环境如移动端Webview、Native的一些特殊事件循环pollyfill

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    }//...
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // ...
}

它的原理是将$nextTick中的回调收集在一个异步调度栈中,在当前页面渲染完成后将调度栈中的方法依次执行。 next-tick.js 内部会通过一系列的边界条件来进行 timerFunc 回调函数的设置。flushCallbacks 最后会在进入到微任务阶段依次的执行callbacks中的函数。至于任务队列出栈时执行的是宏任务还是微任务,取决于入栈状态本身。

Part2: 从框架整体看调度

在看完 Part1 后我们知道了 nextTick 的使用方式和原理。那可能会产生疑问:

  1. 为什么要 DOM 创建完成后调用 nextTick?
  2. 整个Vue的调度就靠 nextTick 实现么?

在回答上面我问题之前,我们可以看看Vue的渲染过程是怎样的。

2-1.Vue的更新过程

Vue 的更新过程官图 image.png

在这个挂载过程中可以看做是发生了这三步:

  1. data changes
  2. Virtual DOM re-render and patch
  3. updated

中间还经历了下面的过程。我们结合一个简单的 MVVM 模型来看 image.png

  1. 被劫持的对象触发setter中的 notify()
  2. 观察者收到通知,执行观察者中的 update()触发 render
  3. 触发vm.__patch__()执行,进行【Virtual DOM re-render and patch】
  4. createElm()执行完毕后完成挂载。

大家在建立对调度初步理解的时候,可以看做,在 update() 这个阶段放置调度栈。

2-2. 调度流程

调度可以看做是 Vue 框架中一个非常重要的渲染优化方式,触发调度是在双向绑定派发通知notify()的阶段触发派发更新update()调用 queueWatcher方法,他不会在每个节点更新的时候立即更新,他会把需要更新的若干更新放到队列里,是vue中一个很厉害的优化点。

接下来我们从源码层面看一下。

2-2-1. Watcher

update() 中,this.sync 是当下要去执行的观察者方法,queueWatcher(this) 则是触发调度栈。

// watcher.js
  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
/**
   * Scheduler job interface.
   * Will be called by the scheduler.
   */
  run () {
    if (this.active) {
      const value = this.get()
      if (
        value !== this.value ||
        // Deep watchers and watchers on Object/Arrays should fire even
        // when the value is the same, because the value may
        // have mutated.
        isObject(value) ||
        this.deep
      ) {
        // set new value
        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 {
          this.cb.call(this.vm, value, oldValue)
        }
      }
    }
  }

2-2-2. 调度核心 scheduler.js

在上一节queueWatcher(this)执行后,我们来看看 scheduler.js 中几个关键源码逻辑

/**
 * 清空两个队列并执行观察者.
 */
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
  /*
  在队列清空前排序.
  确保一下几点:
  1. 组件从父到子更新 (因为父组件总是在子组件前创建)
   
  2. 一个组件的使用者的watcher运行在这个组件渲染它的watchers之后。(因为使用者的watcher在组件渲染watcher之前创建))
  3. 如果组件在父组件的watcher执行期间销毁,那么这个watcher可以跳过
  */
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run() // 执行更新
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()// 重置调度状态

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}
function callUpdatedHooks (queue) {
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated') // 通知生命周期,让在updated注册的方法执行。
    }
  }
}
  /**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher) // flushing 还是初始值,继续往调度里推 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)
    }
  }
}

调用queueWatcher方法后,如果没有正在清空的调用栈,那就 如果是开发环境,而且vue.config.js 中的配置项没有配置异步,直接调用flushSchedulerQueue(),如果是正常生产环境,看... nextTick(flushSchedulerQueue) 这就和 nextTick串到一起了。

image.png

自此我们可以回答开头的两个问题:

1. 为什么要 DOM 创建完成后调用 nextTick?

A:因为nextTick的回调函数在微任务中执行

2. 整个Vue的调度就靠 nextTick 实现么?

A: 整个Vue的调度主要依靠scheduler.js 中的 queueWatcher 结合 nextTick 实现。nextTick 在整个设计中体现的是一个工具,既能在框架中作为私有函数使用,也通过模块化暴露和放置在原型链的方式暴露给框架使用者。

结语

在我们经过 part2 分析源码之后可以发现,Vue在调度层上有两个

  • queueWatcher (对观察者的运行调度)
  • nextTick (基于事件循环的异步方法层)

整个框架调度模型如下: image.png

参考链接

【1】Vue3 官方文档

【2】Vue2.x 官方文档

【3】Vue 源码


最后,如果本文能给你起到帮助请点赞支持一下~转载请标注出处