Reactividad
Introducción
La reactividad es un tema importante en los frameworks de JavaScript. El objetivo es proporcionar una forma sencilla de manipular el estado, de modo que la interfaz se actualice automáticamente según los cambios de estado, y que lo haga de manera eficiente.
Para ello, Owl ofrece un sistema de reactividad basado en proxy, basado en el primitivo reactive
. La función reactive
toma un objeto como primer argumento y una devolución de llamada opcional como segundo argumento; devuelve un proxy del objeto. Este proxy rastrea qué propiedades se leen a través del proxy y llama a la devolución de llamada proporcionada siempre que una de estas propiedades se modifica a través de cualquier versión reactiva del mismo objeto. Lo hace en profundidad, devolviendo versiones reactivas de los subobjetos cuando se leen.
useState
Si bien el primitivo reactive
es muy poderoso, su uso en componentes sigue un patrón muy estándar: los componentes quieren volver a renderizarse cuando cambia parte del estado del que dependen para la renderización. Para este fin, owl proporciona un hook estándar: useState
. En pocas palabras, este hook simplemente llama a reactive con el objeto provisto y la función render del componente actual como su devolución de llamada. Esto hará que vuelva a renderizarse siempre que se modifique cualquier parte del objeto de estado que haya sido leído por este componente.
A continuación se muestra un ejemplo sencillo de cómo se puede utilizar useState
:
class Counter extends Component { static template = xml` <div t-on-click="() => this.state.value++"> <t t-esc="state.value"/> </div>`;
setup() { this.state = useState({ value: 0 }); }}
Este componente lee state.value
cuando se renderiza, suscribiéndolo a los cambios en esa clave. Siempre que el valor cambie, Owl actualizará el componente. Tenga en cuenta que no hay nada especial acerca de la propiedad state
, puede nombrar sus variables de estado como desee y puede tener varias de ellas en el mismo componente si tiene sentido hacerlo. Esto también permite que useState
se use en hooks personalizados que pueden requerir un estado específico para ese hook.
Props reactivas
Desde la versión 2.0, los renderizados de Owl ya no son “profundos” de forma predeterminada: un componente solo es renderizado nuevamente por su padre si sus propiedades han cambiado (usando una prueba de igualdad simple). ¿Qué sucede si el contenido de una propiedad ha cambiado en una propiedad más profunda? Si esa propiedad es reactiva, Owl volverá a renderizar los componentes secundarios que necesitan actualizarse automáticamente, y solo esos componentes, lo hace volviendo a observar los objetos reactivos pasados como propiedades a los componentes. Considere el siguiente ejemplo:
class Counter extends Component { static template = xml` <div t-on-click="() => props.state.value++"> <t t-esc="props.state.value"/> </div>`;}
class Parent extends Component { static template = xml` <Counter state="this.state"/> <button t-on-click="() => this.state.value = 0">Reset counter</button> <button t-on-click="() => this.state.test++" t-esc="this.state.test"/>`;
setup() { this.state = useState({ value: 0, test: 1 }); }}
Al hacer clic en el botón del contador, solo se vuelve a renderizar el contador, porque el componente padre nunca ha leído la clave “valor” en el estado. Al hacer clic en el botón “Reiniciar contador”, sucede lo mismo: solo se vuelve a renderizar el componente contador. Lo que importa no es dónde se actualiza el estado, sino qué partes del estado se actualizan y qué componentes dependen de ellas. Owl logra esto llamando automáticamente a useState
en los objetos reactivos que se pasan como propiedades a un componente secundario.
Al hacer clic en el último botón, el padre se vuelve a renderizar, pero al hijo no le importa la clave test
: no la ha leído. Las propiedades que le damos (this.state
) tampoco han cambiado, por lo que el padre se actualiza, pero el hijo no.
Para la mayoría de las operaciones diarias, “useState” debería cubrir todas sus necesidades. Si tiene curiosidad por conocer casos de uso más avanzados y detalles técnicos, siga leyendo.
Depuración de suscripciones
Owl proporciona una forma de mostrar a qué objetos y claves reactivas está suscrito un componente: puede consultar component.__owl__.subscriptions
. Tenga en cuenta que esto se encuentra en el campo interno __owl__
y no debe usarse en ningún tipo de código de producción, ya que el nombre de esta propiedad o cualquiera de sus propiedades o métodos están sujetos a cambios en cualquier momento, incluso en versiones estables de Owl, y pueden estar disponibles solo en modo de depuración en el futuro.
reactive
La función reactive
es la primitiva de reactividad básica. Toma un objeto o una matriz como primer argumento y, opcionalmente, una función como segundo argumento. La función se llama siempre que se actualiza cualquier valor rastreado.
const obj = reactive({ a: 1 }, () => console.log("changed"));
obj.a = 2; // does not log anything: the 'a' key has not been read yetconsole.log(obj.a); // logs 2 and reads the 'a' key => it is now trackedobj.a = 3; // logs 'changed' because we updated a tracked value
Una propiedad importante de los objetos reactivos es que pueden volver a observarse: esto creará un proxy independiente que rastrea otro conjunto de claves:
const obj1 = reactive({ a: 1, b: 2 }, () => console.log("observer 1"));const obj2 = reactive(obj1, () => console.log("observer 2"));
console.log(obj1.a); // logs 1, and reads the 'a' key => it is now tracked by observer 1console.log(obj2.b); // logs 2, and 'b' is now tracked by observer 2obj2.a = 3; // only logs 'observer1', because observer2 does not track aobj2.b = 3; // only logs 'observer2', because observer1 does not track bconsole.log(obj2.a, obj1.b); // logs 3 and 3, while the object is observed independently, it is still a single object
Debido a que useState
devuelve un objeto reactivo normal, es posible llamar a reactive
en el resultado de un useState
para observar cambios en ese objeto mientras se está fuera del contexto de un componente, o llamar a useState
en objetos reactivos creados fuera de los componentes. En esos casos, se debe tener cuidado con respecto a la duración de esos objetos reactivos, ya que mantener referencias a estos objetos puede evitar la recolección de basura del componente y sus datos incluso si Owl lo ha destruido.
Las suscripciones son efímeras
Las suscripciones a los cambios de estado son efímeras: cada vez que se notifica a un observador que un objeto de estado ha cambiado, se borran todas sus suscripciones, lo que significa que, si aún le interesa, debería volver a leer las propiedades que le interesan. Por ejemplo:
const obj = reactive({ a: 1 }, () => console.log("observer called"));
console.log(obj.a); // logs 1, and reads the 'a' key => it is now tracked by the observerobj.a = 3; // logs 'observer1' and clears the subscriptions of the observerobj.a = 4; // doesn't log anything, the key is no longer observed
Esto puede parecer contra-intuitivo, pero tiene todo el sentido en el contexto de los componentes:
class DoubleCounter extends Component { static template = xml` <t t-esc="'selected: ' + state.selected + ', value: ' + state[state.selected]"/> <button t-on-click="() => this.state.count1++">increment count 1</button> <button t-on-click="() => this.state.count2++">increment count 2</button> <button t-on-click="changeCounter">Switch counter</button> `;
setup() { this.state = useState({ selected: "count1", count1: 0, count2: 0 }); }
changeCounter() { this.state.selected = this.state.selected === "count1" ? "count2" : "count1"; }}
En este componente, si incrementamos el valor del segundo contador, el componente no volverá a renderizarse, lo que tiene sentido, ya que la repetición de la renderización no tendrá ningún efecto, ya que el segundo contador no se muestra. Si activamos el componente para que muestre el segundo contador, ya no queremos que el componente vuelva a renderizarse cuando cambie el valor del primer contador, y esto es lo que sucede: un componente solo vuelve a renderizarse cuando hay cambios en partes del estado que se han leído durante o después de la renderización anterior. Si una parte del estado no se ha leído en la última renderización, sabemos que su valor no influirá en la salida renderizada, por lo que podemos ignorarlo.
Map
y Set
reactivos
El sistema de reactividad tiene un soporte especial integrado para los tipos de contenedores estándar Map
y Set
. Se comportan como cabría esperar: al leer una clave, el observador se suscribe a esa clave, al agregar o quitar un elemento de la misma, se notifica a los observadores que han usado alguno de los iteradores en ese objeto reactivo, como .entries()
o .keys()
, y al borrarlos.
Trampillas de escape
A veces, es conveniente evitar el sistema de reactividad. Crear proxies al interactuar con objetos reactivos es costoso y, si bien, en general, el beneficio de rendimiento que obtenemos al volver a renderizar solo las partes de la interfaz que lo necesitan supera ese costo, en algunos casos, queremos poder optar por no crearlos en primer lugar. Este es el propósito de markRaw
:
markRaw
Marca un objeto para que sea ignorado por el sistema de reactividad, lo que significa que si este objeto alguna vez es parte de un objeto reactivo, se devolverá tal como está y no se observarán claves en ese objeto.
const someObject = markRaw({ b: 1 });const state = useState({ a: 1, obj: someObject,});console.log(state.obj.b); // attempt to subscribe to the "b" key in someObjectstate.obj.b = 2; // No rerender will occur hereconsole.log(someObject === state.obj); // true
Esto es útil en algunos casos excepcionales. Un ejemplo de ello sería si desea utilizar una matriz de objetos que es potencialmente grande para representar una lista, pero se sabe que esos objetos son inmutables:
this.items = useState([ { label: "some text", value: 42 }, // ... 1000 total objects]);
En la plantilla:
<t t-foreach="items" t-as="item" t-key="item.label" t-esc="item.label + item.value"/>
Aquí, en cada render, leemos mil claves de un objeto reactivo, lo que hace que se creen mil objetos reactivos. Si sabemos que el contenido de estos objetos no puede cambiar, esto es un trabajo desperdiciado. Si, en cambio, todos estos objetos se marcan como sin procesar, evitamos todo este trabajo y conservamos la capacidad de apoyarnos en la reactividad para rastrear la presencia e identidad de estos objetos:
this.items = useState([ markRaw({ label: "some text", value: 42 }), // ... 1000 total objects]);
Sin embargo, utilice esta función con precaución: se trata de una vía de escape del sistema de reactividad y, como tal, su uso puede causar problemas sutiles e imprevistos. Por ejemplo:
// This will cause a rerenderthis.items.push(markRaw({ label: "another label", value: 1337 }));
// THIS WILL NOT CAUSE A RENDER!this.items[17].value = 3;// The UI is now desynced from component's state until the next render caused by something else
En resumen: use markRaw
solo si su aplicación se está ralentizando notablemente y el perfil revela que se gasta mucho tiempo creando objetos reactivos inútiles.
toRaw
Mientras que markRaw
marca un objeto para que nunca se vuelva reactivo, toRaw
toma un objeto y devuelve el objeto subyacente no reactivo. Puede ser útil en algunos casos específicos. En particular, debido a que el sistema de reactividad devuelve un proxy, el objeto devuelto no es igual al objeto original:
const obj = {};const reactiveObj = reactive(obj);console.log(obj === reactiveObj); // falseconsole.log(obj === toRaw(reactiveObj)); // true
También puede ser útil durante la depuración, ya que desplegar proxies de forma recursiva en los depuradores puede resultar confuso.
Uso avanzado
Lo que sigue es una colección de pequeños fragmentos que aprovechan el sistema de reactividad en formas “no estándar” para ayudarlo a comprender su poder y cómo su uso puede simplificar su código.
Manejo de notificaciones
Mostrar notificaciones es una necesidad bastante común en las aplicaciones web, es posible que desee mostrar una notificación de cualquier otro componente dentro de la aplicación, y las notificaciones deben apilarse una sobre otra independientemente de qué componente las generó, aquí es cómo podemos aprovechar la reactividad para lograr esto:
let notificationId = 1;const notifications = reactive({});class NotificationContainer extends Component { static template = xml` <t t-foreach="notifications" t-as="notification" t-key="notification_key" t-esc="notification"/> `; setup() { this.notifications = useState(notifications); }}
export function addNotification(label) { const id = notificationId++; notifications[id] = label; return () => { delete notifications[id]; };}
Aquí, la variable notifications
es un objeto reactivo. Observe cómo no le dimos a reactive
una devolución de llamada: esto se debe a que, en este caso, lo único que nos importa es que agregar o eliminar notificaciones en la función addNotification
pase por el sistema de reactividad. El componente NotificationContainer
vuelve a observar este objeto con useState
y se actualiza cada vez que se agregan o eliminan notificaciones.
Almacenamiento
La centralización del estado de la aplicación es una necesidad bastante común en las aplicaciones web. Debido a la forma en que funciona el sistema de reactividad, puedes tratar cualquier objeto reactivo como un almacén y, si llamas a useState
en él, los componentes observan automáticamente solo la parte del almacén que les interesa:
export const store = reactive({ list: [], add(item) { this.list.push(item); },});
export function useStore() { return useState(store);}
En cualquier componente:
import { useStore } from "./store";
class List extends Component { static template = xml` <t t-foreach="store.list" t-as="item" t-key="item" t-esc="item"/> `; setup() { this.store = useStore(); }}
En cualquier lugar de la aplicación:
import { store } from "./store";// Will cause any instance of the List component in the app to updatestore.add("New list item!");
Observe cómo podemos convertir objetos con métodos en objetos reactivos y, cuando estos métodos se utilizan para modificar el contenido del almacén, funciona como se espera. Y, si bien los almacenes son generalmente objetos únicos, es totalmente posible hacer que las instancias de clase sean reactivas:
class Store { list = []; add(item) { this.list.push(item); }}// Essentially equivalent to the previous codeexport const store = reactive(new Store());
Lo cual puede ser útil para realizar pruebas unitarias de la clase por separado.
Local storage synchronization
A veces, quieres conservar algún estado después de recargar, puedes hacerlo almacenándolo en localStorage
, pero ¿qué pasa si quieres actualizar el elemento localStorage
cada vez que cambia el estado, de modo que no tengas que sincronizar manualmente los estados? Bueno, puedes usar el sistema de reactividad para escribir un gancho personalizado que lo hará por ti:
function useStoredState(key, initialState) { const state = JSON.parse(localStorage.getItem(key)) || initialState; const store = (obj) => localStorage.setItem(key, JSON.stringify(obj)); const reactiveState = reactive(state, () => store(reactiveState)); store(reactiveState); return useState(state);}
class MyComponent extends Component { setup() { this.state = useStoredState("MyComponent.state", { value: 1 }); }}
Una cosa importante a tener en cuenta es que ambas veces que llamamos a store
, lo hacemos con reactiveState
, no con state
: necesitamos que store
lea las claves a través de un objeto reactivo para que se suscriba correctamente a los cambios de estado. Observe también que llamamos a store
la primera vez a mano, ya que de lo contrario no se suscribirá a nada y ningún cambio en el objeto hará que se invoque la devolución de llamada reactiva.