Reactividad de JavaScript: un ejemplo simple e intuitivo

Muchos frameworks front-end de JavaScript (como Angular, React y Vue) tienen sus propios sistemas de reactividad. Comprender las características de estos sistemas será útil para cualquier desarrollador, lo ayudará a usar de manera más eficiente los marcos JS modernos.



El material, cuya traducción publicamos hoy, muestra un ejemplo paso a paso de desarrollar un sistema de reactividad en JavaScript puro. Este sistema implementa los mismos mecanismos que se usan en Vue.

Sistema de reactividad


Para alguien que se encuentra por primera vez con el sistema de reactividad Vue, puede parecer una misteriosa caja negra. Considere una aplicación Vue simple. Aquí está el marcado:

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

Aquí está el comando de conexión de framework y el código de la aplicación.

 <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> 

De alguna manera, Vue descubre que cuando el price cambia, el motor necesita hacer tres cosas:

  1. Actualizar el valor del price en la página web.
  2. Vuelva a calcular la expresión en la que el price se multiplica por la quantity y muestre el valor resultante en la página.
  3. Llame a la función totalPriceWithTax y, nuevamente, coloque lo que devuelve en la página.

Lo que sucede aquí se muestra en la siguiente ilustración.


¿Cómo sabe Vue qué hacer cuando cambia la propiedad del precio?

Ahora tenemos preguntas sobre cómo Vue sabe qué necesita actualizarse exactamente cuando cambia el price , y cómo el motor rastrea lo que está sucediendo en la página. Lo que puede observar aquí no parece una aplicación JS normal.

Quizás esto aún no sea obvio, pero el problema principal que debemos resolver aquí es que los programas JS generalmente no funcionan así. Por ejemplo, ejecutemos el siguiente código:

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

¿Qué crees que se mostrará en la consola? Como aquí no se usa nada excepto JS normal, 10 llegará a la consola.


El resultado del programa.

Y cuando utilizamos las capacidades de Vue, en una situación similar, podemos implementar un escenario en el que se recuenta el valor total cuando cambian las variables de price o quantity . Es decir, si el sistema de reactividad se usara en la ejecución del código anterior, entonces no 10, sino 40 se mostrarían en la consola:


Salida de consola generada por código hipotético utilizando un sistema de reactividad

JavaScript es un lenguaje que puede funcionar tanto de forma procesal como orientado a objetos, pero no tiene un sistema de reactividad incorporado, por lo que el código que consideramos al cambiar el price no enviará el número 40 a la consola. Para que el indicador total sea ​​recalculado cuando el price o la quantity cambien, necesitaremos crear un sistema de reactividad por nuestra cuenta y así lograr el comportamiento que necesitamos. Vamos a dividir el camino hacia este objetivo en varios pasos pequeños.

Tarea: almacenamiento de reglas para calcular indicadores


Necesitamos un lugar para guardar información sobre cómo se calcula el indicador total , lo que nos permitirá volver a calcularlo al cambiar los valores de las variables de price o quantity .

▍Solución


Primero, debemos decirle a la aplicación lo siguiente: "Aquí está el código que voy a ejecutar, guárdelo, es posible que necesite ejecutarlo en otro momento". Entonces tendremos que ejecutar el código. Más tarde, si los indicadores de price o quantity han cambiado, deberá llamar al código guardado para volver a calcular el total . Se ve así:


El código de cálculo total debe guardarse en algún lugar para poder acceder a él más tarde.

El código al que puede llamar en JavaScript para realizar alguna acción está formateado como funciones. Por lo tanto, escribiremos una función que se ocupe del cálculo del total , y también crearemos un mecanismo para almacenar funciones que podamos necesitar más adelante.

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

Tenga en cuenta que almacenamos la función anónima en la variable de target y luego llamamos a la función de record . Hablaremos de eso a continuación. También me gustaría señalar que la función de target , utilizando la sintaxis de las funciones de flecha ES6, se puede reescribir de la siguiente manera:

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

Aquí está la declaración de la función de record y la estructura de datos utilizada para almacenar las funciones:

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

Usando la función de record , guardamos la función de target (en nuestro caso { total = price * quantity } ) en la matriz de storage , lo que nos permite llamar a esta función más tarde, posiblemente usando la función de replay , cuyo código se muestra a continuación. Esto nos permitirá llamar a todas las funciones almacenadas en el storage .

 function replay () {   storage.forEach(run => run()) } 

Aquí revisamos todas las funciones anónimas almacenadas en la matriz de storage y ejecutamos cada una de ellas.

Luego, en nuestro código podemos hacer lo siguiente:

 price = 20 console.log(total) // 10 replay() console.log(total) // 40 

No todo parece tan difícil, ¿verdad? Aquí está todo el código, cuyos fragmentos discutimos anteriormente, en caso de que sea más conveniente para usted tratarlo finalmente. Por cierto, este código no se escribe accidentalmente de esa manera.

 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 

