paint-brush
Memoización en React: ¿herramienta poderosa o trampa oculta?por@socialdiscoverygroup
971 lecturas
971 lecturas

Memoización en React: ¿herramienta poderosa o trampa oculta?

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

Demasiado Largo; Para Leer

Un enfoque generalizado en el desarrollo de aplicaciones React es cubrir todo con memorización. El equipo de Social Discovery Group descubrió cómo el uso excesivo de la memorización en las aplicaciones React puede provocar problemas de rendimiento. Aprenda dónde falla y cómo evitar estas trampas ocultas en su desarrollo.
featured image - Memoización en React: ¿herramienta poderosa o trampa oculta?
Social Discovery Group HackerNoon profile picture
0-item


Un enfoque generalizado en el desarrollo de aplicaciones React es cubrir todo con memorización. Muchos desarrolladores aplican esta técnica de optimización generosamente, envolviendo componentes en memorización para evitar re-renderizaciones innecesarias. A primera vista, parece una estrategia infalible para mejorar el rendimiento. Sin embargo, el equipo de Social Discovery Group ha descubierto que, cuando se aplica mal, la memorización puede fallar de formas inesperadas, provocando problemas de rendimiento. En este artículo, exploraremos los lugares sorprendentes donde la memorización puede fallar y cómo evitar estas trampas ocultas en sus aplicaciones React.


¿Qué es la memorización?

La memorización es una técnica de optimización en programación que implica guardar los resultados de operaciones costosas y reutilizarlos cuando se vuelven a encontrar las mismas entradas. La esencia de la memorización es evitar cálculos redundantes para los mismos datos de entrada. De hecho, esta descripción es cierta para la memorización tradicional. Puede ver en el ejemplo de código que todos los cálculos se almacenan en caché.


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


La memorización ofrece varios beneficios, incluida la mejora del rendimiento, el ahorro de recursos y el almacenamiento en caché de resultados. Sin embargo, la memorización de React funciona hasta que llegan nuevos accesorios, lo que significa que solo se guarda el resultado de la última llamada.


 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 }


Herramientas de memorización en React

La biblioteca React nos proporciona varias herramientas para la memorización. Estos son HOC React.memo, los ganchos useCallback, useMemo y useEvent, así como React.PureComponent y el método de ciclo de vida de los componentes de clase, shouldComponentUpdate. Examinemos las tres primeras herramientas de memorización y exploremos sus propósitos y uso en React.


Reaccionar.memo

Este componente de orden superior (HOC) acepta un componente como primer argumento y una función de comparación opcional como segundo. La función de comparación permite la comparación manual de accesorios anteriores y actuales. Cuando no se proporciona ninguna función de comparación, React utiliza de forma predeterminada una igualdad superficial. Es crucial comprender que la igualdad superficial sólo realiza una comparación a nivel superficial. En consecuencia, si los accesorios contienen tipos de referencia con referencias no constantes, React activará una nueva renderización del componente.


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


Reaccionar.useCallback

El gancho useCallback nos permite conservar la referencia a una función pasada durante el renderizado inicial. En renderizaciones posteriores, React comparará los valores en la matriz de dependencias del gancho y, si ninguna de las dependencias ha cambiado, devolverá la misma referencia de función almacenada en caché que la última vez. En otras palabras, useCallback almacena en caché la referencia a la función entre renderizaciones hasta que cambien sus dependencias.


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


Reaccionar.useMemo

El gancho useMemo le permite almacenar en caché el resultado de un cálculo entre renderizaciones. Normalmente, useMemo se utiliza para almacenar en caché cálculos costosos, así como para almacenar una referencia a un objeto cuando se pasa a otros componentes envueltos en el memo HOC o como una dependencia en ganchos.


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


Memorización completa de un proyecto.

En los equipos de desarrollo de React, una práctica generalizada es la memorización integral. Este enfoque normalmente implica:

  • Envolviendo todos los componentes en React.memo
  • Usar useCallback para todas las funciones pasadas a otros componentes
  • Almacenamiento en caché de cálculos y tipos de referencia con useMemo


