La gestión del estado es una de las partes más importantes de una aplicación web. Desde el uso de variables globales hasta enlaces de React y el uso de bibliotecas de terceros como MobX, Redux o XState, por nombrar solo estos 3, es uno de los temas que genera más discusiones, ya que es importante dominarlo para diseñar un aplicación confiable y eficiente.
Hoy propongo construir una mini biblioteca de administración de estado en menos de 50 líneas de JavaScript basada en el concepto de observables. Este ciertamente se puede usar tal como está para proyectos pequeños, pero más allá de este ejercicio educativo, todavía le recomiendo que recurra a soluciones más estandarizadas para sus proyectos reales.
Al iniciar un nuevo proyecto de biblioteca, es importante definir cómo podría verse su API desde el principio para congelar su concepto y guiar su desarrollo antes incluso de pensar en los detalles técnicos de implementación. Para un proyecto real, incluso es posible comenzar a escribir pruebas en este momento para validar la implementación de la biblioteca tal como está escrita según un enfoque TDD.
Aquí queremos exportar una única clase que llamaremos Estado que será instanciada con un objeto que contiene el estado inicial y un único método de observación que nos permite suscribirnos a cambios de estado con observadores. Estos observadores solo deben ejecutarse si una de sus dependencias ha cambiado.
Para cambiar el estado, queremos usar las propiedades de la clase directamente en lugar de utilizar un método como setState.
Dado que un fragmento de código vale más que mil palabras, así es como se vería nuestra implementación final en uso:
const state = new State({ count: 0, text: '', }); state.observe(({ count }) => { console.log('Count changed', count); }); state.observe(({ text }) => { console.log('Text changed', text); }); state.count = 1; state.text = 'Hello, world!'; state.count = 1; // Output: // Count changed 1 // Text changed Hello, world! // Count changed 2
Comencemos creando una clase State que acepte un estado inicial en su constructor y exponga un método de observación que implementaremos más adelante.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; } observe(observer) { this.observers.push(observer); } }
Aquí elegimos utilizar un objeto de estado intermedio interno que nos permitirá mantener los valores de estado. También almacenamos los observadores en una matriz de observadores interna que será útil cuando completemos esta implementación.
Dado que estas 2 propiedades solo se usarán dentro de esta clase, podríamos declararlas como privadas con un poco de azúcar sintáctica anteponiéndolas con un # y agregando una declaración inicial en la clase:
class State { #state = {}; #observers = []; constructor(initialState = {}) { this.#state = initialState; this.#observers = []; } observe(observer) { this.#observers.push(observer); } }
En principio, esta sería una buena práctica, pero usaremos Proxies en el siguiente paso y no son compatibles con propiedades privadas. Sin entrar en detalles y para facilitar esta implementación, usaremos propiedades públicas por ahora.
Cuando describimos las especificaciones de este proyecto, queríamos acceder a los valores de estado directamente en la instancia de clase y no como una entrada a su objeto de estado interno.
Para esto, usaremos un objeto proxy que será devuelto cuando se inicialice la clase.
Como sugiere su nombre, un Proxy le permite crear un intermediario para que un objeto intercepte ciertas operaciones, incluidos sus captadores y definidores. En nuestro caso, creamos un Proxy que expone un primer captador que nos permite exponer las entradas del objeto de estado como si pertenecieran directamente a la instancia de Estado.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, }); } observe(observer) { this.observers.push(observer); } } const state = new State({ count: 0, text: '', }); console.log(state.count); // 0
Ahora podemos definir un objeto de estado inicial al crear una instancia de State y luego recuperar sus valores directamente desde esa instancia. Ahora veamos cómo manipular sus datos.
Agregamos un captador, por lo que el siguiente paso lógico es agregar un definidor que nos permita manipular el objeto de estado.
Primero verificamos que la clave pertenezca a este objeto, luego verificamos que el valor haya cambiado para evitar actualizaciones innecesarias y finalmente actualizamos el objeto con el nuevo valor.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, set: (target, prop, value) => { if (prop in target.state) { if (target.state[prop] !== value) { target.state[prop] = value; } } else { target[prop] = value; } }, }); } observe(observer) { this.observers.push(observer); } } const state = new State({ count: 0, text: '', }); console.log(state.count); // 0 state.count = 1; console.log(state.count); // 1
Ya hemos terminado con la parte de lectura y escritura de datos. Podemos cambiar el valor del estado y luego recuperar ese cambio. Hasta ahora nuestra implementación no es muy útil, así que implementemos observadores ahora.
Ya tenemos una matriz que contiene las funciones de observadores declaradas en nuestra instancia, por lo que todo lo que tenemos que hacer es llamarlas una por una cada vez que un valor haya cambiado.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, set: (target, prop, value) => { if (prop in target.state) { if (target.state[prop] !== value) { target.state[prop] = value; this.observers.forEach((observer) => { observer(this.state); }); } } else { target[prop] = value; } }, }); } observe(observer) { this.observers.push(observer); } } const state = new State({ count: 0, text: '', }); state.observe(({ count }) => { console.log('Count changed', count); }); state.observe(({ text }) => { console.log('Text changed', text); }); state.count = 1; state.text = 'Hello, world!'; // Output: // Count changed 1 // Text changed // Count changed 1 // Text changed Hello, world!
¡Genial, ahora estamos reaccionando a los cambios de datos!
Sin embargo, hay un pequeño problema. Si ha estado prestando atención hasta ahora, originalmente queríamos ejecutar los observadores solo si una de sus dependencias cambiaba. Sin embargo, si ejecutamos este código vemos que cada observador se ejecuta cada vez que se cambia una parte del estado.
Pero entonces, ¿cómo podemos identificar las dependencias de estas funciones?
Una vez más, los Proxies vienen a nuestro rescate. Para identificar las dependencias de nuestras funciones de observador, podemos crear un proxy de nuestro objeto de estado, ejecutarlo con él como argumento y anotar a qué propiedades accedieron.
Simple, pero efectivo.
Al llamar a los observadores, todo lo que tenemos que hacer es verificar si tienen una dependencia en la propiedad actualizada y activarlos solo si es así.
Aquí está la implementación final de nuestra minibiblioteca con esta última parte agregada. Notarás que la matriz de observadores ahora contiene objetos que permiten mantener las dependencias de cada observador.
class State { constructor(initialState = {}) { this.state = initialState; this.observers = []; return new Proxy(this, { get: (target, prop) => { if (prop in target.state) { return target.state[prop]; } return target[prop]; }, set: (target, prop, value) => { if (prop in target.state) { if (target.state[prop] !== value) { target.state[prop] = value; this.observers.forEach(({ observer, dependencies }) => { if (dependencies.has(prop)) { observer(this.state); } }); } } else { target[prop] = value; } }, }); } observe(observer) { const dependencies = new Set(); const proxy = new Proxy(this.state, { get: (target, prop) => { dependencies.add(prop); return target[prop]; }, }); observer(proxy); this.observers.push({ observer, dependencies }); } } const state = new State({ count: 0, text: '', }); state.observe(({ count }) => { console.log('Count changed', count); }); state.observe(({ text }) => { console.log('Text changed', text); }); state.observe((state) => { console.log('Count or text changed', state.count, state.text); }); state.count = 1; state.text = 'Hello, world!'; state.count = 1; // Output: // Count changed 0 // Text changed // Count or text changed 0 // Count changed 1 // Count or text changed 1 // Text changed Hello, world! // Count or text changed 1 Hello, world! // Count changed 2 // Count or text changed 2 Hello, world!
Y ahí lo tienes, en 45 líneas de código hemos implementado una mini biblioteca de gestión de estados en JavaScript.
Si quisiéramos ir más allá, podríamos agregar sugerencias de tipo con JSDoc o reescribir esta en TypeScript para obtener sugerencias sobre propiedades de la instancia de estado.
También podríamos agregar un método no observado que quedaría expuesto en un objeto devuelto por State.observe.
También podría ser útil abstraer el comportamiento del definidor en un método setState que nos permita modificar varias propiedades a la vez. Actualmente, tenemos que modificar cada propiedad de nuestro estado una por una, lo que puede activar múltiples observadores si algunos de ellos comparten dependencias.
En cualquier caso, espero que hayas disfrutado tanto como yo de este pequeño ejercicio y que te haya permitido profundizar un poco más en el concepto de Proxy en JavaScript.
Descargo de responsabilidad: Todos los recursos proporcionados provienen en parte de Internet. Si existe alguna infracción de sus derechos de autor u otros derechos e intereses, explique los motivos detallados y proporcione pruebas de los derechos de autor o derechos e intereses y luego envíelos al correo electrónico: [email protected]. Lo manejaremos por usted lo antes posible.
Copyright© 2022 湘ICP备2022001581号-3