Vue.js源码学习 —— 组件的更新

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

这篇文章是承接上一篇Vue.js源码学习 —— 响应系统的设计的,如果没有看过也建议您看一下,有了前面的这些铺垫理解起来今天的内容很轻松很多。

之前在说组件的渲染和生命周期时我们都把组件的更新跳过去了,现在了解了Vue的响应系统是如何设计的了,我们可以来看一下在数据改变时,Vue是如何去触发组件重新渲染的。这里涉及到异步更新队列和nextTick的实现。

我们之前在讲Watcher实例的update方法时,看到有3种情况:

1
2
3
4
5
6
7
8
9
10
11
update () {
/* istanbul ignore else */
if (this.lazy) {
// 计算属性使用
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}

其实呢,大部分情况都会走到最后的else分支,也就是异步地计算表达式的值。所以今天咱们就从queueWatcher函数开始看去。

我不会在文章中大量地粘贴源代码,您还是一定要将Vue工程clone到本地哦。

观察者的调度队列

这一部分的代码主要位于文件core/observer/scheduler.js中。在文件的顶部我们看到定义了一系列的全局变量,先来看一下都有什么作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 循环更新的最大次数,超过了在开发环境下会给出告警,某个表达式可能导致了死循环更新
export const MAX_UPDATE_COUNT = 100

// Watcher 实例的队列,如果将每个 Watcher 实例看成是任务的话,那这就是一个任务队列
const queue: Array<Watcher> = []
// 处于活跃状态的子组件,主要用于<keep-alive>组件
const activatedChildren: Array<Component> = []
// 用来保存队列中 Watcher 实例的 id
let has: { [key: number]: ?true } = {}
// 记录循环更新的次数
let circular: { [key: number]: number } = {}
// 是否正在等待队列中的任务执行完
let waiting = false
// 是否正在执行队列中的任务
let flushing = false
// 正在执行的 Watcher 实例的 index
let index = 0

我们还看到resetSchedulerState函数,用于将以上的全局变量都恢复到初始状态,下面我们会看到对这些变量的使用。

queueWatcher

queueWatcher函数主要用来将Watcher实例放入观察者队列中,重复的Watcher实例会被跳过。所以我们看到queueWatcher函数中有如下判断:

1
2
3
4
5
6
7
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
// ...
}
}

在确定传入的Watcher实例可以放入队列后,接着做了两件事:

  • 决定如何放这个Watcher实例,我下面还是把Watcher实例称为一个任务,对应下面这段代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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.
    let i = queue.length - 1
    while (i > index && queue[i].id > watcher.id) {
    i--
    }
    queue.splice(i + 1, 0, watcher)
    }

    分了两种情况,如果没有在执行队列中的任务,则将传入的新任务直接放入队列尾端。如果正在执行队列中的任务,则确保传入的新任务能尽快被执行。可以把watcher.id看作是任务的优先级,值越小优先级越高,因为id越小说明watcher被创建的越早。所以此时插入的策略是将新任务放入马上要执行的任务后面,但是在比它优先级低的任务前面。

  • 是否要现在就执行队列中的任务,对应下面这段代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // queue the flush
    if (!waiting) {
    waiting = true

    if (process.env.NODE_ENV !== 'production' && !config.async) {
    flushSchedulerQueue()
    return
    }
    nextTick(flushSchedulerQueue)
    }

    如果当前正在等待上一次队列中的任务完成,也就是waitingtrue时,那就算了。否则就触发队列中的任务执行,在同步时会直接调用flushSchedulerQueue函数,异步时会使用nextTick去触发。对config.async有下面一段测试:

    1
    2
    3
    4
    5
    /**
    * Perform updates asynchronously. Intended to be used by Vue Test Utils
    * This will significantly reduce performance if set to false.
    */
    async: true,

    说明config.asyncfalse,也就是同步时,只是为了做单元测试用的。开启了同步更新会大大降低性能。

flushSchedulerQueue

