目录
- 数据响应式
- 属性描述符
- propertyResponsive
- 依赖收集
- 依赖队列
- 寻找依赖
- 观察器
- 派发更新
- Observer
- 完整代码
- 关于数据响应式
- 关于Object.defineProperty的限制
数据响应式
假设我们现在有这么一个页面
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>p {font-family: '幼圆';font-size: 20px;}</style>
</head><body><p class="firstName">姓:<span></span></p><p class="lastName">名:<span></span></p><p class="sex">性别:<span></span></p><script>const info = {name: "贝蒂小熊",sex: "男"}function renderFirstName() {const firstName = document.querySelector(".firstName>span")firstName.innerHTML = info.name.length > 3 ? info.name.slice(0, 2) : info.name[0]}function renderLastName() {const lastName = document.querySelector(".lastName>span")lastName.innerHTML = info.name.length > 3 ? info.name.slice(2) : info.name.slice(1)}function renderSex() {const sex = document.querySelector(".sex>span")sex.innerHTML = info.sex}renderFirstName()renderLastName()renderSex()</script>
</body></html>
它的页面显示如下
我们可以发现,页面显示的内容实际上是由我们预先定义的数据决定
的,页面本身也不会具有任何数据,此时的页面与数据是高度一致
的
如果我们将数据更改了会怎么样
info.name = "牢大"
界面却并没有及时的同步显示
我们可以说解决这个问题十分简单,直接调用renderFirstName
和renderLastName
函数就行了
info.name = "牢大"
renderFirstName()
renderLastName()
可是为什么更改了name
我们就需要调用renderFirstName
和renderLastName
这两个函数?
我们可以从逻辑上说name
的改变会让一个人的姓和名也跟着变更,而一个人的性别却并不和姓名相关,所以不用调用renderSex
函数,那如果我们将renderSex
的函数修改成以下这样呢
function renderSex() {const sex = document.querySelector(".sex>span")text = info.name === "贝蒂小熊" ? "赛马娘" : "肘击王"sex.innerHTML = info.sex + " - " + text
}
此时的sex
依旧是男
,没有改变,sex
和name
在逻辑上也没有强相关的联系,那么此时应该要调用renderSex
函数吗
似乎有哪里不对,可见除了从逻辑层面解释在哪些属性被修改时应该调用哪些函数之外还可以通过其他方面解释
我们再来看下面这个例子
const obj = {a: "value",b: 1,c: new Symbol(),d: {key: "key"}
}
function e() {//相关操作......
}
function f() {//相关操作......
}
function g() {//相关操作......
}
function h() {//相关操作......
}
此时无论是obj
还是相关的四个函数全是无意义的脏数据
,在逻辑上没有任何关联
,但每个函数都调用了obj
里的某一个属性
,我们并不知道哪些函数调用了哪些属性,那么我们该怎么确定在obj
里的属性被改变时该调用哪些函数
呢
答案其实很简单,当某一个函数访问了某一个属性,那么这个属性被改变时这个函数就需要同步重新运行,无论这个属性与函数在逻辑上是否相关联,一个函数可以访问多个属性,一个属性可以被多个函数访问,函数在运行期间可能会修改多个属性,多个属性被修改会带动更多的函数运行…
这种解决方案我们通常称之为响应式编程,也被称之为数据响应式
那么新的问题又出来了,我们如何记录哪些属性被哪些函数访问了
呢
属性描述符
我们在学习属性描述符
的时候我们学过两个存取属性描述符
,分别是set
和get
,set
会在属性被设置时调用
,get
会在属性被读取时调用
,我们能不能在这两个描述符上完成函数收集
与函数运行
的操作呢?
propertyResponsive
我们定义一个函数用来重写属性的set
和get
描述符
function propertyReponsive(obj, key) {}
这个函数需要传递两个参数,obj
为需要监控的对象,key
为具体监控的属性
我们首先需要获得原属性的值
function propertyReponsive(obj, key) {let _value = obj[key]
}
然后我们需要拦截
原本的get
和set
操作
function propertyReponsive(obj, key) {let _value = obj[key]Object.defineProperty(obj, key, {get() {return _value},set(newValue) {_value = newValue}})
}
现在我们就需要在get
中收集函数
,在set
中调用函数
依赖收集
在get
中收集函数
的这个环节,我们通常称之为依赖收集
,即收集依赖该属性的函数
那么什么是依赖
呢
依赖
简单的来说就是函数在运行期间用到了哪些属性
,就被称之为函数依赖于哪些属性
与依赖收集
对应的操作叫做派发更新
,意思也能简单,就是将收集到的函数重新再运行一遍
就是派发更新
那么现在我们就有了一个新问题,这些依赖
收集到哪呢
依赖队列
我们可以定义一个依赖队列
,专门用来维护各个属性的依赖函数
,这个依赖队列
可以简单的就定义为一个数组
,但为了日后的可维护和可扩展
,我们将其定义为一个类
,这个类的名字就命名为Dep
class Dep {constructor() {this.subs = new Set()}addSub(sub) {this.subs.add(sub)}
}
subs
是一个set
集合,专门用来存放依赖
,之所以定义成set
而不是数组
是因为考虑到了依赖可能会重复
的情况
我们现在虽然解决了如何存放依赖
,那我们怎么才能找到依赖
呢
寻找依赖
我们不妨转变一下思路,我们为什么无法寻找到依赖,因为函数的运行位置我们无法掌握
,函数会通过各种各样的方式被调用运行,我们能不能规定每次调用函数时必须在某个特定的地方调用
,这个地方可以是一个全局变量
,可以是全局对象上的一个属性
,在每次调用函数前函数必须要存放到这个指定的地方
来调用,调用完之后再将函数移除
留待其他函数调用
使用以上方案的话我们在Dep
中寻找依赖
就只需要监听特定变量/属性
就能获得依赖
class Dep {constructor() {this.subs = new Set()}addSub(sub) {this.subs.add(sub)}depend() {if (window.target)this.addSub(window.target)}
}
depend
方法用来在每次属性get
操作被调用时收集当前依赖
并存放到subs
我们先不去考虑如何在每次函数调用
前将函数
存放到特定的地方,只考虑依赖队列
的话这么写无疑能获取依赖
在依赖收集
后我们还需要在属性变更后及时派发更新
class Dep {constructor() {this.subs = new Set()}addSub(sub) {this.subs.add(sub)}depend() {if (window.target)this.addSub(window.target)}notify() {for (const sub of this.subs) {sub()}}
}
notify
方法用于在属性set
操作被调用时将sub里的依赖全部执行一遍
基于此我们就能实现依赖的收集
了,最后我们再修改一下propertyResponse
函数
function propertyReponsive(obj, key) {let _value = obj[key]let dep = new Dep()Object.defineProperty(obj, key, {get() {dep.depend()return _value},set(newValue) {_value = newValuedep.notify()}})
}
观察器
在之前的代码中我其实还遗留了一个问题,就是我们如何将函数
放入window.target
中,我们显然不能在每次函数调用前手动的将函数存放在window.target中,在函数运行结束后再将其移除
我们或许可以封装一个函数
来协助我们做这件事
function watcher(fn) {window.target = fnfn()window.target = null
}
这么写虽然也能实现功能,但不利于日后的维护与扩展
,我们还是将其写成一个类
class Watcher {constructor(fn, vm, ...args) {this.fn = fnthis.vm = vmthis.args = argswindow.target = thisfn.call(this.vm, this.args)window.target = null}
}
实例化一个Watcher
对象需要传递三个参数,一个函数
,一个当前函数对应的上下文
,一个为函数运行时所需的参数
值得注意的是此时window.target
存放的不再是函数
,而是一个Watcher对象
,为什么不直接存放函数
呢,因为如果存放函数
的话this
和参数
都有可能会发生错误,所以综合考虑才传递一个Watcher对象
当sub
不再是一个函数
时,这意味着在依赖队列
里不能再通过简单粗暴的sub()
来派发更新
了,那该怎么解决呢
派发更新
我们或许可以在Watcher
中定义一个方法,由这个方法来负责此函数的更新操作
,在依赖队列
中我们只需要调用这个方法
就能完成派发更新
class Watcher {constructor(fn, vm, ...args) {this.fn = fnthis.vm = vmthis.args = argswindow.target = thisfn.call(this.vm, this.args)window.target = null}update() {this.fn.call(this.vm, this.args)}
}
update
方法负责重新将函数执行一遍
Watcher
改好了还需要修改Dep
class Dep {constructor() {this.subs = new Set()}addSub(sub) {this.subs.add(sub)}depend() {if (window.target)this.addSub(window.target)}notify() {for (const sub of this.subs) {sub.update()}}
}
Observer
现在,以上的代码已经能实现监测一个对象上的一个属性
的数据响应式
功能了,但如果我们需要监听一个对象的全部属性
,乃至全部的子属性
,我们就需要继续封装一个函数
来解决
这里我们还是通过类
的方式实现
class Observer {constructor(obj) {this.data = objif (!Array.isArray(this.data))this.walk()}walk() {for (const key in this.data) {propertyReponsive(this.data, key)}}
}
在Observer
中因为Object.defineProperty
只能监测对象
,对于数组
并不能监测,所以我们在执行walk
之前需要对类型进行判断
我们接下来修改propertyResponse
函数以支持递归监测
function propertyReponsive(obj, key) {let _value = obj[key]if (typeof _value === "object") new Observer(_value)let dep = new Dep()Object.defineProperty(obj, key, {get() {dep.depend()return _value},set(newValue) {_value = newValuedep.notify()}})
}
完整代码
到此为止我们就将整个数据响应式
写完了,我们最后来看看效果
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><style>p {font-family: '幼圆';font-size: 20px;}</style>
</head><body><p class="firstName">姓:<span></span></p><p class="lastName">名:<span></span></p><p class="sex">性别:<span></span></p><input type="text" onchange="this.value===''? info.name='贝蒂小熊': info.name=this.value"><script>class Watcher {constructor(fn, vm, ...args) {this.fn = fnthis.vm = vmthis.args = argswindow.target = thisfn.call(this.vm, this.args)window.target = null}update() {this.fn.call(this.vm, this.args)}}class Dep {constructor() {this.subs = new Set()}addSub(sub) {this.subs.add(sub)}depend() {if (window.target)this.addSub(window.target)}notify() {for (const sub of this.subs) {sub.update()}}}class Observer {constructor(obj) {this.data = objif (!Array.isArray(this.data))this.walk()}walk() {for (const key in this.data) {propertyReponsive(this.data, key)}}}function propertyReponsive(obj, key) {let _value = obj[key]if (typeof _value === "object") new Observer(_value)let dep = new Dep()Object.defineProperty(obj, key, {get() {dep.depend()return _value},set(newValue) {_value = newValuedep.notify()}})}</script><script>const info = {name: "贝蒂小熊",sex: "男"}function renderFirstName() {const firstName = document.querySelector(".firstName>span")firstName.innerHTML = info.name.length > 3 ? info.name.slice(0, 2) : info.name[0]}function renderLastName() {const lastName = document.querySelector(".lastName>span")lastName.innerHTML = info.name.length > 3 ? info.name.slice(2) : info.name.slice(1)}function renderSex() {const sex = document.querySelector(".sex>span")sex.innerHTML = info.sex}new Observer(info)new Watcher(renderFirstName, window)new Watcher(renderLastName, window)new Watcher(renderSex, window)</script>
</body></html>
关于数据响应式
最后我们再来谈谈什么是数据响应式
粗犷的来说,当数据改变时页面会自动的根据数据的变化来变化,而这背后其实是当数据改变时,依赖此数据的函数会同步执行,数据响应式的本质就是依赖收集和派发更新,依赖收集即将数据与被监听的函数关联起来,派发更新即重运行依赖关系的函数,核心就是拦截getter和setter
关于Object.defineProperty的限制
因为Object.defintProperty只能监听单个属性的读取修改操作,当新增属性或者删除属性时无法监听
另外Object.defineProperty也无法监听数组的变化,所以以上两种情况都需要单独监听,而如果使用ES6中的Proxy和Reflect就能很好的处理以上的情况了