El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

Svelte en Producción

Svelte Reactivity Gotchas + Solutions (Si estás usando Svelte en producción deberías leer esto)

· 8 min de lectura
Svelte en Producción

Svelte es un gran framework y mi equipo lo ha estado utilizando para construir aplicaciones de producción durante más de un año con gran éxito, productividad y disfrute. Una de sus características principales es la reactividad como un ciudadano de primera clase, que es muy sencillo de usar y permite que algunos de los más expresivos, código declarativo imaginable: Cuando se cumple alguna condición o algo relevante ha cambiado, no importa por qué o cómo, algún fragmento de código se ejecuta. Es increíble y hermoso. La magia del compilador.

Cuando estás jugando con él, parece que funciona sin problemas, pero a medida que tus aplicaciones se vuelven más complejas y exigentes puedes encontrarte con todo tipo de comportamientos desconcertantes e indocumentados que son muy difíciles de depurar.


Esperemos que este breve post te ayude a aliviar parte de la confusión y a volver al buen camino.

Antes de empezar, dos advertencias:

  1. Todos los ejemplos que aparecen a continuación son inventados. Por favor, no te molestes con comentarios como "podrías haber implementado el ejemplo de alguna otra manera para evitar el problema". Lo sé. Te prometo que nos hemos encontrado con cada uno de estos problemas en bases de código reales, y que cuando una base de código Svelte es bastante grande y compleja, estas situaciones y malentendidos pueden surgir y de hecho surgen.
  2. Las ideas presentadas a continuación son el resultado de trabajar los problemas con los miembros de mi equipo, así como con algunos miembros de la comunidad Svelte.

Gotcha #1: Las dependencias implícitas son malas


Este es un clásico. Digamos que escribes el siguiente código:

<script>
    let a = 4;
    let b = 9;
    let sum;
    function sendSumToServer() {
        console.log("sending", sum);
    }
    $: {
        sum = a + b;
        sendSumToServer();
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>

Todo funciona (haz clic en el enlace REPL de arriba o aquí) pero luego en la revisión del código te dicen que extraigas una función para calcular la suma por "legibilidad" o cualquier otra razón.
Lo haces y obtienes:

<script>
    let a = 4;
    let b = 9;
    let sum;
    function calcSum() {
        sum = a + b;
    }
    function sendSumToServer() {
        console.log("sending", sum);
    }
    $: {
        calcSum();
        sendSumToServer();
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>

El revisor está contento pero oh no, el código ya no funciona. Actualizar a o b no actualiza la suma y no informa al servidor. ¿Por qué?
Bueno, el bloque reactivo no se da cuenta de que a y b son dependencias. ¿Se le puede culpar? Supongo que no, pero eso no te ayuda cuando tienes un gran bloque reactivo con múltiples dependencias implícitas, potencialmente sutiles, y se te ocurre refactorizar una de ellas.

Y puede ser mucho peor...


Una vez que el mecanismo de reconocimiento automático de dependencias pasa por alto una dependencia, pierde su capacidad de ejecutar los bloques reactivos en el orden esperado (es decir, el gráfico de dependencias). En su lugar, los ejecuta de arriba a abajo.

Este código produce el resultado esperado porque Svelte hace un seguimiento de las dependencias, pero esta versión no lo hace porque hay dependencias ocultas como vimos antes y los bloques reactivos se ejecutan en orden.

La cuestión es que si tuviéramos el mismo "código malo" pero en un orden diferente como este, seguiría dando el resultado correcto, como una mina esperando a ser pisada.

Las implicaciones de esto son enormes. Puedes tener "código malo" que funciona porque todos los bloques reactivos están en el orden "correcto" por pura casualidad, pero si copias y pegas un bloque en un lugar diferente del archivo (mientras refactorizas, por ejemplo), de repente todo se rompe y no tienes ni idea de por qué.

Vale la pena reiterar que los problemas pueden parecer obvios en estos ejemplos, pero si un bloque reactivo tiene un montón de dependencias implícitas y pierde el rastro de una sola de ellas, será mucho menos obvio.

De hecho, cuando un bloque reactivo tiene dependencias implícitas, la única forma de entender cuáles son las dependencias es leerlo detenidamente en su totalidad (aunque sea largo y ramificado). Esto hace que las dependencias implícitas sean malignas en un entorno de producción.

Solución A - funciones con lista de argumentos explícita:


Al llamar a funciones desde bloques reactivos o al refactorizar, sólo utilice funciones que tomen todas sus dependencias explícitamente como argumentos, de modo que el bloque reactivo "vea" los parámetros que se pasan y "entienda" que el bloque necesita volver a ejecutarse cuando cambian así.

<script>
    let a = 4;
    let b = 9;
    let sum;
    function calcSum(a,b) {
        sum = a + b;
    }
    function sendSumToServer(sum) {
        console.log("sending", sum);
    }
    $: {
        calcSum(a,b);
        sendSumToServer(sum);
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>

Casi puedo oír a algunos de los lectores que son programadores funcionales diciendo "duh", aún así yo optaría por la solución B (abajo) en la mayoría de los casos porque incluso si tus funciones son más puras necesitarás leer todo el bloque reactivo para entender cuáles son las dependencias.

Solución B - ser explícito


Haz explícitas todas tus dependencias en la parte superior del bloque. Yo suelo usar una sentencia if con todas las dependencias al principio. Así:

<script>
    let a = 4;
    let b = 9;
    let sum;
    function calcSum() {
        sum = a + b;
    }
    function sendSumToServer() {
        console.log("sending", sum);
    }
    $: if (!isNaN(a) && !isNaN(b)) {
        calcSum();
        sendSumToServer();
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>

No estoy tratando de decir que debes escribir código como este cuando se calcula la suma de dos números. Lo que intento decir es que, en el caso general, una condición de este tipo en la parte superior hace que el bloque sea más legible y también inmune a la refactorización. Requiere cierta disciplina (no omitir ninguna de las dependencias) pero, por experiencia, no es difícil hacerlo bien al escribir o modificar el código.

Truco nº 2: Los disparadores primitivos y los basados en objetos no se comportan igual

Esto no es exclusivo de Svelte, pero Svelte lo hace menos obvio.
Considera lo siguiente:

<script>
    let isForRealz = false;
    let isForRealzObj = {value: false};
    function makeTrue() {
        isForRealz = true;
        isForRealzObj.value = true;
    }
    $: if (isForRealz) console.log(Date.now(), "isForRealz became true");
    $: if (isForRealzObj.value) console.log(Date.now(), "isForRealzObj became true");

</script>

<p>
    click the button multiple times, why does the second console keep firing?
</p>
<h4>isForRealz: {isForRealz && isForRealzObj.value}</h4>
<button on:click={makeTrue}>click and watch the console</button>

Si sigues pulsando el botón mientras observas la consola, te darás cuenta de que la sentencia if se comporta de forma diferente para una primitiva y para un objeto. ¿Qué comportamiento es más correcto? Supongo que depende de tu caso de uso, pero si refactorizas de uno a otro prepárate para una sorpresa.
Para primitivas compara por valor, y no se ejecutará de nuevo mientras el valor no cambie.

En el caso de los objetos se podría pensar que se trata de un objeto nuevo y que Svelte simplemente compara por referencia, pero eso no parece ser así porque cuando asignamos usando isForRealzObj.value = true no estamos creando un objeto nuevo sino actualizando el existente, y la referencia sigue siendo la misma.

Solución:


Tenlo en cuenta y ten cuidado. Esto no es tan difícil de vigilar si eres consciente de ello. Si estás usando un objeto y no quieres que el bloque se ejecute cada vez, necesitas recordar poner tu propia comparación con el valor antiguo en su lugar y no ejecutar tu lógica si no hubo cambio.

Gotcha #3: La malvada microtarea (bueno, a veces...)


Muy bien, hasta aquí estábamos calentando motores. Éste viene en múltiples sabores. Demostraré los dos más comunes.

Verás, Svelte agrupa algunas operaciones (como bloques reactivos y actualizaciones DOM) y las programa al final de la cola de actualizaciones piensa en requestAnimationFrame o setTimeout(0). Esto se denomina microtarea o tick.

Una cosa que es especialmente desconcertante cuando te encuentras con ella, es que la asincronía cambia completamente cómo se comportan las cosas porque se escapa de los límites de la micro-tarea. Así que cambiar entre operaciones sync/ async puede tener todo tipo de implicaciones en cómo se comporta tu código.

Podrías enfrentarte a bucles infinitos que antes no eran posibles (al pasar de sync a async) o a bloques reactivos que dejan de activarse total o parcialmente (al pasar de async a sync).

Veamos algunos ejemplos en los que la forma en que Svelte gestiona las microtareas da lugar a comportamientos potencialmente inesperados.

3.1: Estados desaparecidos


¿Cuántas veces cambió aquí el nombre?

<script>
    let name = "Sarah";
    let countChanges = 0;
    $: {
        console.log("I run whenever the name changes!", name);
        countChanges++;
    }   
    name = "John";
    name = "Another name that will be ignored?";
    console.log("the name was indeed", name)
    name = "Rose";

</script>

<h1>Hello {name}!</h1>
<p>
    I think that name has changed {countChanges} times
</p>

Svelte cree que la respuesta es 1 mientras que en realidad es 3
Como he dicho antes, los bloques reactivos sólo se ejecutan al final de la microtarea y sólo "ven" el último estado que existía en ese momento.

En este sentido, no hace realmente honor a su nombre, "reactivo", porque no se dispara cada vez que se produce un cambio (en otras palabras, no se dispara de forma sincrónica mediante una operación "set" en una de sus dependencias, como cabría esperar intuitivamente).

Solución a 3.1


Cuando necesites realizar un seguimiento de todos los cambios de estado a medida que se producen sin perderse ninguno, utiliza en su lugar un almacén. Los almacenes se actualizan en tiempo real y no se saltan estados. Puedes interceptar los cambios dentro de la función set del almacén o suscribiéndote a él directamente (mediante store.subscribe).

Así es como lo harías para el ejemplo anterior:

3.2  Nada de recursividad


A veces querrás tener un bloque reactivo que cambie los valores de sus propias dependencias hasta que se "asiente", es decir, la vieja recursividad.

He aquí un ejemplo algo artificioso para que se entienda mejor cómo puede salir mal:

<script>
    let isSmallerThan10 = true;
    let count = {a:1};
    $: if (count.a) {
        if (count.a < 10) {
            console.error("smaller", count.a);
            // this should trigger this reactive block again and enter the "else" but it doesn't
            count = {a: 11}; 
        } else {
            console.error("larger", count.a);
            isSmallerThan10 = false;
        }
    }
</script>

<p>
    count is {count.a}
</p>
<p>
    isSmallerThan10 is {isSmallerThan10}
</p>

No importa si count es una primitiva o un objeto, la parte else del bloque reactivo nunca se ejecuta y isSmallerThan10 se desincroniza y lo hace silenciosamente (muestra true aunque count sea 11 y debería ser false).
Esto ocurre porque cada bloque reactivo sólo puede ejecutarse como máximo una vez por tick.
Este problema específico afectó a mi equipo cuando cambiamos de un almacén asíncrono a un almacén de actualización optimista, lo que hizo que la aplicación se rompiera de muchas formas sutiles y nos dejó totalmente desconcertados.

Ten en cuenta que esto también puede ocurrir cuando tienes múltiples bloques reactivos actualizando dependencias entre sí en una especie de bucle.

Este comportamiento a veces puede ser considerado una característica, que te protege de bucles infinitos, como aquí, o incluso evita que la aplicación entre en un estado no deseado, como en este ejemplo que fue amablemente proporcionado por Rich Harris.

Solución a 3.2: Asincronía forzada al rescate


Para permitir que los bloques reactivos se ejecuten hasta su resolución, tendrás que colocar estratégicamente llamadas a tick() en tu código.
Un patrón extremadamente útil

$: tick().then(() => {
  //your code here
});

He aquí una versión corregida del ejemplo isSmallerThan10 utilizando este truco.

Resumen


Te he mostrado los gotchas más comunes relacionados con la reactividad de Svelte, basándome en la experiencia de mi equipo, y algunas formas de evitarlos.

A mí me parece que todos los frameworks y herramientas (al menos los que he utilizado hasta la fecha) luchan por crear una implementación de reactividad "libre de gotchas".

Sigo prefiriendo la reactividad de Svelte a todo lo demás que he probado hasta la fecha, y espero que algunos de estos problemas se resuelvan en un futuro próximo o que al menos estén mejor documentados.

Supongo que es inevitable que cuando se utiliza cualquier herramienta para escribir aplicaciones de producción, uno tiene que entender el funcionamiento interno de la herramienta en gran detalle con el fin de mantener las cosas juntas y Svelte no es diferente.

Gracias por leer y ¡buen trabajo!

Fuente

Plataforma de cursos gratis sobre programación