Reescrever aplicativo de React para Svelte, o que você pode esperar

Published: 2021-04-08

Recentemente, reescrevi um aplicativo de tamanho médio em React (~250 componentes React) para Svelte. O resultado dessa ação é um aplicativo mais eficiente, com manutenção mais fácil e código muito mais legível. Mas houve alguns problemas, algumas situações que não são diretas para transformar em código Svelte. Este artigo descreve algumas dessas situações.

Este não é um tutorial de Svelte para programadores React, se você ainda não conhece Svelte, o melhor ponto de partida é o tutorial oficial do Svelte. Este artigo contém muitos links para tutoriais de Svelte, em vez de explicar os princípios do Svelte.

CSS

O Svelte suporta CSS diretamente de uma maneira muito agradável e limpa. No aplicativo React, usei styled components. Existem muitas bibliotecas CSS para React, mas todas elas (que eu conheço) usam o paradigma CSS-in-JS, enquanto no Svelte, você usa um CSS bastante limpo.

No snippet a seguir, mostrarei a implementação simplificada de um botão de barra de ferramentas, em React (com styled components) e com Svelte.

O botão da barra de ferramentas tem uma propriedade (disabled), que desativa a funcionalidade de hover. Também usa algumas variáveis do 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); /* você não pode usar prop diretamente, deve ser usada uma variável CSS */
}
.button.disabled {
  background-color: gray;
}
</style>

O Svelte não suporta variáveis no CSS. Portanto, não é possível passar uma propriedade para o componente Svelte e usá-la no CSS. A única maneira de fazer isso é alternar a classe CSS LIGADA/DESLIGADA. O seletor button.disabled é usado quando disabled é definido como true.

Quando você deseja usar alguns valores (por exemplo, dimensões no CSS), pode usar variáveis CSS.

Hooks do React, especialmente useEffect

Os hooks do React são um recurso muito poderoso. No Svelte, você não encontrará nada semelhante. Mas a maioria dos hooks do React, você não sentirá falta no Svelte.

  • useState - o estado é definido com comandos let
  • useMemo - você pode usar comandos reativos em vez de recomputar o estado interno
  • useCallback - expressões de função são avaliadas apenas uma vez, então não faz sentido
  • useContext - o Svelte tem uma API de contexto muito simples e direta (getContext(), setContext())
  • useReducer - você deve criar uma versão Svelte, usando stores (isso pode ser muito fácil)
  • useRef - você pode usar bind:this em vez disso (ou variável let para variáveis de instância)
  • useEffect - isso é mais complicado…

useEffect - uso simples

Eu uso useEffect no React com muita frequência, infelizmente você deve pensar sobre como implementar a mesma lógica no Svelte.

O uso mais simples é executar algum código na montagem e desmontagem.

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

Esta variante é convertida com métodos svelte onMount e onDestroy

// Svelte

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

Como você pode ver, você tem duas variantes de como executar o código na desmontagem - com a função onDestroy e com o valor de retorno da função onMount.

useEffect - declaração reativa

Quando você deseja executar algum código na mudança de expressão, pode usar a declaração reativa do Svelte.

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

No Svelte, a declaração reativa é executada quando qualquer uma das dependências da declaração é atribuída. Portanto, se você precisar fazer algo apenas quando a dependência mudar, deve verificar o valor por conta própria.

Se você quiser enumerar explicitamente as dependências (como no segundo argumento no método React.useEffect), pode usar este padrão:

// Svelte

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

Isso será chamado após a atribuição da variável height ou width. Você pode usar outras variáveis na função handleChanged, mas essas dependências não acionarão esta declaração reativa.

useEffect - declaração reativa com limpeza

O React useEffect também pode ser usado para alocar alguns recursos, que são dependentes do valor da propriedade. No exemplo a seguir, o componente está mostrando uma lista de tabelas em um banco de dados SQL. Ele está ouvindo por mudanças na estrutura do banco de dados. Quando a propriedade connectionId é alterada, é necessário desconectar do socket antigo e conectar ao novo socket.

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

O Svelte não suporta este cenário de maneira tão direta quanto o React. Mas você pode usar um truque simples para realizar esse comportamento.

// Svelte

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

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

$: $effect;

Como esse truque funciona? Você precisa saber sobre stores, especialmente stores personalizados (tutorial). A função useEffects cria um store personalizado com o método de inscrição. O método subscribe do store deve retornar o método unsubscribe, que é chamado quando a inscrição no store não é mais necessária.

A última linha ( $: $effect ) gerencia a inscrição automática no store (tutorial). Assim, ele chama corretamente socket.on e depois socket.off, quando o valor de connectionId é alterado (ou apenas atribuído no Svelte), de maneira semelhante ao seu equivalente no React.

Por que sinto falta das props do React no Svelte

Claro, também existem props no Svelte. Elas funcionam de maneira semelhante ao React. Mas no React, a única interface externa do componente são as props. No Svelte, você tem vários mecanismos para gerenciar o comportamento dos seus componentes:

  • props - funciona como no React
  • eventos - usa sintaxe especial, eventos não fazem parte do objeto $$props que contém todas as props
  • ações (sintaxe use:action) - mecanismo para reutilizar lógica vinculada a elementos HTML
  • slots - o propósito é o mesmo que a propriedade children do React, com algumas extensões

