Reagir: elevar o estado está matando seu aplicativo

Cover


Você já ouviu falar sobre "elevar o estado"? Eu acho que você tem e essa é a razão exata pela qual você está aqui. Como é possível que um dos 12 principais conceitos listados na documentação oficial do React leve a um desempenho ruim? Neste artigo, consideraremos uma situação em que realmente é o caso.


Etapa 1: levante


Eu sugiro que você crie um jogo simples de jogo da velha. Para o jogo, precisaremos de:


  • Algum estado do jogo. Nenhuma lógica de jogo real para descobrir se ganhamos ou perdemos. Apenas uma matriz bidimensional simples preenchida com undefined , "x" ou "0".


     const size = 10 // Two-dimensional array (size * size) filled with `undefined`. Represents an empty field. const initialField = new Array(size).fill(new Array(size).fill(undefined)) 

  • Um contêiner pai para hospedar o estado do nosso jogo.


     const App = () => { const [field, setField] = useState(initialField) return ( <div> {field.map((row, rowI) => ( <div> {row.map((cell, cellI) => ( <Cell content={cell} setContent={ // Update a single cell of a two-dimensional array // and return a new two dimensional array (newContent) => setField([ // Copy rows before our target row ...field.slice(0, rowI), [ // Copy cells before our target cell ...field[rowI].slice(0, cellI), newContent, // Copy cells after our target cell ...field[rowI].slice(cellI + 1), ], // Copy rows after our target row ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> ) } 

  • Um componente filho para exibir o estado de uma única célula.


     const randomContent = () => (Math.random() > 0.5 ? 'x' : '0') const Cell = ({ content, setContent }) => ( <div onClick={() => setContent(randomContent())}>{content}</div> ) 


Demonstração ao vivo # 1


Até agora, parece bem. Um campo perfeitamente reativo com o qual você pode interagir na velocidade da luz :) Vamos aumentar o tamanho. Digamos, para 100. Sim, é hora de clicar no link de demonstração e alterar a variável de size no topo. Ainda rápido para você? Experimente o 200 ou use a otimização da CPU incorporada no Chrome . Você vê agora um atraso significativo entre o momento em que você clica em uma célula e o momento em que seu conteúdo muda?


Vamos mudar o size volta para 10 e adicionar alguns perfis para investigar a causa.


 const Cell = ({ content, setContent }) => { console.log('cell rendered') return <div onClick={() => setContent(randomContent())}>{content}</div> } 

Demonstração ao vivo # 2


Sim, é isso. console.log simples seria suficiente, pois é executado em todas as renderizações.


Então o que vemos? Com base no número das instruções "cell renderizadas" (para size = N, deve ser N) em nosso console, parece que todo o campo é renderizado novamente cada vez que uma única célula é alterada.


A coisa mais óbvia a fazer é adicionar algumas chaves, como sugere a documentação do React .


 <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} setContent={(newContent) => setField([ ...field.slice(0, rowI), [ ...field[rowI].slice(0, cellI), newContent, ...field[rowI].slice(cellI + 1), ], ...field.slice(rowI + 1), ]) } /> ))} </div> ))} </div> 

Demonstração ao vivo nº 3


No entanto, depois de aumentar o size novamente, vemos que esse problema ainda está lá. Se pudéssemos ver por que algum componente é renderizado ... Felizmente, podemos com alguma ajuda do incrível React DevTools . É capaz de registrar por que os componentes são renderizados. Você precisa ativá-lo manualmente.


Reagir as configurações do DevTools


Uma vez ativado, podemos ver que todas as células foram renderizadas novamente porque seus objetos foram alterados, especificamente, o setContent .


Reagir o relatório nº 1 do DevTools


Cada célula possui dois objetos: content e setContent . Se a célula [0] [0] for alterada, o conteúdo da célula [0] [1] não será alterado. Por outro lado, setContent captura field , cellI e rowI em seu fechamento. cellI e rowI permanecem os mesmos, mas o field muda a cada alteração de qualquer célula.


Vamos refatorar nosso código e manter setContent o mesmo.


Para manter a referência para setContent a mesma, devemos nos livrar dos fechamentos. Podemos eliminar o fechamento rowI e rowI , fazendo com que nosso Cell passe explicitamente cellI e rowI para setContent . Quanto ao field , poderíamos utilizar um recurso interessante de setState - ele aceita retornos de chamada .


 const [field, setField] = useState(initialField) // `useCallback` keeps reference to `setCell` the same. const setCell = useCallback( (rowI, cellI, newContent) => setField((oldField) => [ ...oldField.slice(0, rowI), [ ...oldField[rowI].slice(0, cellI), newContent, ...oldField[rowI].slice(cellI + 1), ], ...oldField.slice(rowI + 1), ]), [], ) 

O que faz com que o App fique assim


 <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} content={cell} rowI={rowI} cellI={cellI} setContent={setCell} /> ))} </div> ))} </div> 

