Una app host de React Native amb una tab bar inferior, cada pestanya carregant un remote diferent en temps d'execució

El shell del host: remotes federats com a pestanyes a React Native

El post 3 tancava amb una promesa: el host deixa de ser una sola pantalla i es converteix en un shell de debò, propietari de la tab bar, amb cada pestanya com un remote carregat en temps d’execució. Això és aquest post.

Fins ara el host ha carregat una pantalla d’un remote, a pantalla completa, sense cap altre lloc on anar. Una app de debò té un marc al voltant de les seves features: una tab bar, navegació, les parts que sempre són a la pantalla. Aquest post li dona aquesta feina al host, i li dona la segona pestanya a un segon remote.

El repartiment de responsabilitats

Una frase resumeix tot el post: el host és propietari del marc, i els remotes són propietaris de les features que hi van dins.

El host instal·la la llibreria de navegació, munta la tab bar i decideix quines pestanyes existeixen. Els remotes continuen sent pantalles i prou. Cap d’ells no fa import d’un navigator, cap no sap que és dins d’una pestanya, i no saben que existeix un segon remote. Afegir una feature hauria de ser avorrit: construeixes un altre remote, hi afegeixes una altra pestanya, la desplegues.

Així dos equips poden ser propietaris de dues pestanyes, construir-les per separat i desplegar-les al seu ritme, perquè cap dels dos ha de compilar contra l’altre.

App host el shell (propietari de la navegació + tab bar)React.lazy · es carrega enarrencarReact.lazy · es carrega enobrir-la per primer copTab bar inferiorPestanya PokédexPestanya Partylist remote:8082 · PokedexScreenparty remote:8083 · PartyScreen

El punt de partida és l’estat final del post 3. Si el vas construir amb mi, aquest és el codi que ja tens. Si no:

git clone https://github.com/warrendeleon/react-native-module-federation
git checkout post-03-shared-singleton

Un segon remote per omplir una segona pestanya

Una pestanya no és una tab bar. Així que afegim un segon remote, party, igual que el post 2 va construir el remote list: una app de React Native nova sobre Re.Pack, sense AppRegistry.registerComponent, exposant una pantalla. Crea-la al costat de les altres, instal·la les seves dependències exactament com vas fer amb list, i copia el rspack.config.mjs de list.

Canvien quatre camps, i els quatre importen: el name del plugin (partyApp), el filename del contenidor (partyApp.container.js.bundle), la pantalla exposada (./PartyScreen) i output.uniqueName ('PartyApp'). L’últim és el fàcil d’oblidar. uniqueName dona nom a les variables globals de càrrega de chunks de webpack, així que dos remotes amb el mateix valor xoquen dins del runtime del host.

La pantalla que exposa, apps/party/src/PartyScreen.tsx, té sis buits:

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const SLOTS = [1, 2, 3, 4, 5, 6];

export default function PartyScreen() {
  const insets = useSafeAreaInsets();
  return (
    <View style={[styles.screen, { paddingTop: insets.top + 24 }]}>
      <Text style={styles.title}>Party</Text>
      <Text style={styles.subtitle}>
        Your party is empty. Add up to 6 Pokémon from the Pokédex.
      </Text>
      <View style={styles.grid}>
        {SLOTS.map(slot => (
          <View key={slot} style={styles.slot}>
            <Text style={styles.slotNumber}>{slot}</Text>
          </View>
        ))}
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, backgroundColor: '#fff' },
  title: { fontSize: 28, fontWeight: '700' },
  subtitle: { fontSize: 14, color: '#6b7280', marginBottom: 24 },
  // alignItems: 'flex-start' stops the default stretch from overriding each slot's aspectRatio.
  grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, alignItems: 'flex-start' },
  slot: {
    width: '47%',
    aspectRatio: 1,
    borderRadius: 12,
    borderWidth: 1,
    borderStyle: 'dashed',
    borderColor: '#e5e7eb',
    backgroundColor: '#f9fafb',
    alignItems: 'center',
    justifyContent: 'center',
  },
  slotNumber: { fontSize: 20, fontWeight: '600', color: '#9ca3af' },
});

Encara no hi ha res en aquests buits, i és a propòsit. L’app no té store, així que l’equip no té on desar els seus membres; això arriba amb els posts d’estat. Avui l’equip propietari d’aquesta feature entrega el més petit que pot posar davant d’un usuari: una pantalla amb una forma i sense dades al darrere. Una pantalla sense props i sense estat continua sent una feature que el shell pot carregar en temps d’execució.

