JavaScript反应性:一个简单直观的示例

许多JavaScript前端框架(例如Angular,React和Vue)都有自己的反应系统。 了解这些系统的功能对于任何开发人员都将是有用的,将帮助他更有效地使用现代JS框架。



我们今天发布的翻译材料显示了使用纯JavaScript开发反应系统的分步示例。 该系统实现了Vue中使用的相同机制。

反应系统


对于第一次接触Vue反应系统的人来说,它似乎就像是一个神秘的黑匣子。 考虑一个简单的Vue应用程序。 这是标记:

<div id="app">    <div>Price: ${{ price }}</div>    <div>Total: ${{ price*quantity }}</div>    <div>Taxes: ${{ totalPriceWithTax }}</div> </div> 

这是框架连接命令和应用程序代码。

 <script src="https://cdn.jsdelivr.net/npm/vue"></script> <script>   var vm = new Vue({       el: '#app',       data: {           price: 5.00,           quantity: 2       },       computed: {           totalPriceWithTax() {               return this.price * this.quantity * 1.03           }       }   }) </script> 

Vue以某种方式发现,当price变化时,引擎需要做三件事:

  1. 刷新网页上的price值。
  2. 重新计算price乘以quantity的表达式,然后在页面上显示结果值。
  3. 调用函数totalPriceWithTax ,然后再次将其返回的内容放在页面上。

下图显示了此处发生的情况。


价格属性发生变化时,Vue如何知道该怎么办?

现在,我们对Vue如何知道price变化时确切需要更新的内容以及引擎如何跟踪页面上发生的情况等问题有所疑问。 您在此处观察到的内容看起来并不像常规的JS应用程序。

也许这还不是很明显,但是我们需要在这里解决的主要问题是JS程序通常不能那样工作。 例如,让我们运行以下代码:

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

您认为控制台中会显示什么? 因为这里除了常规的JS之外什么都没有使用,所以10将进入控制台。


程序的结果

当使用Vue的功能时,在类似情况下,我们可以实现一种方案,其中在pricequantity变量发生变化时重新计算total价值。 也就是说,如果在以上代码的执行中使用了反应性系统,那么控制台上将不会显示10,而是显示40:


使用反应系统由假设代码生成的控制台输出

JavaScript是一种既可以作用于程序又可以面向对象的语言,但是它没有内置的反应系统,因此我们在更改price时考虑的代码不会将数字40输出到控制台。 为了在pricequantity发生变化时重新计算total指标,我们将需要自己创建一个反应系统,从而实现所需的行为。 我们将把实现这一目标的道路分为几个小步骤。

任务:存储指标计算规则


我们需要在某个地方保存有关total指标如何计算的信息,以便在更改pricequantity变量的值时可以重新计算它。

▍解决方案


首先,我们需要告诉应用程序以下内容:“这是我要运行的代码,保存下来,我可能需要再次执行它。” 然后,我们将需要运行代码。 稍后,如果pricequantity指标发生变化,则需要调用保存的代码来重新计算total 。 看起来像这样:


总的计算代码需要保存在某处,以便以后可以访问

您可以在JavaScript中调用以执行某些操作的代码,其格式设置为函数。 因此,我们将编写一个用于处理total的函数,并创建一种机制来存储以后可能需要的函数。

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

请注意,我们将匿名函数存储在target变量中,然后调用record函数。 我们将在下面讨论。 我还想指出,使用ES6箭头函数的语法可以将target函数重写如下:

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

这是record函数的声明和用于存储函数的数据结构:

 let storage = [] //     target function record () { // target = () => { total = price * quantity }   storage.push(target) } 

