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.
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 }
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) }, [])
En los equipos de desarrollo de React, una práctica generalizada es la memorización integral. Este enfoque normalmente implica:
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.
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.
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
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} /> ) }
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} /> ) )
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> ) }
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 |
---|---|
| |
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> ) }
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 } }
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.
Podemos recomendar al menos 4 de estas herramientas para medir el rendimiento del código de su aplicación.
Reaccionar.Profiler
Con
Reaccionar herramientas de desarrollo
Rastreador de renderizado de reacción
Otra herramienta interesante es
Libro de cuentos con el complemento Storybook-Addon-Performance.
Además, en Storybook, puedes instalar un complemento interesante llamado
** Escrito por Sergey Levkovich, ingeniero de software senior de Social Discovery Group