Přepsání aplikace z Reactu do Svelte, co můžete očekávat

Published: 2021-04-08 | Jan Procházka

Nedávno jsem přepsal středně velkou aplikaci v Reactu (~250 React komponent) do Svelte. Výsledkem je aplikace, která je efektivnější, lépe se udržuje a má mnohem čitelnější kód. Narazil jsem ale i na problémy, na situace, které nejsou při převodu do Svelte úplně přímočaré. Tento článek některé z těchto situací popisuje.

Toto není návod na Svelte pro React programátory. Pokud Svelte ještě neznáte, nejlepším výchozím bodem je oficiální Svelte tutorial. Tento článek obsahuje spoustu odkazů na Svelte tutoriály místo vysvětlování principů Svelte.

CSS

Svelte podporuje CSS přímo, velmi pěkným a čistým způsobem. V React aplikaci jsem používal styled components. Pro React existuje mnoho CSS knihoven, ale všechny (pokud vím) používají paradigma CSS-in-JS, zatímco v Svelte používáte poměrně čisté CSS.

V následujícím úryvku ukážu zjednodušenou implementaci tlačítka v toolbaru, v Reactu (se styled components) a v Svelte.

Tlačítko v toolbaru má jednu vlastnost (disabled), která vypíná hover funkčnost. Také používá některé proměnné z tématu.

// 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); /* you cannot use prop directly, CSS variable must be used */
}
.button.disabled {
  background-color: gray;
}
</style>

Svelte nepodporuje žádné proměnné v CSS. Není tedy možné předat vlastnost do Svelte komponenty a použít ji v CSS. Jediný způsob, jak to udělat, je přepínat CSS třídu zapnuto/vypnuto. Selektor button.disabled se použije, když je disabled nastaveno na true.

Když chcete použít nějaké hodnoty (např. rozměry v CSS), můžete použít CSS proměnné.

React hooks, zejména useEffect

React hooks jsou velmi silná funkce. V Svelte nic podobného nenajdete. Většina React hooks vám ale v Svelte chybět nebude.

  • useState – stav se definuje pomocí příkazů let
  • useMemo – místo toho můžete použít reaktivní příkazy pro přepočet vnitřního stavu
  • useCallback – funkční výrazy se vyhodnocují jen jednou, takže nedává smysl
  • useContext – Svelte má velmi jednoduché a přímočaré context API (getContext(), setContext())
  • useReducer – musíte vytvořit Svelte verzi pomocí stores (to může být velmi snadné)
  • useRef – místo toho můžete použít bind:this (nebo let proměnnou pro instanční proměnné)
  • useEffect – to je složitější…

useEffect – jednoduché použití

useEffect v Reactu používám velmi často, bohužel je potřeba si promyslet, jak stejnou logiku implementovat v Svelte.

Nejjednodušší použití je spuštění nějakého kódu při mountu a unmountu.

// React
React.useEffect(() => {
  console.log('MOUNT');
  return () => console.log('UNMOUNT');
}, []);

Tato varianta je pokryta Svelte metodami onMount a onDestroy

// Svelte

onMount(() => {
  console.log('MOUNT');
  return () => console.log('UNMOUNT 1');
});
onDestroy(() => {
  console.log('UNMOUNT 2');
});

Jak je vidět, máte dva způsoby, jak spustit kód při unmountu – pomocí funkce onDestroy a pomocí návratové hodnoty funkce onMount.

useEffect – reaktivní příkaz

Když chcete spustit nějaký kód při změně výrazu, můžete použít Svelte reaktivní příkaz.

// React
React.useEffect(() => {
  console.log('HEIGHT changed, new value:', height);
}, [height]);
// Svelte
$: console.log('HEIGHT changed, new value:', height); 

V Svelte se reaktivní příkaz spustí, když je některé ze závislých výrazů přiřazeno. Pokud tedy potřebujete něco dělat jen při změně závislosti, musíte si hodnotu zkontrolovat sami.

Pokud chcete explicitně vyjmenovat závislosti (jako ve druhém argumentu metody React.useEffect), můžete použít tento vzor:

