Video Summary and Transcription
Esta transcripción proporciona una breve guía del comportamiento de renderizado de React. Explica el proceso de renderizado, comparando elementos nuevos y antiguos, y la importancia de un renderizado puro sin efectos secundarios. También cubre temas como el agrupamiento y el doble renderizado, la optimización del renderizado y el uso del contexto y Redux en React. En general, ofrece valiosos conocimientos para los desarrolladores que buscan entender y optimizar el renderizado de React.
1. Introducción al Renderizado de React
Hola, soy Mark Eriksson, un ingeniero senior de front-end en Replay.io. Estoy aquí para compartir una guía breve sobre el comportamiento de renderizado de React.
Hola, soy Mark Eriksson, y hoy me gustaría compartir con ustedes una guía relativamente breve sobre el comportamiento de renderizado de React. Algunas cosas rápidas sobre mí. Soy un ingeniero senior de front-end en Replay.io, donde estamos construyendo un verdadero depurador de viaje en el tiempo para JavaScript. Si no lo has visto, por favor échale un vistazo. Responderé preguntas prácticamente en cualquier lugar donde haya un cuadro de texto en Internet. Recojo enlaces interesantes a cualquier cosa que parezca útil. Escribo publicaciones de blog extremadamente largas, como la publicación de 8,500 palabras en la que se basa esta charla, y soy un mantenedor de Redux. Pero la mayoría de la gente realmente me conoce como ese tipo con el avatar de los Simpsons.
2. Entendiendo el Renderizado de React
El renderizado es el proceso de React solicitando a los componentos que describan la interfaz de usuario basada en las props y el estado actuales. Luego aplica actualizaciones al DOM. React recopila un árbol de objetos que describen la apariencia del componente y lo compara con el árbol anterior. La fase de renderizado recorre el árbol de componentes y forma el árbol final. La fase de commit aplica cambios al DOM y ejecuta métodos de ciclo de vida. Los pases de renderizado de React comienzan con llamadas de set state. El comportamiento predeterminado es que los componentes padres rendericen recursivamente a sus hijos.
Entonces, comencemos preguntando, ¿qué es el renderizado? El renderizado es el proceso de React pidiendo a tus componentes que describan cómo quieren que se vea la UI ahora, basado en sus props y estado actuales. Y luego tomando eso y aplicando las actualizaciones necesarias al DOM.
Ahora voy a hablar en términos de React, DOM, y la web, pero los mismos principios se aplican para cualquier otro renderizado de React, como React Native o React 3 Fiber. Cuando escribimos componentes, los hacemos devolver etiquetas JSX como ángulos de soporte, mi componente. En tiempo de compilación, estos se convierten en llamadas a funciones a React.createElement, que a su vez devuelve objetos que tienen el tipo, props, y niños. Y estos forman un árbol de objetos que describen cómo deberían verse los componentes ahora.
React llamará a tus componentes, recogerá este árbol de objetos, y luego hará la diferencia entre el actual árbol de elementos contra el último árbol de elementos que renderizó la última vez. Y este proceso se llama reconciliación. Cada pase de renderizado se puede dividir en dos fases diferentes. La primera fase es la fase de renderizado. Y aquí es donde React recorre el árbol de componentes, pregunta a todos los componentes, ¿cómo quieres que se vea la UI ahora, y luego recoge todo eso para formar el árbol final.
Ahora la fase de renderizado se puede dividir en varios pasos. Y de hecho, a partir de React 18, React podría renderizar algunos componentes, pausar, dejar que el navegador se actualice, renderizar algunos más, tal vez manejar algunos datos entrantes como una acción de tecla presionada y una entrada de texto. Y luego, una vez que todos los componentes han sido renderizados, pasa a la fase de commit. Durante la fase de commit, React ha descubierto qué cambios necesitan ser aplicados al DOM y ejecuta todos esos cambios sincrónicamente en una secuencia. También ejecuta ciclos de vida de la fase de commit como el UseLayoutEffectHook o ComponentDidMount y DidUpdate en componentes de clase. Luego, después de un breve retraso, ejecutará los UseEffectHooks más tarde. Y esto le da al navegador la oportunidad de pintar entre las actualizaciones del DOM y los UseEffects en ejecución.
Cada pase de renderizado de React comienza con alguna forma de set state siendo llamado. Para los componentes de función, son los setters del hook UseState y el método dispatch del UseReducer. Para los componentes de clase, es this.setState o this.forceUpdate. También puedes desencadenar renders volviendo a ejecutar el método ReactDom.Render de nivel superior. O también está el nuevo hook UseSyncExternalStore, que escucha las actualizaciones de las bibliotecas externas como Redux. Antes de UseSyncExternalStore, las bibliotecas como React Redux todavía tenían que llamar a setState de alguna forma por dentro. Los componentes de función en realidad no tienen un método de actualización forzada, pero puedes hacer básicamente lo mismo creando un hook UseReducer que simplemente incrementa un contador cada vez.
Ahora es muy importante entender que el comportamiento predeterminado de React es que cada vez que un componente padre se renderiza, React renderizará recursivamente a todos los hijos dentro de este componente. Y creo que aquí es donde mucha gente se confunde. Así que digamos que tenemos un árbol de cuatro componentes, A, B, C, y D. Y llamamos a setState dentro del componente B. React pone en cola un nuevo renderizado.
3. El Proceso y Principios de Renderizado de React
React comienza el renderizado desde la parte superior del árbol de componentes y continúa hacia abajo, incluso si los componentes no están marcados para actualizaciones. El renderizado no siempre resulta en actualizaciones al DOM. React compara los elementos nuevos y antiguos para determinar si ocurrieron cambios. El renderizado es necesario para que React determine si se necesitan actualizaciones. Al escribir componentes, el renderizado debe ser puro y no tener efectos secundarios.
React comienza en la parte superior del árbol, mira A y ve que no estaba marcado para una actualización. Renderizará B. B dice que mi hijo debería ser C. Y porque B se renderizó, React continúa y renderiza el componente C. C dice que tengo un hijo. React sigue adelante. React también renderiza D. Así que aunque C y D no estaban marcados para actualizaciones al llamar a setState, React siguió adelante y renderizó todos los componentes anidados dentro de B.
Algo más que confunde a las personas es pensar que React renderiza componentes porque las props cambiaron. No. Por defecto, simplemente sigue todo el camino hacia abajo en el árbol de componentes. A React no le importa si las props cambian o no. Simplemente recorre todo el subárbol.
Ahora, también es importante entender que el renderizado no significa que siempre hay actualizaciones para el DOM. Cuando React salió por primera vez, el equipo de React habló sobre la idea de renderizar como algo conceptualmente similar a redibujar toda la UI desde cero. Pero en la práctica, lo que sucede es que un componente podría devolver el mismo tipo de descripción que la última vez. Y entonces React comparará los nuevos elementos con los antiguos y verá que en realidad no cambió nada. Y por lo tanto, no se necesitan aplicar actualizaciones reales a esta sección del DOM. Pero para averiguar eso, React tuvo que pasar por el proceso de renderizado y preguntar al componente qué quiere. Así que los renderizados no son realmente algo malo. Así es como React sabe si necesita aplicar alguna actualización al DOM.
Hay algunas reglas que debemos seguir cuando estamos escribiendo componentes que hacen renderizado. Y lo más importante es que el renderizado debe ser puro y no puede tener efectos secundarios. La definición típica de un efecto secundario es cualquier cosa que afecte al mundo fuera de este componente y esta llamada de renderizado. Ahora, no todos los efectos secundarios son obvios. Y solo porque haya un efecto secundario no significa que toda la aplicación va a arder y explotar. Por ejemplo, si mutas una prop, eso es definitivamente un efecto secundario, y eso es malo, y eso causará problemas, pero técnicamente hablando, una declaración de console.log también es un efecto secundario. Y tener eso en el componente no romperá las cosas. Sebastian Markbaga del equipo de React escribió un invitado donde habló sobre las reglas de React y podemos resumir eso diciendo que RenderLogic no debe mutar existente data, hacer matemáticas aleatorias, hacer solicitudes de red, o poner en cola actualizaciones de estado adicionales. Sin embargo, RenderLogic puede literalmente mutar objetos creados dentro de este componente durante el paso de renderizado.
4. Detalles del Renderizado de React
Vale la pena tomarse el tiempo para leer las instrucciones. React almacena la información del componente en una estructura de datos llamada fibra. Durante el renderizado, recorre el árbol de fibras, comparando los tipos de elementos para mayor eficiencia. Evite crear nuevos tipos de componentes dentro de otro componente. Las claves se utilizan para identificar y rastrear cambios en las listas.
Puede lanzar errores si algo sale mal, y puedes inicializar algunos valores de manera perezosa. Por lo tanto, vale la pena tomarse el tiempo para leer esas instrucciones.
Otra cosa que creo que mucha gente no se da cuenta es que tu componente no es React en realidad almacena información sobre el árbol de componentes internamente en una data estructura llamada fibra. Y las fibras son simplemente objetos JavaScript que describen una instancia de componente en el árbol. Mantienen una referencia al tipo de componente, tienen pointers a los componentes padre, hermano e hijo. Almacenan las props y el estado actuales e entrantes, e información sobre si este componente necesita algo como contexto. Estos son los verdaderos data para cada componente.
Entonces, durante el paso de renderizado, React está realmente recorriendo este árbol de objetos de fibra. Está leyendo las props de allí, pasándolas a los componentes, leyendo el estado anterior, aplicando actualizaciones de estado en cola y actualizando la información de seguimiento en el camino. Ahora, no necesitas mirar estos valores para entender cómo usar React, pero puede ser útil saber que así es como React está almacenando todo internamente.
Dijimos antes que React compara los viejos y nuevos árboles de elementos para averiguar qué cambió. Y ese proceso puede ser muy costoso. React intentará reutilizar la mayor cantidad posible del árbol de componentes existente y los nodos del DOM. Pero también toma algunos atajos para acelerar este proceso. Y el más grande es que compara los tipos de elementos actuales y nuevos en cada lugar dado en el árbol. Y si el tipo de elemento ha cambiado a una nueva referencia, asume que todo el existente subárbol de componentes probablemente sería completamente diferente, y desmontará todos los componentes en ese árbol, lo que significa eliminar todos los nodos del DOM en esa ubicación en el árbol. Y luego recrea todo eso desde cero.
Entonces, un error común que veo es cuando las personas intentan crear nuevos tipos de componentes dentro de otro componente mientras se está renderizando. Y esto es malo porque cada vez que el componente padre se renderiza, el componente hijo será una nueva referencia. Y eso significa que siempre fallará la comparación. Y cada vez que el componente padre se renderiza, React destruirá el antiguo componente hijo, desmontará todos los nodos del DOM dentro de allí, y tendrá que recrearlos. Así que nunca, nunca crees tipos de componentes dentro de un componente. Siempre créalos por separado en el nivel superior.
Otra cosa que afecta la reconciliación son las claves. Ahora pasamos lo que parece ser una prop llamada clave como identificador, pero en realidad no es una prop real. En realidad es una instrucción para React de cómo distinguir estas diferentes cosas aparte. Y de hecho, React siempre elimina la clave de las props. Así que nunca puedes tener props.key dentro de un componente, siempre será indefinido. Ahora, la mayoría de las veces usamos claves cuando estamos renderizando listas, porque si la lista va a cambiar en absoluto, React necesita saber qué elementos se agregaron, se actualizaron o se eliminaron. Idealmente, las claves deberían ser ID únicos de tus data.
5. Consejos de Renderizado de React
Si tienes una lista de tareas pendientes, utiliza el ID de la tarea como las claves. Nunca uses valores aleatorios para las claves. Aplica una clave a un componente de React para destruirlo y recrearlo intencionalmente. React agrupa múltiples estados establecidos en un solo paso de renderizado. Después de llamar a setState, el valor actualizado no está disponible de inmediato debido al renderizado asincrónico y al cierre.
Entonces, si tengo una lista de tareas pendientes, preferiría usar to do.id como mis claves. Puedes usar los índices de array como una alternativa si los data no van a cambiar con el tiempo. Y nunca, nunca uses valores aleatorios para las claves.
Ahora también vale la pena mencionar que puedes aplicar una clave a cualquier componente de React en cualquier momento. Y hay momentos en los que podrías querer usar esto para decirle a React, oye, tenía este componente aquí, pero en realidad quiero que lo destruyas y lo recrees intencionalmente cuando algo cambie. Y un buen ejemplo de esto sería tal vez tengo un formulario que se inicializa por props. Tiene su propio estado interno. Y si el usuario selecciona un elemento diferente, quiero que se recrea el formulario desde cero para que todo se inicialice correctamente.
Cada vez que llamamos a set state, se va a poner en cola otro paso de renderizado. Pero React intenta ser eficiente con esto. Y si se ponen en cola múltiples pasos de renderizado en el mismo ciclo de eventos, React en realidad los agrupará en un solo paso de renderizado. Ahora, en React 17, esto solo sucedía automáticamente dentro de los manejadores de eventos como on click. En React 18, React siempre agrupa múltiples estados establecidos en un solo paso de renderizado todo el tiempo. Entonces, en este ejemplo, tenemos dos estados establecidos antes de una llamada de espera, y dos estados establecidos después de una llamada de espera. En React 17, los dos primeros serían agrupados juntos porque son sincrónicos durante el manejador de eventos. Pero dado que la espera causa un nuevo ciclo de eventos, en React 17, cada uno de estos otros estados establecidos causaría un paso de renderizado separado de manera sincrónica tan pronto como hagas la llamada. En React 18, los dos primeros estados establecidos causan un paso de renderizado y los otros dos estados establecidos se agrupan en un segundo paso de renderizado.
Otra fuente común de confusión es la idea de qué sucede con mi valor después de llamar a setState. Y realmente veo que esto sucede todo el tiempo. Las personas intentan llamar a setState con un nuevo valor, y luego intentan registrar la variable de estado pensando que ya se habrá actualizado. Y esto no funciona por un par de razones. La respuesta corta usual es que decimos, bueno, el renderizado de React es asincrónico. Y eso es técnicamente algo cierto. Técnicamente hablando, será sincrónico. Pero al final del ciclo de eventos, por lo que desde el punto de vista de este code, es asincrónico porque no va a suceder de inmediato. Pero el verdadero problema aquí es que el manejador de eventos es un cierre. Solo puede ver valores como counter en el momento en que este componente se renderizó por última vez. La próxima vez que este componente se renderice, habrá una nueva copia de la función handle click, y verá la nueva copia de counter la próxima vez. Entonces, intentar usar este valor justo después de llamar a setState casi siempre es una mala idea. Hay algunos casos límite con el renderizado.
6. Renderizado de React: Agrupación y Doble Renderizado
Si llamas a setState en useLayoutEffect o ComponentDidMount o DidUpdate, se ejecutará de manera sincrónica. Esto te permite capturar nodos DOM, medir su tamaño y renderizar de nuevo basándose en esa información. React DOM y React Native tienen métodos para alterar el comportamiento de agrupación. En React 17 y anteriores, las llamadas pueden envolverse en actualizaciones agrupadas. En React 18, hay un método flush-sync para forzar actualizaciones inmediatas. El doble renderizado en modo estricto atrapa errores. Los componentes de función pueden llamar a setState condicionalmente, similar a getDerivedStateFromProps en componentes de clase.
Uno es que si llamas a setState en useLayoutEffect o ComponentDidMount o DidUpdate, se ejecutará de manera sincrónica. Y la principal razón de esto es que podrías hacer un primer renderizado, y luego quieres capturar los nodos DOM, medir su tamaño y renderizar de nuevo basándose en esa información. Y al hacer el renderizado de manera sincrónica, React actualiza el DOM antes de que el navegador tenga la oportunidad de pintar, y el usuario nunca vio la apariencia intermedia.
Los reconciliadores como React DOM y React Native tienen un par de métodos que pueden alterar este comportamiento de agrupación. En React 17 y anteriores, podríamos envolver las llamadas en actualizaciones agrupadas para forzar la agrupación fuera de los manejadores de eventos. En React 18, hacemos lo contrario. Como la agrupación es la predeterminada, hay un método flush-sync que obliga a React a aplicar las actualizaciones de inmediato. Además, lo que a todos no les gusta es el doble renderizado durante el modo estricto, y React hace esto para intentar atrapar errores. Esto significa que no puedes usar console.log en medio de un componente de función para contar el número de veces que se renderizó. En su lugar, coloca eso en un use effect o utiliza las React DevTools para medir. Y finalmente, hay un caso en el que los componentes de función pueden llamar a setState durante el renderizado, y eso es si lo hacen condicionalmente. Y si haces eso, React verá que llamaste a setState e inmediatamente ejecutará el componente de nuevo con el nuevo estado. Esto es equivalente al comportamiento de getDerivedStateFromProps en los componentes de clase.
7. Optimizando el Renderizado de React y Contexto
Para optimizar el renderizado, evita renders innecesarios utilizando react.memo o devolviendo el mismo objeto de referencia del elemento. Memoriza componentes críticos, actualiza el estado de manera inmutable y utiliza el perfilador de React DevTools para la medición del rendimiento. El contexto en React hace que todos los componentos que lo consumen se vuelvan a renderizar cuando se actualiza.
Entonces, ¿cómo hacemos que esto funcione más rápido? Podríamos decir que un renderizado es un desperdicio si devuelve exactamente el mismo resultado que la última vez, y dado que el resultado del renderizado debería basarse en las props y el estado, si el componente tiene las mismas props en el mismo estado, probablemente esté devolviendo el mismo resultado. Por lo tanto, podemos optimizar el comportamiento saltándonos el renderizado si las props no han cambiado. Y esto también omitirá todo el subárbol. La forma normal de hacer esto es envolver tu componente con react.memo, que automáticamente verifica si alguna de las props ha cambiado. En los componentes de clase, podrías usar ShouldComponentUpdate o PureComponent, o también envolverlo con react.memo. Hay otra forma de hacer esto, y es hacer que el componente padre devuelva el mismo objeto de referencia del elemento exacto que la última vez. Y si haces eso, React omitirá el renderizado de este componente y todos sus hijos. Por lo tanto, podrías usar el hook UseMemo para guardar un elemento de React para más tarde, o también puedes usar props.children. Y la diferencia entre esto y React.memo es que React.memo está efectivamente controlado por el componente hijo, porque lo hemos envuelto. Pero el comportamiento de la misma referencia del elemento está controlado por el componente padre.
Así que dijimos antes que no es cierto que React renderiza componentes hijos cuando las props cambian. React siempre renderiza todos los componentes por defecto. La única vez que las referencias de las props importan es si el componente ya está optimizado usando React.memo. Ahora bien, si estás pasando nuevos hijos de esta manera, estás pasando una nueva referencia de props.children cada vez, y React.memo nunca te ahorrará realmente ningún trabajo. Si necesitas pasar referencias consistentes a un hijo, entonces llama a useCallback o useMemo. Y finalmente, no envuelvas componentes host como Button en React.memo. No te ahorra realmente nada.
Entonces, ¿deberías memorizar todo? La respuesta corta es no. El equipo de React sugiere que no lo hagas por defecto. Busca puntos críticos que sean costosos y simplemente memoriza componentes críticos para mejorar el performance. Además, las actualizaciones de estado siempre deben hacerse de manera inmutable. Si mutas data, entonces, en primer lugar, es probable que cause bugs. En segundo lugar, React podría pensar que en realidad nada ha cambiado y no volver a renderizar tu componente. Siempre, siempre haz actualizaciones de estado de manera inmutable. Si necesitas medir el performance, las React DevTools tienen una pestaña de perfilador, y puedes grabar a ti mismo usando la aplicación durante un par de minutos y luego mirar en el perfilador para ver qué componentes se renderizaron y cuánto tiempo. Puedes hacerlo en modo Dev para tener una idea de los tiempos relativos, y también hay una versión especial de perfilado de React para ver más de lo que serían los tiempos en producción. Entonces, ¿cómo afecta el contexto a esto? El contexto es realmente sobre la inyección de dependencia de un valor en un subárbol. Y para actualizarlo, tienes que llamar a SetStateInApparentComponent, y pasar un nuevo valor hace que todos los componentes que consumen el contexto se vuelvan a renderizar. Ahora mismo, no hay forma de que un componente lea sólo una parte de un valor de contexto. Si lees contextValue.a y la aplicación actualiza contextValue.b, tuvo que hacer un objeto completamente nuevo, por lo que el otro componente también se renderizará.
8. Optimizando el Renderizado de React y Redux
Actualizar el estado pone en cola un renderizado. React renderiza recursivamente por defecto. Las formas de evitar renderizados innecesarios incluyen el uso de React.memo o props.children. React Redux pasa el almacenamiento de Redux desde el contexto, y cada componente se suscribe por separado. Ejecutar nuevos selectores es menos costoso que React haciendo otra pasada de renderizado. Connect envuelve los componentos y actúa como React.memo. El equipo de React está trabajando en un nuevo compilador llamado React forget para mejorar el rendimiento. Se ha discutido la posibilidad de añadir una opción de selectores para usar el contexto.
Entonces, sabemos que actualizar el estado pone en cola un renderizado. React renderiza recursivamente por defecto, le das un nuevo valor a un proveedor de contexto, y esos normalmente provienen del estado del componente, lo que significa que por defecto, llamar a SetStateInApparent va a hacer que toda la aplicación se vuelva a renderizar, independientemente de si el contexto está involucrado o no. Este es simplemente el comportamiento por defecto.
Entonces, ¿cómo evitamos esto? Hay un par de formas. La número 1 es poner el primer hijo del proveedor de contexto dentro de React.memo. La otra opción es usar props.children, en cuyo caso, entra en juego la misma optimización del elemento. Ahora, cuando un componente lee el valor del contexto, va a renderizar, y todos los hijos anidados se van a volver a renderizar desde allí. De nuevo, comportamiento normal.
Entonces, ¿cómo funciona React Redux? Bueno, React Redux pasa el almacenamiento de Redux desde el contexto, y luego cada componente se suscribe al almacenamiento de Redux por separado. Y cada vez que se despacha una acción, lee el estado, extrae un valor del selector, y compara para ver si eso cambió. Eso es muy diferente a cómo se renderiza el contexto. Normalmente, el costo de ejecutar nuevos selectores es menor que el costo de React haciendo otra pasada de renderizado. Así que está bien tener muchos selectores de uso y muchos componentes conectados, pero los selectores sí necesitan ser muy rápidos. Y esa es una de las razones por las que tenemos la biblioteca reselect para la memorización.
Así que connect envuelve los componentes, y actúa un poco como React.memo. De hecho, en realidad tiene React.memo dentro. Use selector está dentro de un componente y no puede evitar que se renderice cuando lo hace el padre. Así que si tienes componentes de función y use selector y tienes grandes árboles renderizando, entonces envuélvelos en React.memo tú mismo. Hay algunas mejoras más en camino. El equipo de React está trabajando en un nuevo compilador llamado React forget, que automáticamente memorizará no sólo tus arrays de dependencia de hook, sino que también optimizará la salida del elemento utilizando el mismo enfoque de memorización. Esto tiene una verdadera posibilidad de mejorar drásticamente el rendimiento de la aplicación React automáticamente. Y luego ha habido una discusión de añadir una opción de selectores para usar el contexto, lo que te permitiría sólo volver a renderizar si una cierta parte del valor del contexto cambia. Así que ambas son cosas a tener en cuenta. Después de que esta charla esté arriba, tendré las diapositivas en mi blog y tendré enlaces a algunos recursos e información adicionales. Espero que esto te dé una mejor idea de cómo funciona React para que puedas usarlo de manera más eficiente. Gracias y diviértete usando React. ♪♪♪
Comments