Llegeix l’inset del safe area amb useSafeAreaInsets, i aquí el singleton compartit del post 3 fa la seva feina: l’inset ve del provider del host, mesurat un cop, i aquest remote el llegeix sense portar la seva pròpia còpia de la llibreria.

Dona-li el seu propi port de dev server perquè no xoqui amb list al 8082. A apps/party/package.json:

"scripts": {
  "start:remote": "react-native start --config rspack.config.mjs --port 8083"
}

Ara hi ha dos remotes, al 8082 i al 8083, cadascun una pantalla esperant un host.

El host rep la navegació

La tab bar és del host, no dels remotes. Instal·la els paquets de navegació només al host:

cd apps/host
npm install @react-navigation/native @react-navigation/bottom-tabs react-native-screens
cd apps/host/ios && bundle exec pod install

react-native-screens porta codi natiu, així que aquest pod install no és opcional, i tampoc ho és una build nativa nova després. Salta-te’l i la build s’atura amb 'RNSViewInteractionAware.h' file not found, que sembla una llibreria trencada i en realitat és un pod que falta. És la primera dependència nativa que assumeix el host des del post 2, i un avanç d’una restricció a la qual la sèrie torna més endavant: el JavaScript pot arribar per la xarxa, el codi natiu no.

La part de federation és el contracte del post 3 llegit al revés. Els singletons compartits continuen sent exactament els mateixos: react, react-native i react-native-safe-area-context. React Navigation i react-native-screens no es comparteixen, perquè cap remote els importa.

Compartir-ho tot per defecte és un costum defensable: una decisió menys per dependència, i cap possibilitat d’oblidar-te d’alguna cosa que necessitaves. Però compartir serveix perquè dues còpies d’una llibreria no es carreguin alhora en un mateix runtime, i la navegació mai surt del host, així que no hi ha una segona còpia amb la qual xocar.

La regla val en els dos sentits: comparteix una llibreria quan hi hagi codi dels dos costats que la toqui, i només llavors.

El shell

Reescriu apps/host/App.tsx. El host passa a ser propietari d’un SafeAreaProvider, un NavigationContainer i un navegador de pestanyes inferior, i el contingut de cada pestanya és un remote carregat en lazy:

import React, { Suspense } from 'react';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';

const PokedexScreen = React.lazy(() => import('listApp/PokedexScreen'));
const PartyScreen = React.lazy(() => import('partyApp/PartyScreen'));

// A remote downloads the first time its tab is opened, so each tab renders behind a Suspense
// spinner. Wrapping once here keeps the lazy boundary out of the remotes.
function withSuspense(Remote: React.ComponentType) {
  return function Tab() {
    return (
      <Suspense fallback={<ActivityIndicator style={styles.loader} size="large" />}>
        <Remote />
      </Suspense>
    );
  };
}

const PokedexTab = withSuspense(PokedexScreen);
const PartyTab = withSuspense(PartyScreen);

const Tab = createBottomTabNavigator();

export default function App() {
  return (
    <SafeAreaProvider>
      <NavigationContainer>
        <Tab.Navigator screenOptions={{ headerShown: false }}>
          <Tab.Screen name="Pokédex" component={PokedexTab} />
          <Tab.Screen name="Party" component={PartyTab} />
        </Tab.Navigator>
      </NavigationContainer>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  loader: { flex: 1 },
});

Cada pestanya és un remote darrere de React.lazy i Suspense, així que l’app només descarrega la pestanya amb la qual arrenca i deixa l’altra fins que la prems.

El host necessita saber on viu el segon remote. Afegeix-lo a remotes a apps/host/rspack.config.mjs:

remotes: {
  listApp: `listApp@http://localhost:8082/${platform}/mf-manifest.json`,
  partyApp: `partyApp@http://localhost:8083/${platform}/mf-manifest.json`,
},

I digues a TypeScript quina forma té el nou import federat, a apps/host/mf-modules.d.ts:

declare module 'partyApp/PartyScreen' {
  import type React from 'react';
  const PartyScreen: React.ComponentType;
  export default PartyScreen;
}

