Reescribir una aplicación de React a Svelte, qué puedes esperar
Published: 2021-04-08
Recientemente he reescrito una aplicación de tamaño medio en React (~250 componentes de React) a Svelte. El resultado de esta acción es una aplicación más eficiente, con un mantenimiento más sencillo y un código mucho más legible. Pero hubo algunos problemas, algunas situaciones que no son directas de transformar en código Svelte. Este artículo describe algunas de estas situaciones.
Este no es un tutorial de Svelte para programadores de React, si aún no conoces Svelte, el mejor punto de partida es el tutorial oficial de Svelte. Este artículo contiene muchos enlaces a tutoriales de Svelte, en lugar de explicar los principios de Svelte.
CSS
Svelte soporta CSS directamente de una manera muy agradable y limpia. En la aplicación React, he utilizado styled components. Hay muchas bibliotecas de CSS para React, pero todas ellas (que yo sepa) usan el paradigma CSS-in-JS, mientras que en Svelte, usas CSS bastante limpio.
En el siguiente fragmento, mostraré una implementación simplificada de un botón de barra de herramientas, en React (con styled components) y con Svelte.
El botón de la barra de herramientas tiene una propiedad (disabled), que desactiva la funcionalidad de hover. También utiliza algunas variables del tema.
// Componente React ToolbarButton({disabled, toolBarHeight})
const ToolbarButton = styled.div`
padding: 5px 15px;
height: ${props.toolBarHeight}px;
${(props) =>
!props.disabled &&
`
&:hover {
background-color: gray;
}
`}
`;
<!-- Componente Svelte -->
<script>
export let disabled;
</script>
<div class="button" class:disabled />
<style>
.button {
padding: 5px 15px;
height: var(--theme-toolbar-height); /* no puedes usar la propiedad directamente, se debe usar una variable CSS */
}
.button.disabled {
background-color: gray;
}
</style>
Svelte no soporta ninguna variable en CSS. Así que no es posible pasar una propiedad al componente Svelte y usarla en CSS. La única forma de hacer esto es activando/desactivando una clase CSS. El selector button.disabled se usa cuando disabled está configurado en true.
Cuando quieras usar algunos valores (por ejemplo, dimensiones en CSS), podrías usar variables CSS.
Hooks de React, especialmente useEffect
Los hooks de React son una característica muy poderosa. En Svelte, no encontrarás nada similar. Pero la mayoría de los hooks de React, no los extrañarás en Svelte.
- useState - el estado se define con comandos let
- useMemo - puedes usar comandos reactivos en su lugar para recomputar el estado interno
- useCallback - las expresiones de función se evalúan solo una vez, por lo que no tiene sentido
- useContext - Svelte tiene una API de contexto muy simple y directa (getContext(), setContext())
- useReducer - debes crear una versión de Svelte, usando stores (esto podría ser muy fácil)
- useRef - puedes usar bind:this en su lugar (o una variable let para variables de instancia)
- useEffect - esto es más complicado…
useEffect - uso simple
Uso useEffect en React muy a menudo, desafortunadamente debes pensar en cómo implementar la misma lógica en Svelte.
El uso más simple es ejecutar algún código en mount y unmount.
// React
React.useEffect(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT');
}, []);
Esta variante se convierte con los métodos de svelte onMount y onDestroy
// Svelte
onMount(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT 1');
});
onDestroy(() => {
console.log('UNMOUNT 2');
});
Como puedes ver, tienes dos variantes, cómo ejecutar código en unmount - con la función onDestroy y con el valor de retorno de la función onMount.
useEffect - declaración reactiva
Cuando quieras ejecutar algún código al cambiar una expresión, podrías usar la declaración reactiva de Svelte.
// React
React.useEffect(() => {
console.log('HEIGHT changed, new value:', height);
}, [height]);
// Svelte
$: console.log('HEIGHT changed, new value:', height);
En Svelte, la declaración reactiva se ejecuta cuando se asigna cualquiera de las dependencias de la declaración. Así que si necesitas hacer algo solo cuando la dependencia cambia, debes verificar el valor por tu cuenta.
Si deseas enumerar explícitamente las dependencias (como en el segundo argumento en el método React.useEffect), podrías usar este patrón:
// Svelte
$: {
height;
width;
handleChanged();
}
Esto se llamará después de asignar la variable height o width. Podrías usar otras variables en la función handleChanged, pero estas dependencias no activarán esta declaración reactiva.
useEffect - declaración reactiva con limpieza
React useEffect también se puede usar para asignar algunos recursos, que dependen del valor de la propiedad. En el siguiente ejemplo, el componente muestra una lista de tablas en una base de datos SQL. Está escuchando cambios en la estructura de la base de datos. Cuando se cambia la propiedad connectionId, es necesario desconectarse del socket antiguo y conectarse al nuevo socket.
// React
function SqlTableList({ connectionId }) {
React.useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
}, [connectionId]);
}
Svelte no soporta este escenario de manera tan directa como React. Pero podrías usar un truco simple para lograr este comportamiento.
// Svelte
const useEffect = subscribe => ({ subscribe });
$: effect = useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
});
$: $effect;
¿Cómo funciona este truco? Necesitas conocer sobre stores, especialmente stores personalizados (tutorial). La función useEffects crea un store personalizado con un método de suscripción. El método subscribe del store debe devolver un método de desuscripción, que se llama cuando la suscripción al store ya no es necesaria.
La última línea ( $: $effect ) gestiona la suscripción automática al store (tutorial). Así que llama correctamente a socket.on y luego a socket.off, cuando el valor de connectionId cambia (o solo se asigna en Svelte), de manera similar a como lo hace el equivalente en React.
Por qué extraño las props de React en Svelte
Por supuesto, también hay props en Svelte. Funcionan de manera similar a como lo hacen en React. Pero en React, la única interfaz externa del componente son las props. En Svelte, tienes varios mecanismos para gestionar el comportamiento de tus componentes:
- props - funcionan como en React
- eventos - usan una sintaxis especial, los eventos no son parte del objeto $$props que contiene todas las props
- acciones (sintaxis use:action) - mecanismo para reutilizar lógica vinculada a elementos HTML
- slots - el propósito es el mismo que la propiedad children de React, con algunas extensiones
Todos estos mecanismos son muy útiles, pero no tienen un acceso unificado como las props en React. A continuación se discuten algunos problemas con los que me he encontrado.
Reenviando eventos
Si creas jerarquías de componentes más complejas, algunos componentes solo reenvían datos de componentes padres.
// React - reenvía todos los eventos, son parte de las props
function Outer(props) {
return <Inner {...props} />;
}
<!-- Svelte - solo los eventos explícitamente nombrados click y keydown son reenviados -->
<Inner {...$$props} on:click on:keydown />;
}
En Svelte, no puedes reenviar todos los eventos, pero puedes reenviar eventos enumerados. Pero si necesitas reenviar todos los eventos, puedes usar funciones de callback (onClick en lugar de on:click), entonces onClick será parte de $$props.
Implementando TabControl
En React es bastante fácil implementar un componente TabControl, que se usará de la siguiente manera:
// React
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
La implementación enumerará a través del array de children, y podría extraer fácilmente la información deseada (título de la página y contenido). Este enfoque no funciona en Svelte, no tiene nada como children. Podrías usar fragmentos de Svelte, el uso se verá de la siguiente manera:
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', slot: 1},
{ label: 'Page 2', slot: 2},
]}>
<svelte:fragment slot='1'>
Page 1 content
</svelte:fragment>
<svelte:fragment slot='2'>
Page 2 content
</svelte:fragment>
</TabControl>
Las pestañas se definen en un array, el diseño de las pestañas (children) se define en fragmentos, que se pasan como slots al componente TabControl. No es tan intuitivo como en React, pero funciona. Solo un gran inconveniente, que puedes ver en la implementación de TabControl:
// TabControl.svelte
<script>
export let tabs = []
</script>
<div>
{#each _.compact(tabs) as tab, index}
<div class="container" class:isInline class:tabVisible={index == value}>
{#if tab.slot == 0}<slot name="0" />
{:else if tab.slot == 1}<slot name="1" />
{:else if tab.slot == 2}<slot name="2" />
{:else if tab.slot == 3}<slot name="3" />
{:else if tab.slot == 4}<slot name="4" />
{:else if tab.slot == 5}<slot name="5" />
{:else if tab.slot == 6}<slot name="6" />
{:else if tab.slot == 7}<slot name="7" />
{/if}
</div>
{/each}
</div>
Los nombres de los slots deben ser cadenas estáticas, por lo que tienes que hacer algo como esto para que funcione.
Usando un componente dinámico
Un enfoque diferente podría ser definir un componente por página de pestaña, pero entonces tendrás muchos archivos pequeños, porque cada componente Svelte debe definirse en su propio archivo.
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', component: Tab1},
{ label: 'Page 2', component: Tab2},
]}/>
// Tab1.svelte
Page 1 content
// Tab2.svelte
Page 2 content
La implementación usará svelte:component para instanciar la pestaña adecuada.
Usando contexto
Esta es la forma menos obvia. Pero de hecho, puedes lograr la misma sintaxis que en React.
<!-- Svelte -->
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
En el componente TabControl debe definirse algún “punto de colección”, por ejemplo, un array de pestañas hijas
<!-- TabControl.svelte -->
<script>
const tabs = [];
setContext('tabs', tabs);
</script>
Y en la página de pestañas, solo registramos la pestaña en el array padre:
<!-- TabPage.svelte -->
<script>
export let label;
const tabs = getContext('tabs');
tabs.push({ label });
</script>
El único inconveniente de este método es que no preserva el orden definido de las páginas de pestañas, si algunas de ellas se renderizan condicionalmente.
<TabControl>
{#if condition_will_be_true_later}
<TabPage label='Page 1'>
Page 1 content
</TabPage>
{/if}
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
Esto se renderizará primero con la Página 2 y luego con la Página 1, lo cual probablemente no es el resultado deseado.
Atención al usar bind:clientHeight y bind:clientWidth
Vincular dimensiones (tutorial) es una gran característica. Pero ten cuidado al usarlo. Está implementado con un iframe oculto (puedes verlo en este REPL). Por mi experiencia, a veces en situaciones más complicadas no funcionó en FireFox. A veces es más seguro usar ResizeObserver (ver esta implementación de acción)
Límite de error
React tiene un gran concepto de límites de error (“try-catch” para componentes). No está soportado en componentes funcionales (de hecho, ErrorBoundary era el único componente de clase en mi aplicación React), pero no fue un problema usar este componente de clase en una aplicación React funcional.
// React
<ErrorBoundary>
{(null).read()}
</ErrorBoundary>
Sin un límite de error, cuando tienes este código en una aplicación React, toda la aplicación fallará. Con un límite de error, solo el interior del límite fallará, otras partes de la aplicación se renderizarán como de costumbre.
Desafortunadamente, Svelte no tiene nada como esto. Tiene un paquete NPM llamado svelte-error-boundary, pero de hecho, solo resuelve una pequeña parte de los problemas y la mayoría de los errores en la aplicación Svelte aún causan un fallo en la aplicación.
Svelte tiene su propia forma de fallar: los componentes de Svelte dejarán de ser reactivos, por lo que la aplicación parece congelada.
Lo único que puedes hacer es detectar esta situación y permitir que el usuario recargue la página.
<!-- Svelte -->
<script>
let counter = 0;
$: counterCopy = counter;
const onunhandledrejection = async e => {
console.log('Error no manejado, verificando si se ha bloqueado', e);
const oldCounter = counter;
counter++;
window.setTimeout(() => {
if (counterCopy <= oldCounter) {
console.log('¡SE DETECTÓ UN FALLO!');
if (window.confirm('Lo sentimos, la aplicación ha fallado.\n¿Recargar la página?')) {
window.location.reload();
}
}
}, 500);
};
</script>
<svelte:window on:unhandledrejection={onunhandledrejection} />
Para la detección, se utiliza el evento unhandledrejection. Este evento podría activarse en más situaciones, algunas de ellas no causan un fallo en Svelte. Esta es la razón de usar las variables counter y counterCopy. Si la declaración reactiva no funciona, significa que todo Svelte está bloqueado y la única forma de recuperarse es recargar la página.
Puedes ver el componente completo ErrorHandler.
Conclusión
A pesar de estos problemas, Svelte es un gran framework y estoy muy contento con el resultado de convertir mi aplicación de React a Svelte. Algunas características, que ahora están implementadas con Svelte, serían casi imposibles de hacer con React.
Por supuesto, si usas muchas bibliotecas de terceros, que solo están disponibles para React, podría ser un problema serio. Pero este no fue mi caso, tenía dependencias mínimas con React, en este caso también reescribir a Svelte podría ser bastante fácil y rápido.