译自: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