React, Redux e Immutable.js: Ingredientes para aplicativos Web eficientes

Publicados: 2022-03-11

React, Redux e Immutable.js estão atualmente entre as bibliotecas JavaScript mais populares e estão rapidamente se tornando a primeira escolha dos desenvolvedores quando se trata de desenvolvimento front-end. Nos poucos projetos React e Redux em que trabalhei, percebi que muitos desenvolvedores que estão começando com o React não entendem completamente o React e como escrever código eficiente para utilizar todo o seu potencial.

Neste tutorial do Immutable.js, construiremos um aplicativo simples usando React e Redux e identificaremos alguns dos usos indevidos mais comuns do React e formas de evitá-los.

Problema de referência de dados

React tem tudo a ver com desempenho. Ele foi construído desde o início para ter um desempenho extremamente alto, apenas rerenderizando partes mínimas do DOM para satisfazer novas alterações de dados. Qualquer aplicativo React deve consistir principalmente em pequenos componentes simples (ou função sem estado). Eles são simples de raciocinar e a maioria deles pode ter a função shouldComponentUpdate retornando false .

 shouldComponentUpdate(nextProps, nextState) { return false; }

Em termos de desempenho, a função de ciclo de vida do componente mais importante é shouldComponentUpdate e, se possível, deve sempre retornar false . Isso garante que esse componente nunca será renderizado novamente (exceto a renderização inicial), fazendo com que o aplicativo React pareça extremamente rápido.

Quando esse não for o caso, nosso objetivo é fazer uma verificação de igualdade barata de props/state antigos vs novos props/state e pular a re-renderização se os dados não forem alterados.

Vamos dar um passo atrás por um segundo e revisar como o JavaScript realiza verificações de igualdade para diferentes tipos de dados.

A verificação de igualdade para tipos de dados primitivos como boolean , string e integer é muito simples, pois eles são sempre comparados por seu valor real:

 1 === 1 'string' === 'string' true === true

Por outro lado, a verificação de igualdade para tipos complexos como objetos , matrizes e funções é completamente diferente. Dois objetos são iguais se tiverem a mesma referência (apontando para o mesmo objeto na memória).

 const obj1 = { prop: 'someValue' }; const obj2 = { prop: 'someValue' }; console.log(obj1 === obj2); // false

Embora obj1 e obj2 pareçam iguais, suas referências são diferentes. Como eles são diferentes, compará-los ingenuamente dentro da função shouldComponentUpdate fará com que nosso componente seja renderizado novamente desnecessariamente.

O importante a notar é que os dados provenientes dos redutores do Redux, se não forem configurados corretamente, sempre serão servidos com referência diferente, o que fará com que o componente seja renderizado novamente a cada vez.

Este é um problema central em nossa busca para evitar a re-renderização de componentes.

Manipulando Referências

Vamos dar um exemplo em que temos objetos profundamente aninhados e queremos compará-lo com sua versão anterior. Poderíamos fazer um loop recursivamente através de objetos aninhados e comparar cada um, mas obviamente isso seria extremamente caro e está fora de questão.

Isso nos deixa com apenas uma solução, que é verificar a referência, mas novos problemas surgem rapidamente:

  • Preservando a referência se nada mudou
  • Alterando a referência se qualquer um dos valores de prop de objeto/array aninhados foi alterado

Esta não é uma tarefa fácil se queremos fazê-lo de uma maneira agradável, limpa e otimizada para o desempenho. O Facebook percebeu esse problema há muito tempo e chamou o Immutable.js para resgatá-lo.

 import { Map } from 'immutable'; // transform object into immutable map let obj1 = Map({ prop: 'someValue' }); const obj2 = obj1; console.log(obj1 === obj2); // true obj1 = obj1.set('prop', 'someValue'); // set same old value console.log(obj1 === obj2); // true | does not break reference because nothing has changed obj1 = obj1.set('prop', 'someNewValue'); // set new value console.log(obj1 === obj2); // false | breaks reference

Nenhuma das funções Immutable.js executa mutação direta nos dados fornecidos. Em vez disso, os dados são clonados internamente, modificados e, se houver alguma alteração, uma nova referência é retornada. Caso contrário, retorna a referência inicial. A nova referência deve ser definida explicitamente, como obj1 = obj1.set(...); .

Exemplos de React, Redux e Immutable.js

A melhor maneira de demonstrar o poder dessas bibliotecas é criar um aplicativo simples. E o que pode ser mais simples do que um aplicativo de tarefas?

Para resumir, neste artigo, vamos percorrer apenas as partes do aplicativo que são críticas para esses conceitos. Todo o código-fonte do código do aplicativo pode ser encontrado no GitHub.

Quando o aplicativo for iniciado, você notará que as chamadas para console.log são convenientemente colocadas em áreas-chave para mostrar claramente a quantidade de rerenderização do DOM, que é mínima.

Como qualquer outro aplicativo de tarefas, queremos mostrar uma lista de itens de tarefas. Quando o usuário clicar em um item de tarefas, nós o marcaremos como concluído. Também precisamos de um pequeno campo de entrada na parte superior para adicionar novos todos e na parte inferior 3 filtros que permitirão ao usuário alternar entre:

  • Todo
  • Concluído
  • Ativo

