paint-brush
Memoização no React: ferramenta poderosa ou armadilha oculta?por@socialdiscoverygroup
771 leituras
771 leituras

Memoização no React: ferramenta poderosa ou armadilha oculta?

por Social Discovery Group15m2024/07/01
Read on Terminal Reader

Muito longo; Para ler

Uma abordagem generalizada no desenvolvimento de aplicativos React é cobrir tudo com memorização. A equipe do Social Discovery Group descobriu como o uso excessivo de memoização em aplicativos React pode levar a problemas de desempenho. Aprenda onde ele falha e como evitar essas armadilhas ocultas no seu desenvolvimento.
featured image - Memoização no React: ferramenta poderosa ou armadilha oculta?
Social Discovery Group HackerNoon profile picture
0-item


Uma abordagem generalizada no desenvolvimento de aplicativos React é cobrir tudo com memoização. Muitos desenvolvedores aplicam essa técnica de otimização liberalmente, agrupando componentes em memoização para evitar novas renderizações desnecessárias. Superficialmente, parece uma estratégia infalível para melhorar o desempenho. No entanto, a equipe do Social Discovery Group descobriu que, quando mal aplicada, a memoização pode falhar de maneiras inesperadas, levando a problemas de desempenho. Neste artigo, exploraremos os lugares surpreendentes onde a memorização pode falhar e como evitar essas armadilhas ocultas em seus aplicativos React.


O que é memorização?

Memoização é uma técnica de otimização em programação que envolve salvar os resultados de operações caras e reutilizá-los quando as mesmas entradas forem encontradas novamente. A essência da memoização é evitar cálculos redundantes para os mesmos dados de entrada. Esta descrição é realmente verdadeira para a memoização tradicional. Você pode ver no exemplo de código que todos os cálculos são armazenados em cache.


 const cache = { } function calculate (a) { if (Object.hasOwn(cache, a)) { return cache[a] } cache[a] = a * a return cache[a] }


A memorização oferece vários benefícios, incluindo melhoria de desempenho, economia de recursos e armazenamento em cache de resultados. No entanto, a memorização do React funciona até que novos adereços cheguem, o que significa que apenas o resultado da última chamada é salvo.


 const prev = { value: null, result: null } function calculate(a) { if (prev.value === a) { return prev.result } prev.value = a prev.result = a * a return prev.result }


Ferramentas de memorização em React

A biblioteca React nos fornece diversas ferramentas para memorização. Estes são o HOC React.memo, os ganchos useCallback, useMemo e useEvent, bem como React.PureComponent e o método de ciclo de vida dos componentes da classe, shouldComponentUpdate. Vamos examinar as três primeiras ferramentas de memorização e explorar seus propósitos e uso no React.


React.memo

Este componente de ordem superior (HOC) aceita um componente como seu primeiro argumento e uma função de comparação opcional como segundo. A função de comparação permite a comparação manual de adereços anteriores e atuais. Quando nenhuma função de comparação é fornecida, o React assume como padrão a igualdade superficial. É crucial compreender que a igualdade superficial apenas realiza uma comparação no nível superficial. Conseqüentemente, se os adereços contiverem tipos de referência com referências não constantes, o React irá acionar uma nova renderização do componente.


 const Button = memo((props) => { return ( <button onClick={props.onClick}> {props.title} </button> ) }, (prevProps, props) => { return props.title === prevProps.title })


React.useCallback