flushSchedulerQueue函数就是用来真正地执行队列的每个任务的。它的主要处理流程如下:

  • 设置flushingtrue,所以当flushingtrue时说明队列中的任务正在执行。

  • 在开始执行之前先将队列中的任务按照id从小到大排序,我们前面也说了id越小说明创建的越早。从注释中可知这样做是为了确保:

    1. 组件的更新先父组件再子组件(因为父组件总是比子组件先创建)。
    2. vm.$watch内部创建的观察者会在渲染观察者之前执行,因为前者会先被创建。
    3. 如果一个组件在其父组件的观察者执行时被销毁了,那这个组件的观察者可以被跳过去。
  • 依次拿出队列中的观察者来执行,等循环结束后队列中的所有任务就都执行完了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 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]
    // 我们在上一篇文章中有说过 before 选项,这里看到在调用 run 之前会先调用 before
    if (watcher.before) {
    watcher.before()
    }
    // id 从 has 移除
    id = watcher.id
    has[id] = null
    // 正式执行观察者,run 的实现过程在上一篇文章里有讲
    watcher.run()
    // 如果执行完观察者发现它又被添加到队列里了,这就导致了循环更新
    // 当循环更新执行的次数达到了最大限制,开发环境下就会给出告警信息了
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
    // ...
    }
    }
  • 获取activatedChildrenqueue的一个副本后,然后调用前面说到的resetSchedulerState函数重置状态。

  • 调用组件生命周期中的updatedactivated的钩子。

queueActivatedComponent

1
2
3
4
5
6
7
8
9
10
/**
* Queue a kept-alive component that was activated during patch.
* The queue will be processed after the entire tree has been patched.
*/
export function queueActivatedComponent (vm: Component) {
// setting _inactive to false here so that a render function can
// rely on checking whether it's in an inactive tree (e.g. router-view)
vm._inactive = false
activatedChildren.push(vm)
}

这是一个我们之前遇到了但是没有分析的函数,现在给补上。在Vue内部创建组件时的insert钩子中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
// ...
insert (vnode: MountedComponentVNode) {
const { context, componentInstance } = vnode
if (!componentInstance._isMounted) {
componentInstance._isMounted = true
callHook(componentInstance, 'mounted')
}
if (vnode.data.keepAlive) {
if (context._isMounted) {
queueActivatedComponent(componentInstance)
}
} else {
activateChildComponent(componentInstance, true /* direct */)
}
}
// ...
}

可以看到queueActivatedComponent是在<keep-alive>组件在patch过程中重新被插入到DOM中时调用的。因为对于<keep-alive>组件来说,它的子组件触发有可能在更新的过程中发生改变,所以这里没有直接触发它的activated钩子,而是先放到了队列中,等一次更新结束后我们上面看到了就会来触发activated钩子了。对于普通的组件来说不会存在这样的问题了,因为它的子组件都是新创建的。

nextTick的实现

这一部分的代码位于文件core/util/next-tick.js中,我们还是先来看在它中定义的全局变量:

1
2
3
4
5
6
7
8
// 可以看到这个变量被导出了,可以在其他文件中使用
// 下面在定义 timerFunc 的过程中会来设置这个值,这样也省了其他再来验证 microtask 是否可用
export let isUsingMicroTask = false

// nextTick 中收集到的要执行的任务
const callbacks = []
// 标识是否需要等待上一次任务的完成
let pending = false

再来看一下flushCallbacks函数,在这个函数中我们看到设置了pendingfalse,标识这个函数一旦被调用任务就会执行了,后面再进来的任务可以再次触发列表中的任务执行。

nextTick

我们知道Vue提供给了开发者一个API:vm.$nextTick,可用于将回调延迟到DOM更新循环之后执行。vm.$nextTick的内部就是使用的我们马上要分析的nextTick函数:

1
2
3
4
5
export function renderMixin (Vue: Class<Component>) {
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
}

好了,我们现在看一下nextTick函数内部的处理流程:

  • 将传入的回调放入回调列表中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    callbacks.push(() => {
    if (cb) {
    try {
    cb.call(ctx)
    } catch (e) {
    handleError(e, ctx, 'nextTick')
    }
    } else if (_resolve) {
    _resolve(ctx)
    }
    })

    可以看到,给回调函数内绑定的this是传入的ctx参数,如果在vm.$nextTick中回调的上下文就是当前的Vue实例对象。

    此外我们注意到,对于外部传入的回调函数,都会使用try/catch,这也是保证代码健壮性的一种手段。

  • 如果pending的值是false,也就是此时不需要等待,那就将状态设置为需要等待,然后调用timerFunc来触发回调列表中回调函数的执行。

  • 最后如果没有传回调函数,并且运行环境中支持Promise,会返回一个Promise对象。

