Isang host React Native app na may bottom tab bar, bawat tab ay nag-lo-load ng ibang remote sa runtime

Ang host shell: mga federated remote bilang tabs sa React Native

May pangako ang pagtatapos ng post 3: hihinto na ang host sa pagiging isang screen lang at magiging tunay na shell, na hawak ang tab bar, at bawat tab ay remote na nilo-load sa runtime. Iyon ang post na ito.

Hanggang ngayon, isang screen lang mula sa isang remote ang nilo-load ng host, fullscreen, na walang ibang mapupuntahan. May frame sa paligid ng mga feature nito ang tunay na app: tab bar, navigation, ang mga bahaging laging nasa screen. Ibinibigay ng post na ito ang trabahong iyon sa host, at ibinibigay ang pangalawang tab sa pangalawang remote.

Ang hatian ng trabaho

Isang pangungusap ang nagbubuod sa buong post: ang host ang may-ari ng frame, at ang mga remote ang may-ari ng mga feature sa loob nito.

Ini-install ng host ang navigation library, ini-mount ang tab bar, at siya ang nagpapasya kung anong mga tab ang meron. Nananatiling payak na screen ang mga remote. Hindi sila nag-i-import ng navigator, hindi nila alam na nasa loob sila ng tab, at hindi nila alam na may pangalawang remote. Dapat nakakabagot ang pagdagdag ng feature: gumawa ka ng isa pang remote, magdagdag ka ng isa pang tab, i-ship mo.

Sa ganitong paraan, puwedeng pag-aari ng dalawang team ang dalawang tab, buuin nang hiwalay, at i-deploy sa sarili nilang takbo, dahil walang isa sa kanila ang kailangang mag-compile laban sa isa pa.

Ang panimulang punto ay ang natapos na estado ng post 3. Kung sumabay kang bumuo, iyon ang code na meron ka na. Kung hindi:

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

Pangalawang remote para punan ang pangalawang tab

Hindi tab bar ang isang tab. Kaya nagdadagdag tayo ng pangalawang remote, party, sa parehong paraan na binuo ng post 2 ang list remote: bagong React Native app sa Re.Pack, walang AppRegistry.registerComponent, at isang screen ang inilalantad. Ang eksaktong ruta, kasunod ng mga hakbang ng list sa post 2:

npx @react-native-community/cli@20.1.0 init Party --directory apps/party --version 0.85.3
( cd apps/party && npm install -D @callstack/repack@5.2.5 @rspack/core@2.0.5 @module-federation/enhanced@2.5.0 @swc/helpers@0.5.23 @react-native-community/cli@20.2.0 @react-native-community/cli-platform-android@20.2.0 @react-native-community/cli-platform-ios@20.2.0 )
cp apps/list/react-native.config.js apps/party/
cp apps/list/rspack.config.mjs apps/party/

Pagkatapos bigyan ito ng parehong halos-walang-laman na entry point ng list: kopyahin ang apps/list/src/index.js papuntang apps/party/src/index.js. Pinapanatili ng index.js sa ugat ang pagre-register nito para sa standalone na pagtakbo; ang federated entry ang walang gagawin sa pag-boot.

Apat na field ang nagbabago sa kakakopya mo lang na apps/party/rspack.config.mjs, at mahalaga ang lahat. Ito ang mga huling halaga nila:

// output, near the top:
      uniqueName: 'PartyApp',

// inside the ModuleFederationPluginV2 options:
        name: 'partyApp',
        filename: 'partyApp.container.js.bundle',
        exposes: {
          './PartyScreen': './src/PartyScreen.tsx',
        },

Ang uniqueName ang madaling makalimutan. Pinapangalanan ng uniqueName ang mga global variable ng webpack para sa chunk loading, kaya nagbabanggaan sa loob ng runtime ng host ang dalawang remote na naglalabas ng parehong halaga.

Ang screen na inilalantad nito, apps/party/src/PartyScreen.tsx, ay may anim na walang lamang puwang:

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' },
});

Wala pang laman ang mga puwang na iyon, at sinadya iyon. Walang store ang app, kaya walang mapaglagyan ng party ang mga miyembro nito; darating iyon sa mga post tungkol sa state. Ngayon, ini-ship ng team na may-ari ng feature na ito ang pinakamaliit na maipapakita nito sa isang user: screen na may hugis at walang datos sa likod. Ang screen na walang props at walang state ay feature pa rin na kayang i-load ng shell sa runtime.