O gancho useCallback nos permite preservar a referência a uma função passada durante a renderização inicial. Nas renderizações subsequentes, o React comparará os valores na matriz de dependência do gancho e, se nenhuma das dependências tiver sido alterada, retornará a mesma referência de função em cache da última vez. Em outras palavras, useCallback armazena em cache a referência à função entre renderizações até que suas dependências sejam alteradas.


 const callback = useCallback(() => { // do something }, [a, b, c])


React.useMemo

O gancho useMemo permite armazenar em cache o resultado de um cálculo entre renderizações. Normalmente, useMemo é usado para armazenar em cache cálculos caros, bem como para armazenar uma referência a um objeto ao passá-lo para outros componentes agrupados no memo HOC ou como uma dependência em ganchos.


 const value = useMemo(() => { return [1, 2, 3, 4, 5].filter(it => it % 2 === 0) }, [])


Memoização completa de um projeto

Nas equipes de desenvolvimento do React, uma prática difundida é a memorização abrangente. Essa abordagem normalmente envolve:

  • Envolvendo todos os componentes em React.memo
  • Usando useCallback para todas as funções passadas para outros componentes
  • Cache de cálculos e tipos de referência com useMemo


No entanto, os desenvolvedores nem sempre compreendem as complexidades desta estratégia e como a memoização pode ser facilmente comprometida. Não é incomum que componentes incluídos no memorando HOC sejam renderizados novamente inesperadamente. No Social Discovery Group, ouvimos colegas afirmarem: “Memorizar tudo não é muito mais caro do que não memorizar nada”.


Notamos que nem todos compreendem totalmente um aspecto crucial da memoização: ela funciona de maneira ideal, sem ajustes adicionais, apenas quando os primitivos são passados para o componente memoizado.


  1. Nesses casos, o componente será renderizado novamente somente se os valores prop tiverem realmente mudado. Primitivos em adereços são bons.

  2. O segundo ponto é quando passamos tipos de referência em adereços. É importante lembrar e compreender que não existe mágica no React – é uma biblioteca JavaScript que opera de acordo com as regras do JavaScript. Tipos de referência em adereços (funções, objetos, arrays) são perigosos.


    Por exemplo:


 const a = { c: 1 } const b = { c: 1 } a === b // false First call: MemoComponent(a) Second call: MemoComponent(b) const MemoComponent = memo(({object}) => { return <div /> }, (prevProps, props) => (prevProps.object === props.object)) // false


Se você criar um objeto, outro objeto com as mesmas propriedades e valores não será igual ao primeiro porque possuem referências diferentes.


Se passarmos o que parece ser o mesmo objeto em uma chamada subsequente do componente, mas na verdade é diferente (já que sua referência é diferente), a comparação superficial que o React usa reconhecerá esses objetos como diferentes. Isso irá acionar uma nova renderização do componente envolvido no memo, quebrando assim a memorização desse componente.


Para garantir uma operação segura com componentes memorizados, é importante usar uma combinação de memo, useCallback e useMemo. Desta forma, todos os tipos de referência terão referências constantes.

Trabalho em equipe: memo, useCallback, useMemo

Vamos quebrar o memorando, certo?

Tudo o que foi descrito acima parece lógico e simples, mas vamos dar uma olhada juntos nos erros mais comuns que podem ser cometidos ao trabalhar com essa abordagem. Alguns deles podem ser sutis e alguns podem ser um pouco exagerados, mas precisamos estar cientes deles e, o mais importante, entendê-los para garantir que a lógica que implementamos com memoização completa não seja quebrada.


Inline para memoização

Vamos começar com um erro clássico, onde em cada renderização subsequente do componente Parent, o componente memorizado MemoComponent será constantemente renderizado novamente porque a referência ao objeto passado nos parâmetros sempre será nova.


 const Parent = () => { return ( <MemoComponent params={[1, 2 ,3]} /> ) }


Para resolver este problema, basta usar o gancho useMemo mencionado anteriormente. Agora, a referência ao nosso objeto será sempre constante.


 const Parent = () => { const params = useMemo(() => { return [1, 2 ,3] ), []) return ( <MemoComponent params={params} /> ) }


Alternativamente, você pode mover isso para uma constante fora do componente se a matriz sempre contiver dados estáticos.


 const params = [1, 2 ,3] const Parent = () => { return ( <MemoComponent params={params} /> ) }


Uma situação semelhante neste exemplo é passar uma função sem memorização. Neste caso, como no exemplo anterior, a memorização do MemoComponent será quebrada passando-se para ele uma função que terá uma nova referência a cada renderização do componente Parent. Conseqüentemente, MemoComponent será renderizado novamente como se não tivesse sido memorizado.


 const Parent = () => { return ( <MemoComponent onClick={() => {}} /> ) }


Aqui, podemos usar o gancho useCallback para preservar a referência à função passada entre renderizações do componente Parent.


 const Parent = () => { const handleClick = useCallback(() => console.log('click') }, []) return ( <MemoComponent onClick={handleClick} /> ) }


Nota tomada.

Além disso, em useCallback, não há nada que impeça você de passar uma função que retorne outra função. Porém, é importante lembrar que nesta abordagem, a função `someFunction` será chamada em cada renderização. É crucial evitar cálculos complexos dentro de `someFunction`.


 function someFunction() { // expensive calculations (?) ... return () => {} } .............................. const Parent = () => { const cachedFunction = useCallback(someFunction(), []) return ( <MemoComponent onClick={cachedFunction} /> ) }


Adereços espalhando

A próxima situação comum é a propagação de adereços. Imagine que você tem uma cadeia de componentes. Com que frequência você considera a distância que o suporte de dados passado do InitialComponent pode percorrer, sendo potencialmente desnecessário para alguns componentes desta cadeia? Neste exemplo, esta propriedade quebrará a memorização no componente ChildMemo porque, a cada renderização do InitialComponent, seu valor sempre mudará. Em um projeto real, onde a cadeia de componentes memoizados pode ser longa, toda memoização será quebrada porque adereços desnecessários com valores em constante mudança são passados para eles:


 const Child = () => {} const ChildMemo = React.memo(Child) const Component = (props) => { return <ChildMemo {...props} /> } const InitialComponent = (props) => { // The only component that has state and can trigger a re-render return ( <Component {...props} data={Math.random()} /> ) }


Para se proteger, certifique-se de que apenas os valores necessários sejam passados para o componente memorizado. Ao invés disso:


 const Component = (props) => { return <ChildMemo {...props} /> }


Use (passe apenas os adereços necessários):


 const Component = (props) => { return ( <ChildMemo firstProp={prop.firstProp} secondProp={props.secondProp} /> ) )


Memorando e filhos

Vamos considerar o seguinte exemplo. Uma situação familiar é quando escrevemos um componente que aceita JSX como filho.


 const ChildMemo = React.memo(Child) const Component = () => { return ( <ChildMemo> <div>Text</div> </ChildMemo> ) }


À primeira vista parece inofensivo, mas na realidade não é. Vamos dar uma olhada mais de perto no código onde passamos JSX como filhos para um componente memoizado. Esta sintaxe nada mais é do que um açúcar sintático para passar este `div` como um suporte chamado `children`.


Os filhos não são diferentes de qualquer outro adereço que passamos para um componente. No nosso caso, estamos passando JSX, e JSX, por sua vez, é um açúcar sintático para o método `createElement`, então essencialmente, estamos passando um objeto regular com o tipo `div`. E aqui, a regra usual para um componente memoizado se aplica: se um objeto não memoizado for passado nos adereços, o componente será renderizado novamente quando seu pai for renderizado, pois cada vez que a referência a este objeto será nova.



A solução para tal problema foi discutida alguns resumos anteriormente, no bloco referente à passagem de um objeto não memorizado. Portanto, aqui, o conteúdo que está sendo passado pode ser memorizado usando useMemo, e passá-lo como filho para ChildMemo não interromperá a memorização deste componente.


 const Component = () => { const childrenContent = useMemo( () => <div>Text</div>, [], ) return ( <ChildMemo> {childrenContent} </ChildMemo> ) }


ParentMemo e ChildMemo

Vamos considerar um exemplo mais interessante.


 const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo> <ChildMemo /> </ParentMemo> ) }


À primeira vista parece inofensivo: temos dois componentes, ambos memorizados. Entretanto, neste exemplo, ParentMemo se comportará como se não estivesse agrupado em memo porque seus filhos, ChildMemo, não foram memorizados. O resultado do componente ChildMemo será JSX, e JSX é apenas um açúcar sintático para React.createElement, que retorna um objeto. Portanto, após a execução do método React.createElement, ParentMemo e ChildMemo se tornarão objetos JavaScript regulares e esses objetos não serão memorizados de forma alguma.

ParentMemo

CriançaMemorando

{`` type: {`` ...`` $$typeof: Symbol(react.memo),`` type: {`` name: "Parent"`` }`` },`` ...``}

{`` type: {`` ...`` $$typeof: Symbol(react.memo),`` type: {`` name: "Child"`` }`` },`` ...``}


Como resultado, passamos um objeto não memorizado para os adereços, quebrando assim a memorização do componente Parent.


 const ParentMemo = React.memo(Parent) const ChildMemo = React.memo(Child) const App = () => { return ( <ParentMemo children={<ChildMemo />} /> ) }


Para resolver esse problema, é suficiente memorizar o filho passado, garantindo que sua referência permaneça constante durante a renderização do componente App pai.


 const App = () => { const child = useMemo(() => { return <ChildMemo /> }, []); return ( <ParentMemo> {child} </ParentMemo> ) }


Não primitivos de ganchos personalizados

Outra área perigosa e implícita são os ganchos personalizados. Ganchos personalizados nos ajudam a extrair lógica de nossos componentes, tornando o código mais legível e ocultando lógica complexa. No entanto, eles também nos escondem se seus dados e funções têm referências constantes. No meu exemplo, a implementação da função submit está oculta no gancho personalizado useForm e, em cada renderização do componente Parent, os ganchos serão executados novamente.


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> };