Sin embargo, los desarrolladores no siempre comprenden las complejidades de esta estrategia y la facilidad con la que la memorización puede verse comprometida. No es raro que los componentes incluidos en el memo HOC se vuelvan a renderizar inesperadamente. En Social Discovery Group, hemos escuchado a colegas afirmar: "Memorizar todo no es mucho más caro que no memorizar nada".


Hemos notado que no todos comprenden completamente un aspecto crucial de la memorización: funciona de manera óptima sin ajustes adicionales solo cuando las primitivas se pasan al componente memorizado.


  1. En tales casos, el componente se volverá a representar solo si los valores de propiedad realmente han cambiado. Los primitivos en los accesorios son buenos.

  2. El segundo punto es cuando pasamos tipos de referencia en accesorios. Es importante recordar y comprender que no hay magia en React: es una biblioteca de JavaScript que opera de acuerdo con las reglas de JavaScript. Los tipos de referencia en accesorios (funciones, objetos, matrices) son peligrosos.


    Por ejemplo:


 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


Si creas un objeto, otro objeto con las mismas propiedades y valores no es igual al primero porque tiene referencias diferentes.


Si pasamos lo que parece ser el mismo objeto en una llamada posterior del componente, pero en realidad es uno diferente (ya que su referencia es diferente), la comparación superficial que usa React reconocerá estos objetos como diferentes. Esto desencadenará una nueva representación del componente incluido en la nota, interrumpiendo así la memorización de ese componente.


Para garantizar una operación segura con componentes memorizados, es importante utilizar una combinación de memo, useCallback y useMemo. De esta forma, todos los tipos de referencia tendrán referencias constantes.

Trabajo en equipo: memo, useCallback, useMemo

Rompamos el memorándum, ¿de acuerdo?

Todo lo descrito anteriormente suena lógico y simple, pero echemos un vistazo juntos a los errores más comunes que se pueden cometer al trabajar con este enfoque. Algunos de ellos pueden ser sutiles y otros pueden ser un poco exagerados, pero debemos ser conscientes de ellos y, lo más importante, comprenderlos para asegurarnos de que la lógica que implementamos con una memorización completa no se rompa.


Incorporación a la memorización

Comencemos con un error clásico, donde en cada renderizado posterior del componente principal, el componente memorizado MemoComponent se volverá a renderizar constantemente porque la referencia al objeto pasado en parámetros siempre será nueva.


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


Para resolver este problema, es suficiente utilizar el gancho useMemo mencionado anteriormente. Ahora, la referencia a nuestro objeto siempre será constante.


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


Alternativamente, puede mover esto a una constante fuera del componente si la matriz siempre contiene datos estáticos.


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


Una situación similar en este ejemplo es pasar una función sin memorizar. En este caso, como en el ejemplo anterior, la memorización del MemoComponent se romperá al pasarle una función que tendrá una nueva referencia en cada renderizado del componente principal. En consecuencia, MemoComponent se renderizará nuevamente como si no estuviera memorizado.


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


Aquí, podemos usar el gancho useCallback para preservar la referencia a la función pasada entre renderizaciones del componente principal.


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


Nota tomada.

Además, en useCallback, no hay nada que le impida pasar una función que devuelva otra función. Sin embargo, es importante recordar que en este enfoque, la función `someFunction` se llamará en cada renderizado. Es crucial evitar cálculos complejos dentro de "alguna función".


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


Propagación de accesorios

La siguiente situación común es la distribución de accesorios. Imagina que tienes una cadena de componentes. ¿Con qué frecuencia considera hasta qué punto puede viajar el accesorio de datos pasado desde InitialComponent, que podría ser innecesario para algunos componentes de esta cadena? En este ejemplo, este accesorio interrumpirá la memorización en el componente ChildMemo porque, en cada representación del componente Inicial, su valor siempre cambiará. En un proyecto real, donde la cadena de componentes memorizados puede ser larga, toda la memorización se interrumpirá porque se les pasan accesorios innecesarios con valores que cambian constantemente:


 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 protegerse, asegúrese de que solo se pasen los valores necesarios al componente memorizado. En lugar de eso:


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


