36

许多前端框架(如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
  • 重新计算pricequantity的乘积,更新页面
  • 调用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,以便在pricequantity变化时,重新运行。

解决

首先我们需要告知应用“下面我要运行的代码先保存起来,我可能在别的时间还要运行!”之后但我们更新代码中pricequantity的值时,之前存储的代码会被再次调用。

// 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

正如我们所期待的那样,pricequantity现在是响应式的了!当pricequantity更跟新时,被监听函数会被重新执行!
现在你应该可以理解Vue文档中的这张图片了吧!
图片描述
看到图中紫色数据圈gettersetter吗?看起来应该很熟悉!每个组件实例都有一个watcher实例(蓝色),它从getter(红线)收集依赖项。稍后调用setter时,它会通知观察者导致组件重新渲染。下图是一个我注释后的版本。
图片描述
虽然Vue实际的代码愿彼此复杂,但你现在知道了基本的实现了。

那么回顾一下

  • 我们创建一个Dep类来收集依赖并重新运行所有依赖(notify)
  • watcher函数来将需要监听的匿名函数,添加到target
  • 使用Object.defineProperty()去创建gettersetter

这是上帝的杰作
2.2k 声望164 粉丝

//loading...