// Svelte

$: {
  height;
  width;
  handleChanged(); 
}

Toto se zavolá po přiřazení proměnné height nebo width. V handleChanged můžete použít i jiné proměnné, ale tyto závislosti tento reaktivní příkaz nespustí.

useEffect – reaktivní příkaz s úklidem

React useEffect lze použít také k alokaci nějakých prostředků, které závisejí na hodnotě vlastnosti. V následujícím příkladu komponenta zobrazuje seznam tabulek v SQL databázi. Naslouchá změnám struktury databáze. Když se změní vlastnost connectionId, je nutné se odpojit od starého socketu a připojit k novému.

// React
function SqlTableList({ connectionId }) {
  React.useEffect(() => {
    socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
    return () => {
      socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
    };
  }, [connectionId]);
}

Svelte tento scénář nepodporuje tak přímočaře jako React. Můžete ale použít jednoduchý trik, jak tohoto chování dosáhnout.

// Svelte

const useEffect = subscribe => ({ subscribe });

$: effect = useEffect(() => {
  socket.on(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
  return () => {
    socket.off(`database-structure-changed-${connectionId}`, handleDatabaseStructureChanged);
  };
});

$: $effect;

Jak tento trik funguje? Je potřeba znát stores, zejména vlastní stores (tutorial). Funkce useEffects vytváří vlastní store s metodou subscribe. Metoda subscribe store musí vracet unsubscribe funkci, která se zavolá, když už není potřeba na store odebírat.

Poslední řádek ($: $effect) zajišťuje automatické přihlášení ke store (tutorial). Takže správně volá socket.on a poté socket.off, když se hodnota connectionId změní (nebo je v Svelte jen přiřazena), podobně jako to dělá React varianta.

Proč mi v Svelte chybí React props

Samozřejmě, i v Svelte existují props. Fungují podobně jako v Reactu. V Reactu jsou ale props jediným vnějším rozhraním komponenty. V Svelte máte několik mechanismů, jak řídit chování komponent:

  • props – fungují jako v Reactu
  • events – používají speciální syntaxi, nejsou součástí objektu $$props obsahujícího všechny props
  • actions (syntaxe use:action) – mechanismus, jak znovu použít logiku navázanou na HTML elementy
  • slots – účel je stejný jako React vlastnost children, s určitými rozšířeními

Všechny tyto mechanismy jsou velmi užitečné, ale nemají jednotný přístup jako props v Reactu. Níže jsou rozebrány některé problémy, na které jsem narazil.

Forwardování událostí

Když vytváříte složitější hierarchie komponent, některé komponenty jen předávají data z rodičovských komponent dál.

// React - forwards all events, they are part of props

function Outer(props) {
  return <Inner {...props} />;
}
 <!-- Svelte - only explicitly named events click and keydown are forwarded -->

 <Inner {...$$props} on:click on:keydown />;
}

V Svelte nemůžete forwardovat všechny události, ale můžete forwardovat vyjmenované události. Pokud ale potřebujete forwardovat všechny události, můžete použít callback funkce (onClick místo on:click), pak bude onClick součástí $$props.

Implementace TabControl

V Reactu je poměrně snadné implementovat komponentu TabControl, která se bude používat následovně:

// React

<TabControl>
  <TabPage label='Page 1'>
    Page 1 content
  </TabPage>
  <TabPage label='Page 2'>
    Page 2 content
  </TabPage>
</TabControl>

Implementace projde pole children a může snadno získat požadované informace (titulek stránky a obsah). Tento přístup v Svelte nefunguje, Svelte nemá nic jako children. Můžete použít Svelte fragmenty, použití pak vypadá takto:

 <!-- 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>

Záložky jsou definované v poli, rozložení záložek (children) je definováno ve fragmentech, které jsou předány jako sloty komponentě TabControl. Není to tak intuitivní jako v Reactu, ale funguje to. Je tu jen jeden poměrně velký zádrhel, který můžete vidět v implementaci 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>

Názvy slotů musí být statické řetězce, takže musíte udělat něco takového, aby to fungovalo.

