vue源码分析之nextTick

67次阅读

共计 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