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.
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.
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:
1export function Card({ title, children }) {2 return (3 <View4 style={Platform.select({5 web: { padding: 24, cursor: 'default' },6 default: { padding: 16 },7 })}8 >9 {Platform.OS === 'web' ? <h3>{title}</h3> : <Text>{title}</Text>}10 {children}11 </View>12 )13}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.
Š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.
Gdje se blizanci nalaze: 27 u primitivima, nijedan u patternima
Raspodjela 38 blizanaca po slojevima:
| Sloj | Blizanci |
|---|---|
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:
1// primitives/Box.tsx2import styled from 'styled-components'3import { boxRecipe, type BoxProps } from './box.recipe'4 5export const Box = styled.div<BoxProps>(boxRecipe)1// primitives/Box.native.tsx2import styled from 'styled-components/native'3import { View } from 'react-native'4import { boxRecipe, type BoxProps } from './box.recipe'5 6export 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.
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.
1// overlay/Tooltip.native.tsx2export function Tooltip({ content, children }: TooltipProps) {3 const [open, setOpen] = useState(false)4 5 return (6 <>7 <Pressable8 onLongPress={() => setOpen(true)}9 onPressOut={() => setOpen(false)}10 >11 {children}12 </Pressable>13 {open ? <TooltipBubble>{content}</TooltipBubble> : null}14 </>15 )16}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.
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.
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:
1import { globSync } from 'glob'2 3const offenders = globSync('packages/ui/src/patterns/**/*.native.{ts,tsx}')4 5if (offenders.length > 0) {6 console.error('A pattern must not have a native variant.')7 console.error('Extract the primitive that differs, and twin that instead:')8 for (const file of offenders) console.error(` ${file}`)9 process.exit(1)10}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.
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:
1b768aa14a346 apps/web/app/LoginScreen.tsx Next.js 16, RSC2b768aa14a346 apps/spa/src/routes/LoginRoute.tsx Vite SPA3b768aa14a346 apps/mobile/App.tsx React Native 0.87Isti 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.
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.
Š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.
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.