在上一篇文章中,梳理了 Vue 实例化和渲染的基本逻辑,并且介绍了订阅者模式这种设计模式,Vue 的「响应式」实现本质上也是一个订阅者模式,但是由于 Vue 需要考虑更加复杂的情况,并且需要在其中作出大量优化操作,因此具体实现也会复杂很多。通过上面对订阅者模式的介绍,观察目标类,观察者管理类,观察者是订阅者模式中的三个基本要素,Vue 内部也会有对应的实现,下面通过更详细地说明 Vue「响应式」的实现,同时发掘在 Vue 中订阅者三要素分别是什么。
Vue 响应式实现
正正如上文开头所述,本文会从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,首先可以总结出三条线的作用:
- 实例化 Vue —— 负责定义好响应式的相关逻辑。
- 渲染 —— 负责执行响应式的逻辑
- 数据更新 —— 负责响应式逻辑的二次执行
上面梳理了的是三条线的主线逻辑,下面开始聚焦到「响应式」的部分。
实例化 Vue —— 负责定义好响应式的逻辑
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve &&
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
回顾前面提到的 observer 方法,这是其中核心的部分,它的基本逻辑是这样的:
- 判断如果有
__ob__则直接使用。 - 没有
__ob__会走一系列的判断,然后把数据传入到new Observer创建响应式数据。
首先要分析的就是这一系列的判断,这些实际上都是对需要做响应式封装的数据进行检查的判断,shouldObserve 是默认为 true 的全局静态变量,isServerRendering 和 Array.isArray 顾名思义判断是否为服务端渲染和判断是否为数组,value._isVue 是判断是否为最根的 Vue 实例,根实例只是一个壳,是不需要处理响应式的,因此比较特别的是 isPlainObject 和 Object.isExtensible,这是两个含义不是很直观的判断。
isPlainObject
“Plain Object — 通过 {} 或者 new Object 创建的纯粹的对象”,这是对于 Plain Object 的定义。在 JavaScript 中,Function,Array 都继承于 Object,也拥有 Object 的特性,但为了避免产生额外的问题,框架在数据上通常都会使用 Plain Object。要区分 Plain Object 也很简单,很多框架里都有关于 Plain Object 的判断实践,而 Vue 则是使用原型判断,例如以下这段代码:
// Plain object
var plainObj1 = {};
var plainObj2 = { name : 'myName' };
var plainObj3 = new Object();
// Non Plain object
var Person = function(){};
console.log(plainObj1.__proto__); // {}
console.log(plainObj2.__proto__); // {}
console.log(plainObj3.__proto__); // {}
console.log(Person.__proto__); // [Function]
打印结果中,原型的值是不一样的,Vue 的 isPlainObject 具体实现如下:
var _toString = Object.prototype.toString;
function isPlainObject (obj) {
return _toString.call(obj) === '[object Object]'
}
Object.isExtensible
“Object.isExtensible() 判断一个对象是否是可扩展的,即是否可以在它上面添加新的属性”,这是 Object.isExtensible() 的说明,看以下的例子:
// 新对象默认可扩展
var empty = {};
console.log(Object.isExtensible(empty)); // true
// 通过 Object.preventExtensions 使变得不可扩展
Object.preventExtensions(empty);
console.log(Object.isExtensible(empty)); // false
// 密封对象不可扩展
var sealed = Object.seal({});
console.log(Object.isExtensible(sealed)); // false
// 冻结对象也不可扩展
var frozen = Object.freeze({});
console.log(Object.isExtensible(frozen)); // false
// 尝试给不可扩展的对象添加属性
empty.a = 1;
console.log('modified empty: ', empty); // modified empty: {}
一个直接创建的 Plain Object 默认是可扩展的,也可以通过一些原生方法把对象变为不可扩展,另外密封和冻结对象都是不可扩展的,不可扩展的元素添加属性不会报错,但是会添加无效。那为什么 Vue 要求响应式数据对象必须要可扩展呢?原因很简单,在上面介绍 observer 方法中,核心的步骤就是要给数据对象添加 __ob__ 属性,用于缓存响应式数据的封装结果。
定义响应式数据
回到实例化 Vue 的流程,在判断传入的数据对象如果没有 __ob__ 属性后,会调用 new Observer,这是响应式处理的真正入口类。
constructor (value: any) {
this.value = value
this.dep = new Dep()
this.vmCount = 0
def(value, '__ob__', this)
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
this.walk(value)
}
}
首先是把当前的 Observer 实例赋值给当前对象的 __ob__ 属性,然后判断如果是数组则遍历每个 item 调用 observer,由于之前调用 observer 时就进行了判断,传入的数据类型只能是数组或者对象,因此这里 else 就按对象处理,调用 walk 方法。
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
walk 主要的作用是为数据对象的每个 key 调用 defineReactive 方法,defineReactive 的主要逻辑是为传入数据的某个 key,基于 Object.defineProperty 劫持 get 和 set 操作,这样数据读取和赋值时就会调用响应式的逻辑。由于基于 Object.defineProperty 实现了这个核心逻辑,因此 Vue 不支持 IE8 下运行。
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) {
dep.depend()
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
}
return value
}
首先看看 get 操作的劫持,首先是通过原生的 getter 获取数据的值,然后判断 Dep.target 是否存在,这里可能会有疑问,没有看到它的赋值时机,所以 Dep.target 究竟是什么呢?实际上现在不用关注它的赋值,因为正如前面强调的,当前这些实例化的操作,只是把「响应式」的数据先定义好,也就是还不用运行,到了渲染过程的时候,才会对 Dep.target 进行赋值。
// 精简了部分逻辑
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = []
}
addSub (sub: Watcher) {
this.subs.push(sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice()
if (process.env.NODE_ENV !== 'production' && !config.async) {
subs.sort((a, b) => a.id - b.id)
}
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
假如 Dep.target 已经赋值了,接下来会执行以下操作:
- 调用
dep.depend()进行依赖收集,在Dep类源码中可以发现,这个方法实际上是把当前 target,即当前渲染的Watcher加入到dep实例的一个数组中,保存下来。 - 如果数据中有子值也是对象,则对子值进行依赖收集。
也就是 get 调用后数据的 dep 会持有关联的 Watcher。
// 精简非正式环境逻辑
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
// #7981: for accessor properties without setter
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
然后看看 set 的操作:
- 获取当前最新的数据值。
- 判断新值如果等于旧值则直接跳过下面的操作。
newVal !== newVal && value !== value是为了避免一些特殊情况,例如newVal是NaN,由于NaN === NaN为false,所以需要这样一个特殊的判断。 - 然后跳过没有原生
setter但有原生getter的情况。 - 接着调用
setter赋新值。 - 最后是调用
dep.notify(),根据上面Dep类的源码可以知道,这实际上就是遍历之前收集的Watcher,然后逐个调用它们的update方法,Watcher会去执行更新逻辑。
到这里,实例化中「响应式」相关逻辑已经完整分析清楚了,订阅者模式的相关要素也很清晰:
Dep是观察目标,Watcher是观察者,每个数据对应一个Dep实例dep,get数据时会触发dep收集了数据相关的Watcher,相当于观察目标收集了观察者。Watcher也记录了相关的 dep,方便后续更新时做优化。这是与普通订阅者模式最大的区别,后续会展开说明。set数据时会触发 dep 通知相关的Watcher更新,而具体的更新逻辑,等第三个小章节“数据更新”再详细说明。
如前面所说的,实例化中的响应式处理实际上是负责定义响应式的逻辑。接下来看看渲染的逻辑。
渲染 —— 负责执行响应式的逻辑
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
回顾调用 $mount,挂载实例的这块代码,重新聚焦几个点:
Watcher绑定的是 Vue 的实例vm,传入的第二个参数是vm的更新方法,里面会先调用vm的_render()方法。Watcher的作用包括在需要时触发_render(),即重新计算vnode,然后_update调用_patch,即重新渲染 DOM,从而实现整个 Vue 实例的更新。
因此对于这个流程,响应式相关的逻辑重点在 new Watcher,接下来看看 Watcher 的 constructor。
// 精简了非 production 的逻辑
constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean) {
this.vm = vm
if (isRenderWatcher) { vm._watcher = this }
vm._watchers.push(this)
if (options) {
this.deep = !!options.deep
this.user = !!options.user
this.lazy = !!options.lazy
this.sync = !!options.sync
this.before = options.before
} else {
this.deep = this.user = this.lazy = this.sync = false
}
this.cb = cb
this.id = ++uid // uid for batching
this.active = true
this.dirty = this.lazy // for lazy watchers
this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : ''
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
if (!this.getter) { this.getter = noop }
}
this.value = this.lazy ? undefined : this.get()
}
constructor 的逻辑里,大部分都是定义变量,需要重点关注的主要是:
- 真正要处理的逻辑在
get()方法里。 Watcher实例的getter就是传入的updateComponent方法,getter会被保存到Watcher实例变量上。
接下来分析一下 get() 方法的逻辑:
get () {
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 {
if (this.deep) { traverse(value) }
popTarget()
this.cleanupDeps()
}
return value
}
get() 方法主要是做两件事,调用 getter 以及进行「收集依赖」,getter 本质上就是 updateComponent,即上面介绍过的渲染更新组件的逻辑,这里不再详述这点,重点关注「收集依赖」的过程。
Dep.target = null
const targetStack = []
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
上面是 pushTarget 的逻辑,它是 Dep 类下面的一个静态方法,本质上就是把当前的 Watcher 加入到一个栈中,并且赋值给 Dep.target,这里可以回应上面在劫持响应式数据 get 逻辑的一个疑问,Dep.target 是在渲染过程中「收集依赖」时赋值的,因此真正执行响应式逻辑实际上是在渲染时才进行的。结合两个特性:
- Vue 实例渲染是递归的,从子到父逐个完成,同时只有一个
Watcher被渲染。 - JS 是单线程的,
Dep.target在同一时刻只会被赋值成一个Watcher。
Vue 就是利用这两个特性,逐个执行 Watcher 的渲染逻辑,最终完成整个 Vue 应用的渲染,最后重点看看 this.cleanupDeps() 的逻辑。
为什么需要 cleanupDeps?
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) { dep.removeSub(this) }
}
let tmp = this.depIds
this.depIds = this.newDepIds
this.newDepIds = tmp
this.newDepIds.clear()
tmp = this.deps
this.deps = this.newDeps
this.newDeps = tmp
this.newDeps.length = 0
}
this.cleanupDeps() 的逻辑主要是分成两块:
- 把
newDepIds里面不存在的dep实例找出来,然后把当前的Watcher从这个dep实例中移除,也就是后续dep对应的数据更新不用再通知当前Watcher。 - 清空当前的
newDepIds,把deps赋值成newDeps。
这样看无法直观看出来为什么需要实现一个这样的逻辑,举一个具体的例子:
<template>
<div id="app">
<div>
a:{{ a }}
<button @click="chnageA">点击修改 a 的值</button>
<HelloWorld1 v-if="a % 2 === 0" :data="a" />
<HelloWorld2 v-else :data="b" />
</div>
</div>
</template>
在这个例子中,a、b 两个 data,a 在模板中直接用到,而 b 仅在 HellowWorld2 中作为 props 传递,当 a 为奇数时 a 和 b 改变都会触发 App 更新渲染。
可以试想一下这样一个过程:
- 初始化时,
a和b都为 1,在初始化的时候经常observe的处理,形成了两个Dep实例,dep(id=1,绑定a)和dep(id=2,绑定b) - 渲染时
new Watcher绑定了 App 这个 Vue 实例,然后Dep.target赋值成当前Watcher,经常Watcher的getter->updateComponent->render()这样一个过程,触发了a和b的get,从而进行依赖收集,把当前Watcher同时放入两个dep中。 - 然后把
a改为2,触发了a的set从而通知Watcher更新,重新触发updateComponent走到render(),这个时候假如没有cleanupDeps(),则这次render()触发依赖收集完成后,只是更新了a的值为2,而后续如果b修改值时,仍会通知Watcher更新,造成一次浪费的订阅更新。对于 Vue 这样的基础框架来说,如果每次依赖收集都重新进行,抛弃内存缓存记录,又会导致性能很差,无法适配各种常见,因此最终 Vue 的做法就是通过Watcher和Dep同时互相记录,来实现渲染优化,即订阅者也可以通知订阅目标抛弃掉一些无用的通知对象,减少浪费。
为了更好地说明这个过程,这里特意做了一张流程图完整表述整个过程:

