Améliorer les performances de Native-Base

par

Hugo

Hugo Foyart

8 minutes de lecture

Améliorer les performances de Native-Base

Depuis quelques temps déjà, nous utilisons NativeBase sur nos applications React-Native. C'est une librairie de composants qui fournit la même approche que Chakra-UI en terme d'API. Celle-ci nous plaît pour cet aspect là, cependant nous avons constaté qu'elle souffre d'un cruel manque de performances qui sera notamment fortement marqué sur des appareils anciens. Nous allons voir dans cet article comment palier au mieux à cela sans pour autant devoir changer de librairie.

À noter que le souci est connu et traqué dans une issue. On peut en effet constater que les performances sont 2 à 3 fois plus lentes que si l'on utilise le style de base de React-Native. Il y a récemment eu des tentatives pour palier au soucis notamment en passant par un plugin Babel, mais malgré une légère amélioration, il semblerait que ça ne soit toujours pas suffisant.

Origine du problème

NativeBase stylise les composants au runtime, c'est-à-dire que les styles sont créés à chaque rendu des composants en allant chercher des propriétés dans le thème si celles-ci existent. On peut aussi ajouter à cela des pseudo props (par exemple _pressed qui se déclenche au clic sur un bouton, _ios qui applique du style uniquement sur iOS) qui peuvent être imbriquées, ajoutant donc encore plus de complexité. De plus, les différentes propriétés peuvent être responsive, nécessitant ainsi de récupérer le bon style selon le breakpoint courant. Enfin, le thème de NativeBase nous permet de passer des propriétés par défaut à un composant, ainsi que différentes variantes qui doivent être fusionnées avec les propriétés courantes du composant. Avec toutes ces contraintes, il est nécessaire pour NativeBase de mettre à plat tous les styles à appliquer au composant et c'est à cet endroit que les performances sont en réelle souffrance. Vous pouvez trouver le code du hook en question ici.

Contourner le problème

La solution la plus simple si nous souhaitons éviter ce souci est de tout simplement réimplementer les composants NativeBase pour styliser grâce aux props, mais sans utiliser le hook problématique. Pour cela, on peut utiliser le hook useStyledSystemPropsResolver définit ici. Dans l'idée, il faudra reproduire le comportement des composants NativeBase en s'inspirant du code de la librairie, tout en y implémentant uniquement ce dont nous avons besoin. Par exemple pour le composant Box, nous aurons :

import { IBoxProps, useStyledSystemPropsResolver } from 'native-base'
import React, { forwardRef, useMemo } from 'react'
import { StyleProp, View, ViewStyle } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

export interface BoxProps extends IBoxProps {}

const Box = forwardRef<View, React.PropsWithChildren<BoxProps>>(
  (
    { children, _text, safeAreaBottom, safeAreaTop, ...props }: React.PropsWithChildren<BoxProps>,
    ref
  ) => {
    const [style, otherProps] = useStyledSystemPropsResolver(props)
    const { top, bottom } = useSafeAreaInsets()
    const safeAreaStyle = useMemo<StyleProp<ViewStyle>>(() => {
      const baseStyle: StyleProp<ViewStyle> = {}
      if (safeAreaBottom) {
        baseStyle.paddingBottom = bottom
      }

      if (safeAreaTop) {
        baseStyle.paddingTop = top
      }

      return baseStyle
    }, [safeAreaBottom, safeAreaTop, top, bottom])

    return (
      <View {...otherProps} style={[style, safeAreaStyle]} ref={ref}>
        {wrapStringChild(children, _text)}
      </View>
    )
  }
)

export default Box

wrapStringChild est :

import React from 'react'

export const wrapStringChild = (children: any, textProps: any) => {
  return React.Children.map(children, child => {
    return typeof child === 'string' ||
      typeof child === 'number' ||
      (child?.type === React.Fragment &&
        (typeof child.props?.children === 'string' ||
          typeof child.props?.children === 'number')) ? (
      <Text {...textProps}>{child}</Text>
    ) : (
      child
    )
  })
}

Ici nous pouvons voir par exemple l'usage que nous faisons de la pseudo prop _text passée en prop de notre Text dans le cas où nous passons une string en enfant de notre composant.

