自己来实现观察者模式

自己来实现观察者模式

今天写自己一个小demo时使用mvc的时候,想自己来实现观察者模式
也就是view层会观察model层数据的变化,相应渲染出改变后的数据

使用发布订阅模式

最开始想的办法是自己model拥有多个修改model.data的方法,如fetch(),patch(),delete(),post(),只需要每个方法里加一句

1
event.emit('dataChanged',this.data)

就能通知在监听的view层进行渲染了

不过有点不优雅,我需要在每个改变数据的方法后都加一句,很麻烦。

使用Object.defineProperty来自动化

之后想到了ES5提供的**Object.defineProperty**提供的getset存取描述符,对这个属性的访问和读取会分别触发getset函数, 也就是如果对data进行设置,就会触发set函数。

get()set()又是什么?

我们平常取值或者赋值都是这样的

1
2
data.val = 1 //现在val = 1
data.val //得到 val的值

在这个过程中,val就是一个值,获得val和修改val总是相等的,修改成多少,之后就会得到多少。

不过js是门很奇怪的语言,有时候会发现获得的值和修改的值不一样,比如cookie

1
2
3
document.cookie //xxx=111; yyy=222;
document.cookie = "zzz=333"
document.cookie //xxx=111; yyy=222; zzz=333

看这里,赋值和取值并不相等,就像是自己调用的是方法一样

1
2
3
document.cookie.get() //xxx=111; yyy=222
document.cookie.set("zzz=333")
document.cookie.get() //xxx=111; yyy=222; zzz=333

似乎是这样的感觉。
原理也就是这样,在js中我们可以把赋值这个过程当成函数调用
赋值调用set函数 ,取值使用get函数。

ES5就提供了Object.defineProperty来自己定义赋值和取值得行为
Object.defineProperty接受三个参数,要定义的对象,要定义的键名,定义行为的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var cookieList = []
Object.defineProperty(document,'myCookie',{
get(){
let cookieString = ''
cooieList.forEach((value)=>{
cookieString+value+'; '
})
return cookieString
},
set(value){
cookieList.push(value)
return value
}
})

document.myCookie // "" 空字符串
document.myCookie = "aaa=111"
document.myCookie //"aaa=111; "
document.myCookie = "bbb=222"
document.myCookie //"aaa=111; bbb=222"

这就是我们仿写的一个cookie,取值变成了调用get函数,赋值变成了调用set()函数
ES6有了定义对象的语法糖,也更简洁明了些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let firstName = 'Barack'
let lastName = 'Obama'

let person = {
get name() { return firstName + ' ' + lastName },
set name(value) {
let nameArray = value.split(' ')
firstName = nameArray[0]
lastName = nameArray[1]
return value
}
}

person.name //Barack Obama
person.name = 'Karl Marx'
firstName //Karl
lastName //Marx

其实在这里设置getset, 有点像java, java中一切皆对象,类中常常有私有变量不能被外部访问,就通过getName()setName()暴露api
通过这种方式,就能对赋值和取值进行控制,比如赋值的时候检测值是否合法这些,js就直接省略了写函数这部分,直接就能把赋值、取值的行为当作函数调用。
不过这种也很误导,赋值和取值竟然会不相等,那为什么不直接告诉我这是一个方法?实际情况下不应该胡乱使用getset


另外查了下,语法似乎更像c#

1
2
3
4
5
6
Class example
{
public string a {get;set;}
public string b {get;set;}
public string c {get;set;}
}

好了,回到正题,现在我们可以在赋值时,通知View层数据改变了

1
2
3
4
5
6
7
8
9
let model = {}
Object.defineProperty(model,'data',{
set(){
//do xxxxx
event.emit('dataChanged',this.data)
}
})

model.data = 'xxx' //触发set()

