Mga app na hiwalay na binuo, nag-i-install ng mga versioned na package (isang typed na contract at isang shared na screen) mula sa iisang registry

Ang contract package: isang versioned na seam sa pagitan ng mga federated remote sa React Native

Nagtapos ang post 4 sa isang pangako: gugustuhin ng dalawang tab na buksan ang parehong Pokémon detail screen, at kailangang magkasundo ang mga app na hiwalay na binuo kung ano ang ipapasa rito.

Binubuo ng post na ito iyon, at kailangan ng tatlong pagbabagong nangangailangan sa isa’t isa. Titigil ang bawat remote sa paglalantad ng isang hiwalay na screen at maglalantad ng buong navigation stack, kaya ang pag-push ng detail ay pag-aari ng tab na nag-push nito. Ang mismong detail screen ay iše-ship bilang isang versioned na package na ii-install ng dalawang tab, hindi bilang isa pang deployable, sa isang dahilang sulit ipaliwanag. At ang kasunduan tungkol sa kung ano ang tumatawid papunta sa screen na iyon ay magiging package din: isang contract na may version number, kung saan naroon ang tunay na aral ng post na ito. Sinisira ng huling yugto ang kasunduan nang sadya, tatlong baitang pataas sa isang version ladder, at nagtatapos sa isang butas na hindi makita ng kahit anong compiler sa codebase.

Magpatuloy mula sa sarili mong post 4 code kung sumunod ka sa build. Kung hindi:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-04-host-shell

Nagkakaroon ng stack ang bawat tab

Sa post 4, iniaabot ng bawat remote sa host ang isang hiwalay na screen at ang host ang nag-aayos sa kanila sa mga tab. Binabago ng isang detail screen ang ibig sabihin ng “loob ng isang tab”: ang pag-tap sa isang row ay dapat mag-push ng bagong screen habang nananatili ang tab bar, at kung sino ang may-ari ng push na iyon ang may-ari ng navigation sa loob ng tab. Trabaho iyon ng remote, hindi ng shell. Kaya bawat remote ay naglalantad na ngayon ng isang stack.

apps/list/src/ListStack.tsx:

import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import PokemonDetailScreen from '@pokedex/detail';
import type { ListParamList } from './routes';
import PokedexScreen from './PokedexScreen';

const Stack = createNativeStackNavigator<ListParamList>();

export default function ListStack() {
  return (
    <Stack.Navigator screenOptions={{ headerShown: false }}>
      <Stack.Screen
        name="PokedexList"
        component={PokedexScreen}
        options={{ title: 'Pokédex' }}
      />
      <Stack.Screen
        name="PokemonDetail"
        component={PokemonDetailScreen}
        options={{ headerShown: true, title: '' }}
      />
    </Stack.Navigator>
  );
}

Ang import PokemonDetailScreen from '@pokedex/detail' na iyon ang pangalawang pagbabago ng post, at babalikan natin ito. Una, ang mga kable sa paligid nito. Nagbago ang pampublikong surface ng remote: naglalantad ito ng ./ListStack kung saan dati ay ./PokedexScreen, kaya nire-rename ng rspack config nito ang entry sa exposes, at ang dalawang lazy import ng host ay nagiging listApp/ListStack at partyApp/PartyStack. Ginagaya ng party app ang lahat gamit ang sarili nitong PartyStack.

Nag-e-evolve din ang shared map, sa dalawang direksyon ng panuntunan ng post 4. Nag-i-import na ngayon ang mga remote ng @react-navigation/native, @react-navigation/native-stack at react-native-screens, kaya sumasali ang tatlo sa mga shared singleton map ng bawat panig: talaan ang map ng kung ano ang ini-import ng higit sa isang partido, at tatlong package pa ang katatawid lang ng linyang iyon. Dalawa sa mga bagong entry ang nagdedeklara ng version nang mano-mano:

'@react-navigation/native': {
  singleton: true,
  version: navPkg.version,
  requiredVersion: pkg.dependencies['@react-navigation/native'],
},

