vue源码分析之nextTick

43次阅读

共计 7814 个字符,预计需要花费 20 分钟才能阅读完成。

Vue 中有个 API 是nextTick, 官方文档是这样介绍作用的:

将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用它,然后等待 DOM 更新。

理解这部分内容,有助于理解 Vue 对页面的渲染过程,同时也可以了解到 beforeUpdateupdated的使用。另外就是通过了解 nextTick 的调用了解 vue 内部是如何使用 Promise 的。这部分内容和之前介绍计算属性的内容也有关联,可以比照着看。

首先看一下我创建的例子:

    <!-- HTML 部分 -->
    <div id="test">
      <p>{{name}}的年龄是{{age}}</p>
      <!-- <p>
        {{info}}
      </p> -->
      <div> 体重 <input type="text" v-model="age" /></div>
      <button @click="setAge"> 设置年龄为 100</button>
    </div>
      // js 部分
      new Vue({
        el: '#test',
        data() {
          return {
            name: 'tuanzi',
            age: 2
          }
        },
        beforeUpdate() {console.log('before update')
          debugger
        },
        updated() {console.log('updated')
          debugger
        },
        methods: {setAge() {
            this.age = 190
            debugger
            this.$nextTick(() => {console.log('next tick', this.age)
              debugger
            })
          }
        }
      })

当页面渲染完成,点击按钮触发事件之后,都会发生什么呢~~

直接介绍计算属性的时候说过,当页面初次加载渲染,会调用模板中的值,这时会触发该值的 getter 设置。所以对于我们这里,data 中的 nameage都会订阅 updateComponent 这个方法,这里我们看下这个函数的定义:

    updateComponent = () => {vm._update(vm._render(), hydrating)
    }

简而言之,这时用来渲染页面的,所以当代码执行到 this.age = 190,这里就会触发agesetter属性,该属性会调用 dep.notify 方法:

  // 通知
  notify() {
    // stabilize the subscriber list first
    // 浅拷贝订阅列表
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order

      // 关闭异步,则 subs 不在调度中排序
      // 为了保证他们能正确的执行,现在就带他们进行排序
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {subs[i].update()}
  }

这里的 this.subs 就是页面初始化过程中,age这个属性收集到的依赖关系,也就是 renderWatcher 实例。接着调用 renderWatcherupdate方法。

  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update() {
    // debugger
    /* istanbul ignore else */
    if (this.lazy) {
      // 执行 computedWacher 会运行到这里
      this.dirty = true
    } else if (this.sync) {this.run()
    } else {
      // 运行 renderWatcher
      queueWatcher(this)
    }
  }

那为了更好的理解这里,我把 renderWatcher 的实例化的代码也贴出来:

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(
    vm,
    updateComponent,
    noop,
    {before() {if (vm._isMounted && !vm._isDestroyed) {callHook(vm, 'beforeUpdate')
        }
      }
    },
    true /* isRenderWatcher */
  )

因此,renderWatcher是没有设置 lazy 这个属性的,同时我也没有手动设置 sync 属性,因此代码会执行到 queueWatcher(this)。注意这里的this,当前属于renderWatcher 实例对象,因此这里传递的 this 就是该对象。

// 将一个 watcher 实例推入队列准备执行
// 如果队列中存在相同的 watcher 则跳过这个 watcher
// 除非队列正在刷新
export function queueWatcher(watcher: Watcher) {
  const id = watcher.id
  debugger
  if (has[id] == null) {has[id] = true
    if (!flushing) {
      // 没有在刷新队列,则推入新的 watcher 实例
      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.

      // 队列已经刷新,则用传入的 watcher 实例的 id 和队列中的 id 比较,按大小顺序插入队列
      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
      }
      debugger
      nextTick(flushSchedulerQueue)
    }
  }
}

这段代码比较简单,就说一点。代码里有个判断是 config.async,这是 Vue 私有对象上的值,默认的是true,因此代码会执行到nextTick 这里,此时会传入一个回调函数 flushSchedulerQueue,我们这里先不说,之后用的的时候再介绍。现在看看nextTick 的实现。

const callbacks = []
let pending = false

export function nextTick(cb?: Function, ctx?: Object) {
  debugger
  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})
  }
}

pendding用来判断是否存在等待的队列,callbacks是执行回调的队列。那对于此时此刻,就是向 callbacks 推入一个回调函数,其中要执行的部分就是flushSchedulerQueue。因为是初次调用这个函数,这里的就会调用到timerFunc

  let timerFunc
  
  const p = Promise.resolve()
  
  timerFunc = () => {p.then(flushCallbacks)
    // In problematic UIWebViews, Promise.then doesn't completely break, but
    // it can get stuck in a weird state where callbacks are pushed into the
    // microtask queue but the queue isn't being flushed, until the browser
    // needs to do some other work, e.g. handle a timer. Therefore we can
    // "force" the microtask queue to be flushed by adding an empty timer.
    if (isIOS) setTimeout(noop)
  }