不过马上就发现Object.defineProperty 并不怎么好用

  1. get set存取描述符会导致递归问题

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let model = {}
    Object.defineProperty(model,'data',{
    set(value){
    this.data = value
    event.emit('dataChanged',this.data)
    }
    })

    model.data //递归调用 页面会卡死

    解决办法很简单,不使用data就可以了,比如使用this._data,或者使用闭包,利用外部环境的变量。

  2. 如果model.data的值不是原始类型,而是对象,那么对model.data对象的的更改不会触发set()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    let model = {}
    Object.defineProperty(model,'data',{
    get(){
    console.log('get')
    return this._data
    },
    set(value){
    this._data = value
    console.log('set')
    return this._data
    }
    })

    model.data //get
    model.data.push({a:1}) //get
    let data = model.data //get
    data.push({b:2}) //不会调用get set
    data //不会调用get

    在实际情况中,往往data都是一堆对象,存储的是引用,解决办法大概可以判断是否是对象,如果是,就递归的定义getset,或者判断这是否是一个会更改data的函数,总而言之是件挺麻烦的事

  3. Object.defineProperty只能设置已知的属性,不能对未知的属性进行设置
    这就导致上面递归解决方案行不通,因为在运行时定义的属性的key是未知的,以就无法设置get,set

使用Proxy来更优雅的实现

关于Proxy的说明可以点击这里,Proxy可以看作是Object.defineProperty()的完全升级版,可以拦截各种对对象的操作,其中就包括getset,Vue3就是基于Proxy来实现响应式跟踪的。

1
2
3
4
5
6
7
8
9
10
11
let array = []
let data = new Proxy(array,{
set(target,key,value,receiver){
console.log(`set ${key}`)
return Reflect.set(target,key,value,receiver)
}
})

let model={data}
model.data.push({a:1}) //set 0 set length 这里push操作会修改'0'和'length'
array.push({b:2}) //原对象不会触发set

Proxy翻译为代理,其实挺贴切的,从上面的例子中可以看出,Proxy并不会修改原始对象的行为,而是会生成一个代理对象,用于拦截各种操作。

同时浏览器还提供了Reflect用来更好的扩展默认行为,阮一峰在ES6文中是这样说的:

Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。

这个概念类似于子类重写父类的行为一样,可以让我们方便的在原来逻辑基础上添加一扩展行为。

最后贴下大概的最终代码

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
//能够检测所有变动的核心,递归检测所有赋值为对象的情况,并代理这个对象
function proxyAllobj(target, key, value, receiver,handler) {
console.log(`SET key:${key} value:${value}`)
/*
排除value 为null的情况,因为typeof null === 'object'
如果这个对象已经是被代理的对象了,不重新代理
*/
if ( value && typeof value === 'object' && Reflect.get(target, '_isProxy', receiver) !== true) {
value = new Proxy(value, handler) //使用新的代理对象替换原对象
console.log(`正在代理化${key}`)
let obj = value
for (const newKey in obj) { //递归检测这个对象中的对象
if (obj.hasOwnProperty(newKey)) {
const nextValue = obj[newKey];
proxyAllobj(obj, newKey, nextValue, obj)
}
}
}
return Reflect.set(target, key, value, receiver)
}

const handler = {
get(target, key, receiver) {
console.log(`GET key:${key}`)
if (key === '_isProxy') { //由于无法判断一个对象是否为代理对象,所以使用一个预留字段来检测是否为代理对象
return true
}
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
return proxyAllobj(target, key, value, receiver,handler)
},
}

const data = new Proxy([], handler)

data.push('a')

const ddd = 2
data.push({ ddd })

data[1] = 4
data[1] = { aaa: { hi: 'hi' } }

data[1].aaa.hi = 5 //能够检测到
const test = data[1].aaa
test.q = 'haha' //能够检测到
console.log(data)

这只是一个小demo,能够实现data内任何数据的改变都能检测到,可以复制到浏览器控制台验证,可能还存在bug,实际情况下应该还会涉及更复杂的错误处理还有性能方面的问题

参考文章