Karaniwang binabasa ng rspack ang version mula sa package na ini-share nito, pero hindi nito magagawa iyon sa isang package na nire-resolve sa pamamagitan ng isang exports map, at may ganoon ang React Navigation. Kung wala ang tahasang version, tahimik na nilalaktawan ng bundler ang provide: walang darating sa share scope, at mamamatay ang app sa paglunsad sa RUNTIME-006 (eager) o tahimik na magba-bundle ng duplicate (hindi eager). Kailangan ito ng mga navigation package; hindi ng react-native-screens. Ang pagtingin kung may exports field sa package.json ng isang package ang magsasabi kung aling trato ang kailangan nito.

Patakbuhin at makikita ang pagbabago sa produkto: i-tap ang isang row, mapu-push ang detail sa loob ng tab, at mananatili sa screen ang tab bar.

Ang Pokédex tab sa iOS: ang pag-tap sa isang row ay nagpu-push ng Pokémon detail screen sa loob ng tab habang nananatiling nakikita ang tab bar sa ibaba

Saan nakalagay ang shared na screen?

Parehong stack ang nagmo-mount ng parehong detail screen. Sa produkto ng seryeng ito, isa itong screen; sa isang totoong app, ito ang uri ng bagay na tunay na pinaghahatian ng dalawang team. Saan ito pupunta?

Ang federated na sagot ay kusang lumilitaw: isang pangatlong remote, isang detailApp sa sarili nitong port, dineklara sa mga remotes map ng dalawang consumer. Gumagana ito nang mekanikal (nire-resolve ng Module Federation ang mga nested remote nang walang reklamo) at pinapanatili nitong maa-update ang screen nang hindi ginagalaw ang alinmang consumer. Pero mali rin itong hiwa. Ang isang federation boundary ay isang deployable, na may sariling dev server, sariling deploy pipeline, at sariling mga paraan ng pagkasira. Hinihiwa ng mga team ang mga boundary na iyon ayon sa domain: isang team, isang domain, isang remote, ilang screen sa loob. Ang isang solong screen ay hindi kailanman karapat-dapat sa gastos na iyon, at ang isang codebase na namimigay ng boundary bawat screen ay papunta sa pag-ship ng app bawat screen.

Ang isang screen na pinaghahatian ng dalawang domain ay isang component, at ilang dekada nang may mekanismo ng paghahatid ang mga shared component: isang package. Kaya ang detail screen ay iše-ship bilang @pokedex/detail, na-publish sa isang registry at ini-install ng dalawang tab app, at bawat isa ay nagmo-mount nito sa sarili nitong stack kung saan nito gusto. Ang mga tab pa rin ang mga deployable. Ang screen ay isang dependency. Ang panuntunang iyan ay ipagtatanggol nang buo pagkalipas ng dalawang post: ang boundary ay domain ng isang team, hindi isang screen.

packages/detail/src/PokemonDetailScreen.tsx, ang mga bahaging mahalaga:

import type { DetailParams } from '@pokedex/contracts';

const POKEMON: Record<number, { name: string; types: string[] }> = {
  1: { name: 'Bulbasaur', types: ['Grass', 'Poison'] },
  4: { name: 'Charmander', types: ['Fire'] },
  7: { name: 'Squirtle', types: ['Water'] },
  25: { name: 'Pikachu', types: ['Electric'] },
  133: { name: 'Eevee', types: ['Normal'] },
};

export interface PokemonDetailScreenProps {
  route: { params: DetailParams };
}

export default function PokemonDetailScreen({ route }: PokemonDetailScreenProps) {
  const { id } = route.params;
  const pokemon = POKEMON[id];
  // nire-render ang dex number, pangalan, sprite at mga type chip
}

