译自:The Best Explanation of JavaScript Reactivity
很多响应式 JavaScript 框架(如:Angular,React 和 Vue)都有他们自己的响应式引擎。理解了什么是响应式和它是如何工作的,能够很好的提高开发技巧,以及更有效的使用前端 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 的方法,以便当 price 或 quantity 发生变化时重新运行并得到新的 total
方案
首先,我们需要一些方法来告诉我们的应用程序,“将我要运行的代码保存下来,我可能会在其他时间调用。” 然后我们将运行代码,如果 price 或 quantity 变量产生变化,再次执行存储下来的代码,进而更新 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 的每个属性( price 和 quantity)都有自己的内部 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 属性( price 和 quantity )的访问。这样一来,当属性被访问时,我们能够将 target 存储进订阅者列表 subscribers, 并且当属性值发生变化时能够执行存储在订阅者列表中的相应 target 函数。
解决方案:Object.defineProperty()
Object.defineProperty() 函数,从 ES5 中被引入。在开始展示它怎样与 Dep 一起使用 前,我们需要先了解下 Object.defineProperty() 的用法,它允许我们定义属性的 setter 和 getter以及属性的一些其他特性(这里只介绍 get 和 set )。如下:

可以看到,它实际上只打印了两条信息,并未获取 get 或者设置 set 任何值,因为,我们在 Object.defineProperty() 中定义的 set 和 get 会覆盖原生的 get 和 get。现在我们进行实现以下原生的功能,对于 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 => 调用
price的dep.notify(),重新调用所以依赖price的target
这和这两种想法,我们的最终代码如下:
1 |
|
这正是我们希望得到的! price 和 quantity 属性变成了响应式的!无论何时,price 或者 quantity 发生变化时,我们的代码总是能够重新执行,并触发更新。
现在,Vue文档中的插图看上去应该更加明了了

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

现在看起来是不是,更明白了?
显然,Vue在幕后是如何做到这一点的更为复杂,但我们现在对其响应式有了基本的认识,对吧!
我们学到了什么?
- 如何创建一个
Dep类,来收集依赖项(depend)并重新运行所有依赖项(notify) - 如何创建一个
watcher来管理我们正在运行的,可能需要被添加为一个依赖的代码(target) - 如何使用
Object.defineProperty()来创建setter和getter