Esto es lo que se mostrará en la consola del navegador después de que comience.


Resultado del código

Reto: una solución confiable para almacenar funciones


Podemos continuar escribiendo las funciones que necesitamos cuando sea necesario, pero sería bueno si tuviéramos una solución más confiable que se pueda escalar con la aplicación. Quizás sea una clase que mantenga una lista de funciones escritas originalmente en la variable de target y que reciba notificaciones si necesitamos volver a ejecutar estas funciones.

▍Solución: clase de dependencia


Un enfoque para resolver el problema anterior es encapsular el comportamiento que necesitamos en una clase, que se puede llamar dependencia. Esta clase implementará el patrón de programación estándar del observador.

Como resultado, si creamos una clase JS utilizada para administrar nuestras dependencias (que estará cerca de cómo se implementan mecanismos similares en Vue), podría verse así:

 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())       //  -     } } 

Tenga en cuenta que en lugar de la matriz de storage , ahora almacenamos nuestras funciones anónimas en la matriz de subscribers . En lugar de la función de record , ahora se llama al método depend . También aquí, en lugar de la función de replay , se notify función de notify . Aquí se explica cómo ejecutar nuestro código con la clase 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 -    

Nuestro nuevo código funciona igual que antes, pero ahora está mejor diseñado y parece que es mejor reutilizarlo.

Lo único que parece extraño hasta ahora es trabajar con una función almacenada en la variable de target .

Tarea: mecanismo para crear funciones anónimas


En el futuro, necesitaremos crear un objeto de clase Dep para cada variable. Además, sería bueno encapsular el comportamiento de crear funciones anónimas en algún lugar, al que se debe llamar al actualizar los datos relevantes. Quizás esto nos ayude con una función adicional, que llamaremos watcher . Esto llevará al hecho de que podemos reemplazar esta construcción del ejemplo anterior con una nueva función:

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

De hecho, una llamada a la función de watcher que reemplaza este código se verá así:

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

▍ Solución: función de observador


Dentro de la función de watcher , cuyo código se presenta a continuación, podemos realizar varias acciones simples:

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

Como puede ver, la función de watcher toma, como argumento, la función myFunc , la escribe en la variable target global, llama a dep.depend() para agregar esta función a la lista de suscriptores, llama a esta función y restablece la variable target .
Ahora obtenemos los mismos valores 10 y 40 si ejecutamos el siguiente código:

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

Quizás se pregunte por qué implementamos target como una variable global, en lugar de pasar esta variable a nuestras funciones, si es necesario. Tenemos buenas razones para hacer eso, luego lo comprenderá.

Tarea: propio objeto Dep para cada variable


Tenemos un solo objeto de clase Dep . ¿Qué sucede si necesitamos que cada una de nuestras variables tenga su propio objeto de clase Dep ? Antes de continuar, muevamos los datos con los que trabajamos a las propiedades del objeto:

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

Imagine por un momento que cada una de nuestras propiedades ( price y quantity ) tiene su propio objeto interno de clase Dep .


Propiedades de precio y cantidad

Ahora podemos llamar a la función de watcher esta manera:

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

Como estamos trabajando aquí con el valor de la propiedad data.price , necesitamos el objeto de clase Dep de la propiedad price para colocar una función anónima (almacenada en target ) en su matriz de suscriptores (llamando a dep.depend() ). Además, dado que estamos trabajando con data.quantity , necesitamos el objeto Dep de la propiedad de quantity para colocar una función anónima (nuevamente, almacenada en el target ) en su matriz de suscriptores.

Si representa esto en forma de diagrama, obtiene lo siguiente.


Las funciones se dividen en matrices de suscriptores de objetos de clase Dep correspondientes a diferentes propiedades

Si tenemos una función anónima más en la que trabajamos solo con la propiedad data.price , entonces la función anónima correspondiente solo debe ir al Depósito del objeto de esta propiedad.


Se pueden agregar observadores adicionales a solo una de las propiedades disponibles.

¿Cuándo podría necesitar llamar a dep.notify() para funciones suscritas a cambios en la propiedad de price ? Esto será necesario al cambiar el price . Esto significa que cuando nuestro ejemplo esté completamente listo, el siguiente código debería funcionar para nosotros.


Aquí, al cambiar el precio, debe llamar a dep.notify () para todas las funciones suscritas al cambio de precio

Para que todo funcione de esta manera, necesitamos alguna forma de interceptar los eventos de acceso a la propiedad (en nuestro caso, es el price o la quantity ). Esto permitirá, cuando esto ocurra, guardar la función de target en una matriz de suscriptores, y cuando cambie la variable correspondiente, ejecutar la función almacenada en esta matriz.

▍Solución: Object.defineProperty ()


Ahora debemos familiarizarnos con el método estándar ES5 Object.defineProperty (). Le permite asignar captadores y definidores a las propiedades de los objetos. Permítanme, antes de pasar a su uso práctico, demostrar el funcionamiento de estos mecanismos con un simple ejemplo.

 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 //      