May dalawang sadyang bagay dito. May dala ang screen na sarili nitong kopya ng Pokémon data: may isa rin ang list app, at naghiwalay na ang dalawa, dahil pangalan ang kailangan ng listahan at pati mga type ang kailangan ng screen na ito. Ang dalawang kopya ng parehong mga katotohanan ay isang amoy, at sadyang itinanim: bubura ang live na data sa dalawa sa susunod na post. At ang props ay tina-type nang structural ({ route: { params: DetailParams } }) sa halip na i-import ang mga type ng React Navigation. Ang screen ay mino-mount ng dalawang stack na hindi pa nito nakikita kailanman, kaya idinedeklara nito ang hugis na kailangan nito at nananatiling malaya sa isang navigation dependency. Ang import nito ng DetailParams ay ang contract package, na kailangang ipaliwanag ngayon ng post na ito.

Ang kasunduang walang app na maaaring mag-ari

Tingnan kung ano ang kailangang magtugma. Nagpu-push ang list app ng PokemonDetail na may { id }. Magpu-push ang party app ng parehong ruta kapag may mga miyembro na itong mata-tap. Binabasa ng screen ang route.params.id. Tatlong codebase ang humahawak sa isang hugis, at walang isa mang makakapagdefine nito para sa iba: ang isang depinisyon sa list app ay invisible sa package ng screen, at ang pagkopya ng type nang mano-mano sa bawat codebase ay drift na may timer.

Ang parehong problema ay nandiyan isang antas pataas, kung saan kinokonsumo ng host ang mga remote. Ang mga ambient declaration ng host ay naglalarawan ng mga module na walang file na matuturo:

declare module 'listApp/ListStack' {
  import type { ListStackModule } from '@pokedex/contracts';
  const ListStack: ListStackModule;
  export default ListStack;
}

Ang isang ambient declaration ay isang pangakong sinulat-kamay. Pinaniniwalaan ito ng TypeScript dahil walang anumang maipang-check dito: ang module na inilalarawan nito ay nire-resolve sa runtime, mula sa isang server, matagal pagkatapos lumabas ng compiler. Ang pagturo ng declaration sa isang type mula sa isang installed na package ay hindi ginagawang macha-check ang pangako; ginagawa nitong shared ang pangako, para ang host at ang remote ay maglarawan man lang ng surface mula sa isang depinisyon sa halip na dalawang hula. Ang hindi pa rin nito mahuhuli, sabihin nang tapat: kapag ni-rename ng remote ang expose nito, maiiwang luma ang declaration at mananatiling berde ang compiler. Ang check sa seam na iyon ay wala sa build time. Itabi ang pangungusap na iyan; ang dulo ng post na ito ay nakatayo sa ibabaw niyan.

Kaya ang mga kasunduan ay nasa @pokedex/contracts, isang package na may mga type at wala nang iba:

// packages/contracts/src/params.ts
export interface DetailParams {
  id: number;
}

export type DetailParamList = {
  PokemonDetail: DetailParams;
};

Bawat stack ay nag-e-embed ng fragment sa sarili nitong param list (type ListParamList = DetailParamList & { PokedexList: undefined }), binabasa ng package ng screen ang DetailParams, at tina-type ng host ang mga module declaration nito mula sa parehong lugar. Lahat ng nasa package ay binubura sa build. Walang umaabot mula rito sa anumang bundle; nandiyan ito para ang bawat tsc sa repo ay mag-check laban sa iisang depinisyon.

I-publish ang dalawa sa isang registry

Kailangan ng package ang isang registry, at hindi puwede ang mga file: path: ang isang path ay isang lokasyon sa isang makina, at ang punto ng dalawang package ay ang mga app na hiwalay na binuo (sa isang totoong organisasyon, mga repo na may magkakaibang may-ari) ay mag-install ng parehong artifact ayon sa pangalan at version. Ang Verdaccio ay isang npm registry sa iisang proseso, sapat para maging totoo ang buong daloy:

npx verdaccio                                    # :4873, nananatiling nakabukas
npm adduser --registry http://localhost:4873     # kahit anong username, password at email

