Object 的变化侦测

🥨 如何追踪变化

这里的函数defineReactive用来对Object.defineProperty进行封装。
从函数的名字可以看出,其作用是定义一个响应式数据。
也就是在这个函数中进行变化追踪,封装后只需要传递datakeyval就行了。
封装好之后,每当从datakey中读取数据时,get函数被触发;每当往data的key中设置数据时,set函数被触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function defineReactive(data, key, val) {
Object.defineProperty(data, key, {
enumerable:true,
configurable:true,
get:function() {
return val
},
set:function (newVal) {
if (val === newVal) { // 插入性能优化
return
}
val = newVal
}
})
}

🫓 如何收集依赖

总结起来,其实就一句话,在getter中收集依赖,在setter中触发依赖。

1
2
3
<template>
<h1>{{ name }}</h1>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function defineReactive (data, key, val) {
let dep = [] // 新增 - 依赖项目
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {

dep.push(window.target) // 新增 - 依赖
return val
},
set: function (newVal) {
if(val === newVal){
return
}
// 新增 - 依赖滚动刷新
for (let i = 0; i < dep.length; i++) {
dep[i](newVal, val)
}
val = newVal
}
})
}

🍗 追踪依赖改造

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
48
49
50
51
52
53
54
55
export default class Dep {
subs: any
constructor() {
this.subs = [] // 依赖项
}

addSub(sub) {
this.subs.push(sub) //新增依赖
}

removeSub(sub) {
remove(this.subs, sub) // 移除依赖
}

depend() {
if (window.target) { // 风控把关
this.addSub(window.target)
}
}

notify() {
const subs = this.subs.slice() // 深拷贝依赖群组
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 更新各个依赖项
}
}
}

function remove(arr, item) {
if (arr.length) {
const index = arr.indexOf(item)
if (index > -1) {
return arr.splice(index, 1)
}
}
}

function defineReactive(data, key, val) {
let dep = new Dep() // 修改 new 依赖项
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function () {
dep.depend() // 添加依赖
return val
},
set: function (newVal) {
if (val === newVal) {
return
}
val = newVal
dep.notify() // 依赖通知
}
})
}

🥯 依赖是谁

在上面的代码中,我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个watch,这时需要抽象出一个能集中处理这些情况的类。然后,我们在依赖收集阶段只收集这个封装好的类的实例进来,通知也只通知它一个。接着,它再负责通知其他地方。所以,我们要抽象的这个东西需要先起一个好听的名字。嗯,就叫它Watcher吧。现在就可以回答上面的问题了,收集谁?Watcher

🥙 什么是Watcher

Watcher是一个中介的角色,数据发生变化时通知它,然后它再通知其他地方。
关于Watcher,先看一个经典的使用方式:

1
2
3
4
// keypath 观测 data.key, 此处为 data.a.b.c
vm.$watch('a.b.c', function (newVal, oldVal) {
// 做点什么
})

这段代码表示当data.a.b.c属性发生变化时,触发第二个参数中的函数


思考一下,怎么实现这个功能呢?好像只要把这个watcher实例添加到data.a.b.c属性的Dep中就行了。然后,当data.a.b.c的值发生变化时,通知Watcher
接着,Watcher再执行参数中的这个回调函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default class Watcher {
constructor(vm, expOrFn, cb) {
this.vm = vm
// 执行this.getter(),就可以读取data.a.b.c的内容
this.getter = parsePath(expOrFn)
this.cb = cb
this.value = this.get()
}

get() {
window.target = this
let value = this.getter.call(this.vm, this.vm)
window.target = undefined
return value
}

update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue)
}
}