Podemos entender pelo código se é seguro passar o método submit como um suporte para o componente memorizado ComponentMemo? Claro que não. E na pior das hipóteses, a implementação do gancho personalizado pode ser assim:


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = () => {} return { submit } }


Ao passar o método submit para o componente memoized, quebraremos a memoização porque a referência ao método submit será nova a cada renderização do componente Parent. Para resolver esse problema, você pode usar o gancho useCallback. Mas o ponto principal que gostaria de enfatizar é que você não deve usar cegamente dados de ganchos personalizados para passá-los para componentes memorizados se não quiser quebrar a memorização que você implementou.


 const Parent = () => { const { submit } = useForm() return <ComponentMemo onChange={submit} /> }; const useForm = () => { const submit = useCallback(() => {}, []) return { submit } }


Quando a memoização é excessiva, mesmo que você cubra tudo com memoização?

Como qualquer abordagem, a memorização completa deve ser usada com cuidado e deve-se esforçar-se para evitar a memorização flagrantemente excessiva. Vamos considerar o seguinte exemplo:


 export function App() { const [state, setState] = useState('') const handleChange = (e) => { setState(e.target.value) } return ( <Form> <Input value={state} onChange={handleChange}/> </Form> ) } export const Input = memo((props) => (<input {...props} />))


