July 10, 2023

Vue2源码(2):数据劫持原理

1 数据劫持是什么

Vue 最独特的特性之一,是其响应式系统。

而实现响应式的过程中,要实现数据劫持才能监听数据的变化

数据劫持就是:拦截属性的读取和修改,简单来说就是数据的任何变化都要能监测到,这样才能根据数据变化做对应操作

2 数据劫持源码逻辑

2.1 initState() 初始化

Vue 在执行完生命周期钩子函数 beforeCreate 之后,会根据配置项是否存在,执行对应的相应的初始化(如Props、methods、data、computed等等)

如果 data 存在

会执行 initData(),进行 data 数据初始化操作

下面进行 initData() 初始化的分析

这里的源码:

vue-main\src\core\instance\state.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function initState(vm: Component) {
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)

// Composition API
initSetup(vm)

if (opts.methods) initMethods(vm, opts.methods)

// data 数据初始化
if (opts.data) {
initData(vm)
} else {
const ob = observe((vm._data = {}))
ob && ob.vmCount++
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}

2.2 initData() 数据初始化

进入 initData()主要做了 3 件事:

1 首先就是判断 data 的类型

当然,我们知道 data 还是希望写成函数类型的:

因为数据以函数返回值形式定义,这样每复用一次组件,就会返回一份新的data,拥有自己的作用域,类似于给每个组件实例创建一个私有的数据空间,让各个组件实例维护各自的数据

我们组件的date单纯的写成对象形式,这些实例用的是同一个构造函数,由于JavaScript的特性所导致,所有的组件实例共用了一个data,就会造成该组件的数据会被其他组件应影响

2 如果methods、props 和 data 中没有重名的属性,就用 proxy 代理 data 中的属性到 vm 上

即: vm.name -> vm._data.name

3 调用 observe() 进行数据劫持,这里就是数据劫持真正的开始

这部分的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function initData(vm: Component) {
let data: any = vm.$options.data
data = vm._data = isFunction(data) ? getData(data, vm) : data || {}
if (!isPlainObject(data)) {
data = {}
__DEV__ &&
warn(
'data functions should return an object:\n' +
'https://v2.vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
vm
)
}
// proxy data on instance
const keys = Object.keys(data)
const props = vm.$options.props
const methods = vm.$options.methods
let i = keys.length
while (i--) {
const key = keys[i]
if (__DEV__) {
if (methods && hasOwn(methods, key)) {
warn(`Method "${key}" has already been defined as a data property.`, vm)
}
}
if (props && hasOwn(props, key)) {
__DEV__ &&
warn(
`The data property "${key}" is already declared as a prop. ` +
`Use prop default value instead.`,
vm
)
} else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
}
// observe data
const ob = observe(data)
ob && ob.vmCount++
}

2.3 observe()

这里主要做一些规范处理。接下来主要列一些什么情况下数据会被标记为响应式,什么情况下不被响应式

这部分的源码:

vue-main\src\core\observer\index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function observe(
value: any,
shallow?: boolean,
ssrMockReactivity?: boolean
): Observer | void {
if (value && hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
return value.__ob__
}
if (
shouldObserve &&
(ssrMockReactivity || !isServerRendering()) &&
(isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value.__v_skip /* ReactiveFlags.SKIP */ &&
!isRef(value) &&
!(value instanceof VNode)
) {
return new Observer(value, shallow, ssrMockReactivity)
}
}

2.4 Observer类

Observer 构造函数会真正的将数据标记为响应式

2.4.1 如果是数组

先对数组的每一项进行监测

调用 observeArray() ,遍历数组,对每一元素调用 observe() 方法将元素变为响应式数据

这里的源码:

vue-main\src\core\observer\index.ts

1
2
3
4
5
observeArray(value: any[]) {
for (let i = 0, l = value.length; i < l; i++) {
observe(value[i], false, this.mock)
}
}
拦截数组方法

有些方法会修改数组本身,这些方法被称为变异方法 (push、pop、shift、unshift、splice、sort、reverse)

当开发者调用这些方法修改data中的数组数据的时候,Vue需要及时得知并作出反应

因为需要在数组原型上修改7个方法,但是直接修改会干掉原有的方法,我们需要用 Object.create() 拷贝一份数组原型出来,在复制出来的原型上面做功能扩展

这里的源码:

vue-main\src\core\observer\array.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const arrayProto = Array.prototype
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
if (__DEV__) {
ob.dep.notify({
type: TriggerOpTypes.ARRAY_MUTATION,
target: this,
key: method
})
} else {
ob.dep.notify()
}
return result
})
})