Redutor Redux

Todos os dados no aplicativo Redux vivem dentro de um único objeto de armazenamento e podemos olhar para os redutores apenas como uma maneira conveniente de dividir o armazenamento em partes menores que são mais fáceis de raciocinar. Como o redutor também é uma função, ele também pode ser dividido em partes ainda menores.

Nosso redutor será composto por 2 peças pequenas:

  • lista de afazeres
  • filtro ativo
 // reducers/todos.js import * as types from 'constants/ActionTypes'; // we can look at List/Map as immutable representation of JS Array/Object import { List, Map } from 'immutable'; import { combineReducers } from 'redux'; function todoList(state = List(), action) { // default state is empty List() switch (action.type) { case types.ADD_TODO: return state.push(Map({ // Every switch/case must always return either immutable id: action.id, // or primitive (like in activeFilter) state data text: action.text, // We let Immutable decide if data has changed or not isCompleted: false, })); // other cases... default: return state; } } function activeFilter(state = 'all', action) { switch (action.type) { case types.CHANGE_FILTER: return action.filter; // This is primitive data so there's no need to worry default: return state; } } // combineReducers combines reducers into a single object // it lets us create any number or combination of reducers to fit our case export default combineReducers({ activeFilter, todoList, });

Conectando com Redux

Agora que configuramos um redutor Redux com dados Immutable.js, vamos conectá-lo ao componente React para passar os dados.

 // components/App.js import { connect } from 'react-redux'; // ….component code const mapStateToProps = state => ({ activeFilter: state.todos.activeFilter, todoList: state.todos.todoList, }); export default connect(mapStateToProps)(App);

Em um mundo perfeito, a conexão deve ser realizada apenas em componentes de rota de nível superior, extraindo os dados em mapStateToProps e o resto é React básico passando adereços para os filhos. Em aplicativos de grande escala, tende a ser difícil acompanhar todas as conexões, por isso queremos mantê-las no mínimo.

É muito importante notar que state.todos é um objeto JavaScript regular retornado da função combineReducers do Redux (todos sendo o nome do redutor), mas state.todos.todoList é uma Lista Imutável e é fundamental que ela permaneça em tal form até passar na verificação shouldComponentUpdate .

Evitando a Rerenderização de Componentes

Antes de nos aprofundarmos, é importante entender que tipo de dados deve ser servido ao componente:

  • Tipos primitivos de qualquer tipo
  • Objeto/array apenas na forma imutável

Ter esses tipos de dados nos permite comparar superficialmente os adereços que entram nos componentes do React.

O próximo exemplo mostra como diferenciar os adereços da maneira mais simples possível:

 $ npm install react-pure-render
 import shallowEqual from 'react-pure-render/shallowEqual'; shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); }

A função rasaEqual verificará a diferença de props/state apenas 1 nível de profundidade. Funciona extremamente rápido e está em perfeita sinergia com nossos dados imutáveis. Ter que escrever este shouldComponentUpdate em cada componente seria muito inconveniente, mas felizmente existe uma solução simples.

Extraia shouldComponentUpdate em um componente separado especial:

 // components/PureComponent.js import React from 'react'; import shallowEqual from 'react-pure-render/shallowEqual'; export default class PureComponent extends React.Component { shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState); } }

Em seguida, basta estender qualquer componente em que essa lógica shouldComponentUpdate seja desejada:

 // components/Todo.js export default class Todo extends PureComponent { // Component code }

Essa é uma maneira muito limpa e eficiente de evitar a rerenderização de componentes na maioria dos casos e, posteriormente, se o aplicativo ficar mais complexo e de repente exigir uma solução personalizada, ele poderá ser alterado facilmente.

Há um pequeno problema ao usar PureComponent ao passar funções como props. Como o React, com a classe ES6, não vincula isso automaticamente às funções, temos que fazer isso manualmente. Podemos conseguir isso fazendo um dos seguintes:

  • use a ligação de função de seta ES6: <Component onClick={() => this.handleClick()} />
  • use bind : <Component onClick={this.handleClick.bind(this)} />

Ambas as abordagens farão com que o Component seja renderizado novamente porque uma referência diferente foi passada para onClick todas as vezes.

Para contornar esse problema, podemos pré-vincular funções no método construtor da seguinte forma:

 constructor() { super(); this.handleClick = this.handleClick.bind(this); } // Then simply pass the function render() { return <Component onClick={this.handleClick} /> }

Se você se encontrar pré-vinculando várias funções na maioria das vezes, podemos exportar e reutilizar pequenas funções auxiliares:

 // utils/bind-functions.js export default function bindFunctions(functions) { functions.forEach(f => this[f] = this[f].bind(this)); } // some component constructor() { super(); bindFunctions.call(this, ['handleClick']); // Second argument is array of function names }

Se nenhuma das soluções funcionar para você, você sempre poderá escrever as condições shouldComponentUpdate manualmente.

Manipulando dados imutáveis ​​dentro de um componente