Todos esses mecanismos são muito úteis, mas não têm acesso unificado como as props no React. Abaixo são discutidos alguns problemas que encontrei.

Encaminhamento de eventos

Se você criar hierarquias de componentes mais complexas, alguns componentes apenas encaminham dados de componentes pai.

// React - encaminha todos os eventos, eles fazem parte das props

function Outer(props) {
  return <Inner {...props} />;
}
 <!-- Svelte - apenas eventos explicitamente nomeados click e keydown são encaminhados -->

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

No Svelte, você não pode encaminhar todos os eventos, mas pode encaminhar eventos enumerados. Mas se você precisar encaminhar todos os eventos, pode usar funções de callback (onClick em vez de on:click), então onClick fará parte de $$props.

Implementando TabControl

No React é bastante fácil implementar um componente TabControl, que será usado da seguinte forma:

// React

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

A implementação irá enumerar através do array de children e poderá extrair facilmente as informações desejadas (título e conteúdo da página). Essa abordagem não funciona no Svelte, ele não tem nada como children. Você pode usar fragmentos do Svelte, o uso ficará assim:

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

As abas são definidas em um array, o layout das abas (children) é definido em fragmentos, que são passados como slots para o componente TabControl. Não é tão intuitivo quanto no React, mas funciona. Apenas um grande problema, que você pode ver na implementação do 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>

Os nomes dos slots devem ser strings estáticas, então você tem que fazer algo assim para fazê-lo funcionar.

Usando componente dinâmico

Outra abordagem poderia ser definir um componente por página de aba, mas então você terá muitos arquivos pequenos, porque cada componente Svelte deve ser definido em seu próprio arquivo.

 <!-- Svelte -->

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

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

A implementação usará svelte:component para instanciar a aba correta.

Usando contexto

Esta é a maneira menos óbvia. Mas, de fato, você pode alcançar a mesma sintaxe que no React.

 <!-- Svelte -->

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

No componente TabControl deve ser definido algum “ponto de coleta”, por exemplo, um array de abas filhas

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

E na página da aba, apenas registramos a aba no array pai:

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

A única armadilha desse método é que ele não preserva a ordem definida das páginas de abas, se algumas delas forem renderizadas 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>

Isso será renderizado com a Página 2 primeiro e depois a Página 1, o que provavelmente não é o resultado desejado.

Atenção ao usar bind:clientHeight e bind:clientWidth

Vincular dimensões (tutorial) é um ótimo recurso. Mas tenha cuidado ao usá-lo. É implementado com um iframe oculto (você pode vê-lo neste REPL). Pela minha experiência, às vezes em situações mais complicadas não funcionava no FireFox. Às vezes é mais seguro usar ResizeObserver (veja esta implementação de ação)

Limite de erro

O React tem um ótimo conceito de limites de erro (“try-catch” para componentes). Não é suportado em componentes funcionais (na verdade, ErrorBoundary era o único componente de classe no meu aplicativo React), mas não havia problema em usar esse componente de classe em um aplicativo React funcional.

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

Sem limite de erro, quando você tem esse código em um aplicativo React, todo o aplicativo falhará. Com o limite de erro, apenas o interior do limite falhará, outras partes do aplicativo serão renderizadas normalmente.

Infelizmente, o Svelte não tem nada parecido. Ele tem um pacote NPM chamado svelte-error-boundary, mas na verdade, ele resolve apenas uma pequena parte dos problemas e a maioria dos erros em um aplicativo Svelte ainda causa falha no aplicativo.

O Svelte tem sua própria maneira de falhar - os componentes Svelte deixarão de ser reativos, então o aplicativo parece congelado.

A única coisa que você pode fazer é detectar essa situação e permitir que o usuário recarregue a página.

<!-- Svelte -->

<script>
  let counter = 0;
  $: counterCopy = counter;
  const onunhandledrejection = async e => {
    console.log('Erro não tratado, verificando se travou', e);
    const oldCounter = counter;
    counter++;
    window.setTimeout(() => {
      if (counterCopy <= oldCounter) {
        console.log('FALHA DETECTADA!!!');
        if (window.confirm('Desculpe, o aplicativo travou.\nRecarregar a página?')) {
          window.location.reload();
        }
      }
    }, 500);
  };
</script>

<svelte:window on:unhandledrejection={onunhandledrejection} />

Para detectar, é usado o evento unhandledrejection. Este evento pode ser acionado em mais situações, algumas delas não causam falha no Svelte. Esta é a razão para usar as variáveis counter e counterCopy. Se a declaração reativa não funcionar, isso significa que todo o Svelte travou e a única maneira de se recuperar é recarregar a página.

Você pode ver o componente completo ErrorHandler.

Conclusão

Apesar desses problemas, o Svelte é um ótimo framework e estou muito satisfeito com o resultado de converter meu aplicativo de React para Svelte. Alguns recursos, que agora estão implementados com Svelte, seriam quase impossíveis de fazer com React.

Claro, se você usa muitas bibliotecas de terceiros, que estão disponíveis apenas para React, isso pode ser um problema sério. Mas esse não foi o meu caso, eu tinha dependências mínimas com React, nesse caso, reescrever para Svelte também pode ser bastante fácil e rápido.