Preskoči na sadržaj

Gdje sam u našem dizajn sistemu razdvojio web i React Native kod

Od 74 komponente u našem dizajn sistemu 38 ima React Native blizanca, od toga 27 primitiva i nijedan pattern. Svaki blizanac postoji zbog drugačijeg elementa, događaja ili prozora.

7 min čitanja9 sekcija

Naš dizajn sistem za više platformi ima 74 komponente, a 38 od njih ima nativnog blizanca: fajl .native.tsx pored web fajla, koji React Native bundler bira umjesto njega zbog sufiksa u imenu.

Bez konteksta to zvuči kao da se pola sistema razišlo. Nije. Svaki blizanac pokriva samo mali dio po kojem se platforme razlikuju. To pravilo je najkorisnije čemu me ovaj sistem naučio o dijeljenju koda između weba i React Nativea.

01

Kako se web i mobilni kod razilaze

Web i mobilna verzija proizvoda počnu identično, a dvije godine kasnije dijele samo ime. Viđao sam to na stvarnim projektima. Na jednoj platformi za sastanke pet ljudi je osam mjeseci gradilo na sve strane i završilo s tri UI frameworka i četiri biblioteke za padajuće menije u istom codebaseu, a zajednički sloj komponenti nikad nije nastao.

S uobičajenim alatima razilaženje ništa ne košta, pa ga sama disciplina ne može spriječiti. S Platform.select granu možete staviti gdje god vam je zgodno:

snippet.tsx
TypeScript · 13 linija
export function Card({ title, children }) {  return (    <View      style={Platform.select({        web: { padding: 24, cursor: 'default' },        default: { padding: 16 },      })}    >      {Platform.OS === 'web' ? <h3>{title}</h3> : <Text>{title}</Text>}      {children}    </View>  )}

Nijedan red ovdje sam po sebi nije pogrešan. Problem je što takva grana ne ostavlja trag: nema fajla koji možete prebrojati, nema sloja na koji je ograničena i nijedna provjera ne pada kad se pojavi deseta. Uslovni importi imaju isti problem, samo nivo više. Bilo koji fajl se može razići, a niko to ne primijeti dok neko ne pokuša promijeniti obje platforme odjednom.

02

Šta sam htio: ekrani i patterni napisani jednom

Podjela po platformama mora negdje postojati, jer div nije View. Nisam htio podjelu koja se vidi s mjesta poziva.

To daje precizan cilj. Svaki ekran treba napisati samo jednom, kao i svaki pattern, na primjer formu, dijalog ili meni. Smiju se razlikovati samo stvari koje rade direktno s elementom platforme.

03

Gdje se blizanci nalaze: 27 u primitivima, nijedan u patternima

Raspodjela 38 blizanaca po slojevima:

SlojBlizanci
primitives/27
overlay/8
form/2
navigation/1
patterns/0 od 26

Jedna druga brojka govori više od tabele: 33 od 38 blizanaca ne importuju nijednu drugu komponentu. To su listovi u stablu komponenti. Svaki od njih prevodi jedan element platforme, na primjer div u View ili input u TextInput, i ništa više.

Par izgleda ovako:

primitives/Box.tsx
TypeScript · 5 linija
// primitives/Box.tsximport styled from 'styled-components'import { boxRecipe, type BoxProps } from './box.recipe' export const Box = styled.div<BoxProps>(boxRecipe)
primitives/Box.native.tsx
TypeScript · 6 linija
// primitives/Box.native.tsximport styled from 'styled-components/native'import { View } from 'react-native'import { boxRecipe, type BoxProps } from './box.recipe' export const Box = styled(View)<BoxProps>(boxRecipe)

Oba fajla koriste iste propse i isti recept, a razlikuje se samo element. Sve iznad importuje Box i nikad ne sazna koju je verziju dobilo.

Dakle, ta brojka ne znači da je pola sistema duplirano. Pola komponenti je jedan fajl koji dijele obje platforme, a kod druge polovine razlika se svodi na jedan primitiv.

04

Tri razloga zbog kojih blizanac smije postojati: element, događaj, prozor

Blizanac smije postojati iz jednog od tri razloga, a dva od njih nemaju veze s elementima.

Prvi razlog je drugačiji element i odnosi se na 33 od 38 blizanaca. Ti blizanci su mehanički i samostalni, a ništa iznad njih ne može znati koju je verziju dobilo.

