Hola Habr! Mi nombre es Kamo Spertsyan, estoy involucrado en el desarrollo React Native en Profi.ru. Si decide utilizar la tecnología React Native para ofrecer rápidamente características del producto y centrarse en la velocidad de desarrollo, es probable que tenga problemas de rendimiento. Al menos eso es lo que nos pasó. Después de seis meses de desarrollo activo, el rendimiento de nuestra aplicación cayó por debajo de un nivel crítico: todo fue extremadamente lento. Por lo tanto, retomamos la optimización: eliminamos todos los "frenos" durante el inicio, las transiciones entre pantallas, las pantallas de representación, las reacciones a las acciones del usuario. Como resultado, en tres meses llevaron la experiencia del usuario al nivel nativo. En este artículo quiero hablar sobre cómo optimizamos la aplicación en React Native y resolvimos el problema de los renders de múltiples componentes.
Reuní recomendaciones que ayudarán a minimizar la cantidad de redibujos sin sentido de componentes. Para mayor claridad, en los ejemplos comparo las implementaciones "malas" y "buenas". El artículo será útil para aquellos que ya enfrentan un rendimiento deficiente de la aplicación y para aquellos que no desean permitirlo en el futuro.Usamos React Native emparejado con Redux. Algunos de los consejos están relacionados con esta biblioteca. También en el ejemplo, uso la biblioteca Redux-thunk para simular el trabajo con la red.¿Cuándo pensar en el rendimiento?
De hecho, vale la pena recordarlo desde el comienzo del trabajo en la aplicación. Pero si su aplicación ya se está ralentizando, no se desespere, todo se puede arreglar.Todos lo saben, pero por si acaso, mencionaré: es mejor verificar el rendimiento en dispositivos débiles. Si está desarrollando en dispositivos potentes, es posible que no esté al tanto de los "frenos" de los usuarios finales. Decide por ti mismo los dispositivos que te guiarán. Mida el tiempo o FPS en los gráficos de control para compararlos con los resultados después de la optimización.React Native de fábrica proporciona la capacidad de medir aplicaciones FPS a través de Developer Tools → Show perf monitor. El valor de referencia es 60 cuadros por segundo. Cuanto más bajo es este indicador, más fuerte se "ralentiza" la aplicación, no responde o reacciona con retraso a las acciones del usuario. Uno de los principales efectos en FPS es la cantidad de renders, cuya "gravedad" depende de la complejidad de los componentes.Descripción de ejemplo
Muestro todas las recomendaciones sobre el ejemplo de una aplicación simple con una lista de noticias. La aplicación tiene una pantalla, que se encuentra FlatList
con las noticias. Una noticia es un componente NewsItem
que consta de dos componentes más pequeños: el título ( NewsItemTitle
) y el cuerpo ( NewsItemBody
). El ejemplo completo se puede ver aquí . Además en el texto hay enlaces a varias ramas del repositorio para ejemplos específicos. El repositorio se utiliza para la comodidad de los lectores que desean explorar ejemplos más profundamente. El código en el repositorio y los ejemplos a continuación no dicen ser perfectos, se necesitan únicamente con fines de demostración.A continuación, todos los componentes se muestran esquemáticamente con enlaces y accesorios.En el método de representación de cada componente, agregué el resultado a la consola de información única sobre él:SCREEN
ITEM_{no}
ITEM_TITLE_{no}
ITEM_BODY_{no}
donde se {no}
encuentra el número de serie de la noticia para distinguir entre diferentes representaciones de noticias de múltiples representaciones de la misma.Para probar en cada refresh
lista de noticias, se agregan noticias adicionales a su comienzo. Al mismo tiempo, se muestra el siguiente mensaje en la consola:--------------[ REFRESHING ]--------------
Estos registros ayudarán a comprender si hay un problema en algún componente en particular y luego a determinar si fue posible optimizarlo.Si se implementa correctamente, nuestro registro después del lanzamiento y varias actualizaciones deberían verse así:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
--------------[ REFRESHING ]--------------
SCREEN
ITEM_4
ITEM_TITLE_4
ITEM_BODY_4
En el primer inicio, se dibujan la pantalla y dos noticias iniciales. Al actualizar el tablero, la pantalla se vuelve a mostrar, porque sus datos realmente han cambiado. Más noticias surgen. Todas las noticias anteriores no se vuelven a dibujar, ya que no hubo cambios en sus datos.¿Cuándo se representa un componente?
En React y React Native, hay dos condiciones para representar un componente:- cambiando sus accesorios / estado,
- render del componente padre.
Una función se puede redefinir en un componente shouldComponentUpdate
: recibe nuevos Props y State como entrada y le indica si el componente debe representarse. A menudo, para evitar representaciones innecesarias, es suficiente una comparación superficial de los objetos Props y State. Por ejemplo, esto elimina representaciones innecesarias cuando cambia el componente principal, si no afectan al componente secundario. Para no escribir una comparación de superficie manualmente cada vez, puede heredar un componente del React.PureComponent
que encapsula esta verificación.Cuando usamos la función de enlace de conexión, la biblioteca Redux crea un nuevo componente que está "conectado" al Estado global. Los cambios en este estado desencadenan un métodomapStateToProps
que devuelve nuevos accesorios. A continuación, se inicia una comparación de accesorios antiguos y nuevos, independientemente de si el componente se declaró PureComponent
o no.Considere estos matices en nuestro ejemplo.Dejaremos NewsItem
que el componente pase connect
, NewsItemTitle
herede de React.Component
y NewsItemBody
- de React.PureComponent
.→ Código de ejemplo completoexport class NewsItemTitle extends React.Component
export class NewsItemBody extends React.PureComponent
Así se verá el registro después de una actualización de la placa:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1
Puede ver que las noticias y los componentes de los titulares se vuelven a dibujar. Los consideraremos a su vez.NewsItem
declarado usando connect
. Como accesorio, este componente recibe un identificador, por el cual posteriormente recibe noticias en mapStateToProps
:const mapStateToProps = (state, ownProps) => ({
item: state.newsMap[ownProps.itemKey],
});
Dado que al actualizar el tablero, todas las noticias se descargan nuevamente, el objeto se item
actualizará y luego se referirá a varias celdas de memoria. En otras palabras, serán objetos diferentes, incluso si todos los campos contenidos son iguales. Por lo tanto, se obtiene una comparación del componente State'ov anterior y el nuevo false
. El componente se volverá a representar, a pesar de que, de hecho, los datos no han cambiado.NewsItemTitle
se hereda de React.Component
, por lo que se vuelve a representar cada vez que se representa el componente principal. Esto sucede independientemente de los valores de los accesorios antiguos y nuevos.NewsItemBody
heredado de React.PureComponent
, por lo que compara accesorios antiguos y nuevos. En las noticias 1 y 2, sus valores son equivalentes, por lo tanto, el componente se representa solo para las noticias 3.Para optimizar las representacionesNewsItemTitle
solo declaralo como React.PureComponent
. En el caso de, NewsItem
debe redefinir la función shouldComponentUpdate
:shouldComponentUpdate(nextProps) {
return !shallowEqual(this.props.item, nextProps.item);
}
→ Código de ejemplo completoAquí shallowEqual
hay una función para la comparación de superficie de objetos que proporciona Redux. Puedes escribir así:shouldComponentUpdate(nextProps) {
return (
this.props.item.title !== nextProps.item.title ||
this.props.item.body !== nextProps.item.body
);
}
Así es como se verá nuestro registro después de esto:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
NotashouldComponentUpdate
NewsItem
, NewsItemTitle
. . NewsItemTitle
- NewsItem
, .
React.memo y componentes funcionales
No es shouldComponentUpdate
posible anular en un componente funcional. Pero esto no significa que para optimizar un componente funcional, debe reescribirlo en uno de clase. Para tales casos, se proporciona la función de memorización React.memo . Acepta una entrada de componente y una función de comparación opcional areEqual
. Cuando se le llama, areEqual
obtiene accesorios viejos y nuevos y debería devolver el resultado de la comparación. La diferencia con shouldComponentUpdate
lo que areEqual
debería regresar true
si los accesorios son iguales, y no al revés.Por ejemplo, la NewsItemTitle
memorización puede verse así:areEqual(prevProps, nextProps) {
return shallowEqual(prevProps, nextProps);
}
export OptimizedNewsItemTitle = React.memo(NewsItemTitle, areEqual)
Si usted no pasa areEqual
en React.memo
, a continuación, se hará una comparación superficial de accesorios, por lo que nuestro ejemplo se puede simplificar:export OptimizedNewsItemTitle = React.memo(NewsItemTitle)
Lambda funciona en accesorios
Para procesar eventos de componentes, las funciones se pueden pasar a sus accesorios. El ejemplo más llamativo es la implementación onPress
. A menudo se utilizan funciones lambda anónimas para esto. Digamos NewsItemBody
que queremos mostrar solo la vista previa, y si hace clic en ella, todo el texto. Para hacer esto, al renderizar NewsItem
, NewsItemBody
pasaremos el siguiente accesorio:<NewsItemBody
...
onPress={() => this.props.expandBody()}
...
/>
Así es como se ve el registro con esta implementación cuando shouldComponentUpdate
se NewsItem
elimina el método :SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
Los cuerpos de noticias 1 y 2 se representan, aunque sus datos no han cambiado, pero lo NewsItemBody
están PureComponent
. Esto se debe al hecho de que para cada render el NewsItem
valor de los accesorios onPress
se crea de nuevo. Técnicamente, onPress
con cada render, apunta a una nueva área en la memoria, por lo que una comparación superficial de los apoyos en NewsItemBody
devuelve falso. El problema se soluciona con la siguiente entrada:<NewsItemBody
...
onPress={this.props.expandBody}
...
/>
Iniciar sesión:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1
→ Código de ejemplo completoDesafortunadamente, una función anónima no puede reescribirse siempre como método o campo de clase para dicho registro. El caso más común es cuando dentro de la función lambda se utilizan las variables de alcance de la función en la que se declara.Considere este caso en nuestro ejemplo. Para pasar de la lista general a la pantalla de una noticia, agregamos el procesamiento de hacer clic en el cuerpo de la noticia. El método del renderItem
componente FlatList
se verá así:const renderItem = ({item}) => (
<NewsItem
itemKey={item}
onBodyPress={() => this.onItemBodyPress(item)}
/>
);
onBodyPress
No se puede declarar una función anónima en una clase, porque la variable item
que se necesita para ir a una noticia específica desaparecerá del alcance .La solución más simple al problema es cambiar la firma de los accesorios del onBodyPress
componente NewsItem
para que el parámetro requerido se pase a la función cuando se llama. En este caso, este es el identificador de noticias.const renderItem = ({item}) => (
<NewsItem
itemKey={item}
onBodyPress={item => this.onItemBodyPress(item)}
/>
);
En este caso, ya podemos eliminar la función anónima en el método de clase de componente.const renderItem = ({item}) => (
<NewsItem
itemKey={item}
onBodyPress={this.onItemBodyPress}
/>
);
Sin embargo, tal solución requerirá que cambiemos el componente NewsItem
.class NewsItemComponent extends React.Component {
render() {
...
return (
...
<NewsItemBody
...
onPress={() => this.props.onBodyPress(this.props.item)}
...
/>
...
);
}
Y nuevamente volvemos al problema indicado: pasamos una nueva función lambda al componente hijo para cada render del padre. Solo que ahora hemos bajado un nivel. Iniciar sesión:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
Para deshacerse de este problema en la raíz, puede usar el gancho useCallback . Permite memorizar una llamada de función con pasar un argumento. Si el argumento de la función no cambia, el resultado de la llamada useCallback
apuntará a la misma área de memoria. En nuestro ejemplo, esto significa que al volver a dibujar las mismas noticias, el onPress
componente de utilería NewsItemBody
no cambiará. Los ganchos solo se pueden usar en componentes funcionales, por lo que el aspecto final del componente NewsItem
será el siguiente:function NewsItemComponent(props) {
...
const {itemKey, onBodyPress} = props.item;
const onPressBody = useCallback(() => onBodyPress(itemKey), [itemKey, onBodyPress]);
return (
<View>
...
<NewsItemBody
...
onPress={onPressBody}
...
/>
</View>
);
}
Y el registro:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1
→ Código de ejemplo completoMatrices y objetos
En JavaScript, las funciones se representan como objetos, junto con las matrices. Por lo tanto, el ejemplo del bloque anterior es un caso especial de crear un nuevo objeto en accesorios. Es bastante común, así que lo puse en un párrafo separado.Cualquier creación de nuevas funciones, matrices u objetos en accesorios lleva a un renderizador de componentes. Considere esta regla en el siguiente ejemplo. Pasemos en un NewsItemBody
estilo combinado de dos valores:<NewsItemBody
...
style={[styles.body, styles.item]}
...
/>
Y nuevamente, el registro muestra los procesadores de componentes adicionales:SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
Para resolver este problema, puede seleccionar un estilo separado que combinará body
y item
, por ejemplo, moverá la declaración de la matriz [styles.body, styles.item]
a una variable global.→ Código de ejemplo completoReductores de matriz
Considere otra fuente popular de "frenos" asociados con el uso FlatList
. Una aplicación clásica que contiene una larga lista de elementos del servidor implementa la paginación. Es decir, carga un conjunto limitado de elementos en forma de la primera página, cuando finaliza la lista de elementos actuales, carga la página siguiente, y así sucesivamente. Un reductor de lista de elementos podría verse así:const newsIdList = (state = [], action) => {
if (action.type === 'GOT_NEWS') {
return action.news.map(item => item.key);
} else if (action.type === 'GOT_OLDER_NEWS') {
return [...state, ...action.news.map(item => item.key)];
}
return state;
};
Cuando cada página siguiente se carga al estilo de la aplicación, se crea una nueva matriz de identificadores. Si pasamos esta matriz a los accesorios más FlatList
adelante, así es como se verán los registros de representación del componente:SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..10>
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
ITEM_<1..30>
Para este ejemplo, hice algunos cambios en la aplicación de prueba.- Establezca el tamaño de la página en 10 noticias.
item
NewsItem
FlatList
-, connect. NewsItem
React.Component
.- .
- . №1 .
El ejemplo muestra que cuando se carga cada página siguiente, todos los elementos antiguos se procesan nuevamente, luego los elementos antiguos y los elementos de la página nueva se procesan nuevamente. Para los amantes de las matemáticas: si el tamaño de la página es igual X
, cuando i
se carga la i-ésima página , en lugar de representar solo X
elementos nuevos, se representan los elementos (i - 1) * X + i * X
.“Ok”, dices, “entiendo por qué todos los elementos se dibujan después de agregar una nueva página: el reductor devolvió una nueva matriz, una nueva área de memoria, todo eso. Pero, ¿por qué necesitamos renderizar la lista anterior antes de agregar nuevos elementos? "Buena pregunta", te responderé. Esto es una consecuencia de trabajar con el estado del componente VirtualizedList
sobre cuya baseFlatList
. No entraré en detalles, ya que sacan un artículo separado. A quién le importa, le aconsejo que profundice en la documentación y la fuente.¿Cómo deshacerse de tal falta de optimización? Reescribimos el reductor para que no devuelva una nueva matriz para cada página, sino que agrega elementos a la existente:¡Atención! Antipatrón!. , , ,
PureComponent
, . , . .
Redux.
const newsIdList = (state = [], action) => {
if (action.type === 'GOT_NEWS') {
return action.news.map(item => item.key);
} else if (action.type === 'GOT_OLDER_NEWS') {
action.news.forEach(item => state.push(item.key));
return state;
// return [...state, ...action.news.map(item => item.key)];
}
return state;
};
Después de eso, nuestro registro se verá así:SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..30>
Eliminamos la representación de elementos antiguos antes de agregar elementos a una nueva página, pero los elementos antiguos aún se dibujan después de actualizar la lista. El número de representaciones para la página siguiente ahora es igual i * X
. La fórmula se ha vuelto más simple, pero no nos detendremos allí. Solo tenemos X
nuevos elementos, y solo queremos X
nuevos renders. Utilizaremos los trucos ya familiares para eliminar los renders de noticias que no han cambiado los accesorios. Volver conectar a NewsItem
:SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<11..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<21..30>
¡Multa! Ahora podemos estar satisfechos con nosotros mismos. No hay ningún lugar para optimizar.→ Código de ejemplo completoUn lector atento indicará que después de aplicar connect al NewsItem
registro, se verá como en el último ejemplo, sin importar cómo implemente el reductor. Y será correcto: si el componente de noticias comprueba sus accesorios antes de renderizar, no importa si el reductor usa la matriz anterior o si crea una nueva. Solo se dibujan elementos nuevos y solo una vez. Sin embargo, cambiar la matriz anterior en lugar de crear una nueva nos salva de representaciones innecesarias del componente FlatList
utilizado en ella VirtualizedList
y de iteraciones innecesarias de comprobaciones de equivalencia de accesorios NewsItem
. Con una gran cantidad de elementos, esto también aumenta el rendimiento.El uso de matrices y objetos mutables en los reductores debe ser con extrema precaución. En este ejemplo, esto está justificado, pero si tiene, digamos, normal PureComponent
, cuando agrega elementos a la matriz mutable, los componentes no se procesarán. De hecho, sus accesorios permanecen sin cambios, ya que antes y después de actualizar los puntos de la matriz a la misma área de memoria. Esto puede conducir a consecuencias inesperadas. No es de extrañar que el ejemplo descrito viole los principios de Redux .Y algo más...
Si utiliza bibliotecas de nivel de presentación, le aconsejo que se asegure de comprender en detalle cómo se implementan. En nuestra aplicación, usamos un componente Swipeable
de la biblioteca react-native-gesture-handler
. Le permite implementar un bloque de acciones adicionales al deslizar una tarjeta de la lista.En código, se ve así:<Swipeable
...
renderRightActions={this.renderRightActions}
...
>
Método renderRightActions
o renderLeftActions
devuelve el componente que se muestra después del deslizamiento. Determinamos y cambiamos la altura del panel durante el cambio de componentes para ajustar el contenido necesario. Este es un proceso que requiere muchos recursos, pero si ocurre durante la animación de deslizamiento, el usuario no ve interferencia.El problema es que el componente Swipeable
llama al método renderRightActions
al momento de representar el componente principal. Todos los cálculos e incluso la representación de la barra de acción, que no es visible antes del deslizamiento, se realizan por adelantado. Por lo tanto, todas estas acciones se realizan para todas las tarjetas de la lista al mismo tiempo. Esto causó importantes "frenos" al desplazarse por el tablero.El problema se resolvió de la siguiente manera. Si el panel de acción se dibuja junto con el componente principal, y no como resultado del deslizamiento, el método renderRightActions
devuelve uno vacío del View
tamaño del componente principal. De lo contrario, dibujamos el panel de acciones adicionales como antes.Doy este ejemplo porque las bibliotecas de soporte no siempre funcionan como se espera. Y si se trata de bibliotecas de nivel de presentación, entonces es mejor asegurarse de que no están desperdiciando recursos innecesarios.recomendaciones
Después de eliminar los problemas descritos en el artículo, aceleramos significativamente la aplicación en React Native. Ahora es difícil distinguirlo en rendimiento de uno similar, implementado de forma nativa. El exceso de renderizado ralentizó tanto la carga de pantallas individuales como la reacción a las acciones del usuario. Sobre todo, se notaba en las listas, donde se dibujan docenas de componentes a la vez. No hemos optimizado todo, pero las pantallas principales de la aplicación ya no se ralentizan.Los puntos principales del artículo se enumeran brevemente a continuación.- React Native : Props/State- .
- ,
React.PureComponent
, , . - ,
shouldComponentUpdate
React.Memo
. - - . , (shallow compare). , .
- El soporte de bibliotecas de nivel de presentación puede conducir a un desperdicio inesperado de recursos. Vale la pena tener cuidado en su aplicación.
Eso es todo. Espero que encuentre útil la información. Estaré encantado de cualquier comentario!Fuentes utiles
- Comprender la representación en React + Redux
- Comparar objetos en JavaScript
- Mejora del rendimiento en componentes funcionales React usando React.memo ()
- Cómo Discord logra el rendimiento nativo de iOS con React Native