vue为什么没有提供对数组属性的监听呢?

既然 Object.defineProperty 有劫持getter,setter能力,那么 vue 为什么没用它来实现对数组属性的监听呢?

尤大在github上这样回答:

如果你知道数组的长度,理论上是可以预先给所有的索引设置 getter/setter 的。但是一来很多场景下你不知道数组的长度,二来,如果是很大的数组,预先加 getter/setter 性能负担较大。

总而言之就是理论上 Vue 是可以这样做,但是出于性能考虑没这样做,而是用了一种数组变异办法来触发视图更新。

2.4.2 如果是对象

vue-main\vue-main\src\core\observer\index.ts

遍历当前对象的每个元素

这里可以理解,为什么只有对象已有属性才能实现数据劫持

1
2
3
4
5
const keys = Object.keys(value)  // 拿到当前对象value的所有键  
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock)
}

defineReactive() 方法,会进行 Object.defineProperty() 的逻辑:

首先 new Dep(),该 dep 在后面 get set 时都会使用这个闭包产生的 dep,这个时候就能明白了数据对象中每个数据字段都有属于自己的 dep 常量(这里涉及到依赖收集理念)

1
const dep = new Dep()

接下来缓存原有描述属性中可能存在的 get 方法和 set 方法,分别缓存在变量 gettersetter 中,为什么要缓存呢?主要是接下来会执行Object.defineProperty 重新定义属性的 getter/setter,这会导致原有的 getset 方法被重新覆盖

1
2
const getter = property && property.get
const setter = property && property.set

然后,默认会将对象进行深度劫持(如果对象层级比较深,显然是比较大的性能负担,这也是Vue3优化的一个问题)

1
observe()

接下来我们看看拦截器的 get 方法都做了些啥:

因为是读取属性,首先要确保不能影响正常逻辑,要调用前面缓存的 getter,拿到正确的返回结果

然后就是 getter 的主要使命,收集依赖:

Dep.target 存在,则执行dep.depend()。那么 Dep.target 是什么?其实就是 watcher

(依赖收集内容放到后面的文章讲)

vue-main\vue-main\src\core\observer\index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
get: function reactiveGetter() {
const value = getter ? getter.call(obj) : val // 拿到正确的返回结果
if (Dep.target) {
if (__DEV__) {
dep.depend({ //依赖收集
target: obj,
type: TrackOpTypes.GET,
key
})
} else {
dep.depend()
}
if (childOb) {
childOb.dep.depend()
if (isArray(value)) {
dependArray(value)
}
}
}
return isRef(value) && !shallow ? value.value : value
},

接下来我们看看拦截器的set方法都做了些啥:

set 函数主要做两件事:

这便是数据劫持的主要内容

想要理解它,还需要知道依赖收集中的 watcher、dep关系

3 Object.defineProperty API缺点

3.1 不能监听对象/数组新增和删除

在模板中使用对象属性peo

1
{{ peo }}

点击事件,为对象新增一个属性 c ,但是界面不会更新数据

1
this.peo.c = '5555'

解决方法:

使用 this.$set(object, key, value) 新增

1
this.$set(this.peo, 'c', 5555);

而数组前面说过了,只能通过splice、push、unshift方法新增和删除,直接修改数组索引,也是无法监听的

3.2 初始化阶段递归执行 Object.defineProperty 带来的性能负担

为了将对象深度劫持,会递归执行Object.defineProperty,如果对象层级比较深,显然是比较大的性能负担

About this Post

This post is written by Duan WeiCheng, licensed under CC BY-NC 4.0.