响应式JavaScript

译自:The Best Explanation of JavaScript Reactivity

很多响应式 JavaScript 框架(如:AngularReactVue)都有他们自己的响应式引擎。理解了什么是响应式和它是如何工作的,能够很好的提高开发技巧,以及更有效的使用前端 JavaScript 框架。

响应式系统

当第一次看到 Vue 的响应式系统是如何工作的,你会觉得非常神奇。

以下面简单的 Vue App 为例:

price 发生变化时,Vue 知道它应该至少做三件事

  • 更新页面上的 price
  • 重新计算表达式 price * quantity ,然后更新到页面上
  • 再次调用 totalPrice 函数,并更新到页面上

等等,你一定在想: “Vue 是怎么知道 price 何时发生变化的呢?以及,它是如何跟踪这一切的?”

当然,JavaScript 通常可不是这样工作的

如果这对我们来说不是很明显,那么我们面临的最大问题是:JavaScript 语言本身通常不是这样工作的

例如:执行下面代码片段:

我们看到,在计算 total = price * quantity 时,即使下面将 price 设置为 20,结果仍然是 20.

在Vue中,每当价格或数量得到更新时,我们都希望total得到更新,我们希望得到:

1
total is 40

很遗憾,JavaScript 并不是响应式的!所以这个结果在现实中是行不通的。为了让 total 可响应,我们需要让 JavaScript 做一些不同的事情。

问题1

我们需要保存计算 total 的方法,以便当 pricequantity 发生变化时重新运行并得到新的 total

方案

首先,我们需要一些方法来告诉我们的应用程序,“将我要运行的代码保存下来,我可能会在其他时间调用。” 然后我们将运行代码,如果 pricequantity 变量产生变化,再次执行存储下来的代码,进而更新 total

我们可以通过 record 函数来实现这个过程,这样我们就可以再次运行它。

record 函数的定义比较简单:

我们将 targe 函数存储下来(在我们的例子中是:() => { total = price * quantity }),以便后面的调用,我们可能需要一个 replay (回放)函数,来执行我们存储下来的所有 target

因此在我们的代码中,我们可以这样:

很简单,对吧?下面是完整的代码,以便你通读一遍并再次加深理解。

问题2

我们可以根据需要继续进行记录目标( targets ),但是如果我们有一个可扩展且健壮的解决方案,岂不是更好?可能是一个负责维护 targets 列表的类,当需要它们重新运行时,这些 target 会收到通知

解决方案:Dep 类(Dependency Class)

解决这个问题的一种方法是,将这种行为封装成一个实现标准观察者模式的类。

因此,如果我们创建类来管理这些依赖( dependencies )(这更近似于 Vue 处理事情的方式),它看起来可能想这样:

注意:我们现在使用 subscribers 取代 storage ,来存储匿名函数。使用 depend 取代 record 函数。使用 notify 取代 replay ,现在来执行以下我们的代码:

依然能够良好运行,并且现在的代码更加可重用。唯一感觉有点奇怪的是 target 的设置 和 运行!

问题3

将来我们计划为每个变量创建一个 dep 实例,那么,如果将监听更新的匿名函数封装起来,岂不是更好?或许一个 watcher 函数能够满足我们的需求。

所以,watcher 可能需要实现如下代码:

然后,我们只进行如下调用:

解决方案:Watcher 函数

watcher 函数里,我们可以做一些简单的事情:

上面你已经看到了,watcher 函数将 func 函数赋值给了全局变量 target ,调用 dep.depend() ,添加 target 为 订阅者,调用 target 初始化 total,最后重置 target

现在我们运行下面的代码:

你可能在想,为什么要把 total 设置为全局变量,而不是在需要它的时候把它设置为局部变量呢?嗯,这个是有充分的理由的,在后面将会体现出来。

问题4

现在我们有了 Dep 类,但是我们想让每个变量都有自己的 Dep 实例。在我们继续这个话题前,先来了解一下属性。

我们假设一下,data 的每个属性( pricequantity)都有自己的内部 Dep

watcher 函数的调用可以变成如下:

data.price 被访问时,我们希望 price 属性的 Dep 类 能够将匿名函数添加( dep.depend() )到订阅者列表( subscribers )中,当 data.quantity 被访问时,我们也希望 quantity 属性的 Dep 类 能够将匿名函数添加( dep.depend() )到订阅者列表( subscribers )中。

另外,如果我们还有其他的匿名函数,该函数中只有 data.price 被访问,那么我们只希望对 price 属性的 Dep 类进行添加订阅者。

那么,price 的所有订阅者(target)已经添加完毕了,应该什么时候调用 dep.notify() 呢?显然,应该在 price 更新的时候。因此我们想要得到的效果,就像下面这样:

上面可以看到,我们是不需要显式的调用 dep.notify()

为此,我们需要方法来劫持对data 属性( pricequantity )的访问。这样一来,当属性被访问时,我们能够将 target 存储进订阅者列表 subscribers, 并且当属性值发生变化时能够执行存储在订阅者列表中的相应 target 函数。

解决方案:Object.defineProperty()

Object.defineProperty() 函数,从 ES5 中被引入。在开始展示它怎样与 Dep 一起使用 前,我们需要先了解下 Object.defineProperty() 的用法,它允许我们定义属性的 settergetter以及属性的一些其他特性(这里只介绍 getset )。如下:

可以看到,它实际上只打印了两条信息,并未获取 get 或者设置 set 任何值,因为,我们在 Object.defineProperty() 中定义的 setget 会覆盖原生的 getget。现在我们进行实现以下原生的功能,对于 get() ,我们期望返回一个值,对于 set() 我们也需要把属性值更新,所以我们需要一个 内部变量 internalValue 作为中间变量,来存储当前的 price 的值。

所以,我们还是有方法能够在属性被访问 get 和被设置 set 值的时候得到通知。使用Object.keys(data),并且对 data 的属性列表进行递归 forEach,可以对 data 的所有属性,做同样的处理,不吗?

将两种方法结合起来

像上面的代码片段(匿名函数 target 中的代码),当执行并获取 price 的值时,我们 price 能够记住这个匿名函数 target ,这样一来,如果 price 发生变化,或者被设置新的值,就会触发这个函数重新调用,因为它知道这行代码依赖了它。所以我们可以这样想:

  • Get => 记住这个匿名函数,我们将会在它发生变化时再次调用
  • Set => 调用保存的匿名函数,更新它的值

或者以我们的 Dep 类为例

  • Price Get => 调用 dep.depend() 来保存当前的 target
  • Price Set => 调用 pricedep.notify(),重新调用所以依赖 pricetarget

这和这两种想法,我们的最终代码如下:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

let data = { price: 10, quantity: 2 }
let target = null

class Dep { // 依赖管理类
constructor() {
// 用于存储依赖的targets,当执行notify()时
// 所有依赖的 target 被调用
this.subscribers = []
}
depend() { // 代替前面使用的 record 函数
if (target && !this.subscribers.includes( target )) {
// 只有当 target 存在且未被订阅过时,进行订阅
this.subscribers.push( target );
}
}
notify() { // 代替前面的 replay 函数
// 执行所有的 target(observer)
this.subscribers.forEach(sub => sub());
}
}

Object.keys(data).forEach(key => {

let internalValue = data[ key ]
// 每个属性内置一个 dep 实例,当属性发生变化时,只触发自带的 dep 实例依赖的所有 target
const dep = new Dep()

Object.defineProperty(data, key, {
get() {
console.log(`Getting price: ${internalValue}`)
// 调用get时存储 target
dep.depend()
return internalValue
},
set(newVal) {
console.log(`Setting price to: ${newVal}`)
internalValue = newVal
// 值发生变化时,发送通知,触发当前dep存储的所有 target 调用
dep.notify()
}
});
})

function watcher(func) {
target = func
target()
target = null
}

watcher(() => {
data.total = data.price * data.quantity
})
console.log(data.total)
data.price = 20
console.log(data.total)
data.quantity = 3
console.log(data.total)

// 运行结果如下:
// Getting price: 10
// Getting price: 2
// 20
// Setting price to: 20
// Getting price: 20
// Getting price: 2
// 40
// Setting price to: 3
// Getting price: 20
// Getting price: 3
// 60

这正是我们希望得到的! pricequantity 属性变成了响应式的!无论何时,price 或者 quantity 发生变化时,我们的代码总是能够重新执行,并触发更新。

现在,Vue文档中的插图看上去应该更加明了了

看到包含 settergetter 的紫色圆饼了吗?它看起来很眼熟吧!每一个组件实例都要一个 Watcher实例(蓝色圆饼),它从 getters 中收集依赖(红线),后面一旦 setter 被调用,他就会通知(notify)监视器(watcher)触发更新,进而重新渲染组件。下面是我自己的注解图片:

现在看起来是不是,更明白了?

显然,Vue在幕后是如何做到这一点的更为复杂,但我们现在对其响应式有了基本的认识,对吧!

我们学到了什么?

  • 如何创建一个 Dep 类,来收集依赖项(depend)并重新运行所有依赖项(notify)
  • 如何创建一个 watcher 来管理我们正在运行的,可能需要被添加为一个依赖的代码( target
  • 如何使用 Object.defineProperty() 来创建 settergetter