Použití dynamické komponenty

Jiný přístup může být definovat komponentu pro každou záložku, ale pak budete mít spoustu malých souborů, protože každá Svelte komponenta musí být definována ve vlastním souboru.

 <!-- Svelte -->

<TabControl tabs={[
  { label: 'Page 1', component: Tab1},
  { label: 'Page 2', component: Tab2},
  ]}/>

// Tab1.svelte
Page 1 content
// Tab2.svelte
Page 2 content

Implementace použije svelte:component k instanciaci správné záložky.

Použití contextu

Tohle je nejméně zřejmý způsob. Ve skutečnosti ale můžete dosáhnout stejné syntaxe jako v Reactu.

 <!-- Svelte -->

<TabControl>
  <TabPage label='Page 1'>
    Page 1 content
  </TabPage>
  <TabPage label='Page 2'>
    Page 2 content
  </TabPage>
</TabControl>

V komponentě TabControl musí být definován nějaký „sběrný bod“, např. pole dětských záložek

<!-- TabControl.svelte -->
<script>
  const tabs = [];
  setContext('tabs', tabs);
</script>

A v TabPage jen zaregistrujeme záložku v rodičovském poli:

<!-- TabPage.svelte -->
<script>
  export let label;
  const tabs = getContext('tabs');
  tabs.push({ label });
</script>

Jediný zádrhel tohoto způsobu je, že nezachovává definované pořadí záložek, pokud jsou některé z nich vykreslovány podmíněně.


<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>

Tohle se vykreslí s Page 2 jako první a Page 1 až potom, což pravděpodobně není požadovaný výsledek.

Pozor při použití bind:clientHeight a bind:clientWidth

Vazba rozměrů (tutorial) je skvělá funkce. Při jejím použití je ale potřeba opatrnost. Je implementována pomocí skrytého iframe (můžete to vidět v tomto REPL). Z mých zkušeností v komplikovanějších situacích někdy nefungovala ve Firefoxu. Někdy je bezpečnější použít ResizeObserver (viz tato implementace action)

Error boundary

React má skvělý koncept error boundaries („try-catch“ pro komponenty). Není podporován ve funkcionálních komponentách (ve skutečnosti byl ErrorBoundary jedinou class komponentou v mé React aplikaci), ale nebyl problém tuto class komponentu ve funkcionální React aplikaci používat.

// React
<ErrorBoundary>
  {(null).read()}
</ErrorBoundary>

Bez error boundary, pokud máte tento kód v React aplikaci, spadne celá aplikace. S error boundary spadne jen vnitřek boundary, ostatní části aplikace se vykreslí jako obvykle.

Bohužel Svelte nic takového nemá. Existuje NPM balíček svelte-error-boundary, ale ve skutečnosti řeší jen malou část problémů a většina chyb v Svelte aplikaci stále způsobí pád aplikace.

Svelte má svůj vlastní způsob pádu – Svelte komponenty přestanou být reaktivní, takže aplikace vypadá jako zamrzlá.

Jediné, co můžete udělat, je tuto situaci detekovat a nechat uživatele stránku znovu načíst.

<!-- 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} />

Pro detekci se používá událost unhandledrejection. Tato událost může nastat v různých situacích, z nichž některé pád Svelte nezpůsobí. Proto se používají proměnné counter a counterCopy. Pokud reaktivní příkaz nefunguje, znamená to, že celé Svelte spadlo a jediný způsob obnovy je znovunačtení stránky.

Celou komponentu ErrorHandler si můžete prohlédnout na GitHubu.

Závěr

Navzdory těmto problémům je Svelte skvělý framework a jsem s výsledkem přepsání své aplikace z Reactu do Svelte velmi spokojený. Některé funkce, které jsou nyní implementované v Svelte, by byly v Reactu téměř nemožné.

Samozřejmě, pokud používáte spoustu knihoven třetích stran, které jsou dostupné jen pro React, může to být vážný problém. To ale nebyl můj případ, měl jsem v Reactu minimální závislosti, a v takovém případě může být přepis do Svelte poměrně snadný a rychlý.