Drugi je drugačiji događaj. Ekrani na dodir nemaju hover, pa se Tooltip.native otvara dugim pritiskom, bez kašnjenja. Na webu delayShow sprječava treperenje dok kursor prelazi preko reda ikona. Dugi pritisak već pokazuje namjeru, pa bi kašnjenje samo djelovalo kao kvar.

overlay/Tooltip.native.tsx
TypeScript · 16 linija
// overlay/Tooltip.native.tsxexport function Tooltip({ content, children }: TooltipProps) {  const [open, setOpen] = useState(false)   return (    <>      <Pressable        onLongPress={() => setOpen(true)}        onPressOut={() => setOpen(false)}      >        {children}      </Pressable>      {open ? <TooltipBubble>{content}</TooltipBubble> : null}    </>  )}

Treći je drugačiji prozor. NativeSelect.native je potreban zato što sistemski picker mora biti iznad tastature, a do zasebnog nativnog prozora može se doći samo preko React Native komponente Modal. Zajednički Portal predaje stablo hostu, pa bi lista ostala ispod tastature. Taj kvar se vidi samo na uređaju, i to samo unutar forme.

Preostala tri blizanca importuju tačno po jednu komponentu i ne mijenjaju sliku.

Drugih razloga nema. Ako predloženi blizanac ne odgovara nijednom od ova tri, to je pattern koji se pokušava odvojiti.

05

Kad je patternu trebala nativna razlika, izdvojio sam primitiv

To se i desilo. Patternu je na nativnoj strani na jednom mjestu trebao drugi element, a najlakše bi bilo napraviti Dialog.native.tsx s kopijom cijelog dijaloga i jednim promijenjenim redom.

Pattern nisam dirao. Izdvojio sam primitiv čija je jedina razlika bio element, dao tom primitivu blizanca, a pattern ostavio zajedničkim. To se desilo četiri puta, a PositionedLayer, DialogActionsRow i MenuSeparator su tri primitiva koja su tako nastala.

Princip je da se razdvaja samo ono po čemu se platforme razlikuju, a da ostatak komponente ostane zajednički. Razlog za razliku između platformi skoro je uvijek mali: jedan element, događaj ili prozor. Komponenta u kojoj se ta razlika vidi obično je velika. Kad se razdvoji cijela komponenta, kopira se sve što se nije razlikovalo, zajedno s onom jednom stvari koja jeste, a od tog dana se dvije kopije razilaze.

Pošto se isto rješenje pod pritiskom ponovilo četiri puta, pretvorio sam ga u pravilo.

06

CI odbija svaki nativni fajl u patternima

Nisam htio da pravilo zavisi od toga hoće li se neko sjetiti, pa se provjerava automatski: pattern ne smije imati .native varijantu. U našem sloju provjera to pravilo se zove pattern-native i zbog njega CI pada na 11 subjekata.

Pojednostavljena verzija ideje stane u nekoliko redova:

snippet.ts
TypeScript · 10 linija
import { globSync } from 'glob' const offenders = globSync('packages/ui/src/patterns/**/*.native.{ts,tsx}') if (offenders.length > 0) {  console.error('A pattern must not have a native variant.')  console.error('Extract the primitive that differs, and twin that instead:')  for (const file of offenders) console.error(`  ${file}`)  process.exit(1)}

Prava provjera dio je veće skripte za konvencije, pored nekoliko srodnih provjera. CI pokreće cijeli taj lanac umjesto da ga ponovo implementira: skripte za provjeru i tri stvarna builda, po jedan za Next, Vite i Metro. Srž provjere mala je kao verzija iznad. Poruka o grešci važna je koliko i izlazni kod, jer sljedećoj osobi kaže šta treba uraditi umjesto toga.

07

Ekran za prijavu: jedan poziv komponente, bajt po bajt isti u tri aplikacije

Stvaran ekran pokazuje rezultat bolje od tabele. LoginForm.tsx je jedan fajl od 135 redova, bez nativnog blizanca, sastavljen od komponenti Box, Button, Card, Input i Text. Podjela po platformama dešava se u tim primitivima.

Poziv komponente na ekranu za prijavu bajt po bajt je isti u sve tri aplikacije:

snippet.txt
Tekst · 3 linije
b768aa14a346  apps/web/app/LoginScreen.tsx        Next.js 16, RSCb768aa14a346  apps/spa/src/routes/LoginRoute.tsx  Vite SPAb768aa14a346  apps/mobile/App.tsx                 React Native 0.87

Isti je, dakle, poziv. Omotači oko njega se razlikuju: na webu je forma u komponenti Box koja je centrira, na telefonu u SafeAreaView, a i API URL je drugi. To su razlike platforme i proizvoda koje su izvan dizajn sistema, a identičan poziv tačno pokazuje gdje je ta granica.

Modal pokazuje isto za težu komponentu. Portal, upravljanje fokusom i mjerenje ostali su zajednički, jer su dijelovi koji se razlikuju spušteni u primitive.

08

Zajednički tokeni i recepti drže podjelu tako nisko

To omogućavaju dvije stvari ispod komponenti.

Prva je da se tokeni razrješavaju po platformi. createTheme() pretvara token u string koji se na svakoj platformi razrješava drugačije: na webu je to CSS varijabla, a na nativnoj strani sentinel vrijednost koju zatim zamijeni nativni resolver. Zato se ${theme.color.fg.default} svugdje ponaša isto, a svaka boja ima jednu definiciju. Drugu nema gdje napisati, pa se boje ne mogu razići.

Druga je da su recepti zajednički. Recepti stilova nalaze se iznad elemenata i primjenjuju ih obje platforme. Razlikuje se samo ono što aktivira stil: :hover na webu i pressed na nativnoj strani. Blizanac koji dijeli recept ne može izgledati drugačije od web verzije. Mijenja se samo element ispod njega.

Zajednički recepti su i mjesto gdje je najveći rizik ovako nisko postavljene podjele. Kad smo stvarnu stranicu prenijeli na telefon, aplikacija iz prvog builda srušila se na scroll viewu. Zajednički stack recept uvijek emituje svojstva poravnanja, a React Native ih traži na content containeru umjesto na samom scroll viewu. Svako pravilo je samo po sebi bilo ispravno, a zajedno su pokvarila ekran. Ni uzrok ni popravka nisu bili u stranici: recept smo razdvojili na verziju s poravnanjem i verziju bez njega. Ta priča je u tekstu o prenošenju stvarne stranice. Kad je podjela na dnu, rizik je na istih nekoliko mjesta kao i razlika. Taj kompromis bih napravio svaki put, jer kad nešto pukne, tražite na jednom mjestu umjesto na trideset.

09

Šta bih uradio drugačije

Raspodjelu bih pogledao ranije. Tabela iznad je najkorisniji artefakt u sistemu, a napravio sam je kasno. Sam broj blizanaca izgleda alarmantno. Broj po slojevima, zajedno s tim koliko blizanaca importuje drugu komponentu, pokazuje da li je podjela ograničena. Oba broja se lako izračunaju na bilo kojoj veličini sistema i daju odgovor prije nego što pitanje postane rasprava.

I generalno bih ranije radio preglede. Tri osnovna uzroka koja sam našao kad sam pregledao sve komponente zbog druge vrste problema postojala su već dugo prije toga. Da sam taj pregled uradio na 30 komponenti, a ne na 75, koliko ih je sistem tada već imao, bio bi za red veličine jeftiniji. Isto važi i za blizance: onog koji ne bi trebao postojati najjeftinije je uhvatiti dok je još jedan fajl.

Tri razloga bih zapisao kao pravilo za nove blizance. Kad neko predloži novog blizanca, prvo pitanje je o kojem se od tri razloga radi: drugačiji element, događaj ili prozor. Tek nakon toga dolazi pitanje kako treba izgledati na nativnoj strani. Ako ne odgovara nijednom, rješenje je izdvojiti primitiv.

Kako je nastao ovaj tekstIzrada našeg dizajn sistema za više platformi, koji pokriva Next.js, React Native i Vite SPA.Pročitajte više o projektu →Povezana uslugaDizajn sistem i biblioteka komponentiPogledajte uslugu →Sljedeći tekstKako ubrzati izvještaj u Redshiftu kad ne smijete mijenjati ni upit ni šemuPročitajte →

Blog

Ako radite na nečemu sličnom, javite nam se s detaljima.

Prvi poziv traje 30 minuta i ni na šta vas ne obavezuje.

Opišite nam svoj projekatcontact@microtech.consulting