Binabasa nito ang safe-area inset sa pamamagitan ng useSafeAreaInsets, ang shared singleton mula sa post 3 na gumagawa ng trabaho nito: galing sa provider ng host ang inset, isang beses na sinukat, at binabasa ito ng remote na ito nang hindi nagdadala ng sariling kopya ng library.

Bigyan mo ito ng sarili nitong dev-server port para hindi bumangga sa list sa 8082. Sa apps/party/package.json:

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

May dalawa nang remote ngayon, sa 8082 at 8083, bawat isa ay screen na naghihintay ng host.

Tumatanggap ng navigation ang host

Sa build na ito, sa host ang tab bar, hindi sa mga remote. I-install ang mga navigation package sa host lamang:

( cd apps/host && npm install @react-navigation/native@7.3.1 @react-navigation/bottom-tabs@7.18.0 react-native-screens@4.25.2 )
( cd apps/host/ios && bundle exec pod install )

Hindi opsyonal ang pod install na iyon, at hindi rin ang bagong native build pagkatapos. May dalang native code ang react-native-screens. Laktawan mo iyon at hihinto ang build sa 'RNSViewInteractionAware.h' file not found, na parang sirang library pero kulang na pod lang talaga. Ito ang unang native dependency na kinuha ng shell mula pa noong post 2, at pasilip sa isang limitasyong babalikan ng serye: kayang dumating sa network ang JavaScript, hindi ang native code.

Ang bahaging federation ay ang kontrata ng post 3 na binasa nang pabaligtad. Nananatiling eksaktong pareho ang mga shared singleton: react, react-native, at react-native-safe-area-context. Hindi shared ang React Navigation at ang react-native-screens, dahil walang remote na nag-i-import ng mga ito.

May maipagtatanggol na dahilan ang pagbabahagi ng lahat bilang default: isang desisyong mas kaunti bawat dependency, at walang tsansang makalimutan ang isang bagay na kailangan mo. Pero ang silbi ng pagbabahagi ay para hindi sabay na ma-load ang dalawang kopya ng isang library sa iisang runtime, at hindi kailanman umaalis sa host ang navigation, kaya walang pangalawang kopyang mabanggaan.

Ang paghawak ng magkabilang panig sa isang library ang dahilan para pag-isipan ang pagbabahagi, hindi ang dahilan para maging kailangan ito. Ang nagpapasya ay kung may dalang pagkakakilanlan ang library: state sa antas ng module, isang React context, isang native module na nagre-register nang isang beses lang bawat proseso. Sa post 3 nanggaling ang pagsusuring iyon, at ginagawa itong panuntunan tungkol sa kung sino ang sumasagot sa bawat seam ng post 7. Kailangang iisang kopya ang mga iyon. Ang library na puro halaga na ini-import ng magkabilang panig, na walang instance na pagtatalunan, ay ligtas na madoble at kadalasan mas mura pa.

Ang shell

Isulat muli ang apps/host/App.tsx. May-ari na ngayon ang host ng isang SafeAreaProvider, isang NavigationContainer, at isang bottom tab navigator, at ang laman ng bawat tab ay remote na nilo-load nang 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 },
});

Remote sa likod ng React.lazy at Suspense ang bawat tab, kaya ang tab lang na pinagbubuksan ang kinukuha ng app at iniiwan ang isa hanggang sa pindutin mo.

Kailangang malaman ng host kung saan naroon ang pangalawang remote. Idagdag ito sa remotes sa apps/host/rspack.config.mjs:

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

At sabihin sa TypeScript ang hugis ng bagong federated import, sa apps/host/mf-modules.d.ts:

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

Isa pang file, o masisira ang mga test bago pa tumakbo. Nag-i-ship ng ES modules ang React Navigation at ang react-native-screens, at ang mga package lang mismo ng React Native ang pinapadaan sa Babel ng base jest preset: nakalista sa transformIgnorePatterns nito ang react-native, @react-native, at @react-native-community, at wala nang iba. Sa 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)/)',
  ],
};

Patakbuhin mo

Apat na terminal na ngayon, isa bawat remote, isa para sa host, kasama ang 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

Nagbu-boot ang host sa Pokédex tab at nagre-render ng list remote. Pindutin ang Party at bantayan ang terminal ng 8083: sa sandaling iyon dumarating ang unang request nito, hindi sa pag-boot. Kinukuha ng host ang container, pinapatakbo ito, at lumalabas ang walang lamang grid.

Ang host shell na tumatakbo sa iOS: sa pagpindot ng Party tab, nilo-load ang party remote sa runtime at lumalabas ang anim nitong walang lamang puwang

