上一章我们通过从零构建了一个极简响应式系统后,对响应式系统中的
Dep
类、Watcher
类和defineReactive
方法都有了一定的了解。这一章我们将会结合源码来看看Vue
到底是如何实现响应式系统的,以及还有哪些细节需要我们注意和优化。
这一章主要分为以下几个模块:
observe
方法的作用与实现Observer
类的实现defineReactive
方法做了哪些额外处理?拦截数组变异方法 -
Vue
是如果处理数组的监听的?$set/$del
的实现Dep
类的实现Watcher
类的实现
observe
方法
上一章我们已经提到了defineReactive
方法是可以将对象的某一个key
转换成响应式。如果我们想直接将一个对象或者数组里的值全部转换成响应式,就不得不每次都去循序遍历处理。
因此,为了解决这个问题,Vue
封装了一个方法,专门用于监测对象或数据:如果是对象,就循环遍历处理所有的key
;如果是数组,就遍历每一个元素,对每一个元素进行响应式处理。下面我们看一下这个observe
方法的具体实现。响应式的核心代码是在src/core/observer
目录下,我们先打开该目录下的index.js
文件,找到observe
方法:
export function observe (value: any, asRootData: ?boolean): Observer | void {
// 1. 如果不是对象或者是VNode则不检测
if (!isObject(value) || value instanceof VNode) {
return
}
let ob: Observer | void
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)
}
if (asRootData && ob) {
ob.vmCount++
}
return ob
}
第一步,先判断要监测的value
是否是对象,如果不是则不需要监测。如果value
是VNode
,那么也不需要监测,因为虽然VNode
中会使用数据,但是监测这种依赖关系没有意义。
// 1. 如果不是对象或者是VNode则不检测
if (!isObject(value) || value instanceof VNode) {
return
}
第二步,判断value
是否具有__ob__
属性,如果存在且为Observer
类的实例,说明该value
已经被监测了,返回相应的监测对象即可。否则,说明value
尚未被监测,继续进行判断,这里一共有5个判断条件
// shouldObserve 用于控制是否数据需要监测,因为有的时候是不需要监测的
shouldObserve &&
// 服务端渲染也不需要监测
!isServerRendering() &&
// 只有数组或对象才会被监测
(Array.isArray(value) || isPlainObject(value)) &&
// 被 Object.seal 或 Object.freeze等处理过的对象不可被监测
Object.isExtensible(value) &&
// 如果是 Vue 实例则不会被监测
!value._isVue
如果所有条件满足,那么就会实例化一个Observer
类。可以看出,observe
函数主要是做了一些判断,实际的响应式处理都在Observer
类当中,后续我们会继续分析Observer
类。
最后,如果是根数据的话,那么ob.vmCount
会加1,这个有什么用呢?实际上从这里我们可以得出,如果是根数据被监测的话,那么ob.vmCount
是大于0的,而Vue.set
方法里进行监测时,会判断ob.vmCount
为true时不会进行监测。因此Vue.set
方法是无法对根数据里的key
处理成响应式。
if (asRootData && ob) {
ob.vmCount++
}
Observer
类
Observer
类的定义在observe
方法的上方,部分代码如下:
export class Observer {
value: any;
dep: Dep;
vmCount: number; // number of vms that have this object as root $data
constructor (value: any) {
this.value = value
this.dep = new Dep()
// 当为根数据时,vmCount > 0,$set 方法不进行处理
this.vmCount = 0
// 在 value 上添加属性 __ob__
def(value, '__ob__', this)
// 1. 如果是数组,由于 Object.defineProperty 无法检验数据的变化,因此需要额外处理
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
} else {
// 2. 如果是对象,遍历对每个 key 进行响应式处理
this.walk(value)
}
}
}
Observer
实例化时主要做了这几项工作:
首先是记录下要监测的值,同时生成一个dep
实例。注意,这里的dep
不同于上一章我们提到的闭包里的dep
,闭包里的dep
是属于某一个字段的,而这里的dep
是针对value
这个对象的。是在这个对象发生改变时,触发这个dep
相关的Watcher
更新。
接下来,value
上被添加了__ob__
属性,用于保存当前实例,前面observe
就是通过__ob__
访问已经实例化好的Observer
对象,达到不重复监测的目的。
最后,如果value
是数组,则会调用observeArray
方法,我们看一下该方法是怎样的:
observeArray (items: Array<any>) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
这里不难发现,observeArray
就是遍历value
里的每一个元素,然后进行响应式处理。
当代码走到else
分支时,代表value
是一个对象,此时会执行walk
方法:
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
walk
方法比较简单,就是通过defineReactive
方法对对象里的每个key
做响应式处理。下面我们看一下defineReactive
的实现。
defineReactive
方法
defineReactive
方法代码如下所示:
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
const dep = new Dep()
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
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
},
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
return
}
/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
// #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()
}
})
}
可以看出,defineReactive
方法和我们上一章实现的思路大致是一致的:在get
的时候,使用dep.depend()
来建立起Dep
和Watcher
之间的依赖关系,在set
的时候通过dep.notify()
来通知相应Watcher
进行更新。
所以这里我们主要讨论一下不一样的地方。
首先,defineReactive
在响应式处理前做了一层判断:
const property = Object.getOwnPropertyDescriptor(obj, key)
if (property && property.configurable === false) {
return
}
如果property.configurable
为false
的话,那么是defineProperty
方法是无效的,因此不做任何处理。
接着,通过property
获取了这个字段原有的get
和set
,并且缓存起来:
const getter = property && property.get
const setter = property && property.set
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && observe(val)
!shallow && observe(val)
这段代码则表示当val
值是对象时,需要进行递归监测,这样才能保证对象里任何的属性都是响应式的。
但是上面的这行代码(!getter || setter) && arguments.length === 2
就比较难以理解了,这牵涉到Vue
中的两个issue
:#7302和#7828。我也纠结了比较长的时间,下面讲一下我的理解。
Vue
原本处理对象时的walk
方法是这样定义的:
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
defineReactive
是传入三个参数的,也就是将对应的值也传进入了。这样做会有什么问题呢?我们知道,当获取obj[keys[i]]
这个值的时候,实际上会调用keys[i]
这个字段的get
方法,如果用户自己定义了这个get
方法,那么获取值时是无法预知用户会如何定义的。这里参考《Vue技术内幕》中的一句话。
之所以在深度观测之前不取值是因为属性原本的 getter 由用户定义,用户可能在 getter 中做任何意想不到的事情,这么做是出于避免引发不可预见行为的考虑。
所以,我们在深度监测之前,如果用户自己设置了get
,那么我们就跳过监测,可以参考#7302。所以代码改成只传两个参数:
// walk 方法
walk (obj: Object) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i], obj[keys[i]])
}
}
// defineReactive 方法
const getter = property && property.get
const setter = property && property.set
if ((!getter && arguments.length === 2) {
val = obj[key]
}
let childOb = !shallow && observe(val)
这样,如果传入的只有两个参数,也就是没有传value
值时,如果用户设置了get
,那么就不会执行val = obj[key]
这行代码,因此此时val
的值为undefined
,那么observe(val)
就不会进行监测了。
但是这样又会存在怎样的问题呢?如果对于某个字段没有get
,我们会对这个字段的值(这里代称叫做value
)进行深度监测。监测完后,我们使用了Object.defineProperty
对这个字段添加了get
和set
方法,此时get
又是存在的。如果改变value
里的值,由于该字段的get
是存在的,所以在递归监测时会跳过对value
的深度监测。这就导致了前后不一致的情况,详情参考#7828。
为了解决这个问题,我们将判断方式改为,增加了setter
判断:
if ((!getter || setter) && arguments.length === 2) {
val = obj[key]
}
这样,当getter
和setter
同时存在时,值一样会被深度监测。至此,这几行代码就解析完了,如果这里的解析存在偏差,恳请各位不吝赐教~
好了,我们看下后面的代码,在get
方法里:
if (childOb) {
childOb.dep.depend()
if (Array.isArray(value)) {
dependArray(value)
}
}
如果childOb
存在,说明value
是对象或数组,且已经被监测,这个时候需要记录这个value
与当前Watcher
之间的关系。另外,如果值是数组的话,会执行dependArray
方法:
function dependArray (value: Array<any>) {
for (let e, i = 0, l = value.length; i < l; i++) {
e = value[i]
e && e.__ob__ && e.__ob__.dep.depend()
if (Array.isArray(e)) {
dependArray(e)
}
}
}
这里主要分为value
数组和对象两种情况:
- 如果
value
是数组,那么会遍历数组,遍历的结果如果是对象或数组,那么e.__ob__
存在,进行依赖搜集。如果是数组,重复该步骤。 - 如果
value
是数组,遍历键值对,如果遍历的值为数组,重复上述步骤,如果是对象,重复本步骤
所以,本质上这段代码是递归处理了对象和数组的依赖搜集过程,这里需要慢慢理解一下递归的过程。
拦截数组变异方法
如果监测的value
为数组时,这里会出现一种问题:如果数组新增了某个元素,那么实际上defineProperty
是不会检测到这种数组的增删变化的,因此新增的元素也不能自动进行响应式处理了。
那么Vue
又是如何处理这种情况的呢?让我们回到Observer
类中,当value
是数组时,做了以下处理:
if (Array.isArray(value)) {
if (hasProto) {
protoAugment(value, arrayMethods)
} else {
copyAugment(value, arrayMethods, arrayKeys)
}
this.observeArray(value)
}
function protoAugment (target, src: Object) {
target.__proto__ = src
}
这里主要看一下protoAugment
方法,它将value
的__proto__
指向了arrayMethods
,我们看看arrayMethods
是什么,打开./array.js
文件:
const arrayProto = Array.prototype
// 继承于Array
export const arrayMethods = Object.create(arrayProto)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
/**
* Intercept mutating methods and emit events
*/
methodsToPatch.forEach(function (method) {
// cache original method
// 缓存数组的原始方法
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// 调用数组的原始方法
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
// 如果是增加了新的元素,那么需要将新增元素也进行响应式处理
if (inserted) ob.observeArray(inserted)
// notify change
// 数组改变了,通知相应 watcher 更新
ob.dep.notify()
return result
})
})
首先,arrayMethods
继承于Array
,因此拥有数组相关的属性方法。然后,遍历数组中所有与增删操作相关的方法名,进行响应式处理,这样每次数组的增删方法时,都会触发这里的mutator
函数。mutator
函数主要做了以下三点工作:
执行数组原有的方法,保持原有输出不变。
如果执行的是增加相关的方法,如
push,unshift,splice
,记录下增加了哪些元素,然后将这些元素也进行响应式处理。因为此时能够”感受“到数组变化了,所以会通知数组相关的
watcher
进行更新。
$set/$del
的实现
现在还存在一个问题:如果我们为对象添加了一个属性,我们却不知道这个对象发生了变化,从而也就不能对这个属性自动进行响应式处理了。同样,如果我们对数组用索引
来增加新值的时候,而不触发数组变异方法,也就无法感知变化了。
那么Vue
是如何处理这一问题的呢?这里就要提到$set
方法的实现了,它的目的就是为了解决设置新的属性不具备响应式的问题。
export function set (target: Array<any> | Object, key: any, val: any): any {
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
// 1. 如果是数组,key 是 index,且是有效索引,那么将对应的 value 进行替换即可
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
// 2. 如果对象自身就存在这个键,直接赋值即可
if (key in target && !(key in Object.prototype)) {
target[key] = val
return val
}
const ob = (target: any).__ob__
// 3. 如果是 Vue的实例,或者是 根数据,那么不应该被设置。
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
// 4. 如果没有 ob,说明对象本来就不是响应式的,这里也没必要做响应式处理
if (!ob) {
target[key] = val
return val
}
// 5. 如果对象本来就是响应式的,那么添加的 key 也要做响应式处理
defineReactive(ob.value, key, val)
// 6. 对象变化了,通知相关 watcher 更新
ob.dep.notify()
return val
}
如代码中注释所示,前4个判断都只将键值进行普通设置处理。到了第5步,也就是数组索引超出边界或者对象设置了新的属性时,才会对新增属性进行响应式处理。这里看一下第6步,为什么需要通知Watcher
更新呢?这里举一个例子:
// dom
<div> {{ user.name }}</div>
// index.vue
export default {
data() {
return {
user: {}
}
},
mounted() {
this.$set(this.user, 'name', 'qgh')
}
}
我们分析一下,因为data
里的user
没有name
属性,所以name
属性是没有做过响应式处理的,那么改变this.user.name
的时候也不会触发渲染watcher
更新,即视图不会发生任何变化。这里需要注意的是,获取user.name
的前提是需要获取user
这个对象,而上面我们已经提到过的childObj
在这里就起作用了,它会在获取user
的时候,执行childObj.dep.depend()
,建立与渲染watcher
的关系。最后user
形式如下:
user: {
// __ob__是Observer,里面的dep记录着相应的watcher
__ob__: Observer
}
当我们使用$set
的时候,触发的Watcher
更新,实际上就是这里的渲染Watcher
更新,所以user.name
也会随之更新。又因为$set
将name
设置为响应式了,所以后续更改user.name
视图也会更新。这就是$set
妙处所在了。另外$del
的实现也相差不多,最终也会通知watcher
更新,这里不做过多介绍了,有兴趣的可以自己看源码了解一下。
Dep
类
Dep
类是在./dep.js
文件下单独定义的:
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)
}
removeSub (sub: Watcher) {
remove(this.subs, sub)
}
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
notify () {
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update()
}
}
}
看过《响应式原理上篇》的同学应该知道,上篇的实现与这里几乎相差无几,所以还有不了解Dep
实现细节的同学,可以阅读一下本系列文章的《响应式原理上篇》。这里主要介绍一下另外一个细节:
Dep.target = null
const targetStack = []
export function pushTarget (target: ?Watcher) {
targetStack.push(target)
Dep.target = target
}
export function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
我们知道Dep.target
代表的是当前的Watcher
,那么这里为什么要用栈
的形式来处理target
呢?
试想一个场景:当前Dep.target
存在时,我们想执行某段代码,但并不想要进行依赖搜集,此时用栈的优点就体现出来了。我们可以往栈里推入一个null
,即targetStack
为[watcher, null]
那么在执行后续代码时,取得的当前target
也就是最后一个target
为null
,那么就不会进行依赖搜集了。当这段代码执行完成后,我们把null
弹出去,即targetStack
为[watcher]
,这时target
就又会恢复原来的watcher
,后续代码就可以正常进行依赖搜集了。
Watcher
类
Watcher
类定义在./watcher.js
文件,相较于Dep
类,Watcher
类则显得复杂许多。我们先看看Watcher
的constructor
:
// 渲染 watcher
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
首先如果是渲染Watcher
,则会单独记录到vm
实例上,因此可以通过调用vm._watcher
强制重新渲染界面,这也是$forceUpdate
的核心实现原理。而所有的Watcher
都被记录到vm._watchers
上,方便后续移除相应的Watcher
。
接下来是options
的一些处理:
// watcher 触发后的回调函数
this.cb = cb
this.id = ++uid // uid for batching
// watcher 是否还能使用
this.active = true
// 是否是惰性计算
this.dirty = this.lazy // for lazy watchers
// 上一次计算搜集的依赖
this.deps = []
// 当前计算搜集的依赖
this.newDeps = []
// 上一次计算搜集的依赖id
this.depIds = new Set()
// 当前计算搜集的依赖id
this.newDepIds = new Set()
this.expression = process.env.NODE_ENV !== 'production'
? expOrFn.toString()
: ''
// parse expression for getter
if (typeof expOrFn === 'function') {
// 如果是函数,直接赋值即可
this.getter = expOrFn
} else {
// 解析 'a.b.c' 的形式,取得对应函数
this.getter = parsePath(expOrFn)
if (!this.getter) {
this.getter = noop
process.env.NODE_ENV !== 'production' && warn(
`Failed watching path: "${expOrFn}" ` +
'Watcher only accepts simple dot-delimited paths. ' +
'For full control, use a function instead.',
vm
)
}
}
这里的dirty
是computed
计算属性实现的关键,我们将会在下一章进行讲解,这里先放一放。
另外,deps
和newDeps
分别记录了上一次计算和当前计算搜集的依赖.这是因为每次计算的时候,搜集的依赖可能不一样,所以每次计算的时候,都会将新的依赖重新记录一遍:
addDep (dep: Dep) {
const id = dep.id
// 如果新的 dep 中不包含该 dep,则添加该 dep
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
// 如果旧的 dep 中不包含该 dep,则在dep 里添加该 watcher
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
而在每次计算完毕后,又会将新的依赖赋值给旧的依赖,将新的依赖置空:
// 进行依赖搜集
get () {
// 标明当前正在执行的 watcher
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 {
// 如果 deep 为 true 的话,会循环遍历获取对象里的每一个值,
// 从而触发每一个相关的 watcher 进行 update
if (this.deep) {
traverse(value)
}
// 当前正在执行的 watcher 结束,不需要标明了
popTarget()
// 搜集完依赖后,清除依赖
this.cleanupDeps()
}
return value
}
// 计算完成后,将新 deps 赋值给旧 deps, 移除新 deps
cleanupDeps () {
let i = this.deps.length
// 清除在新 deps 中不存在的旧 dep
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
// 将新 deps 赋值给旧 deps, 移除新 deps
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
}
注意,这里有个细节就是deep
代表深度搜集依赖。例如 { user: { name: { first: 'a' } } }
这个对象,如果我们获取user
时,这时只会将user
作为依赖项进行搜集。但是如果deep
为true
时,会调用traverse
方法,该方法会遍历对象,将对象内部所有的键值(如user
的name
和name
的first
)获取一遍,这个获取的过程也就是搜集依赖的过程,所以最终会将对象内所有字段全做为依赖搜集。
Watcher
类剩下没介绍的主要就是update
方法了:
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
可以看出,这里有三种情况,我们将会在下一章结合数据初始化过程分别讲讲这三种情况,并学习computed
和watch
两个方法的具体实现。
总结
这一章我们主要通过源码的角度,分别了解了observe
,Observer类
,defineReactive
,$set/$del
,Dep
类,Watcher
类的实现,以及介绍了Vue
做的一些特殊处理,比如变异数组的拦截等。建议最好是能够亲自动手调试一下源码,才能更好的理解这几者之间的关系。
下一章我们将会回到Vue
的实例化过程,看看再实例化过程中,到底是如何处理数据响应式的,同时我们也会彻底地理解computed
和watch
两个方法的具体实现过程。
最后,如果你觉得这篇文章对你有所帮助,可以点赞/关注/收藏三连哦!码字不易,你的支持和喜欢是对我最大的鼓励~~
如果你有任何疑问都可以在评论区留言,我都会一一查看。如果你也是前端的爱好者,可以私信我进群和其他人一起交流,一起在前端的路上学习提升自己!