这段代码可以把自己主动添加到data.a.b.c的Dep中去,是不是很神奇?因为我在get方法中先把window.target设置成了this,也就是当前watcher实例,然后再读一下data.a.b.c的值,这肯定会触发getter。
触发了getter,就会触发收集依赖的逻辑。
而关于收集依赖,上面已经介绍了,会从window.target中读取一个依赖并添加到Dep中。
这就导致,只要先在window.target赋一个this,然后再读一下值,去触发getter,就可以把this主动添加到keypath的Dep中。
有没有很神奇的感觉啊?依赖注入到Dep中后,每当data.a.b.c的值发生变化时,就会让依赖列表中所有的依赖循环触发update方法,也就是Watcher中的update方法。而update方法会执行参数中的回调函数,将value和oldValue传到参数中。
所以,其实不管是用户执行的vm.$watch(‘a.b.c’, (value, oldValue) => {}),还是模板中用到的data,都是通过Watcher来通知自己是否需要发生变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 解析简单路径
*/
const bailRE = /[^\w.$]/
export function parsePath (path) {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}

🍞 关键字速查表

🍞Object.defineProperty(obj, prop, descriptor)

obj
要在其上定义属性的对象。
prop
要定义或修改的属性的名称。
descriptor
enumerable:当且仅当该属性为 true 时,该属性才能够出现在对象的枚举属性中。默认为 false
configurable:当且仅当该属性的为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。


🍞Array.slice(begin, end)

slice()方法返回一个新的数组对象,这一对象是一个由 beginend(不包括end)决定的原数组的浅拷贝。原始数组不会被改变
提取规则为 Array[ begin, end )
begin 可选
从该索引处开始提取原数组中的元素(从0开始)。
如果该参数为负数,则表示从原数组中的倒数第几个元素开始提取,slice(-2)表示提取原数组中的倒数第二个元素到最后一个元素(包含最后一个元素)。
如果省略 begin,则 slice 从索引 0 开始。
end 可选
在该索引处结束提取原数组元素(从0开始)。slice会提取原数组中索引从 begin 到 end 的所有元素(包含begin,但不包含end)。

1
2
3
4
5
var fruits = ['Banana', 'Orange', 'Lemon', 'Apple', 'Mango'];
var citrus = fruits.slice(1, 3);

// fruits contains ['Banana', 'Orange', 'Lemon', 'Apple', 'Mango']
// citrus contains ['Orange','Lemon']

🍞Array.splice(start[, deleteCount[, item1[, item2[, ...]]]])

splice() 方法通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。
此方法会改变原数组
start
指定修改的开始位置(从0计数)。如果超出了数组的长度,则从数组末尾开始添加内容;如果是负值,则表示从数组末位开始的第几位(从-1计数,这意味着-n是倒数第n个元素并且等价于array.length-n);如果负数的绝对值大于数组的长度,则表示开始位置为第0位。
deleteCount 可选
整数,表示要移除的数组元素的个数。
如果 deleteCount 大于 start 之后的元素的总数,则从 start 后面的元素都将被删除(含第 start 位)。
如果 deleteCount 被省略了,或者它的值大于等于array.length - start(也就是说,如果它大于或者等于start之后的所有元素的数量),那么start之后数组的所有元素都会被删除。
如果 deleteCount 是 0 或者负数,则不移除元素。这种情况下,至少应添加一个新元素。
item1, item2, … 可选
要添加进数组的元素,从start 位置开始。如果不指定,则 splice() 将只删除数组元素。


🍞fun.call(thisArg, arg1, arg2, ...)

call()方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。
注意:该方法的语法和作用与 apply() 方法类似,只有一个区别,就是 call() 方法接受的是一个参数列表,而 apply() 方法接受的是一个包含多个参数的数组。
thisArg
fun 函数运行时指定的 this 值。需要注意的是,指定的 this 值并不一定是该函数执行时真正的 this 值,如果这个函数在非严格模式下运行,则指定为 nullundefinedthis 值会自动指向全局对象(浏览器中就是 window 对象),同时值为原始值(数字,字符串,布尔值)的 this 会指向该原始值的自动包装对象。
arg1, arg2, …
指定的参数列表。


🍞regexObj.test(str)

test() 方法执行一个检索,用来查看正则表达式与指定的字符串是否匹配。返回 truefalse