Neste exemplo, é tentador agrupar o método handleChange em useCallback porque passar handleChange em sua forma atual interromperá a memorização do componente Input, pois a referência a handleChange sempre será nova. No entanto, quando o estado muda, o componente Input será renderizado novamente de qualquer maneira porque um novo valor será passado para ele na proposta de valor. Portanto, o fato de não agruparmos handleChange em useCallback não impedirá que o componente Input seja renderizado constantemente. Neste caso, usar useCallback seria excessivo. A seguir, gostaria de fornecer alguns exemplos de código real visto durante revisões de código.


 const activeQuestionNumber = useMemo(() => { return activeQuestionIndex + 1 }, [activeQuestionIndex]) const userAnswerImage = useMemo(() => { return `/i/call/quiz/${quizQuestionAnswer.userAnswer}.png` }, [quizQuestionAnswer.userAnswer])


Considerando o quão simples é a operação de adicionar dois números ou concatenar strings, e que obtemos primitivos como saída nesses exemplos, é óbvio que usar useMemo aqui não faz sentido. Exemplos semelhantes aqui.


 const cta = useMemo(() => { return activeOverlayName === 'photos' ? 'gallery' : 'profile' }, [activeOverlayName]) const attendeeId = useMemo(() => { return userId === senderId ? recipientId : senderId }, [userId, recipientId, senderId])


