许多前端框架(如Angular,React,Vue)都有自己的响应式引擎。通过理解如何响应,提议提升你的开发能力并能够更高效地使用JS框架。本文中构建的响应逻辑与Vue的源码是一毛一样的!
响应系统
初见时,你会惊讶与Vue的响应系统。看看以下面这些简单代码
<div id="app">
<div>Price:${{price}}</div>
<div>Total:${{price*quantity}}</div>
<div>Taxes:${{totalPriceWithTax}}</div>
</div>
<script src="https://cdn.jsdeliver.net/npm/vue"></script>
<script>
var vm = new Vue({
el: '#app',
data: {
price: 5.00,
quantity: 2,
},
computed: {
totalPriceWithTax(){
return this.price * this.quantity * 1.03
}
}
})
</script>
- 更新页面上的
price
- 重新计算
price
与quantity
的乘积,更新页面 - 调用
totalPriceWithTax
函数并更新页面
等等,你可能会疑惑为何Vue知道price
变化了,它是如何跟踪所有的变化?
这并非日常的JS编程会用到的
如果你疑惑,那么最大的问题是业务代码通常不涉及这些。举个例子,如果我运行下面代码:
let price = 5
let quantity = 2
let total = price *quantity
price = 20
console.log(`total is ${total}`)
即便我们从未使用过Vue,我们也能知道会输出10。
>> total is 10
更进一步,我们想要在price和quantity更新时
total is 40
遗憾的是,JS是一个程序,看着它它也不会变成响应式的。这时我们需要coding
难题
我们需要存储计算的total
,以便在price
或quantity
变化时,重新运行。
解决
首先我们需要告知应用“下面我要运行的代码先保存起来,我可能在别的时间还要运行!”之后但我们更新代码中price
或quantity
的值时,之前存储的代码会被再次调用。
// save code
let total = price * quantity
// run code
// later on rung store code again
所以通过记录函数,可以在变量改变时多次运行:
let price = 5
let quantity = 2
let total = 0
let target = null
target = function(){
total = price * quantity
}
record() // 稍后执行
target()
注意target
存储了一个匿名函数,不过如果使用ES6的箭头函数语法,我们可以写成这样:
target = () => {
total = price * quantity
}
然后我们再简单滴定义一下record
函数:
let storage = [] //在starage 中存放target函数
function record(){
storage.push(target)
}
我们存储了target(上述例子中就是{ total = price * quantity }),我们在稍后会用到它,那时使用target,就可以运行我们记录的所有函数。
function target(){
storage.forEach(run => run())
}
遍历storage执行其中存储的所有的匿名函数。在代码中我们可以这样:
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
足够简单吧!如果你想看看目前阶段完整的代码,请看:
let price = 5
let quantity = 2
let quantity = 0
let target = null
let storage = []
function record () {
storage.push(target)
}
function replay() {
storage.forEach(run => run())
}
target = () => {
total = price * quantity
}
record()
target()
price = 20
console.log(total) // => 10
replay()
console.log(total) // => 40
难题
功能虽然可以实现,但是代码似乎不够健壮。我们需要一个类,来维护目标列表,在需要重新执行时来通知执行。
解决
通过将所需要的方法封装成一个依赖类,通过这个类实现标准的观察者模式。
如果我们使用一个类来管理相关依赖,(这很接近VUE的表现方式)代码看起来就像下面这样:
class Dep {
constructor(){
this.subscribers = []
}
depend() {
if(target && !this.subscribers.includes(target)){
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
你会发现现在匿名函数被储存在subscribers
而不是原来的storage
。同时,现在的记录函数叫做depend
而不是record
,通知函数是notify
而非replay
。看看他们执行情况:
const dep = new Dep()
let price = 5
let quantity = 2
let quantity = 0
let target = () => {
total = price * quantity
}
dep.depend() //将target添加进subscribers
target() //执行获取total
price = 20
console.log(total) // => 10
dep.notify()
console.log(total) // => 40
现在代码的复用性已经初见端倪,但是还有一件别扭的事,我们还需要配置与执行目标函数。
难题
以后我们会为每个变量创建一个Dep类,对此我们应该使用一个watcher函数来监听并更新数据,而非使用这样的方式:
let target = () => {
total = price * quantity
}
dep.depend()
target()
期望中的代码应该是:
watcher(() => {
total = price * quantity
})
解决 实现watcher函数
在watcher函数中我们做了下面这些事:
function watcher(myFunc){
target = myFunc
dep.depend()
target()
target = null
}
如你所见,watcher接受一个myFunc作为参数,将其赋值给全局变量target,并将它添加微订阅者。在执行target后,重置target为下一轮做准备!
现在只需要这样的代码
price = 20
console.log(total) // => 10
dep.notify()
console.log(total) // => 40
你可能会疑惑为什么target是一个全局变量的形式,而非作为一个参数传入。这个问题在结尾处会明朗起来!
难题
现在我们拥有了一个简单的Dep类,但我们真正想要的是每个变量都能拥有一个自己的Dep类。先让我们把之前讨论的特性变成一个对象吧!
let data = { price: 5,quantity: 2}
我们先假设,每个属性都有自己的Dep类:
现在我们运行
watcher(() => {
totla = data.price * data.quantity
})
由于total需要依赖price和quantity两个变量,所以这个匿名函数需要被写入两者的subscriber数组中!
同时如果我们又有一个匿名函数,只依赖data.price,那么它仅需要被添加进price的dep的subscriber数组中
但我们改变price的值时,我们期待dep.notify()被执行。在文章的最末,我们期待能够有下面这样的输出:
>> total
10
>> price =20
>> total
40
所以现在我们需要去挂载这些属性(如quantity和price)。这样当其改变时就会触发subscriber数组中的函数。
解决 Object.defineProperty()
我们需要了解Object.defineProperty函数
ES5种提出的,他允许我们为一个属性定义getter与setter函数。在我们把它和Dep结合前,我先为你们演示一个非常基础的用法:
let data = { price: 5,quantity: 2}
Object.defineProperty(data,'price',{
get(){
console.log(`Getting price ${internalValue}`);
return internalValue
}
set(newValue){
console.log(`Setting price ${newValue}`);
internalValue = newValue
}
})
total = data.price * data.quantity // 调用get
data.price = 20 // 调用set
现在当我们获取并设置值时,我们可以触发通知。通过Object.keys(data)返回对象键的数组。运用一些递归,我们可以为数据数组中的所有项运行它。
let data = { price: 5,quantity: 2}
Object.keys(data).forEach((key) => {
let internalValue = data[key]
Object.defineProperty(data, key,{
get(){
console.log(`Getting ${key}:${internalValue}`);
return internalValue
}
set(newValue){
console.log(`Setting ${key} to ${newValue}`);
internalValue = newValue
}
})
})
total = data.price * data.quantity
data.price = 30
现在你可以在控制台上看到:
Getting price: 5
Getting quantity: 20
Setting price to 30
成亲了
total = data.price * data.quantity
类似上述代码运行后,获得了price的值。我们还期望能够记录这个匿名函数。当price变化或事被赋予了一个新值(译者:感觉这是一回事)这个匿名函数就会被促发。
Get => 记住这个匿名函数,在值变化时再次执行!
Set => 值变了,快去执行刚才记下的匿名函数
就Dep而言:
Price被读 => 调用dep.depend()保存当前目标函数
Price被写 => 调用dep.notify()去执行所有目标函数
好的,现在让我们将他们合体,并祭出最后的代码。
let data = {price: 5,quantity: 2}
let target = null
class Dep {
constructor(){
this.subscribers = []
}
depend() {
if(target && !this.subscribers.includes(target)){
this.subscribers.push(target)
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
Object.keys(data).forEach((key) => {
let internalValue = data[key]
const dep = new Dep()
Object.defineProperty(data, key,{
get(){
dep.depend()
return internalValue
}
set(newValue){
internalValue = newValue
dep.notify()
}
})
})
function watcher(myFunc){
target = myFunc
target();
target = null;
}
watch(() => {
data.total = data.price * data.quantity
})
猜猜看现在会发生什么?
>> data.total
10
>> data.price = 20
20
>> data.total
40
>> data.quantity = 3
3
>> data.total
60
正如我们所期待的那样,price
和 quantity
现在是响应式的了!当price
和 quantity
更跟新时,被监听函数会被重新执行!
现在你应该可以理解Vue文档中的这张图片了吧!
看到图中紫色数据圈getter
和setter
吗?看起来应该很熟悉!每个组件实例都有一个watcher实例(蓝色),它从getter(红线)收集依赖项。稍后调用setter时,它会通知观察者导致组件重新渲染。下图是一个我注释后的版本。
虽然Vue实际的代码愿彼此复杂,但你现在知道了基本的实现了。
那么回顾一下
- 我们创建一个Dep类来收集依赖并重新运行所有依赖(notify)
- watcher函数来将需要监听的匿名函数,添加到target
- 使用Object.defineProperty()去创建
getter
和setter
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。