Utilice (pase sólo los accesorios necesarios):


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


Memo y niños

Consideremos el siguiente ejemplo. Una situación familiar es cuando escribimos un componente que acepta JSX como hijo.


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


A primera vista parece inofensivo, pero en realidad no lo es. Echemos un vistazo más de cerca al código en el que pasamos JSX como elementos secundarios a un componente memorizado. Esta sintaxis no es más que azúcar sintáctico para pasar este "div" como un accesorio llamado "niños".


Los niños no son diferentes de cualquier otro accesorio que le pasemos a un componente. En nuestro caso, estamos pasando JSX, y JSX, a su vez, es azúcar sintáctico para el método `createElement`, por lo que esencialmente estamos pasando un objeto normal con el tipo `div`. Y aquí, se aplica la regla habitual para un componente memorizado: si se pasa un objeto no memorizado en los accesorios, el componente se volverá a representar cuando se represente su padre, ya que cada vez la referencia a este objeto será nueva.



La solución a tal problema se discutió algunos resúmenes antes, en el bloque sobre el paso de un objeto no memorizado. Entonces, aquí, el contenido que se pasa se puede memorizar usando useMemo, y pasarlo como elemento secundario a ChildMemo no interrumpirá la memorización de este componente.


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


ParentMemo y ChildMemo

Consideremos un ejemplo más interesante.


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


A primera vista parece inofensivo: tenemos dos componentes y ambos se memorizan. Sin embargo, en este ejemplo, ParentMemo se comportará como si no estuviera incluido en una nota porque sus elementos secundarios, ChildMemo, no están memorizados. El resultado del componente ChildMemo será JSX, y JSX es simplemente azúcar sintáctico para React.createElement, que devuelve un objeto. Entonces, después de que se ejecute el método React.createElement, ParentMemo y ChildMemo se convertirán en objetos JavaScript normales y estos objetos no se memorizarán de ninguna manera.

Nota para padres

niñomemo

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

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


Como resultado, pasamos un objeto no memorizado a los accesorios, interrumpiendo así la memorización del componente principal.


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


Para solucionar este problema, es suficiente memorizar el elemento secundario pasado, asegurando que su referencia permanezca constante durante el procesamiento del componente de la aplicación principal.


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


No primitivos de ganchos personalizados

Otra área peligrosa e implícita son los ganchos personalizados. Los enlaces personalizados nos ayudan a extraer la lógica de nuestros componentes, haciendo que el código sea más legible y ocultando una lógica compleja. Sin embargo, también nos ocultan si sus datos y funciones tienen referencias constantes. En mi ejemplo, la implementación de la función de envío está oculta en el useForm del enlace personalizado y, en cada representación del componente principal, los enlaces se volverán a ejecutar.


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


¿Podemos entender por el código si es seguro pasar el método de envío como accesorio al componente memorizado ComponentMemo? Por supuesto que no. Y en el peor de los casos, la implementación del gancho personalizado podría verse así:


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


Al pasar el método de envío al componente memorizado, interrumpiremos la memorización porque la referencia al método de envío será nueva con cada representación del componente principal. Para resolver este problema, puede utilizar el gancho useCallback. Pero el punto principal que quería enfatizar es que no debes usar ciegamente datos de enlaces personalizados para pasarlos a componentes memorizados si no quieres romper la memorización que has implementado.


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


¿Cuándo es excesiva la memorización, incluso si se cubre todo con la memorización?

Como cualquier enfoque, la memorización completa debe usarse con cuidado y uno debe esforzarse por evitar una memorización descaradamente excesiva. Consideremos el siguiente ejemplo:


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


