Riscrivere un’app da React a Svelte, cosa puoi aspettarti
Published: 2021-04-08
Di recente ho riscritto un’applicazione di medie dimensioni in React (~250 componenti React) in Svelte. Il risultato è un’app più efficiente, più facile da manutenere e con un codice molto più leggibile. Ma ci sono stati alcuni problemi, alcune situazioni che non sono così immediate da trasformare in codice Svelte. Questo articolo descrive alcune di queste situazioni.
Questo non è un tutorial di Svelte per programmatori React; se non conosci ancora Svelte, il punto di partenza migliore è il tutorial ufficiale di Svelte. Questo articolo contiene molti link ai tutorial di Svelte, invece di spiegare direttamente i principi di Svelte.
CSS
Svelte supporta il CSS direttamente in modo molto pulito ed elegante. Nell’app React ho usato styled components.
Esistono molte librerie CSS per React, ma tutte (per quanto ne so) usano il paradigma CSS-in-JS, mentre in Svelte usi CSS piuttosto pulito.
Nel seguente snippet mostrerò un’implementazione semplificata di un pulsante di toolbar, in React (con styled components) e in Svelte.
Il pulsante di toolbar ha una proprietà (disabled) che disabilita la funzionalità di hover. Inoltre usa alcune variabili dal tema.
// React component ToolbarButton({disabled, toolBarHeight})
const ToolbarButton = styled.div`
padding: 5px 15px;
height: ${props.toolBarHeight}px;
${(props) =>
!props.disabled &&
`
&:hover {
background-color: gray;
}
`}
`;
<!-- Svelte component -->
<script>
export let disabled;
</script>
<div class="button" class:disabled />
<style>
.button {
padding: 5px 15px;
height: var(--theme-toolbar-height); /* non puoi usare direttamente la prop, devi usare una variabile CSS */
}
.button.disabled {
background-color: gray;
}
</style>
Svelte non supporta variabili all’interno del CSS. Quindi non è possibile passare una proprietà a un componente Svelte e usarla nel CSS. L’unico modo per farlo è attivare/disattivare una classe CSS. Il selettore button.disabled viene usato quando disabled è impostato a true.
Quando vuoi usare alcuni valori (ad es. dimensioni nel CSS), puoi usare le variabili CSS.
React hooks, in particolare useEffect
Gli hook di React sono una funzionalità molto potente. In Svelte non troverai nulla di simile. Ma la maggior parte degli hook di React non ti mancherà in Svelte.
- useState - lo stato è definito con comandi let
- useMemo - puoi usare le istruzioni reattive per ricalcolare lo stato interno
- useCallback - le espressioni di funzione vengono valutate solo una volta, quindi non ha senso
- useContext - Svelte ha una API di contesto molto semplice e diretta (getContext(), setContext())
- useReducer - devi creare una versione Svelte usando gli store (può essere molto semplice)
- useRef - puoi usare bind:this al suo posto (o una variabile let per le variabili di istanza)
- useEffect - questo è più complicato…
useEffect - uso semplice
Uso spesso useEffect in React; purtroppo in Svelte devi ragionare su come implementare la stessa logica.
L’uso più semplice è eseguire del codice in mount e in unmount.
// React
React.useEffect(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT');
}, []);
Questa variante è coperta dai metodi di Svelte onMount e onDestroy
// Svelte
onMount(() => {
console.log('MOUNT');
return () => console.log('UNMOUNT 1');
});
onDestroy(() => {
console.log('UNMOUNT 2');
});
Come puoi vedere, hai due varianti per eseguire codice in unmount: con la funzione onDestroy e con il valore di ritorno della funzione onMount.
useEffect - istruzione reattiva
Quando vuoi eseguire del codice al cambiare di un’espressione, puoi usare una istruzione reattiva di Svelte.
// React
React.useEffect(() => {
console.log('HEIGHT changed, new value:', height);
}, [height]);
// Svelte
$: console.log('HEIGHT changed, new value:', height);
In Svelte, l’istruzione reattiva viene eseguita quando una qualsiasi delle dipendenze dell’istruzione è stata assegnata. Quindi, se devi fare qualcosa solo quando la dipendenza cambia, devi controllare il valore da solo.
Se vuoi elencare esplicitamente le dipendenze (come nel secondo argomento del metodo React.useEffect), puoi usare questo pattern:
// Svelte
$: {
height;
width;
handleChanged();
}
Questo verrà chiamato dopo l’assegnazione della variabile height o width. Puoi usare altre variabili nella funzione handleChanged, ma queste dipendenze non attiveranno questa istruzione reattiva.
useEffect - istruzione reattiva con cleanup
React useEffect può essere usato anche per allocare alcune risorse che dipendono dal valore di una proprietà. Nel seguente esempio, il componente mostra l’elenco delle tabelle in un database SQL. Sta ascoltando i cambiamenti della struttura del database. Quando la proprietà connectionId cambia, è necessario disconnettersi dal vecchio socket e connettersi al nuovo.
// React
function SqlTableList({ connectionId }) {
React.useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
}, [connectionId]);
}
Svelte non supporta questo scenario in modo così diretto come React. Ma puoi usare un semplice trucco per ottenere questo comportamento.
// Svelte
const useEffect = subscribe => ({ subscribe });
$: effect = useEffect(() => {
socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
return () => {
socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
};
});
$: $effect;
Come funziona questo trucco? Devi conoscere gli store, in particolare gli store personalizzati (tutorial).
La funzione useEffect crea uno store personalizzato con il metodo subscribe. Il metodo subscribe dello store deve restituire un metodo di unsubscribe, che viene chiamato quando la sottoscrizione allo store non è più necessaria.
L’ultima riga ($: $effect) gestisce la sottoscrizione automatica allo store (tutorial). Quindi chiama correttamente socket.on e poi socket.off quando il valore di connectionId cambia (o viene semplicemente assegnato in Svelte), in modo simile a quanto fa la controparte React.
Perché mi mancano le props di React in Svelte
Ovviamente, anche in Svelte esistono le props. Funzionano in modo simile a React. Ma in React l’unica interfaccia esterna del componente sono le props. In Svelte hai diversi meccanismi per gestire il comportamento dei tuoi componenti:
- props - funzionano come in React
- events - usano una sintassi speciale, gli eventi non fanno parte dell’oggetto $$props che contiene tutte le props
- actions (sintassi use:action) - meccanismo per riutilizzare logica legata agli elementi HTML
- slots - lo scopo è lo stesso della proprietà children di React, con alcune estensioni
Tutti questi meccanismi sono molto utili, ma non hanno un accesso unificato come le props in React. Di seguito sono discussi alcuni problemi che ho incontrato.
Forwarding degli eventi
Se crei gerarchie di componenti più complesse, alcuni componenti si limitano a inoltrare i dati dai componenti genitori.
// React - inoltra tutti gli eventi, fanno parte delle props
function Outer(props) {
return <Inner {...props} />;
}
<!-- Svelte - solo gli eventi esplicitamente nominati click e keydown vengono inoltrati -->
<Inner {...$$props} on:click on:keydown />;
}
In Svelte non puoi inoltrare tutti gli eventi, ma puoi inoltrare eventi elencati esplicitamente. Tuttavia, se devi inoltrare tutti gli eventi, puoi usare funzioni di callback (onClick invece di on:click), così onClick farà parte di $$props.
Implementare TabControl
In React è abbastanza semplice implementare un componente TabControl, che verrà usato nel modo seguente:
// React
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
L’implementazione scorrerà l’array children e potrà facilmente estrarre le informazioni desiderate (titolo e contenuto della pagina). Questo approccio non funziona in Svelte, che non ha nulla di simile a children. Puoi usare i fragment di Svelte; l’uso sarà il seguente:
<!-- 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>
Le tab sono definite in un array, il layout delle tab (children) è definito in fragment, che vengono passati come slot al componente TabControl. Non è così intuitivo come in React, ma funziona. C’è solo una trappola piuttosto grande, che puoi vedere nell’implementazione di 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>
I nomi degli slot devono essere stringhe statiche, quindi devi fare qualcosa del genere per farlo funzionare.
Usare componenti dinamici
Un approccio diverso potrebbe essere definire un componente per ogni pagina della tab, ma allora avrai molti piccoli file, perché ogni componente Svelte deve essere definito nel proprio file.
<!-- Svelte -->
<TabControl tabs={[
{ label: 'Page 1', component: Tab1},
{ label: 'Page 2', component: Tab2},
]}/>
// Tab1.svelte
Page 1 content
// Tab2.svelte
Page 2 content
L’implementazione userà svelte:component per istanziare la tab corretta.
Usare il context
Questo è il modo meno ovvio. Ma in realtà puoi ottenere la stessa sintassi di React.
<!-- Svelte -->
<TabControl>
<TabPage label='Page 1'>
Page 1 content
</TabPage>
<TabPage label='Page 2'>
Page 2 content
</TabPage>
</TabControl>
Nel componente TabControl deve essere definito un qualche “punto di raccolta”, ad esempio un array di tab figlie
<!-- TabControl.svelte -->
<script>
const tabs = [];
setContext('tabs', tabs);
</script>
E nella pagina della tab registriamo semplicemente la tab nell’array del genitore:
<!-- TabPage.svelte -->
<script>
export let label;
const tabs = getContext('tabs');
tabs.push({ label });
</script>
L’unica trappola di questo metodo è che non preserva l’ordine definito delle pagine della tab, se alcune di esse vengono renderizzate in modo condizionale.
<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>
Questo verrà renderizzato con prima Page 2 e poi Page 1, che probabilmente non è il risultato desiderato.
Attenzione quando usi bind:clientHeight e bind:clientWidth
Il binding delle dimensioni (tutorial) è una grande funzionalità. Ma fai attenzione quando lo usi. È implementato con un iframe nascosto (puoi vederlo in questo REPL). Dalla mia esperienza, a volte in situazioni più complicate non ha funzionato in Firefox. A volte è più sicuro usare ResizeObserver (vedi questa implementazione di action)
Error boundary
React ha un ottimo concetto di error boundary (“try-catch” per componenti). Non è supportato nei functional component (in realtà, ErrorBoundary era l’unico class component nella mia applicazione React), ma non c’era alcun problema a usare questo class component in un’applicazione React funzionale.
// React
<ErrorBoundary>
{(null).read()}
</ErrorBoundary>
Senza error boundary, se hai questo codice in un’applicazione React, l’intera applicazione andrà in errore. Con un error boundary, fallirà solo l’interno del boundary, mentre le altre parti dell’applicazione verranno renderizzate normalmente.
Purtroppo, Svelte non ha nulla di simile. Esiste un pacchetto NPM chiamato svelte-error-boundary, ma in realtà risolve solo una piccola parte dei problemi e la maggior parte degli errori in un’app Svelte causa comunque il crash dell’app.
Svelte ha il suo modo di “crashare”: i componenti Svelte smettono di essere reattivi, quindi l’app sembra bloccata.
L’unica cosa che puoi fare è rilevare questa situazione e permettere all’utente di ricaricare la pagina.
<!-- Svelte -->
<script>
let counter = 0;
$: counterCopy = counter;
const onunhandledrejection = async e => {
console.log('Unhandler error, checking whether crashed', e);
const oldCounter = counter;
counter++;
window.setTimeout(() => {
if (counterCopy <= oldCounter) {
console.log('CRASH DETECTED!!!');
if (window.confirm('Sorry, App has crashed.\nReload page?')) {
window.location.reload();
}
}
}, 500);
};
</script>
<svelte:window on:unhandledrejection={onunhandledrejection} />
Per il rilevamento viene usato l’evento unhandledrejection. Questo evento può essere generato in più situazioni, alcune delle quali non causano il crash di Svelte. Questo è il motivo dell’uso delle variabili counter e counterCopy. Se l’istruzione reattiva non funziona, significa che l’intero Svelte è crashato e l’unico modo per recuperare è ricaricare la pagina.
Puoi vedere il componente completo ErrorHandler.
Conclusione
Nonostante questi problemi, Svelte è un ottimo framework e sono molto soddisfatto del risultato della conversione della mia app da React a Svelte. Alcune funzionalità, che ora sono implementate con Svelte, sarebbero state quasi impossibili da realizzare con React.
Ovviamente, se usi molte librerie di terze parti disponibili solo per React, questo può essere un problema serio. Ma non era il mio caso: avevo dipendenze minime da React; in questo caso anche la riscrittura in Svelte può essere abbastanza semplice e veloce.