查看原文
其他

【第1418期】JavaScript 响应式原理的最佳解释

花生 前端早读课 2019-05-31

前言

第二届Vue开发者大会,你有关注吗?今日早读文章由汽车之家@花生翻译投稿分享。

译者注:本文旨在解释响应式的原理(也可以说数据双向绑定的实现),虽然话题较老,但部分观点很奇特。

@花生,就职于汽车之家用户产品中心团队,云云搬砖码农的中的一员。

正文从这开始~~

🚀 开始

许多前端的JavaScript框架(例如Angular,React和Vue)都有自己的响应式引擎。了解其响应原理及工作原理,有助于提升自己并更加有效地使用框架。在下面的文章中,模拟了Vue源码中的响应原理,我们一起来看一下。

💡 响应式

当你第一次看到Vue工作时,你会觉得它很神奇。以这个简单的Vue应用程序为例:

<div id="app">
 
<div>Price: ${{ price}}</div>
 
<div>Total: ${{ price * quantity }}</div>
 
<div>Taxes: ${{ totalPriceWithTax}}</div>
</div>
<script>
 
var vm = new Vue({
   el
: '#app',
   data
: {
     price
: 5.00,
     quantity
: 2
   
},
   computed
: {
     totalPriceWithTax
() {
       
return  this.price * this.quantity * 1.03
     
}
   
}
 
})
</script>

当price发生变化后,Vue会做三件事:

  • 在页面上更新price的值。

  • 重新计算price * quantity的值,并更新页面。

  • 再次调用totalPriceWithTax,并更新页面。

不过这并不重要,重要的是当price变化时,Vue怎么知道该更新什么,以及它是如何跟踪所有内容的?

通常的JavaScript代码是实现不了这样的功能的。那么我们看下边的代码:

let price = 5
let quantity
= 2
let total
= price * quantity // => 10
price
= 20
console
.log(`total is ${total}`)

它会打印10:

>> total is 10

在Vue中,我们希望price或quantity变化后total跟着更新,我们想要的是如下的结果:

>> total is 40

不巧的是,JavaScript不是响应式的,所以我们没有得到想要的结果。这时候我们就得想点办法,来达到我们的目的。

⚠️ 问题一

我们需要保存计算total的方法,以便在price或quantity发生变化时再一次调用。

✅ 解决方案

首先,我们需要一些方法来告诉我们的应用程序,“存储我将要调用的这段代码,我可能会在其他时间再次调用。”紧接着我们来执行这段代码,当price或quantity变量更新后,再次运行之前存储的代码。

我们可以创建一个记录函数来保存我们要的东西,这样我们就可以再次调用它:

let price = 5
let quantity
= 2
let total
= 0
let target
= null
target
= () => { total = price * quantity }
record
()
target
()

注意,我们在target变量中存储一个匿名函数,然后调用record函数。record的定义很简单:

let storge = [] // 用来存储target
// 记录函数
function record (){
 storge
.push(target)
}

我们已经保存了target({total = price * quantity}),因此我们可以之后再运行它,这时我们可以使用一个replay函数,来运行我们所记录的所有内容。

function replay (){
 storge
.forEach(run => run())
}

这将遍历存储在storage这个数组中的所有匿名函数,并执行每个函数。

然后就会变成这样:

price = 20
console
.log(total) // => 10
replay
()
console
.log(total) // => 40

下面是完整的代码,你可以通读以方便理解:

let price = 5
let quantity
= 2
let total
= 0
let target
= null
let storge
= [] // 用来存储target
// 记录函数
function record (){
 storge
.push(target)
}
function replay (){
 storge
.forEach(run => run())
}
target
= () => { total = price * quantity }
record
()
target
()
price
= 20
console
.log(total) // => 10
replay
()
console
.log(total) // => 40

⚠️ 问题二

我们可以根据需要,继续记录target这类的代码,但最好有一个一劳永逸的办法。

✅ 解决方案:依赖类

我们来解决这个问题的方法是将这种行为(target这种匿名函数)封装到它自己的类中,这是一个标准编程中实现观察者模式的依赖类

因此,如果我们创建一个JavaScript类来管理我们的依赖项(使它更接近Vue的处理方式),就像这样:

class Dep { // 例子
 constructor
() {
   
this.subscribers = [] //  替代之前的storage
 
}
 depend
() {  //  替代之前的record
   
if (target && !this.subscribers.includes(target)) {
     
this.subscribers.push(target)
   
}
 
}
 notify
() { // 替代之前的replay
   
this.subscribers.forEach(sub => sub())  //  运行我们的target或观察者
 
}
}

注意,我们现在将匿名函数存储在subscribers中,而不是storage,record也变成了depend,使用notify来代替replay,然后就会变成这样:

const dep = new Dep()
let price
= 5
let quantity
= 2
let total
= 0
let target
= () => { total = price * quantity }
dep
.depend() // target添加到subscribers中
target
() // 运行并得到total
console
.log(total) // => 10
price
= 20
console
.log(total) // => 10
dep
.notify()  // 调用subscribers里存储的target
console
.log(total) // => 40

改了命名,依旧可以运行,但更适合复用。唯一有点别扭的就是target的存储和调用。

⚠️ 问题三

我们会为每个变量创建一个依赖类,并且对创建匿名函数的行为进行封装,从而做到响应式。而不是像这样调用(这是上面的部分代码):

target = () => { total = price * quantity }
dep
.depend()
target
()

我们可以改为:

watcher(() => {
 total
= price * quantity
})

✅ 解决方案: 监听函数(观察者模式)

在我们的监听函数中,我们可以做一些简单的事情:

function watcher(myFun) {
 target
= myFun
 dep
.depend()
 target
()
 target
= null
}

正如你所看到的,watcher函数接受myFunc参数,将其赋给全局的target上,调用dep.depend()将其添加到subscribers里,之后调用并重置target。

运行下面的代码:

price = 20
console
.log(total)
dep
.notify()
console
.log(total)

输出:

>> 10
>> 40

还有个问题没有说,为什么我们将target设置为全局变量,而不是在需要的时候将其传递到函数中。这个答案,请在后边的内容里寻找。

⚠️ 问题四

我们有一个Dep class,但我们真正想要的是每个变量都有它自己的依赖类,我们把每个属性都放到一个对象里。

let data = { price: 5, quantity: 2 }

假设一下,我们的每个属性(price和quantity)都有自己的依赖类。

运行下面的代码:

watcher(() => {
 total
= data.price * data.quantity
})

因为data.price值被访问,我希望price属性的依赖类将我们存储在target中的匿名函数,通过调用dep.depend()将其推到它的订阅者(用来存储target)数组中。

同理,因为data.quantity被访问,我同样希望quantity属性的依赖类将这个存储在target中的匿名函数推入其订阅者(用来存储target)数组中。

如果我有另一个匿名函数,里边只是data.price被访问,我希望只是将其推送到price属性的依赖类中。

我们需要在price更新的时候,来调用dep.notify(),我们想要的结果就是这样的:

console.log(total) // >> 10
price
= 20 // 此时,需要调用price上的notify()
console
.log(total) // >> 40

我们需要一些方法来连接data里的属性(如price或quantity),所以当它被访问时,我们可以将target保存到我们的订阅者数组中,当它被改变时,运行我们存储在订阅者数组中的函数。

✅ 解决方案: Object.defineProperty()

我们需要了解下ES5中的Object.defineProperty()函数。它可以为属性定义getter和setter函数。让我们看一下它的基本用法:

let data = { price: 5, quantity: 2 }
Object.defineProperty(data, 'price', {
 
get() {
   console
.log(`I was accessed`)
 
},
 
set(newVal) {
   console
.log(`I was changed`);
 
}
})
data
.price // 调用get() >> I was accessed
data
.price = 20 // 调用set() >> I was changed

如你所见,控制台有两行输出,但是,它实际上并没有get或set任何值,因为我们的用法并不合理。我们现在将其恢复,get()方法返回一个值,set()方法更新一个值,我们添加一个变量internalValue来存储我们当前的price。

let data = { price: 5, quantity: 2 }
let internalValue
= data.price // 初始的值
Object.defineProperty(data, 'price', {
 
get() {
   console
.log(`Getting price: ${internalValue}`)
   
return internalValue
 
},
 
set(newVal) {
   console
.log(`Setting price to: ${newVal}`);
   internalValue
= newVal
 
}
})
total
= data.price * data.quantity  // 调用get() >> Getting price: 5
data
.price = 20 // 调用set()  >> Setting price to: 20

当我们的get和set正常工作时,控制台输出的结果也不会出现其他可能。

所以,当我们获取和设置值时,我们就可以得到我们想要的通知。通过一些递归,我们可以为data内的所有属性运行Object.defineProperty。这时候就可以用到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(newVal) {
     console
.log(`Setting ${key} to: ${newVal}`);
     internalValue
= newVal
   
}
 
})
})
total
= data.price * data.quantity
data
.price = 20

现在每个属性都有了get和set,控制台的输出很好的证实了这一点。

Getting price: 5
Getting quantity: 2
Setting price to: 20

🛠 结合这两个想法

total = data.price * data.quantity

当这段代码运行并获取price值时,我们希望price记住这个匿名函数(target)。这样,如果price发生变化或者被赋新值时,它就会重新触发这个函数,因为它知道这一行依赖于它。你可以这样理解。

Get =>记住这个匿名函数,当我们的值发生变化时,我们会再次运行它。

Set =>运行保存的匿名函数,我们的值发生改变。

或者就我们的Dep Class而言

访问price (get) => 调用dep.depend()以保存当前target

修改price (set) => 用price调用dep.notify(), 重新运行全部的targets

让我们结合这两个想法,然后看看我们的最终代码:

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(newVal) {
     internalValue
= newVal
     dep
.notify()
   
}
 
})
})
function watcher(myFun) {
 target
= myFun
 target
()
 target
= null
}
watcher
(() => {
 data
.total = data.price * data.quantity
})

现在我们在控制台试一试:

正是我们所希望的!每当price或quantity更新时,我们的total都会赋值。这个来自Vue文档的插图的意义就很明显了。

你看到那个带getter和setter的data圈(紫色)了吗?看起来应该很眼熟!每个组件实例都有一个watcher实例(蓝色圈),它从getter中收集(红线)依赖项。稍后调用setter时,它会通知监视器,从而实现重新渲染的功能。下边是我修改后的插图:

显然,Vue在幕后做的更为复杂,但你现在已经对其原理有所了解了。

⏪ 总结

  • 如何创建一个Dep class来收集依赖项(depend)并重新运行所有依赖项(notify)。

  • 如何创建一个watcher来监听我们正在运行的代码,可能需要保存这些代码(target)并添加为依赖项。

  • 怎样使用Object.defineProperty()创建getter和setter。

关于本文
译者:@花生
作者:@Gregg Pollack
原文:
https://medium.com/vue-mastery/the-best-explanation-of-javascript-reactivity-fea6112dd80d

最后,为你推荐

【活动】第二届Vue.js开发者大会,11月24号杭州见!

【第1397期】如何在 JavaScript 中更好地使用数组


【第1394期】JavaScript 2018 中即将迎来的新功能

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存