En este ejemplo, es tentador incluir el método handleChange en useCallback porque pasar handleChange en su forma actual interrumpirá la memorización del componente de entrada, ya que la referencia a handleChange siempre será nueva. Sin embargo, cuando el estado cambia, el componente de entrada se volverá a representar de todos modos porque se le pasará un nuevo valor en la propiedad de valor. Entonces, el hecho de que no hayamos ajustado handleChange en useCallback no impedirá que el componente de entrada se vuelva a renderizar constantemente. En este caso, utilizar useCallback sería excesivo. A continuación, me gustaría brindar algunos ejemplos de código real visto durante las revisiones de código.


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


Considerando lo simple que es la operación de sumar dos números o concatenar cadenas, y que obtenemos primitivas como salida en estos ejemplos, es obvio que usar useMemo aquí no tiene sentido. Ejemplos similares aquí.


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


Según las dependencias en useMemo, se pueden obtener diferentes resultados, pero nuevamente, son primitivos y no hay cálculos complejos en su interior. Ejecutar cualquiera de estas condiciones en cada renderizado del componente es más económico que usar useMemo.


Conclusiones

  1. Optimización: no siempre es beneficiosa. *Las optimizaciones de rendimiento no son gratuitas y es posible que el costo de estas optimizaciones no siempre sea proporcional a los beneficios que obtendrá de ellas.
    *
  2. Mida los resultados de las optimizaciones. *Si no mides, no podrás saber si tus optimizaciones han mejorado algo o no. Y lo más importante, sin mediciones, no sabrás si empeoraron las cosas.
    *
  3. Medir la efectividad de la memorización. *Solo se puede entender si se debe utilizar la memorización completa o no midiendo su rendimiento en su caso específico. La memorización no es gratuita al almacenar en caché o memorizar cálculos, y esto puede afectar la rapidez con la que se inicia la aplicación por primera vez y la rapidez con la que los usuarios pueden comenzar a usarla. Por ejemplo, si desea memorizar algún cálculo complejo cuyo resultado debe enviarse al servidor cuando se presiona un botón, ¿debe memorizarlo cuando se inicia la aplicación? Quizás no, porque existe la posibilidad de que el usuario nunca presione ese botón y ejecutar ese cálculo complejo podría ser completamente innecesario.
    *
  4. Piensa primero y luego memoriza. *Memorizar los accesorios pasados a un componente solo tiene sentido si está incluido en `memo`, o si los accesorios recibidos se usan en las dependencias de los ganchos, y también si estos accesorios se pasan a otros componentes memorizados.
    *
  5. Recuerde los principios básicos de JavaScript. Mientras trabaja con React o cualquier otra biblioteca y marco, es importante no olvidar que todo esto está implementado en JavaScript y opera de acuerdo con las reglas de este lenguaje.


¿Qué herramientas se pueden utilizar para la medición?

Podemos recomendar al menos 4 de estas herramientas para medir el rendimiento del código de su aplicación.


Reaccionar.Profiler

Con Reaccionar.Profiler , puede empaquetar el componente específico que necesita o la aplicación completa para obtener información sobre el tiempo de las renderizaciones iniciales y posteriores. También podrás entender en qué fase exacta se tomó la métrica.


Reaccionar herramientas de desarrollo

Reaccionar herramientas de desarrollo es una extensión del navegador que le permite inspeccionar la jerarquía de componentes, rastrear cambios en estados y accesorios y analizar el rendimiento de la aplicación.


Rastreador de renderizado de reacción

Otra herramienta interesante es Rastreador de renderizado de reacción , que ayuda a detectar rerenderizaciones potencialmente innecesarias cuando los accesorios de componentes no memorizados no cambian o cambian a otros similares.


Libro de cuentos con el complemento Storybook-Addon-Performance.

Además, en Storybook, puedes instalar un complemento interesante llamado rendimiento del complemento del libro de cuentos de Atlassian. Con este complemento, puede ejecutar pruebas para obtener información sobre la velocidad de renderizado inicial, re-renderizado y renderizado del lado del servidor. Estas pruebas se pueden ejecutar para múltiples copias, así como para múltiples ejecuciones simultáneamente, minimizando las imprecisiones en las pruebas.



** Escrito por Sergey Levkovich, ingeniero de software senior de Social Discovery Group