Agora Cell precisa passar cellI e rowI para o setContent .


 const Cell = ({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) } 

Demonstração ao vivo # 4


Vamos dar uma olhada no relatório do DevTools.


Reagir o relatório do DevTools nº 2


O que ?! Por que diabos diz "adereços parentais alterados"? Então, o problema é que toda vez que nosso campo é atualizado, o App é renderizado novamente. Portanto, seus componentes filhos são renderizados novamente. Ok O stackoverflow diz algo útil sobre a otimização do desempenho do React? A Internet sugere usar o shouldComponentUpdate ou seus parentes próximos: PureComponent e memo .


 const Cell = memo(({ content, rowI, cellI, setContent }) => { console.log('cell render') return ( <div onClick={() => setContent(rowI, cellI, randomContent())}> {content} </div> ) }) 

Demonstração ao vivo # 5


Yay Agora, apenas uma célula é renderizada novamente quando o conteúdo é alterado. Mas espere ... Houve alguma surpresa? Seguimos as melhores práticas e obtivemos o resultado esperado.


Uma risada maligna deveria estar aqui. Como não estou com você, por favor, tente o máximo possível para imaginá-lo. Vá em frente e aumente o size na demonstração ao vivo nº 5 . Desta vez, você pode ter que ir com um número um pouco maior. No entanto, o atraso ainda está lá. Por que ???


Vamos dar uma olhada no relatório DebTools novamente.


Reagir o relatório do DevTools nº 3


Há apenas uma renderização do Cell e foi bem rápida, mas também uma renderização do App , que levou algum tempo. O fato é que, a cada nova renderização do App cada Cell precisa comparar seus novos objetos com os objetos anteriores. Mesmo que decida não renderizar (que é precisamente o nosso caso), essa comparação ainda leva tempo. O (1), mas esse O (1) ocorre size * size vezes!


Etapa 2: mova-o para baixo


O que podemos fazer para contornar isso? Se a renderização do App nos custar muito, teremos que parar de renderizar o App . Não é possível se continuar hospedando nosso estado no App usando useState , porque é exatamente isso que desencadeia as renderizações novamente. Portanto, temos que mudar nosso estado para baixo e deixar que cada Cell inscreva no estado por conta própria.