Com base nas dependências em useMemo, resultados diferentes podem ser obtidos, mas, novamente, são primitivos e não há cálculos complexos dentro deles. Executar qualquer uma dessas condições em cada renderização do componente é mais barato do que usar useMemo.


Conclusões

  1. Otimização - nem sempre benéfica. *As otimizações de desempenho não são gratuitas e o custo dessas otimizações pode nem sempre ser proporcional aos benefícios que você obterá com elas.
    *
  2. Meça os resultados das otimizações. *Se você não medir, não poderá saber se suas otimizações melhoraram alguma coisa ou não. E o mais importante: sem medições, você não saberá se elas pioraram as coisas.
    *
  3. Meça a eficácia da memorização. *O uso ou não da memoização completa só pode ser entendido medindo seu desempenho em seu caso específico. A memorização não é gratuita ao armazenar em cache ou memorizar cálculos, e isso pode afetar a rapidez com que seu aplicativo é inicializado pela primeira vez e a rapidez com que os usuários podem começar a usá-lo. Por exemplo, se você deseja memorizar algum cálculo complexo cujo resultado precisa ser enviado ao servidor quando um botão é pressionado, você deve memorizá-lo quando sua aplicação for inicializada? Talvez não, porque há uma chance de o usuário nunca pressionar esse botão e executar esse cálculo complexo pode ser totalmente desnecessário.
    *
  4. Pense primeiro e depois memorize. * Memoizar adereços passados para um componente só faz sentido se ele estiver empacotado em `memo`, ou se os adereços recebidos forem usados nas dependências dos ganchos, e também se esses adereços forem passados para outros componentes memoizados.
    *
  5. Lembre-se dos princípios básicos do JavaScript. Ao trabalhar com React ou qualquer outra biblioteca e framework, é importante não esquecer que tudo isso é implementado em JavaScript e opera de acordo com as regras desta linguagem.


Quais ferramentas podem ser usadas para medição?

Podemos recomendar pelo menos 4 dessas ferramentas para medir o desempenho do código do seu aplicativo.


React.Profiler

Com React.Profiler , você pode agrupar o componente específico necessário ou o aplicativo inteiro para obter informações sobre o horário das renderizações inicial e subsequente. Você também pode entender em qual fase exata a métrica foi obtida.


Ferramentas de desenvolvedor React

Ferramentas de desenvolvedor React é uma extensão do navegador que permite inspecionar a hierarquia de componentes, rastrear alterações em estados e adereços e analisar o desempenho do aplicativo.


Rastreador de renderização React

Outra ferramenta interessante é Rastreador de renderização React , o que ajuda a detectar re-renderizações potencialmente desnecessárias quando props em componentes não memorizados não mudam ou mudam para similares.


Livro de histórias com o complemento storybook-addon-performance.

Além disso, no Storybook, você pode instalar um plugin interessante chamado desempenho de complemento de livro de histórias da Atlassian. Com este plugin, você pode executar testes para obter informações sobre a velocidade de renderização inicial, re-renderização e renderização do lado do servidor. Esses testes podem ser executados em múltiplas cópias, bem como em múltiplas execuções simultaneamente, minimizando imprecisões nos testes.



** Escrito por Sergey Levkovich, engenheiro de software sênior do Social Discovery Group