Com a configuração atual de dados imutáveis, a re-renderização foi evitada e ficamos com dados imutáveis ​​dentro das props de um componente. Existem várias maneiras de usar esses dados imutáveis, mas o erro mais comum é converter os dados imediatamente em JS simples usando a função imutável toJS .

Usar o toJS para converter profundamente dados imutáveis ​​em JS simples nega todo o propósito de evitar a re-renderização porque, como esperado, é muito lento e, como tal, deve ser evitado. Então, como lidamos com dados imutáveis?

Ele precisa ser usado como está, é por isso que a API Immutável fornece uma ampla variedade de funções, mapeia e é mais comumente usada dentro do componente React. A estrutura de dados todoList proveniente do Redux Reducer é uma matriz de objetos em forma imutável, cada objeto representando um único item de tarefas:

 [{ id: 1, text: 'todo1', isCompleted: false, }, { id: 2, text: 'todo2', isCompleted: false, }]

A API Immutable.js é muito semelhante ao JavaScript normal, então usaríamos todoList como qualquer outro array de objetos. A função de mapa se mostra melhor na maioria dos casos.

Dentro de um callback map temos todo , que é um objeto ainda em forma imutável e podemos passá-lo com segurança no componente Todo .

 // components/TodoList.js render() { return ( // …. {todoList.map(todo => { return ( <Todo key={todo.get('id')} todo={todo}/> ); })} // …. ); }

Se você planeja realizar várias iterações encadeadas em dados imutáveis, como:

 myMap.filter(somePred).sort(someComp)

... então é muito importante primeiro convertê-lo em Seq usando toSeq e após as iterações, transformá-lo de volta no formato desejado, como:

 myMap.toSeq().filter(somePred).sort(someComp).toOrderedMap()

Como o Immutable.js nunca altera diretamente os dados, ele sempre precisa fazer outra cópia deles, executar várias iterações como essa pode ser muito caro. Seq é uma sequência de dados preguiçosa e imutável, o que significa que executará o menor número possível de operações para realizar sua tarefa enquanto ignora a criação de cópias intermediárias. Seq foi construído para ser usado desta forma.

Dentro do componente Todo use get ou getIn para obter os adereços.

Simples o suficiente certo?

Bem, o que eu percebi é que na maioria das vezes pode ficar muito ilegível tendo um grande número de get() e especialmente getIn() . Então decidi encontrar um ponto ideal entre desempenho e legibilidade e depois de alguns experimentos simples descobri que as funções immutable.js toObject e toArray funcionam muito bem.

Essas funções convertem superficialmente (1 nível de profundidade) objetos/arrays Immutable.js em objetos/arrays JavaScript simples. Se tivermos dados profundamente aninhados, eles permanecerão em forma imutável, prontos para serem passados ​​para componentes filhos e é exatamente disso que precisamos.

É mais lento que get() apenas por uma margem insignificante, mas parece muito mais limpo:

 // components/Todo.js render() { const { id, text, isCompleted } = this.props.todo.toObject(); // ….. }

Vamos ver tudo em ação

Caso você ainda não tenha clonado o código do GitHub, agora é um ótimo momento para fazê-lo:

 git clone https://github.com/rogic89/ToDo-react-redux-immutable.git cd ToDo-react-redux-immutable

Iniciar o servidor é tão simples (certifique-se de que o Node.js e o NPM estejam instalados) assim:

 npm install npm start 

Exemplo de Immutable.js: Todo App, com um campo "enter todo", cinco todos (segundo e quarto riscados), um seletor de rádio para todos vs. concluído vs. ativo e um botão "excluir tudo".

Navegue até http://localhost:3000 em seu navegador da web. Com o console do desenvolvedor aberto, observe os logs enquanto adiciona alguns itens de tarefas, marque-os como concluídos e altere o filtro:

  • Adicionar 5 itens de tarefas
  • Altere o filtro de 'Todos' para 'Ativo' e depois de volta para 'Todos'
    • Sem re-renderização de tarefas, apenas alteração de filtro
  • Marcar 2 itens de tarefas como concluídos
    • Dois todos foram renderizados novamente, mas apenas um de cada vez
  • Altere o filtro de 'Todos' para 'Ativo' e depois de volta para 'Todos'
    • Apenas 2 itens de tarefas concluídos foram montados/desmontados
    • Os ativos não foram renderizados novamente
  • Excluir um único item de tarefas do meio da lista
    • Apenas o item de tarefas removido foi afetado, outros não foram renderizados novamente

Embrulhar

A sinergia do React, Redux e Immutable.js, quando usado corretamente, oferece algumas soluções elegantes para muitos problemas de desempenho que são frequentemente encontrados em grandes aplicações web.

Immutable.js nos permite detectar mudanças em objetos/arrays JavaScript sem recorrer às ineficiências de verificações de igualdade profundas, o que, por sua vez, permite que o React evite operações de rerenderização caras quando não são necessárias. Isso significa que o desempenho do Immutable.js tende a ser bom na maioria dos cenários.

Espero que você tenha gostado do artigo e que ele seja útil para construir soluções inovadoras em React em seus projetos futuros.

Relacionado: Como os componentes do React facilitam o teste de interface do usuário