数据更新 —— 负责响应式逻辑的二次执行
相对来说,数据 set 后的更新逻辑比较好理解,上面大概提到了,但其中的内部逻辑却是三条主线里最复杂的。上面稍微提到过,当数据 set 后,会触发 dep.notify(),即遍历之前收集的 Watcher,然后逐个调用它们的 update 方法,因此首先来看看 Watcher 的 update 方法:
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
上面 Watcher 的 constructor 的代码里展示过,lazy 和 sync 这两个变量默认都是 false,因此可以先不用理会,也就是说 update 的主逻辑是把当前的 Watcher 作为参数调用 queueWatcher,顾名思义是把 Watcher 放入到一个队列中,接下来看看 queueWatcher 的具体处理。
// 精简了非 production 的逻辑
let waiting = false
let flushing = false
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
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)
}
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
根据这里的逻辑,默认的情况都是直接把传入的 Watcher 加入到一个队列中,然后使用 nextTick 调用 flushSchedulerQueue,nextTick 大家都比较熟悉,作用是把方法按周期调用,因此组件的实际渲染更新都不是即时的,而是每隔一个周期中集中处理,接下来看看 flushSchedulerQueue 的逻辑。
// 已精简非 production 逻辑
function flushSchedulerQueue () {
currentFlushTimestamp = getNow()
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id)
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
}
const activatedQueue = activatedChildren.slice()
const updatedQueue = queue.slice()
resetSchedulerState()
// call component updated and activated hooks
callActivatedHooks(activatedQueue)
callUpdatedHooks(updatedQueue)
}
这里主要处理了三件事情:
- 按 id 从小到大排序
Watcher,Watchwe的id是创建时自增的,渲染是从外层递归的,也就是父元素会排在队列的前面。为什么要这样排呢?实际上也是为了性能优化,把父元素放在队列的前面,就会优先处理父元素,因此如果父元素销毁了,就可以直接跳过后面子元素的渲染更新。 - 遍历队列调用每个
Watcher的run()方法。 queue.length是动态的,Vue 没有把队列长度缓存起来,是因为queue在调用过程中可能会增删Watcher,例如上面的例子中,a的改变可以导致HelloWorld1的Watcher加入到队列中,而HellowWorld2的Watch则不再需要被渲染,因此queue的长度无法缓存。
run () {
if (this.active) {
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 {
this.cb.call(this.vm, value, oldValue)
}
}
}
}
最后看看 run() 方法,首先会调用 get() 方法,也就是会重新调用 updateComponent 和进行依赖收集,这里再关注一下 get() 后半部分的逻辑,之前文章内提到的 Watcher 其实都是绑定 Vue 实例的渲染 Watcher,Vue 中还有用户 Watcher,也就是平常监听 data 或者 props 值变化用的 Watcher,对于这些 Watcher,会有有效的返回值 value,因此 run() 里面还会对比 value 是否有变化,如果有就重新赋值,并且会执行回调。
至此,Vue「响应式」的整个逻辑以及在各个环节中分别所做的处理已经讲述完成,作为 Vue 的核心部分,「响应式」的整个逻辑较为庞大,也涉及实例化、渲染、数据更新三个环节,同时内部还有很多的性能考虑,因此单纯去看「响应式」的核心代码也不大好理解,后面还会有一篇短文来解答一些数据更新的常见问题。最后制作了一张完整的 Vue「响应式」逻辑流程图供参考。

本文由 Kayo Lee 发表,本文链接:https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-two.html
Pingback
评论列表