Pour certains composants, par exemple Stack, des propriétés se basent sur des valeurs qui ne sont pas forcément explicites dans notre thème, mais présentes dans le thème de base. Par exemple dans le cas du composant Stack, la propriété space se base sur différentes sizes définies ici. L'objectif est donc de simplement dupliquer ces propriétés dans notre composant.

interface Props extends IStackProps {}

const sizes = {
  gutter: 0,
  '2xs': 1,
  xs: 2,
  sm: 3,
  md: 4,
  lg: 6,
  xl: 7,
  '2xl': 8,
}

const Stack = forwardRef<View, React.PropsWithChildren<Props>>(
  (
    { direction, space, divider, reversed, children, ...props }: React.PropsWithChildren<Props>,
    ref
  ) => {
    // @ts-ignore
    const size = sizes[space] ?? space

    return (
      <Box flexDirection={direction} {...props} ref={ref}>
        {getSpacedChildren(
          children,
          size,
          direction === 'row' ? 'X' : 'Y',
          reversed ? 'reverse' : 'normal',
          divider
        )}
      </Box>
    )
  }
)

export default Stack

Et voilà, nous avons reproduit le comportement du composant Stack sans rien casser !

Un composant plus compliqué à reproduire fut le Button.

Dans un premier temps, on récupère les sizes du thème par défaut

const sizes = {
  lg: {
    px: '4',
    py: '2',
    _text: {
      fontSize: 'md',
    },
  },
  md: {
    px: '3',
    py: '2',
    _text: {
      fontSize: 'sm',
    },
  },
  sm: {
    px: '2',
    py: '2',
    _text: {
      fontSize: 'xs',
    },
  },
  xs: {
    px: '2',
    py: '2',
    _text: {
      fontSize: '2xs',
    },
  },
}

On assigne ensuite des valeurs par défaut à nos props :