所以这里关键就是timerFunc内部是如何触发任务的。

timerFunc

代码中对于timerFunc的实现给了很详细的注释,我大概总结一下如下:

  • 如果当前运行环境环境是支持Promise的,那就利用Promise.then将回调函数放入微任务队列(microtask queue)中等待执行。

    1
    2
    3
    4
    5
    6
    7
    8
    // 返回一个 fulfilled 状态的 Promise对象,这样 flushCallbacks 就一定会被执行到了
    const p = Promise.resolve()
    timeFunc = () => {
    p.then(flushCallbacks)
    // 兼容 iOS
    if (isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
  • 如果当前不支持Promise对象,那就看是否支持MutationObserverMutationObserver的回调也是放到microtask queue中执行的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    let counter = 1
    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

    因为 MutationObserver 观察的是DOM的变化,所以这里创建了一个 textNode 元素,并且每次调用的时候都改变 counter 的值,一次触发 textNode的改变。

  • 如果PromiseMutationObserver都不支持,那看setImmediate是否支持,如果支持就使用setImmediatesetImmediate中的回调函数是放在宏任务队列(macrotask queue)中执行的。

    1
    2
    3
    timerFunc = () => {
    setImmediate(flushCallbacks)
    }
  • 如果以上都不支持,就使用setTimeout了,setTimeout中的回调函数也是放在宏任务队列中执行的。

    1
    2
    3
    timerFunc = () => {
    setTimeout(flushCallbacks, 0)
    }

这样就实现了nextTick的功能。有一需要注意,在平时的开发中我们使用vm.$nextTick通常是希望在数据更新后做一些事情,所以要注意vm.$nextTick调用的位置。因为从上面的实现过程中看到callbacks中的回调函数就是按照调用nextTick的顺序放的,在timerFunc函数中也并没有做什么特殊的处理。所以我们要自己确保数据改变在前,nextTick的调用在后。举个来自Vue官方文档中的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
new Vue({
// ...
methods: {
// ...
example: function () {
// 修改数据
this.message = 'changed'
// DOM 还没有更新
this.$nextTick(function () {
// DOM 现在更新了
// `this` 绑定到当前实例
this.doSomethingElse()
})
}
}
})

组件的更新

我们再来看一下组件是如何被触发更新的,在mountComponent函数中我们曾看见创建一个Watcher实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
// ...

callHook(vm, 'beforeMount')
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 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 */)

// ...
}

这里创建的Watcher实例专门是用来更新组件使用的,每一个组件在挂载时都会创建一个,并且一个组件实例只对应一个render watcher。在Watcher的构造器函数中,当isRenderWatchertrue时,会设置vm._watcher = this

updateComponent函数中的一系列执行过程在Vue.js源码学习 —— Vue实例挂载的实现中已经分析过了。一旦updateComponent函数被调用,最终会触发选项中的render函数执行创建vnode。这个vnode又会经过patch创建对应的DOM元素。

那在render函数执行的过程中因为要使用data选项中的数据对象,因此就会触发它们的getter执行,render watcher就会收集到数据对象对应的依赖。

data选项中的数据对象在初始化时都会转换为响应式的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const vm = new Vue({
el: '#app',
data: {
message: 'Hello World'
},
render (h) {
let children = [
// message 的 getter 会被触发
h('div', this.message),
h('button', {
on: {
click: () => {
this.message = 'Hi Vue!'
}
}
}, 'Click Me')
]
return h('div', children)
}
})

我们还是画图来总结一下这些操作流程:

到目前为止响应系统的工作流程就说完了,其实这个模块完全可以拿到别的项目中去用。下一步我们将看一下Vue的业务中是如何使用响应系统的,这一部分在数据选项的初始化过程中。

本文作者:意林
本文链接:http://shinancao.cn/2020/02/04/Vue-Source-Code-11/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!