现在毫无因为的是 timerFunc 这个函数会被调用。但是有个问题,p.then(flushCallbacks)这句话会执行么?来看个例子:

function callback() {console.log('callback')
}

let p = Promise.resolve()
function func() {p.then(callback)
}

console.log('this is start')

func()

console.log('this is pre promise 1')

let a = 1
console.log('this is pre promise 2')
console.log(a)

思考一下结果是什么吧。看看和答案是否一致:

说回上面,p.then(flushCallbacks)这句话在这里会执行,但是是将 flushCallbacks 这个方法推入了微任务队列,要等其他的同步代码执行完成,执行栈空了之后才会调用。所以对于 renderWatcher 来说,目前就算执行完了。

接下来代码执行到这里:

this.$nextTick(() => {console.log('next tick', this.age)
  debugger
})

看下 $nextTick 的定义:

  Vue.prototype.$nextTick = function (fn: Function) {return nextTick(fn, this)
  }

这里定义 $nextTick 是定义在 Vue 的原型对象上,所以在页面中可以通过 this.$nextTick 调用,同时传入的 this 就是当前页的实例。所以看会 nextTick 定义的部分,唯一的区别是,这是的 penddingfalse,因此不会再调用一次timerFunc

setAge里的同步代码都执行完了,因此就轮到 flushCallbacks 出场。来看下定义:

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

这里定义的位置和定义 nextTick 是在同一个文件里,因此 penddingcallbacks是共享的。主要就看 copies[i]() 这一段。经过前面的执行,此时 callbacks.length 的值应该是 2。copies[1]指的就是先前推进队列的flushSchedulerQueue

/**
 * Flush both queues and run the watchers.
 *
 * 刷新队列并且运行 watcher
 */
function flushSchedulerQueue() {currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.

  // 给刷新队列排序,原因如下:// 1. 组件的更新是从父组件开始,子组件结束
  // 2. 组件的 userWatcher 的运行总是先于 renderWatcher
  // 3. 如果父组件的 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
      }
    }
  }

watcher.before这个方法是存在的,先前的代码中有,在初始化 renderWatcher 时传入了这个参数。这里就调用了 callHook(vm, 'beforeUpdate'),所以能看出来,此时beforeUpdate 执行了。接着执行 watcher.run()runWatcher类上定义的一个方法。

  /**
   * Scheduler job interface.
   * Will be called by the scheduler.
   */
  run() {
    debugger
    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) {
          try {this.cb.call(this.vm, value, oldValue)
          } catch (e) {handleError(e, this.vm, `callback for watcher "${this.expression}"`)
          }
        } else {this.cb.call(this.vm, value, oldValue)
        }
      }
    }
  }

this.active初始化的值就是 true,get方法之前的文章也提到过,这里再贴一遍代码:

  /**
   * Evaluate the getter, and re-collect dependencies.
   */
  get() {
    // debugger
    pushTarget(this)
    let value
    const vm = this.vm
    try {value = this.getter.call(vm, vm)
    } catch (e) {if (this.user) {handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {throw e}
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {traverse(value)
      }
      popTarget()
      this.cleanupDeps()}
    return value
  }

这部分代码之前说过,这里就不再说了,只提一点,此时的 this.getter 执行的是 updateComponent, 其实也就是里面定义的vm._update(vm._render(), hydrating)。关于renderupdate我会在分析虚拟 dom 时介绍。

现在需要知道的是,页面此时会重新渲染,我在 setAge 方法中修改了 age 的值,当 vm._update 执行完,就会发现页面上的值变化了。那接着就执行 callbacks 中的下一个值,也就是我写在 $nextTick 中的回调函数,这个就很简单,没必要再说。点击按钮到现在新的页面渲染完成,执行的结果就是:

before update
updated
next tick 100

这里就把整个流程讲完了,但是我想到 vue 文档中说的:

在修改数据之后立即使用它,然后等待 DOM 更新

假设我现在要是把 $nextTick 放到修改值之前呢。把 setAge 修改一下。

  setAge() {this.$nextTick(() => {console.log('next tick', this.age)
      debugger
    })
    debugger
    this.age = 100
  }

思考一下,此时点击按钮,页面会打印出什么东西。按照逻辑,因为 $nextTick 写在了前面,因此会被先推进 callbacks 中,也就会被第一个执行。所以此时我以为打印出来的 age 还是 2。但我既然都这样说了,那结果肯定是和我以为的不一样,但我有一部分想的没错,就是优先推入,优先调用。当我忘了一点,大家也可以会想一下,renderWatcher是如何被触发的?

$nextTick回调现在是进入了微任务队列,所以会继续执行接下来的赋值。此时会触发 age 设置的 setter 里的 dep.notify。但在调用之前,新的值就已经传给 age 了。所以当$nextTick 里的回调执行时,会触发 agegetter,拿到的值就是新的值。

整个 nextTick 事件就介绍完了。

正文完
 0