Vamos criar uma classe dedicada que será um contêiner para o nosso estado.


 class Field { constructor(fieldSize) { this.size = fieldSize // Copy-paste from `initialState` this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) } cellContent(rowI, cellI) { return this.data[rowI][cellI] } // Copy-paste from old `setCell` setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] } map(cb) { return this.data.map(cb) } } const field = new Field(size) 

Então, nosso App pode ficar assim:


 const App = () => { return ( <div> {// As you can see we still need to iterate over our state to get indexes. field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> ) } 

E nosso Cell pode exibir o conteúdo do field por conta própria:


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Demonstração ao vivo # 6


Nesse ponto, podemos ver nosso campo sendo renderizado. No entanto, se clicarmos em uma célula, nada acontece. Nos logs, podemos ver "setCell" para cada clique, mas a célula permanece em branco. A razão aqui é que nada diz à célula para renderizar novamente. Nosso estado fora do React muda, mas o React não sabe disso. Isso tem que mudar.


Como podemos disparar uma renderização programaticamente?


Com as classes, temos forceUpdate . Isso significa que precisamos reescrever nosso código em classes? Na verdade não. O que podemos fazer com os componentes funcionais é introduzir algum estado fictício, que mudamos apenas para forçar nosso componente a renderizar novamente.


Veja como podemos criar um gancho personalizado para forçar as renderizações novamente.


 const useForceRender = () => { const [, setDummy] = useState(0) const forceRender = useCallback(() => setDummy((oldVal) => oldVal + 1), []) return forceRender } 

Para acionar uma nova renderização quando nosso campo é atualizado, precisamos saber quando ele é atualizado. Isso significa que precisamos poder, de alguma forma, assinar atualizações de campo.


 class Field { constructor(fieldSize) { this.size = fieldSize this.data = new Array(this.size).fill(new Array(this.size).fill(undefined)) this.subscribers = {} } _cellSubscriberId(rowI, cellI) { return `row${rowI}cell${cellI}` } cellContent(rowI, cellI) { return this.data[rowI][cellI] } setCell(rowI, cellI, newContent) { console.log('setCell') this.data = [ ...this.data.slice(0, rowI), [ ...this.data[rowI].slice(0, cellI), newContent, ...this.data[rowI].slice(cellI + 1), ], ...this.data.slice(rowI + 1), ] const cellSubscriber = this.subscribers[this._cellSubscriberId(rowI, cellI)] if (cellSubscriber) { cellSubscriber() } } map(cb) { return this.data.map(cb) } // Note that we subscribe not to updates of the whole filed, but to updates of one cell only subscribeCellUpdates(rowI, cellI, onSetCellCallback) { this.subscribers[this._cellSubscriberId(rowI, cellI)] = onSetCellCallback } } 

Agora podemos assinar atualizações de campo.


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => field.subscribeCellUpdates(rowI, cellI, forceRender), [ forceRender, ]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Demonstração ao vivo # 7


Vamos jogar com size com esta implementação. Tente aumentá-lo para os valores que antes pareciam atrasados. E ... é hora de abrir uma boa garrafa de champanhe! Temos um aplicativo que processa uma célula e uma célula apenas quando o estado dessa célula muda!


Vamos dar uma olhada no relatório do DevTools.


Reagir o relatório do DevTools nº 4


Como podemos ver agora, apenas o Cell está sendo renderizado e é uma loucura rápida.


E se dizer que agora o código da nossa Cell é uma causa potencial de vazamento de memória? Como você pode ver, em useEffect , useEffect atualizações de células, mas nunca cancelamos a inscrição. Isso significa que, mesmo quando o Cell é destruído, sua assinatura permanece. Vamos mudar isso.


Primeiro, precisamos ensinar a Field o que significa cancelar a inscrição.


 class Field { // ... unsubscribeCellUpdates(rowI, cellI) { delete this.subscribers[this._cellSubscriberId(rowI, cellI)] } } 

Agora podemos aplicar unsubscribeCellUpdates ao nosso Cell .


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Demonstração ao vivo # 8


Então, qual é a lição aqui? Quando faz sentido mover o estado para baixo na árvore de componentes? Nunca! Bem, na verdade não. :) Siga as práticas recomendadas até que elas falhem e não faça otimizações prematuras. Honestamente, o caso que consideramos acima é um pouco específico, no entanto, espero que você o lembre se precisar exibir uma lista realmente grande.


Etapa bônus: refatoração do mundo real


Na demonstração ao vivo nº 8 , usamos o field global, o que não deve ser o caso em um aplicativo do mundo real. Para resolvê-lo, poderíamos hospedar o field em nosso App e passá-lo para a árvore usando [context] ().


 const AppContext = createContext() const App = () => { // Note how we used a factory to initialize our state here. // Field creation could be quite expensive for big fields. // So we don't want to create it each time we render and block the event loop. const [field] = useState(() => new Field(size)) return ( <AppContext.Provider value={field}> <div> {field.map((row, rowI) => ( <div key={rowI}> {row.map((cell, cellI) => ( <Cell key={`row${rowI}cell${cellI}`} rowI={rowI} cellI={cellI} /> ))} </div> ))} </div> </AppContext.Provider> ) } 

Agora podemos consumir field partir do contexto em nossa Cell .


 const Cell = ({ rowI, cellI }) => { console.log('cell render') const forceRender = useForceRender() const field = useContext(AppContext) useEffect(() => { field.subscribeCellUpdates(rowI, cellI, forceRender) return () => field.unsubscribeCellUpdates(rowI, cellI) }, [forceRender]) const content = field.cellContent(rowI, cellI) return ( <div onClick={() => field.setCell(rowI, cellI, randomContent())}> {content} </div> ) } 

Demonstração ao vivo # 9


Felizmente, você encontrou algo útil para o seu projeto. Sinta-se livre para me comunicar seus comentários! Certamente aprecio qualquer crítica e pergunta.

Source: https://habr.com/ru/post/pt471300/


All Articles