const Button = ({
  isLoading,
  isDisabled,
  _text,
  _loading,
  _disabled,
  colorScheme = "primary",
  leftIcon,
  rightIcon,
  _pressed,
  children,
  variant = "solid",
  _spinner,
  size = "md",
  startIcon,
  endIcon,
  ...props
}: PropsWithChildren<RNButtonProps>) => {

Nous définissions le style pour les différentes variants :

const variantProps = useMemo<RNPressableProps>(() => {
  switch (variant) {
    case 'outline':
      return {
        borderWidth: '1',
        borderColor:
          colorScheme === 'muted' ? 'muted.200' : isDisabled ? 'muted.500' : `${colorScheme}.300`,
        _text: {
          color: isDisabled ? 'muted.500' : `${colorScheme}.500`,
        },
        _icon: {
          color: `${colorScheme}.600`,
        },
        _pressed: {
          bg: `${colorScheme}.600:alpha.50`,
          borderColor: `${colorScheme}.700`,
        },
        _spinner: {
          size: 'sm',
        },
      }
    case 'link':
      return {
        _text: {
          color:
            colorScheme === 'muted' ? 'muted.800' : isDisabled ? 'muted.500' : `${colorScheme}.600`,
          textDecorationLine: 'underline',
        },
      }
    case 'ghost': {
      if (colorScheme === 'muted') {
        return {
          _text: {
            color: 'muted.500',
          },
        }
      }
      return {
        _text: {
          color: isDisabled ? 'muted.500' : `${colorScheme}.500`,
        },
        _pressed: {
          bg: `${colorScheme}.600:alpha.50`,
          borderColor: `${colorScheme}.700`,
        },
        _spinner: {
          size: 'sm',
        },
      }
    }
    case 'unstyled':
      return {}
    case 'solid':
    default:
      return {
        bg: `${colorScheme}.500`,
        _pressed: {
          bg: `${colorScheme}.700`,
        },
        _disabled: {
          bg: 'trueGray.300',
        },
        _text: {
          color: 'white',
        },
        _loading: {
          bg: 'warmGray.50',
        },
      }
  }
}, [variant, isDisabled, colorScheme])

Ensuite on applique le bon style au bouton et au texte selon la variante et l'état du bouton (disabled, loading, etc) :

const {
  // @ts-ignore
  _text: variantText = {},
  _pressed: variantPressed,
  // @ts-ignore
  _spinner: variantSpinner,
  // @ts-ignore
  _loading: variantLoading,
  _disabled: variantDisabled = {},
  ...variantPropsRest
} = variantProps
const { _text: sizeText = {}, _icon: sizeIcon, ...sizePropsRest } = sizeProps
const bg =
  !isDisabled && !isLoading
    ? props.bg ?? props.bgColor ?? variantPropsRest.bg ?? variantPropsRest.bgColor
    : isLoading
    ? _loading?.bg ?? _loading?.bgColor ?? variantLoading?.bg ?? variantLoading?.bgColor
    : _disabled?.bg ?? _disabled?.bgColor ?? variantDisabled.bg ?? variantDisabled.bgColor
const textColor = _text?.color ?? variantText?.color
const contrastTextColor = useContrastText(bg, textColor)

On applique correctement le style de notre texte :

const allTextProps = useMemo(() => {
  const textProp = {
    ...variantText,
    ...sizeText,
    ..._text,
  }

  if (contrastTextColor && !_text?.color) {
    textProp.color = contrastTextColor
  }

  return textProp
}, [variantText, sizeText, _text, contrastTextColor])

Enfin on rend le tout :

return (
  <RNPressable
    flexDirection="row"
    borderRadius="sm"
    justifyContent="center"
    alignItems="center"
    _pressed={{
      ...variantPressed,
      ..._pressed,
    }}
    opacity={isDisabled ? 0.5 : isLoading ? 0.8 : 1}
    disabled={isDisabled || isLoading}
    {...variantPropsRest}
    {...sizePropsRest}
    {...props}
    {...(isDisabled ? variantDisabled : {})}
    {...(isDisabled ? _disabled : {})}
    {...(isLoading ? variantLoading : {})}
    {...(isLoading ? _loading : {})}
    {...spacingProps}
  >
    <Stack direction="row" space="2" alignItems="center">
      {!!startIcon && !isLoading && startIcon}
      {isLoading && (
        <Spinner
          size="sm"
          color={allTextProps?.color ?? 'coolGray.800'}
          focusable={false}
          {..._spinner}
        />
      )}
      {typeof children === 'string' ? (
        <Text fontWeight="medium" {...allTextProps}>
          {children}
        </Text>
      ) : (
        children
      )}
      {!!endIcon && !isLoading && endIcon}
    </Stack>
  </RNPressable>
)

Et voilà nous avons Button qui fonctionne comme si il était importé via NativeBase !

De manière générale, la reproduction des composants va se dérouler en plusieurs étapes :

  • Se rendre sur le code source du composant sur le GitHub de NativeBase
  • Récupérer les parties du composant qui nous intéressent pour notre cas d'utilisation
  • Récupérer les potentielles propriétés du thème par défaut de NativeBase, et potentiellement les fusionner avec notre thème
  • Appliquer manuellement les pseudo props
  • Rendre les composants de React-Native auxquels on applique les styles qui découlent du hook useStyledSystemPropsResolver.

Conclusion

Bien que cela ce soit avéré assez fastidieux, nous avons pu constater une très nette amélioration des performances sur nos applications affectées par ce souci. Cet article n'a pas pour but de vous empêcher de démarrer un projet en utilisant NativeBase. Néanmoins, c'est un problème à prendre en considération si votre application possède des écrans avec un contenu assez founi (beaucoup d'éléments dans une ScrollView, listes de grande taille, etc.). Si vous souffrez de ce soucis dans un projet existant, n'hésitez pas à tester notre solution avant de migrer vers une autre librairie ! Et si toutefois vous souhaitez vraiment migrer vers une autre librairie, vous pouvez retrouver cet article où j'explique comment créer un design system avec Styled-System et Styled-Components. Il y a aussi un tout nouveau venu, Tamagui, qui est très prometteur que ce soit au niveau de l'API proposée ou au niveau de la performance.

Continuer la discussion sur Twitter

18 avenue Parmentier
75011 Paris
+33 1 43 57 39 11
hello@premieroctet.com

Suivez nos aventures

GitHub

Naviguez à vue