Si ejecuta este código en la consola del navegador, mostrará el siguiente texto.


Resultados de obtención y fijación

Como puede ver, nuestro ejemplo simplemente imprime un par de líneas de texto en la consola. Sin embargo, no lee ni establece valores, ya que redefinimos la funcionalidad estándar de getters y setters. Restauraremos la funcionalidad de estos métodos. Es decir, se espera que los captadores devuelvan los valores de los métodos correspondientes y los establecedores los establezcan. Por lo tanto, agregaremos una nueva variable, internalValue , al código, que usaremos para almacenar el valor del price actual.

 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 //      

Ahora que getter y setter funcionan de la forma en que deberían funcionar, ¿qué crees que entrará en la consola cuando se ejecute este código? Echa un vistazo a la siguiente figura.


Salida de datos a la consola

Entonces, ahora tenemos un mecanismo que le permite recibir notificaciones al leer los valores de las propiedades y cuando se escriben nuevos valores en ellos. Ahora, habiendo reelaborado un poco el código, podemos equipar a los captadores y definidores con todas las propiedades del objeto de data . Aquí usaremos el método Object.keys() , que devuelve una matriz de claves del objeto que se le pasó.

 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 

Ahora todas las propiedades del objeto de data tienen captadores y definidores. Esto es lo que aparece en la consola después de ejecutar este código.


Salida de datos a la consola por getters y setters

Asamblea del sistema de reactividad


Cuando total = data.price * data.quantity un fragmento de código como total = data.price * data.quantity y se obtiene el valor de la propiedad de price , necesitamos que la propiedad de price "recuerde" la función anónima correspondiente ( target en nuestro caso). Como resultado, si se cambia la propiedad del price , es decir, se establece en un nuevo valor, esto llevará a una llamada a esta función para repetir las operaciones que realiza, ya que sabe que una determinada línea de código depende de ello. Como resultado, las operaciones realizadas en getters y setters se pueden imaginar de la siguiente manera:

  • Getter: debe recordar la función anónima, a la que llamaremos nuevamente cuando cambie el valor.
  • Setter: es necesario ejecutar la función anónima almacenada, lo que conducirá a un cambio en el valor resultante correspondiente.

Si usa la clase Dep ya conocida en esta descripción, obtendrá lo siguiente:

  • Al leer un valor de propiedad, se llama a dep.depend() para guardar la función de target actual.
  • Cuando se escribe un valor en una propiedad, se llama a dep.notify() para reiniciar todas las funciones almacenadas.

Ahora combinaremos estas dos ideas y, finalmente, llegaremos al código que nos permite lograr nuestro objetivo.

 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 }) 

Experimentemos con este código en la consola del navegador.


Listo experimentos de código

Como puede ver, ¡funciona exactamente como lo necesitamos! ¡Las propiedades de price y quantity han vuelto reactivas! Todo el código que es responsable de generar el total cuando el price o la quantity cambia se ejecuta repetidamente.

Ahora, después de haber escrito nuestro propio sistema de reactividad, esta ilustración de la documentación de Vue le resultará familiar y comprensible.


Sistema de reactividad vue

¿Ves este hermoso círculo púrpura que dice contienen captadores y establecedores? Ahora debería estar familiarizado con usted. Cada instancia del componente tiene una instancia del método del observador (círculo azul), que recopila dependencias de los captadores (línea roja). Cuando, más tarde, se llama al setter, se le notifica al método del observador, lo que conduce a la representación del componente. Aquí está el mismo esquema, provisto de explicaciones que lo conectan con nuestro desarrollo.


Vue diagrama de reactividad con explicaciones

Creemos que ahora, después de haber escrito nuestro propio sistema de reactividad, este esquema no necesita explicaciones adicionales.

Por supuesto, en Vue, todo esto es más complicado, pero ahora debe comprender el mecanismo subyacente en los sistemas de reactividad.

Resumen


Después de leer este material, aprendiste lo siguiente:

  • Cómo crear una clase Dep que recopile funciones utilizando el método depend y, si es necesario, las llame nuevamente utilizando el método de notify .
  • Cómo crear una función de watcher que le permita controlar el código que ejecutamos (esta es la función de target ), que puede necesitar guardar en el objeto de la clase Dep .
  • Cómo usar el método Object.defineProperty() para crear captadores y establecedores.

Todo esto, compilado en un solo ejemplo, condujo a la creación de un sistema de respuesta en JavaScript puro, al comprender que puede comprender las características del funcionamiento de dichos sistemas utilizados en los marcos web modernos.

Estimados lectores! Si, antes de leer este material, imaginaba mal las características de los mecanismos de los sistemas de reactividad, dígame, ¿ha logrado lidiar con ellos?

Source: https://habr.com/ru/post/es418633/


All Articles