Un fitxer més, o els tests es trenquen abans d’arrencar. React Navigation i react-native-screens publiquen mòduls ES, i el preset de jest de React Native només passa per Babel els paquets del mateix React Native: el seu transformIgnorePatterns llista react-native, @react-native i @react-native-community, i res més. A apps/host/jest.config.js:

module.exports = {
  preset: '@react-native/jest-preset',
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|react-native-screens)/)',
  ],
};

Executa-ho

Ara quatre terminals, un per remote, un per al host, més la build:

cd apps/list && npm run start:remote      # :8082
cd apps/party && npm run start:remote     # :8083
cd apps/host && npm start                 # :8081
cd apps/host && npm run ios

El host arrenca a la pestanya Pokédex i renderitza el remote list. Prem Party i mira el terminal del 8083: la seva primera petició arriba en aquell moment, no en arrencar. El host descarrega el contenidor, l’executa, i apareix la graella buida.

El shell del host a iOS: en prémer la pestanya Party es carrega el remote party en temps d'execució i apareixen els seus sis buits

Dues features, construïdes i servides per dues apps diferents, dins d’una tab bar que no és de cap de les dues.

Ara trenca-ho

El host i el remote es posen d’acord en un nom escrit en dos fitxers diferents, i res comprova que continuïn coincidint. Trenca l’acord a propòsit. A apps/party/rspack.config.mjs, canvia el nom del contenidor:

new Repack.plugins.ModuleFederationPluginV2({
  name: 'partyRemote',        // el host continua demanant partyApp
  filename: 'partyApp.container.js.bundle',

Reinicia el dev server de party. Un canvi a la config de rspack no es recarrega en calent, així que un refresc normal continua servint el manifest vell i amaga la fallada. Després rellança l’app i prem Party:

[ Federation Runtime ]: Unhandled error. ([ScriptManager] Failed while resolving script locator:)
while loading "./PartyScreen" from webpack/container/reference/partyApp
  args: [ { scriptId: 'partyRemote', caller: undefined } ]
  originalError: [Error: No resolver was able to resolve script partyRemote]

Llegeix-ho de darrere cap endavant i la cadena queda clara. El host va demanar partyApp, perquè aquesta és la clau al seu mapa de remotes. Va descarregar el manifest del 8083, i el manifest declarava un contenidor anomenat partyRemote. El runtime va buscar llavors un script amb aquest nom, i el host no té cap partyRemote registrat, així que el seu resolver no té amb què emparellar-lo. No faltava res i res estava caigut. El canvi de nom es va aplicar en un dels dos fitxers que han de coincidir, no en tots dos.

Fixa’t en on apareix la fallada. Surt una pantalla vermella, no l’spinner: un error sense capturar, no una pestanya que espera per sempre. Suspense gestiona una promesa que encara no s’ha resolt; no captura una que es rebutja. Res en aquesta app està pendent d’això, cosa que està bé en un portàtil amb dos dev servers i molt menys bé quan el remote ve d’una CDN (una xarxa de distribució de contingut) en producció. Capturar-ho bé necessita un error boundary, i això arriba més endavant a la sèrie.

Torna a posar el nom, reinicia el server, prem Party i la graella torna.

Què has construït, i què ve ara

El host ja és un shell. És propietari de la navegació i de la tab bar; cada pestanya és un remote construït i desplegat pel seu compte i carregat en temps d’execució. Els remotes continuen sent simples: renderitzen una pantalla i no saben res de com estan col·locats. Una tercera feature és un tercer remote i una tercera pestanya, sense tocar les que ja estan desplegades.

El codi acabat d’aquest post és el tag post-04-host-shell, perquè el puguis comparar amb el teu:

git checkout post-04-host-shell

El següent a la sèrie: les dues pestanyes voldran obrir la mateixa pantalla de detall d’un Pokémon. Una pantalla que comparteixen dos equips és un component, no una altra unitat de desplegament, i la primera cosa en què dues apps construïdes per separat s’han de posar d’acord és què se li passa. Cap dels dos costats pot ser propietari d’aquest acord, així que es converteix en un paquet: un contracte, publicat i instal·lat per versió.

Fonts

Warren de Leon
Warren de Leon

Software Engineering Manager. Recentment he liderat l'equip de Mobile Platform a Hargreaves Lansdown. Escric sobre lideratge tècnic, React Native i com construir bons equips.

Veure perfil