使用record函数,我们将target函数(在本例中为{ total = price * quantity } )保存在storage数组中,这使我们以后可以调用此函数,可能使用replay函数,其代码如下所示。 这将使我们能够调用存储在storage所有函数。

 function replay () {   storage.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 storage = [] function record () {   storage.push(target) } function replay () {   storage.forEach(run => run()) } target = () => { total = price * quantity } record() target() price = 20 console.log(total) // 10 replay() console.log(total) // 40 

这是启动后将在浏览器控制台中显示的内容。


代码结果

挑战:可靠的功能存储解决方案


我们可以在必要时继续写下所需的功能,但是如果我们有一个可以随应用程序扩展的更可靠的解决方案,那就太好了。 也许它将是一个类,该类维护最初写入target变量的函数列表,并且在我们需要重新执行这些函数时会接收通知。

▍解决方案:依赖类


解决上述问题的一种方法是将我们需要的行为封装在一个类中,这可以称为依赖。 此类将实现标准的观察者编程模式。

结果,如果我们创建一个用于管理依赖项的JS类(这将类似于Vue中实现类似机制的方式),则它看起来可能像这样:

 class Dep { // Dep -    Dependency   constructor () {       this.subscribers = [] //  ,                               //    notify()   }   depend () { //   record       if (target && !this.subscribers.includes(target)){           //    target                //                this.subscribers.push(target)       }   }   notify () { //   replay       this.subscribers.forEach(sub => sub())       //  -     } } 

请注意,我们现在将匿名函数存储在subscribers数组中,而不是storage数组中。 现在将调用depend方法,而不是record函数。 同样在这里, notify功能代替replay功能。 这是使用Dep类运行代码的方法:

 const dep = new Dep() let price = 5 let quantity = 2 let total = 0 let target = () => { total = price * quantity } dep.depend() //   target    target() //     total console.log(total) // 10 -   price = 20 console.log(total) // 10 -    ,    dep.notify() //   -  console.log(total) // 40 -    

我们的新代码与以前一样,但是现在设计得更好了,感觉可以更好地重用。

到目前为止,唯一看起来很奇怪的事情是使用存储在target变量中的函数。

任务:创建匿名函数的机制


将来,我们将需要为每个变量创建一个Dep类的对象。 此外,最好将在某些地方创建匿名函数的行为封装起来,在更新相关数据时应调用该函数。 也许这将有助于我们使用附加功能,我们将其称为watcher 。 这将导致这样一个事实,我们可以用新功能替换之前示例中的此构造:

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

实际上,替换该代码的对watcher函数的调用将如下所示:

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

▍解决方案:观察者功能


watcher函数内部(下面提供其代码),我们可以执行一些简单的操作:

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

如您所见, watcher函数将myFunc函数作为参数,将其写入全局target变量,调用dep.depend()将该函数添加到订阅者列表,调用此函数并重置target变量。
现在,如果执行以下代码,我们将得到所有相同的值10和40:

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

也许您想知道为什么我们将target实现为全局变量,而不是在必要时将此变量传递给我们的函数。 我们有充分的理由这样做,以后您将了解。

任务:为每个变量拥有Dep对象


我们只有一个类Dep对象。 如果我们需要每个变量都有自己的Dep类对象,该怎么办? 在继续之前,让我们将使用的数据移动到对象的属性中:

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

想象一下,我们的每个属性( pricequantity )都有自己的内部Dep类对象。


价格和数量属性

现在我们可以像这样调用watcher函数:

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

由于我们在这里使用data.price属性的值,因此我们需要price属性的Dep类对象将匿名函数(存储在target )放置在其订户数组中(通过调用dep.depend() )。 另外,由于我们在data.quantity使用data.quantity ,因此需要quantity属性的Dep对象在其订户数组中放置一个匿名函数(同样,存储在target )。

如果以图表形式进行描述,则会得到以下结果。


函数属于对应于不同属性的Dep类对象的订户数组

如果我们还有一个匿名函数,其中仅使用data.price属性,则相应的匿名函数应仅存储到此属性的Dep类对象中。


只能将附加的观察者添加到可用属性之一。

对于预订了price属性更改的函数,何时可能需要调用dep.notify() ? 更改price时将需要这样做。 这意味着当我们的示例完全准备就绪时,以下代码将为我们工作。


在这里,更改价格时,您需要为预订了更改价格的所有函数调用dep.notify()

为了使所有内容都能以这种方式工作,我们需要某种方式来拦截属性访问事件(在我们的例子中,它是pricequantity )。 发生这种情况时,这将允许将target函数保存到订阅者数组中,并且当相应的变量更改时,可以执行存储在该数组中的函数。

▍解决方案:Object.defineProperty()


现在,我们需要熟悉标准的ES5方法Object.defineProperty()。 它允许您将getter和setter分配给对象的属性。 在继续实际使用之前,请允许我以一个简单的示例演示这些机制的操作。

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

如果在浏览器控制台中运行此代码,它将显示以下文本。


Getter和Setter结果

如您所见,我们的示例仅将两行文本打印到控制台。 但是,它不读取或设置值,因为我们重新定义了getter和setter的标准功能。 我们将恢复这些方法的功能。 即,期望getter返回相应方法的值,并由setter设置它们。 因此,我们将在代码中添加一个新变量internalValue ,该变量将用于存储当前price值。

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

既然getter和setter的工作方式应该正确,那么执行此代码后,您认为将如何进入控制台? 看下图。


数据输出到控制台

因此,现在我们有了一种机制,使您可以在读取属性值以及将新值写入属性值时接收通知。 现在,在稍微修改代码之后,我们可以为getter和setter装备data对象的所有属性。 在这里,我们将使用Object.keys()方法,该方法返回传递给它的对象的键的数组。

 let data = { price: 5, quantity: 2 } Object.keys(data).forEach(key => { //        data   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       }   }) }) let total = data.price * data.quantity data.price = 20 

现在, data对象的所有属性都有getter和setter。 这是运行此代码后出现在控制台中的内容。


获取器和设置器将数据输出到控制台

反应系统组装


total = data.price * data.quantity诸如total = data.price * data.quantity类的代码片段并在其中获得price属性的值时,我们需要price属性来“记住”相应的匿名函数(在本例中为target )。 结果,如果更改了price属性,即将其设置为新值,这将导致对该函数的调用以重复执行它执行的操作,因为它知道某些代码行依赖于此。 结果,可以想象在getter和setter中执行的操作如下:

  • Getter-您需要记住匿名函数,当值更改时,我们将再次调用该函数。
  • 设置器-必须执行存储的匿名函数,这将导致相应结果值的更改。

如果使用此描述中已知的Dep类,则会得到以下信息:

  • 读取属性值时,将调用dep.depend()来保存当前的target函数。
  • 将值写入属性时,将调用dep.notify()重新启动所有存储的函数。

现在,我们将结合这两个想法,最后,我们将介绍允许我们实现目标的代码。

 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]   //         //   Dep   const dep = new Dep()   Object.defineProperty(data, key, {       get() {           dep.depend() //    target           return internalValue       },       set(newVal) {           internalValue = newVal           dep.notify() //           }   }) }) //   watcher   dep.depend(), //        function watcher(myFunc){   target = myFunc   target()   target = null } watcher(() => {   data.total = data.price * data.quantity }) 

让我们在浏览器控制台中试用此代码。


准备好的代码实验

如您所见,它完全可以满足我们的需求! pricequantity属性已变得反应灵敏! 重复执行负责pricequantity变化时产生total所有代码。

现在,在我们编写了自己的反应性系统之后,Vue文档中的插图对于您而言似乎是熟悉且可以理解的。


Vue反应系统

看到这个美丽的紫色圆圈,上面写着“ 包含getter和setter”吗? 现在他应该对您很熟悉。 组件的每个实例都有一个观察者方法的实例(蓝色圆圈),该方法收集对吸气剂的依赖关系(红线)。 稍后,当调用setter时,它将通知观察者方法,这将导致组件的重新呈现。 这是相同的方案,并提供与我们的开发相关的说明。


Vue反应性图及说明

我们相信,现在,我们已经编写了自己的反应性系统,该方案不需要其他说明。

当然,在Vue中,所有这些都比较复杂,但是现在您应该了解反应系统的基本机制。

总结


阅读此材料后,您了解了以下内容:

  • 如何创建一个Dep类,该类使用depend方法来收集函数,并在必要时使用notify方法再次调用它们。
  • 如何创建一个watcher函数,使您可以控制我们运行的代码(这是target函数),您可能需要将其保存在Dep类的对象中。
  • 如何使用Object.defineProperty()方法创建getter和setter。

所有这些,都聚集在一个示例中,通过了解可以理解现代Web框架中使用的此类系统的功能,可以使用纯JavaScript创建响应系统。

亲爱的读者们! 如果在阅读本文之前,您对反应系统机制的功能没有很好的想象,请告诉我,您现在是否能够应对它们?

Source: https://habr.com/ru/post/zh-CN418633/


All Articles