Isang one-line na .npmrc sa ugat ng repo ang nagtuturo ng scope dito (@pokedex:registry=http://localhost:4873/) at napa-publish ang dalawang package:

( cd packages/contracts && npm install && npm run build && npm publish )
( cd packages/detail && npm install && npm run build && npm publish )
+ @pokedex/contracts@1.0.0
+ @pokedex/detail@1.0.0

Idinedeklara ng detail package ang mga kailangan nito bilang mga peer sa halip na i-bundle ang mga ito:

"peerDependencies": {
  "@pokedex/contracts": ">=1",
  "react": "*",
  "react-native": "*",
  "react-native-safe-area-context": ">=5"
}

Ang isang peer range ay isang pahayag tungkol sa compatibility, at ang >=1 ay maluwag: sinasabi nitong gumagana ang screen na ito sa kahit anong contract mula 1.0.0 pataas. Ang mga maluwag na range ay bumibili para sa mga consumer ng kalayaang mag-upgrade sa sarili nilang bilis. Ang kapalit ay katumpakan, at ipapakita ng dulo ng post na ito ang presyo.

Lahat ng tatlong app ay nag-i-install ng dalawang package. Isang tunay na install, by version, mula sa registry:

npm install @pokedex/contracts@^1.0.0 @pokedex/detail@^1.0.0

Bawat app ay nag-a-unpack ng sarili nitong kopya sa node_modules, hawak ang version na hiniling nito. Ang apat na kopya ng contract sa codebase ay parang ang drift problem na naman, hanggang mapansin mo ang pagkakaiba: may version number ang mga kopyang ito, at ang mga version number ang paksa ng natitirang bahagi ng post.

Ngayon sirain natin: ang version ladder

Gumagana ang setup. Parehong tab ang nagmo-mount ng installed na screen, ang mga push ay nagta-type-check laban sa installed na contract, at maayos ang simulator. Ang interesanteng tanong ay kung ano ang mangyayari kapag gumalaw ang mga package at hindi ang mga app, dahil sa isang totoong organisasyon, hindi sila gagalaw nang sabay. Tatlong baitang.

1.1.0, isang dagdag

Balang araw, magpapasa ang party ng slot-instance id kapag ang na-tap na Pokémon ay galing sa isang party slot. Optional na field, additive na pagbabago:

export interface DetailParams {
  id: number;
  uid?: string;
}

I-bump sa 1.1.0, i-build, i-publish. Pagkatapos, sa list app, ang bahaging nilalaktawan ng karamihan ng mga paliwanag:

npm install
up to date, audited 1188 packages in 926ms

Walang nangyayari. Pini-pin ng lockfile ang 1.0.0, at iginagalang ng npm install ang lockfile. Sinasabi ng caret sa package.json kung ano ang tatanggapin ng app na ito; ang lockfile ang nagpapasya kung ano ang makukuha nito. Ang pagkuha ng minor ay isang sadyang hakbang:

npm update @pokedex/contracts
changed 1 package, and audited 1188 packages in 868ms

Ngayon ipinapakita ng npm ls @pokedex/contracts ang bagong resolusyon, kasama ang screen na tumatanggap nito sa pamamagitan ng maluwag nitong peer:

+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@1.0.0
  `-- @pokedex/contracts@1.1.0 deduped

Pumapasa ang tsc nang walang pagbabago sa source kahit saan: ang isang consumer na binuo laban sa 1.0.0 ay hindi kailanman nagpapasa ng uid, at natutugunan pa rin ang type. Iyon ang dahilan kung bakit ligtas i-roll out ang isang additive na pagbabago nang hindi sabay-sabay.

2.0.0, isang field na kailangang naroon

Ang susunod na pagbabago ay ang urì na sumisira. Gusto ng detail screen na ipakita kung saan ito binuksan, kaya nagiging required ang param, at natututo ang screen na i-render ito:

export interface DetailParams {
  id: number;
  uid?: string;
  source: 'pokedex' | 'party';
}

Ang isang required na field ay nagpapawalang-bisa sa lumang hugis, kaya aakyat ang contract sa 2.0.0. Susundan ito ng package ng screen doon: @pokedex/detail@2.0.0, binuo laban sa bagong contract, nagre-render ng maliit na origin line mula sa route.params.source. Balik sa list app, na nasa registry na ang dalawa:

npm install
up to date, audited 1188 packages in 886ms

Nananatili ito sa 1.1.0. Tumatanggi ang caret sa major nang hindi inuutusan, na siyang nag-iisang piyesa ng buong kaayusang ito na gumagana by default.

Ang blind spot

Tatlong team ang naglalabas sa tatlong bilis. Ang party team ay nag-a-adopt ng parehong 2.0.0 at malinis na nagko-compile: wala pa itong pinu-push, kaya walang gastos sa kanila ang bagong requirement. Ang list team ay may release na nakalabas at nananatili sa 1.1.0 na contract. Pero ang package ng screen ay isa lang dependency, at ang mga dependency ay bina-bump: may isang tao sa list team na kumuha ng bagong screen nang hindi kinukuha ang bagong contract.

npm install @pokedex/detail@^2.0.0
changed 1 package, and audited 1188 packages in 1s

Walang babala. Sinasabi ng peer range ng screen ang >=1, hawak ng list app ang 1.1.0, at 1.1.0 >= 1. Kuntento ang npm:

+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@2.0.0
  `-- @pokedex/contracts@1.1.0 deduped

Narito ang bahaging sulit bagalan. Ang list app ay nagba-bundle na ngayon ng isang screen na ang runtime code ay bumabasa ng route.params.source. Itanong kung ano ang na-check ng compiler nito, at ang sagot ay: sinasabi ng mga published na type declaration ng screen na ang props ay tumatanggap ng DetailParams, na-import mula sa @pokedex/contracts at nire-resolve sa kung anumang kopya ang na-install ng consumer. Nire-resolve ito ng list app sa 1.1.0, kung saan wala ang source. Kaya ang push ng list app na { id } ay perpektong nagta-type-check laban sa mismong screen na malapit nang bumasa ng isang field na hindi kailanman ipinadala rito. Bawat tsc sa codebase ay berde, at bawat isa ay nag-che-check ng ibang pangungusap kaysa sa bibigkasin ng runtime.

Sa runtime, ipinu-push ng list tab si Bulbasaur:

Ang Pokémon detail screen na nagbabasa ng 'Opened from the' na walang kasunod, dahil may mas lumang bersyon ng contract ang list app at hindi kailanman ipinasa ang field na nire-render ng mas bagong screen

“Opened from the ”, tapos wala na. Walang crash, walang red box, walang babala sa kahit anong terminal. Isang pangungusap na may butas, ipinadala sa isang user.

Hindi nabigo ang contract. Ginawa nito ang kaya ng isang compile-time na contract, na ang hawakan ang bawat app sa version na na-install ng app na iyon. Hindi rin nabigo ang maluwag na peer; ginawa nito nang eksakto ang sinasabi ng >=1. Ang butas ay nasa pagitan nila: ang isang type ay isang compile-time na pangako, at wala na ito pagtawid ng aktwal na value. Ang pag-check sa value na dumarating ay ibang trabaho, ng ibang mga tool, sa ibang oras.

Ang naitayo mo, at ang susunod

Bumaba ang navigation sa mga remote. Ang shared na screen ay iše-ship bilang versioned na package, dahil ang isang federation boundary ay domain ng isang team at ang isang screen ay isang component. Ang mga kasunduan (params at mga hugis ng module) ay nasa isang contract package na ini-install ng bawat panig, at semver ang namamahala sa paghihiwalay ng tatlong codebase: ang mga minor ay lumalabas nang hindi sabay at walang pinsala, ang mga major ay naghihintay ng pahintulot, at ang nag-iisang pagkabigong nakalusot ay tahimik na ginawa iyon, sa puwang sa pagitan ng isang maluwag na peer range at isang binurang type.

Ang tapos na code para sa post na ito ay ang post-05-contracts tag:

git checkout post-05-contracts

Ang susunod sa serye: totoong data. Mawawala ang mga hardcoded na listahan ng Pokémon, pareho ng kopya, at bawat screen ay magbabasa mula sa isang store na pinapakain ng PokéAPI. Doon makukuha ng butas sa pangungusap na iyon ang tunay nitong sagot, dahil ang isang type na na-check sa build time ay walang magagawa laban sa isang value na lumilitaw sa runtime.

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