Dalawang feature, binuo at inihain ng dalawang magkahiwalay na app, na nasa iisang tab bar na hindi pag-aari ng alinman sa kanila.

Ngayon, sirain natin

Nagkakasundo ang host at ang remote sa isang pangalang nakasulat sa dalawang magkaibang file, at walang tumitingin kung magkatugma pa sila. Sirain mo ang kasunduan nang sadya. Sa apps/party/rspack.config.mjs, palitan ang pangalan ng container:

new Repack.plugins.ModuleFederationPluginV2({
  name: 'partyRemote',        // partyApp pa rin ang hinihingi ng host
  filename: 'partyApp.container.js.bundle',

I-restart ang dev server ng party. Hindi hot-reload ang pagbabago sa config ng rspack, kaya ang lumang manifest pa rin ang inihahain ng payak na refresh at natatago ang depekto. Pagkatapos ay i-relaunch ang app at pindutin ang 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]

Basahin mo ito nang pabaligtad at malinaw ang kadena. Humingi ang host ng partyApp, dahil iyon ang key sa remotes map nito. Kinuha nito ang manifest sa 8083, at nagdeklara ang manifest ng container na partyRemote. Naghanap ang runtime ng script na may ganoong pangalan, at walang partyRemote na nakarehistro sa host, kaya walang maitutugma ang resolver nito. Walang nawawala at walang down. Naisagawa ang pagpapalit ng pangalan sa isa lang sa dalawang file na kailangang magkatugma, hindi sa pareho.

Pansinin kung saan lumilitaw ang depekto. Red box ang lalabas, hindi ang spinner: hindi nahuling error, hindi tab na walang katapusang naghihintay. Hinahawakan ng Suspense ang promise na hindi pa tapos; hindi nito nahuhuli ang isang tinanggihan. Walang nagbabantay para roon sa app na ito, na ayos lang sa laptop na may dalawang dev server at hindi na masyadong ayos kapag galing sa CDN (content delivery network) ang remote sa produksyon. Kailangan ng error boundary sa paligid ng lazy na tab para mahuli ito nang maayos. Walang sariling post ang serye para rito, kaya ituring itong trabahong may utang pa ang shell na ito: kasama ng paghahatid mula sa CDN darating ang kuwento ng fallback at pagbawi, kung saan hindi na problema ng laptop ang nawawalang remote.

Ibalik mo ang pangalan, i-restart ang server, pindutin ang Party, at babalik ang grid.

Ang nabuo mo, at ang susunod

Shell na ang host ngayon. Ito ang may-ari ng navigation at ng tab bar; bawat tab ay remote na binuo at na-deploy nang mag-isa at nilo-load sa runtime. Nananatiling simple ang mga remote: nagre-render sila ng screen at walang alam sa kung paano sila nakaayos. Pangatlong remote at pangatlong tab ang isang pangatlong feature, nang walang pagbabago sa mga na-ship na.

Tapusin nang eksakto sa huling estado ng post na ito. Inilalathala ng gabay ang mga file na sumusuporta sa build; nasa tag ang mga manifest, config at maliliit na pagbabago. Para maging byte-for-byte na katulad ng tag ang tree mo, i-copy ang reference tree sa ibabaw ng sa iyo. Ang file na tama mong naisulat ay ino-overwrite ng kaparehong laman, at pinupunan ng pag-copy ang hindi inilathala ng teksto:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-04-host-shell /tmp/pokedex-ref-04
cp -R /tmp/pokedex-ref-04/. .

Ang tapos na code para sa post na ito ay ang tag na post-04-host-shell, kung nag-clone ka sa halip na sabayang magbuo:

git checkout post-04-host-shell

Susunod sa serye: gugustuhin ng dalawang tab na buksan ang parehong detail screen ng Pokémon. Ang isang screen na pinaghahatian ng dalawang team ay isang component, hindi isa pang deployable. Ang unang bagay na dapat pagkasunduan ng dalawang app na hiwalay na binuo ay kung ano ang ipapasa rito. Walang panig na puwedeng mag-angkin ng kasunduang iyon, kaya nagiging package ito: isang kontrata, na inilalathala at ini-install ayon sa bersyon.

Mga Sanggunian

Warren de Leon
Warren de Leon

Software Engineering Manager. Pinakahuling pinamunuan ang Mobile Platform team sa Hargreaves Lansdown. Sumusulat tungkol sa engineering leadership, React Native, at pagbuo ng magagandang team.

Tingnan ang profile