¶Ytelse i frontend med React
React har blitt go-to-rammeverket for frontend, og de fleste applikasjonene får aldri ytelsesproblemer. Både pc-er og mobiler har som regel nok kraft til å håndtere selv litt tyngre SPA-er. Som vi har lært: Premature optimization is the root of all evil – DonaldKnuth.
Et av problemene er at kode som er optimalisert for ytelse sjelden er like elegant som annen kode. I tillegg vet man sjelden hva som faktisk er tregt før tregheten viser seg. Å anta at en del av appen blir treg, og så optimalisere den på forhånd, kan gi både mindre lesbar kode og en app som yter dårligere. Antakeligvis kaster du også bort mye tid på unødvendig optimalisering.
Av og til må man likevel fikse ytelsen, og da er det greit å vite hvordan. I denne bloggposten ser vi på de vanlige triksene, hva de er og hvordan de fungerer, på verktøy for å identifisere flaskehalser, og på noen kanskje mindre åpenbare grep.
React-dokumentasjonen forklarer ikke dette godt nok etter min mening. Og først må vi forstå det grunnleggende.
¶Hvordan React fungerer
For å forstå ytelsen til react apper, så bør en faktisk også forstå hvordan react i seg selv fungerer. Dette vet du kanskje, men en liten oppfrisker kan være greit.
Komponenter
En React-komponent er bare en funksjon som begynner med stor bokstav og som mottar et props-objekt.
// Component without parameters/props
const MyComponent = () => <div>Hello</div>
// Component with parameters/props
const MyComponentWithProps = ({to}: {to: string}) => <div>Hello {to}</div>
// Since parameters aren't an object, this is not a component
const NoComponent = (to: string) => <div>Hello {to}</div>
// Since not uppercase name, not a component
const noComponent = () => <div>Hello</div>
Når en definerer disse kan det være vanskelig å skjønne hva som er en komponent. Men i bruk med JSX er det ikke så vanskelig å se.
<MyComponent />
<MyComponentWithProps to={"world"}/>
{NoComponent("world")}
{noComponent()}
Hva er forskjellen egentlig? En funksjon er bare en JavaScript funksjon, så ingen/minimal overhead. En komponent har litt “React overhead”, og kan i tillegg bruke hooks. Jeg vil si tommelfingerregelen er: skal en funksjon returnere JSX, lag det som en komponent, overhead er neglisjerbar.
En komponent vil rendres hver gang dens parent rendres. Dette skjer uavhengig av om props endres eller ikke. I tillegg kan en komponent rendre uavhengig av parent, dersom en av dens hooks fyrer. En funksjon kan altså ikke ha hooks, så den rendrer bare hver gang parent rendrer.
Hooks
Hooks lar deg «hooke» deg inn i React. De to vanligste, useState og useEffect, er obligatorisk pensum, men det som betyr noe her, er at en hook kan få en komponent til å rendre uten input fra forelderen.
useState returnerer [state, setState]. Kaller du setState, markerer React komponenten som «skitten» og rendrer hele komponenten på nytt, inkludert alle barn, Dette skjer uansett om tilstandens-endringen angår dem eller ikke. På den måten kan en enkelt state-endring øverst i treet utløse en komplett re-render av en hel side.
Reacts tre faser
React rendrer når enten en forelder re-rendres eller en hook fyrer; startpunktet kan være roten eller et hvilket som helst sted i treet.
Prosessen går i tre faser:
- Render – all komponentkode kjøres, hooks opprettes, og JSX blir til en ny virtuell DOM.
- Reconciliation – React sammenligner den nye og den forrige virtuelle DOM-en og lager en diff.
- Commit – diff-en utføres på den virkelige DOM-en; useLayoutEffect kjøres synkront, useEffect asynkront.
Med små datamengder og få oppdateringer er det greit at React rendrer alt hver gang – og det fungerer overraskende bra. Problemet oppstår først når datamengdene eller oppdateringene blir store nok.
¶Reacts performance hooks
Det finnes mange triks, men først må du forstå tre ting: useMemo, memo og useCallback. De er grunnmuren i all React-ytelse.
useMemo - memoiser tunge operasjoner
useMemo memoiserer resultatet av en funksjon. Den lagrer forrige verdi og kjører funksjonen på nytt bare når en av avhengighetene endrer seg. Med andre ord, perfekt for tunge beregninger.
const primes = useMemo(() => calculatePrimeNumbers(number), [number]);
Avhengighetslista avgjør når cachen skal invalideres; endres number, regnes primtallene ut på nytt. Hooken bruker litt minne til å huske forrige resultat, samt avhengighetene, så minne-kostnaden er gjerne akseptabel sammenlignet med å beregne samme tunge verdi flere ganger.
useMemo husker bare en verdi, forrige resultat av funksjonen.
Et lite, men viktig sidespor: likhetssjekker i React
Avhengighetslista forteller React hvilke verdier den skal sammenligne. Sammenligningen skjer med Object.is, så primitiver må være like, og objekter må være samme referanse.
{} === {} // false
const a = {}; a === a // true
Object.is({}, {}) // false
Object.is(a, a) // true
Dersom du sender et nytt objekt inn i lista, selv om den er strukturelt helt likt, vil alltid verdien regnes som endret, og memoiseringen bryter sammen:
Et banalt eksempel:
const foo = {a: 1, b: 2};
// alltid ny referanse → alltid ny beregning
const bar = useMemo(() => expensiveWork(foo), [foo]);
// trygg dersom foo.a/b er primitive
const baz = useMemo(() => expensiveWork(foo), [foo.a, foo.b]);
Derfor må objekter som brukes i avhengighetslista enten komme fra state/context som ikke gjenskapes ved hver render, eller du må selv lagre dem med useMemo/useCallback før du bruker dem i andre hooks.
React.memo – memoiser hele komponenten
Når en forelder rendres på nytt, rendres også alle barna, uavhengig av om tilstanden til barnet er endret. Har du en tung komponent med masse JSX, kan både render- og reconciliation-fasen være bortkastet. React.memo stopper dette ved å cache forrige resultat og bare rendre på nytt hvis props faktisk endres.
Memo er enkel i bruk, wrapp komponenten og eksporter den wrappede versjonen.
const ExpensiveInternal = ({ message }: ExpensiveProps) => (
<div className="example-result">A lot work do to this, message is {message}</div>
);
export const Expensive = React.memo(ExpensiveInternal);
Unngå følgende inlinet versjon; den er vanskeligere å debugge i DevTools:
export const Expensive = React.memo(({message}: ExpensiveProps) => (
<div className="example-result">A lot work do to this, message is {message}</div>
));
Memo sammenligner hver prop med Object.is, men kun på første nivå – objekter og funksjoner må derfor ha samme referanse for å unngå re-render. Memo kan ta en parameter til, en funksjon der du kan sammenligne forrige og nåverdi og bestemme om de er like.
export const Expensive = React.memo(ExpensiveInternal, (prevProps, nextProps) => {
return prevProps.message === nextProps.message;
});
Funksjoner i props kan imidlertid ikke sammenlignes på innhold; derfor trenger vi useCallback.
useCallback - sørg for at funksjoner er identiske
useCallback brukes nesten utelukkende for å forhindre rerender av en memoisert-komponent. Uten useCallback lages funksjonen på nytt ved hver render, og memo-sammenligningen slår aldri til, komponenten rendrer unødvendig.
Eksempel:
const ExpensiveInternal = ({ message, onClick }: ExpensiveProps) => (
<div onClick={onClick}>A lot work do to this, message is {message}</div>
);
const Expensive = React.memo(ExpensiveInternal);
const Parent = ({id}: {id: string }) => {
const onClick = () => {
console.log("Callback called with id", id)
};
return <Expensive message={"Hello"} onClick={onClick} />
}
I dette eksempelet hjelper det ikke med memo. Dette fordi ved hver re-render av Parent så blir onClick skapt på nytt. Og da er ikke prevProps og nextProps identisk lenger. useCallback fikser dette:
const Parent = ({id}: {id: string }) => {
const onClick = useCallback(() => {
console.log("Callback called with id", id)
}, [id]);
return <Expensive message={"Hello"} onClick={onClick} />
}
Sidespor, hva skjer dersom dependency array er feil
Dette er kanskje det største problemet med å bruke useMemo og useCallback. I eksemplene brukt her er dependencies enkle. Men i en virkelig applikasjon kan de bli ganske komplekse, og funksjonen inni kan være stor. Det kan med andre ord være vanskelig å se at riktige dependencies er lagt ved.
Si at vi glemte å legge ved id i useCallback fra eksempelet over:
const Parent = ({id}: {id: string }) => {
const onClick = useCallback(() => {
console.log("Callback called with id", id)
}, []);
return <Expensive message={"Hello"} onClick={onClick} />
}
Dette betyr at useCallback ikke blir kalt dersom id endres i props. Konsekvensen er at useCallback blir kjørt en gang, og den binder funksjonen til den første verdien av id, en klassisk “stale closure”. Dette kan gi noen ganske kjipe bugs. Med andre ord, ikke bruk useMemo og useCallback dersom du ikke trenger.
Det skal sies at dette problemet gjelder for andre hooks med dependencies og som tar funksjoner som argument, f.eks. useEffect.
Et triks for å forhindre useCallback endringer, refs
Anta at du vil unngå unødig re-render a <Expensive />. Det skal kun skje dersom det er helt nødvendig. Det at onClick forholder seg til id er jo egentlig ikke relevant for Expensive. Sagt med andre ord, det at funksjon endrer seg, endrer strengt tatt ikke hvordan Expensive fungerer. Så hadde det ikke vært fint om en kunne droppet parameterne til useCallback, men likevel gjøre funksjonen dynamisk?
Det viser seg at det er mulig, med mutable refs. F.eks.
const Parent = ({id}: {id: string }) => {
const idRef = useRef(id)
idRef.current = id
const onClick = useCallback(() => {
console.log("Callback called with id", idRef.current)
}, []);
return <Expensive message={"Hello"} onClick={onClick} />
}
Dette trikset bør nok brukes med måte, men fungerer fint.
¶Andre optimaliseringer
React-hjelpere som useMemo, memo og useCallback stopper det meste av unødvendig rendering, men de hjelper lite hvis annen kode er treg. En algoritme med kjøretid O(n²) som kjøres fra en hook eller event-handler, vil fremdeles fryse UI-et når datasettet vokser.
I produksjon hadde vi denne type kode. Og det ser jo enkelt ut, og er null stress med lite data.
for (const order of orders) {
const customer = customers.find(c => c.id === order.customerId)
if (customer) {
order.customerName = customer.name
}
}
customers.find vil i verste fall måtte søke i hele customers lista, derfor har koden kvadratisk kjøretid, O(n²). Ved lite data, si 100*100 elementer, det gir maks 10 000 iterasjoner. Men vokser det til 1000 * 1000, så har vi 1 000 000 iterasjoner. Kvadratisk kjøretid, det må vi unngå.
Ved å bygge et midlertidig Map ble kompleksiteten redusert til O(n):
const customerMap = new Map(customers.map(c => [c.id, c]))
for (const order of orders) {
const customer = customerMap.get(order.customerId)
if (customer) {
order.customerName = customer.name
}
}
Resultat: 1 000 iterasjoner i stedet for 1 000 000. UI-et oppleves som raskt selv med store datamengder.
Dette er igjen et banalt eksempel. Iterasjoner kan skje på mange måter, så det er ikke alltid like opplagt når kvadratisk kjøretid oppstår. Et god profileringsverktøy vil hjelpe deg med å finne problemer.
¶Hvordan finne ut hva som skal optimaliseres
Endelig har vi kommet til den viktige delen av denne bloggposten. Dette er kanskje det vanskeligst med performance tuning, “hva bør endres”?
Om du søker på “react profiling”, så lander du typisk på <Profiler>. For oss app-utviklere er nok ikke dette det mest nyttige vertøyet. Det er lavnivå, der du selv må bestemme hva som skal skje med output. Det gir lite fleksibilitet til å interaktivt finne problemer.
Det du ønsker å bruke er React Developer Tools. Men beskrivelsene på React sidene er relativt sparsommelig.
Det finnes flere nyttige faner til dette i DevTools. Vi skal se på følgende:
- React Profiler (fra React selv)
- Performance (standard i chromium nettlesere)
- Memory (standard i chromium nettlesere)
Før du går i gang, du må ha et realistisk miljø å teste i. Uten dette blir det bare gjetting. Så hvorfor ikke bare teste i prod?
Performance og Memory er tilgjengelig på alle nettsider, men det du gjerne trenger er React Profiler.
Realistisk miljø og ytelse
For å faktisk forstå ytelsen i produksjon, så anbefales å bruke et prod likt miljø. Lokalt miljø vil sjeldent kunne simulere produksjon, i hvert fall ikke uten mye innsats.
Så hvorfor ikke bare teste i prod eller staging/QA? Vel, produksjonsbyggene til React, inneholder ikke profiling informasjon, og med god grunn, det har overhead. Det hadde vært ønskelig å koble lokal frontend mot produksjon. Du har antakeligvis sikkerhet som forhindrer dette, så mange tenker dette ikke er mulig.
Dette kan omgås, la meg fortelle deg om et lite triks.
Proxy til produksjonsmiljø
Dette kan løses med en god gammeldags proxy. Du kan f.eks. bruke vite proxy, som etter min mening er det enkleste. Vite kan brukes, selv om du til vanlig bruker noe annet for å bygge din app.
Trikset her er at du retter all nettverkstrafikk fra din frontend app mot proxy. Proxy sørger for at requestene som sendes videre skrives om sånn at server godtar den, og samme på vei tilbake. Dette er sannsynligvis enklere enn du skulle tro.
Følgende vite oppsett sørger for at alle requests går mot et eksternt miljø, f.eks. produksjon. Origin og host headere endres slik at server aksepterer requesten. Cookies endres fra server til klient, slik klient aksepterer dem. Dette er et ganske komplett eksempel for hvordan dette kan settes opp, også med andre byggverktøy som f.eks. webpack.
import {defineConfig} from 'vite';
const PROD_API = process.env.PROXY_TARGET || 'https://production.example.com';
const DEV_SERVER = 'http://localhost:3000';
export default defineConfig({
server: {
port: 5173,
cors: true,
proxy: {
'^/api/.*': {
target: PROD_API,
changeOrigin: true,
secure: true,
headers: {
origin: PROD_API
},
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
const cookies = proxyRes.headers['set-cookie'];
if (cookies) {
proxyRes.headers['set-cookie'] = cookies.map(c =>
c.replace(/Domain=[^;]+/gi, 'Domain=localhost')
.replace(/;\s*Secure/gi, '')
);
}
});
proxy.on('proxyReq', (_, req) => {
// Logging if needed
// console.log(`→ API ${req.method} ${req.url} → ${PROD_API}`);
});
}
},
// Static assets → Webpack dev server
'/': {
target: DEV_SERVER,
changeOrigin: false,
ws: true,
}
}
}
});
Med dette oppsettet kan du teste din ditt lokale frontend app direkte mot et produksjonsmiljø. Det betyr at du kan raskt teste endringer, og bruke alle lokale verktøy som React profiler.
React profiler dev tools
Dette er et uvurderlig verktøy for å forstå appen din. På områder av appen du vet du har problemer, kjører du en profiling sesjon. Profiler vil deretter gi deg dyp innsikt i hva som skjer. Typisk antall re-renders, hvor lang tid hver re-render tar, og hvorfor noe ble re-rendered. Her har jeg selv hatt mange aha opplevelser.
Det er viktig å skru på instillingen som viser hvorfor en komponent rendres:
Eksempel på kjøring av en tung side:
Her ser vi mange ting:
- React gjør re-render totalt 11 ganger i løpet av sesjonen
- Stolpene som er høye er de vi må inspisere, dette er de trege
- RoutesOverviewMapInternal som er memo’ed, en tung komponent, rendres 7 ganger.
- RoutesOverviewMapInternal blir re-rendret flere ganger på grunn av assignToSlotSelector
Dette gir gode hint på hva som må fikses. Kan vi redusere re-renders, kan vi fikse RoutesOverviewMapInternal? Viser seg at ved å bruke useCallback på assignToSlotSelector så kan vi redusere re-renders av denne tunge komponenten til 4 ganger. Ved å bruke refs trikset forklart over, reduserte vi til 2.
Performance tab
Dette er et generelt verktøy i Chromium devtools som gir deg innsikt generell javascript performance. En svært nyttig funksjon, er at du kan gjøre slowdown, sånn at du kan se hvordan brukerne dine har det. De fleste sitter neppe på fete M4 MacBook Pro.
I tillegg kan du gjøre generell JS profilering. Og det kan være noen ting som dukker opp. F.eks. bottom-up tabben, lar deg drille ned i koden som bruker lengst tid, og ofte dukker det opp ting du kanskje ikke hadde tenkt over. Her fant vi en svært ineffektiv selector, som tok 46 % CPU i profileringstiden. En liten tweak i kode, problemet var borte.
Her er det mye info å drille ned i.
Memory
Ved dårlig ytelse er det ikke uvanlig at appen din håndterer mye data. Og da kan det være nyttig å se på minnebruk. Her er memory tabben veldig nyttig. Allocation sampling, kan vise kode som holder på store datastrukturer.
I vår applikasjon fant vi sub-optimal kode som lagde mye større datastrukturer enn nødvendig.
Denne koden klarte altså å allokere ~99 MB på heapen. Dette var ganske komplisert kode, men følgende eksempel illustrerer problemet:
markers.forEach(marker => {
const group = groups.get(marker.key) || { ids: [] }
groups.set(marker.key, {
...group,
ids: group.ids.concat(marker.id),
onClick: () => marker.onClick(group.ids.concat(marker.id))
})
})
Ved å skrive om til dette, så ble minnebruk redusert til under 1 MB.
for (const marker of markers) {
const group = groups.get(marker.key) || { ids: [] }
const ids = group.ids.concat(marker.id)
const fn = marker.onClick || group._fn
groups.set(marker.key, {
...group,
ids,
_fn: fn,
onClick: fn ? () => fn(ids) : undefined
})
}
Så hva skjer her? Datamengdene som går gjennom denne funksjonen er ganske store. Og da onClick binder både group og marker, så beholdes i praksis hele datastrukturen i minne. I den fiksede versjonen holder onClick kun på ids.
¶Hvordan innføre endringer
Etter en del analyse har du funnet ut hvor du har størst problemer. Og du har gjerne noen teorier om hvordan fikse dem. Eneste måten med trygghet vite at noe er bedre er å teste før og etter endring opp mot hverandre.
Jeg pleier å ha to apper oppe samtidig, en med og en uten endring. Da kan jeg profilere omtrent samtidig og sammenligne. Her kan det bli mye “prøv å feil”, men med et godt oppsett kan dette være ganske moro.
¶Jeg har gjort “alt”, men applikasjonen min er fremdeles treg
Enkelte ganger så lager vi overambisiøse applikasjoner. Vi prøver å stappe for mye inn på en side. Dersom du ikke faktisk klarer å optimalisere det godt nok er det på tide å spørre seg selv om du faktisk lager riktig løsning.
Dette er selvsagt et større UX spørsmål, men av og til kan det være lurt å bryte ned problemer også i frontend, ikke la appliasjonen gjøre for mye, eller gape over for mye data. I vårt system har vi tidvis måtte gjøre denne type avgjørelser, da brukernes maskiner f.eks. ikke klarer å håndtere antall DOM noder som vi ønsker å vise og endre samtidig.
¶React Compiler
React Compiler er laget for å automatisk putte inn useMemo, memo og useCallback, dette basert på at det “beviselig kan innføres”. Denne er nå releaset i versjon 1. Dette kan forenkle koden din veldig, da du i stor grad slipper å tenke på disse tingene.
For å kunne bruker React Compiler må du følge absolutt alle Rules of React. Du må også være på versjon 17, 18, eller 19. Lokalt må du slå på strict mode, som du nok uansett burde gjøre.
For større legacy apper, så kan det være krevende å følge alle reglene. Og ikke alle biblioteker følger dem. Konsekvensen kan være at Compiler optimaliserer feil. Appen din kan slutte å fungere runtime, f.eks. på grunn av overoptimistisk memoisering. Dette har jeg selv opplevd, selv om jeg mente vi fulgte “Rules of React”. Dette var riktignok i RC1, så vi får håpe versjon 1 har løst det meste. React teamet anbefaler likevel forsiktighet når det skal innføres i legacy apper. F.eks. bør en gjøre Incremental adoption.
Det å bruker Compiler er etter alt å dømme ikke trivielt, og jeg vil påstå den innfører en del magi i applikasjonen din. Planen er at vi innfører det i appliasjonen vår snart. Det å forstå useMemo, memo og useCallback vil uansett være nyttig kunnskap å ha, for å forstå hva Compiler gjør, eventuelt hva den gjør feil.
¶Oppsummering
Det er mange ting en må tenke på når en skal lage en bra frontend applikasjon. Men, når en først skal fikse ytelse, er et lite knippe teknikker som trengs for å komme i gang. Dette baserer seg på useMemo, memo og useCallback. I tillegg er det viktig å beherske dev tools best mulig. Jeg vil også hevde det er uvurderlig å kunne jobbe direkte mot et prodlikt miljø, med realistisk trøkk.
I tillegg til alt jeg har skreve om her, bør du forstå keys, og det kan hende du vil bruke useTransition eller useDeferredValue. Det finnes også et utall open source biblioteker som kan hjelpe appen din på forskjellige måter.
Avslutningsvis, ikke tenk for mye på ytelse. Om du ikke har spesielle behov, skriv normalt god kode. Vent med optimalisering til du har behov for det.
