@levinzhang
2018-08-19T05:55:15.000000Z
字数 3638
阅读 538
很多前端JavaScript框架(如Angular、React和Vue)都有自己的反应性(Reactivity)引擎,这是实现数据双向绑定等功能的基础。理解反应式是什么以及如何运行能够提升你的开发水平,同时能够更高效地使用JavaScript框架。在本文中,作者使用基础的JavaScript语法构建了与Vue源码相同的反应性功能。
很多前端JavaScript框架(如Angular、React和Vue)都有自己的反应性(Reactivity)引擎。理解反应式是什么以及如何运行能够提升你的开发水平,同时能够更高效地使用JavaScript框架。在本文中,我们构建了与Vue源码相同的反应性功能。
当你第一次见到Vue的反应性系统时,你可能会感觉有些神奇。以下面这个简单的Vue应用为例:
不知道基于什么原因,Vue能够知道price
的值是否发生了变化,并且在变化的时候能够完成如下三件事情:
price
的值;price * quantity
,并更新页面;totalPriceWithTax
函数并更新页面。但是,稍等,我似乎听到你想问,Vue如何知道在price
发生变化的时候都要更新哪些值,它又是如何跟踪所有的内容的呢?
这并不是JavaScript编程通常的运行方式。
如果这对你来说不那么直观,那么我们需要明白程序通常并不是按照这种方式来运行的。例如,如果我运行下面的样例代码:
你猜将会打印出什么内容呢?因为我们没有使用Vue,它将会打印出10
:
在Vue中,我们想要在price
或quantity
更新的时候,total
也进行更新。我们希望的输出是:
但令人遗憾的是,JavaScript是过程性的,并不是反应式的,所以在现实代码并这并不可行。为了让total
具有反应性,我们必须让JavaScript语言按照不同的方式来运行。
我们需要记住如何计算total
,这样才能在price
或quantity
发生变化的时候重新运行。
首先,我们需要有某种方法告诉我们的应用,“我将要运行的代码是什么,将它存储起来,在稍后某个时间点我可能需要你运行它”。然后,我们运行代码,在price
或quantity
变量发生变化的时候,再次运行存储的代码:
我们想到的办法可能就是将函数的内容记录下来,这样就能再次运行了:
需要注意,我们在target
变量中存储了一个匿名函数,然后调用了record
函数。如果采用ES6的箭头语法的话,我还可以写成如下的形式:
record
的定义非常简单:
我们将target
存储了起来(我们的示例中也就是{ total = price * quantity }
),这样的话,我们就能在随后运行它,可能会借助一个replay
函数运行我们记录下来的所有内容。
这样会遍历我们在storage
数组中存储的所有匿名函数,并运行它们。
那么在我们的代码中,只需:
非常简单,对吧?如果你想要通读代码并再次尝试的话,下面给出了完整的代码。
我们可以按需继续记录target,但是更好的方式是有一种健壮的方案,能够扩展我们的应用。我们可以使用一个类,让这个类维护一个target的列表,当需要它们重新运行的时候,这个类会得到通知。
要解决这个问题,我们将这些行为封装到单独的类中,使用一个依赖类(Dependency Class)来实现标准的观察者模式编程。
如果我们创建JavaScript类来管理依赖的话(类似于Vue的处理方式),它看起来可能会如下所示:
需要注意,我们这里不再使用storage
,而是使用subscribers
来存储匿名函数,也不再使用record
函数了,而是调用depend
,同时使用notify
代替了replay
。要让它运行起来,只需:
它依然可以运行,而且我们的代码看上去具备了一定的可重用性。唯一感觉尤其诡异的地方就是设置和运行target
。
未来,每个类都会有一个Dep类,如果能将创建匿名函数观察更新的行为封装起来就更好了。接下来,watcher
函数将会出场来负责这种行为。
所以,我们将不会再调用:
(这就是上面示例的代码)
相反,我们只需这样调用:
在Watcher函数中,我们可以做几件很简单的事情:
可以看到,watcher
函数接受一个myFunc
变量,将其作为我们的全局target
属性,调用dep.depend()
,将会以订阅者的形式添加我们的target,调用target
函数并重置target
。
现在,我们可以运行下面的代码:
你可能会想,我们为什么要将target
实现为全局变量,而不是将其传递给所需的函数。这里有一定的原因,在本文结束的时候,相信你就明白了。
我们现在有了一个Dep
类,但是我们真正想要实现的是每个变量都有自己的Dep。在进行下一步讲解之前,我们先将它们放到属性中。
我们先假设每个属性(price
和quantity
)都有其自己的内部Dep类。
现在,当我们运行:
因为访问到了data.price
的值,所以我希望price
属性的Dep类要将我们的匿名函数(存储在target
中)放到它的订阅数组中(通过调用dep.depend()
)。因为data.quantity
也被访问到了,所以我希望quantity
属性的Dep类要将该匿名函数(存储在target
中)放到它的订阅数组中:
如果我还有其他的匿名函数只访问data.price
的话,我希望要将这个函数放到price
属性的Dep类中。
那么,我该在何时为price
的订阅者调用dep.notify()
呢?答案是为price
赋值的时候。在本文结束的时候,我希望能够在命令行中实现如下的效果:
我们希望能有某种方式嵌入到数据属性中(price
或quantity
),这样的话,当属性被访问的时候,能够将target
存储到订阅者数组中,当属性变更时,能够运行存储在订阅者数组中的函数。
我们需要学习ES 5 JavaScript所提供Object.defineProperty()函数。它允许我们为属性定义getter和setter函数。在展示如何与Dep类协作之前,我们看一下它的基础用法。
可以看到,这里只是打印了两条日志。但是,它并没有实际get
和set
值,这是因为我们将功能覆盖掉了。现在,我们将功能添加回来。get()
预期要返回一个值,而set()
依然要更新值,所以我们添加一个internalValue
变量来存储当前的price
值。
我们的get
和set
都能正常运行了,你觉得控制台的打印信息会是什么呢?
所以,当取值和设置值的时候,我们有了一种得到提醒的方法。借助一些递归,我们就可以将其用到数据数组的所有条目中了。
值得一提的是,Object.keys(data)
能够返回对象中key所组成的数组。
现在,所有的属性都有getter和setter了,我们来看一下控制台:
当这样的代码运行并尝试get price
属性的值时,我们希望price
能够记住这个匿名函数(target
)。通过这种方式,如果price
发生了变化,或者被set
了一个新的值,这个函数就能重新运行,因为它能够知道这行代码依赖该属性。所以,你可以按照如下的方式来思考。
Get=>记住该匿名函数,当值发生变化的时候我们会重新运行。
Set=>运行保存的匿名函数,我们的值就会发生变化。
或者,在Dep Class的场景下:
Price 访问(get) =>调用dep.depend()
保存当前的target
;
Price set =>调用price的dep.notify()
,重新运行所有的target
。
接下来,我们将这两个理念组合起来,并看一下最终的代码。
在我们运行的时候,看一下控制台的输出:
完全符合我们的预期!现在price
和quantity
都是反应式的了。当price
或quantity
的值更新时,我们的代码完全重新运行了。
Vue文档中的图示对你来说应该就非常清晰了。
看到漂亮的Data圆圈中的getters和setters了吗?它看起来似曾相识!每个组件实例都有一个watcher
(蓝色圆圈),它会从getter中收集依赖(红线)。当setter随后被调用时,它会**通知**watcher,从而会导致组件的重新渲染。如下的图片添加了一些我自己的注释。
现在,是不是感觉一目了然了呢****?
当然,Vue底层的处理要更复杂,但是你现在已经掌握了它的基础。