Kodėl Apollo Client tapo tokiu populiariu
Kai prieš kelerius metus pirmą kartą susidūriau su GraphQL, atrodė, kad tai tik dar vienas hype’as. Bet greitai supratau, kad tai tikrai keičia žaidimo taisykles, ypač kai reikia valdyti sudėtingas duomenų užklausas front-end’e. O Apollo Client? Tai tarsi šveicariškas peilis GraphQL pasaulyje – turi viską, ko reikia, ir dar šiek tiek daugiau.
Apollo Client iš esmės yra state management biblioteka, sukurta specialiai darbui su GraphQL. Skirtingai nuo Redux ar MobX, kur tenka rašyti krūvas boilerplate kodo, Apollo Client supranta GraphQL užklausų specifiką ir automatizuoja daugybę dalykų. Cache’inimas, optimistiniai atnaujinimai, error handling – visa tai veikia iš dėžės.
Kas įdomiausia, Apollo Client nėra susietas su konkrečiu framework’u. Nors dažniausiai matau jį naudojamą su React, jis puikiai veikia ir su Vue, Angular, ar net vanilla JavaScript projektais. Tai suteikia neįtikėtiną lankstumą, ypač jei dirbi su įvairiais projektais.
Pradedame nuo pagrindų: setup ir pirmoji užklausa
Pirmiausia reikia įsidiegti reikiamus package’us. Paprastai tai atrodo maždaug taip:
npm install @apollo/client graphql
Dabar reikia sukonfigūruoti Apollo Client instanciją. Štai kaip aš paprastai tai darau savo projektuose:
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://jūsų-graphql-endpoint.com/graphql',
cache: new InMemoryCache()
});
Tas InMemoryCache yra tikra magija. Jis automatiškai cache’ina visas užklausas ir atnaujina UI, kai duomenys pasikeičia. Nereikia galvoti apie cache invalidation ar kitus sudėtingus dalykus – bent jau pradžioje.
React aplikacijoje dar reikia apvynioti savo komponentus su ApolloProvider:
function App() {
return (
<ApolloProvider client={client}>
<YourComponents />
</ApolloProvider>
);
}
Pirmoji užklausa paprastai atrodo labai paprasta. Naudojame useQuery hook’ą:
import { useQuery, gql } from '@apollo/client';
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Kraunasi...</p>;
if (error) return <p>Klaida: {error.message}</p>;
return (
<ul>
{data.users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Matote, kaip paprasta? Trys eilutės destruktūrizacijos, ir jau turite loading state’ą, error handling’ą ir duomenis. Pirmą kartą tai išbandęs, tiesiog negalėjau patikėti, kiek kodo sutaupiau palyginus su tradiciniais fetch ar axios metodais.
Cache’inimo strategijos ir kaip jas panaudoti protingai
Dabar pereikime prie įdomesnių dalykų. Apollo Client cache’inimas yra vienas iš tų aspektų, kuris gali sutaupyti daug laiko, bet taip pat gali sukelti nemažai galvos skausmo, jei nesuprantate, kaip jis veikia.
Pagal nutylėjimą Apollo naudoja cache-first strategiją. Tai reiškia, kad jei duomenys jau yra cache’e, jis net nedarys network request’o. Tai puiku performance’ui, bet kartais norite gauti naujausius duomenis. Štai kur praverčia fetch policies:
const { loading, error, data } = useQuery(GET_USERS, {
fetchPolicy: 'network-only' // Visada eina į serverį
});
Yra keletas fetch policy variantų:
- cache-first – naudoja cache’ą, jei duomenys egzistuoja
- cache-and-network – grąžina cache’ą, bet vis tiek daro užklausą
- network-only – ignoruoja cache’ą, visada eina į serverį
- no-cache – nei naudoja, nei išsaugo cache’e
- cache-only – niekada nedaro network request’o
Aš dažniausiai naudoju cache-and-network kritiniams duomenims, kur noriu rodyti senus duomenis iškart, bet taip pat gauti naujausius fone. Pavyzdžiui, vartotojo profilio informacijai ar dashboard statistikai.
Vienas dalykas, kurį tikrai turite suprasti – tai kaip Apollo identifikuoja objektus cache’e. Pagal nutylėjimą jis naudoja __typename ir id laukus. Jei jūsų API naudoja kitą identifikatorių (pvz., _id MongoDB atveju), turite tai nurodyti:
const cache = new InMemoryCache({
typePolicies: {
User: {
keyFields: ['_id']
}
}
});
Mutations ir optimistinis UI atnaujinimas
Skaityti duomenis yra paprasta, bet kaip juos keisti? Čia ateina mutations. useMutation hook’as veikia panašiai kaip useQuery, tik šiek tiek kitaip:
import { useMutation, gql } from '@apollo/client';
const ADD_USER = gql`
mutation AddUser($name: String!, $email: String!) {
addUser(name: $name, email: $email) {
id
name
email
}
}
`;
function AddUserForm() {
const [addUser, { loading, error }] = useMutation(ADD_USER);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await addUser({
variables: {
name: 'Jonas',
email: '[email protected]'
}
});
} catch (err) {
console.error('Klaida:', err);
}
};
return <form onSubmit={handleSubmit}>...</form>;
}
Bet štai kur prasideda tikroji magija – optimistinis UI. Vietoj to, kad lauktumėte serverio atsakymo, galite iškart atnaujinti UI, tarsi operacija jau pavyko. Jei kažkas nepavyksta, Apollo automatiškai grąžina seną būseną:
const [addUser] = useMutation(ADD_USER, {
optimisticResponse: {
addUser: {
__typename: 'User',
id: 'temp-id',
name: 'Jonas',
email: '[email protected]'
}
},
update: (cache, { data: { addUser } }) => {
const existingUsers = cache.readQuery({ query: GET_USERS });
cache.writeQuery({
query: GET_USERS,
data: {
users: [...existingUsers.users, addUser]
}
});
}
});
Pirmą kartą tai įgyvendinus, jautiesi kaip burtininkas. Vartotojas spaudžia mygtuką, ir UI atsinaujina akimirksniu. Jokio loading spinner’io, jokio laukimo. Tai ypač svarbu mobiliose aplikacijose ar lėtame internete.
Vienas patarimas iš patirties – būkite atsargūs su optimistiniu UI sudėtingose formose. Jei operacija gali nepavykti dėl validacijos klaidų, geriau rodykite loading būseną. Nieko blogesnio nei vartotojas mato, kad kažkas pridėta, o po sekundės viskas išnyksta su klaidos pranešimu.
Fragmentai ir kodo perpanaudojimas
Kai projektas auga, pastebite, kad rašote tuos pačius laukus vėl ir vėl. Štai čia praverčia GraphQL fragmentai. Tai tarsi komponentai jūsų užklausoms:
const USER_FRAGMENT = gql`
fragment UserDetails on User {
id
name
email
avatar
createdAt
}
`;
const GET_USER = gql`
${USER_FRAGMENT}
query GetUser($id: ID!) {
user(id: $id) {
...UserDetails
posts {
id
title
}
}
}
`;
const GET_USERS = gql`
${USER_FRAGMENT}
query GetUsers {
users {
...UserDetails
}
}
`;
Tai ne tik sutaupo rašymo laiko, bet ir padeda išlaikyti konsistenciją. Jei reikia pridėti naują lauką, darote tai vienoje vietoje, ir viskas automatiškai atsinaujina.
Aš paprastai laikau fragmentus atskiruose failuose pagal tipus. Pavyzdžiui, userFragments.js, postFragments.js ir t.t. Tai padeda organizuoti kodą ir lengviau jį rasti.
Pagination ir infinite scroll
Vienas iš dažniausių scenarijų – turite daug duomenų ir reikia juos rodyti dalimis. Apollo Client turi puikų palaikymą pagination’ui, bet reikia žinoti, kaip jį naudoti.
Paprasčiausias būdas – offset pagination:
const GET_POSTS = gql`
query GetPosts($offset: Int!, $limit: Int!) {
posts(offset: $offset, limit: $limit) {
id
title
content
}
}
`;
function PostList() {
const { loading, data, fetchMore } = useQuery(GET_POSTS, {
variables: { offset: 0, limit: 10 }
});
const loadMore = () => {
fetchMore({
variables: {
offset: data.posts.length
}
});
};
return (
<div>
{data?.posts.map(post => <PostItem key={post.id} post={post} />)}
<button onClick={loadMore}>Krauti daugiau</button>
</div>
);
}
Bet offset pagination turi problemų su performance’u dideliuose duomenų kiekiuose. Geriau naudoti cursor-based pagination:
const GET_POSTS = gql`
query GetPosts($after: String, $limit: Int!) {
posts(after: $after, limit: $limit) {
edges {
node {
id
title
content
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
Apollo Client turi specialią fetchMore funkciją, kuri automatiškai sujungia naujus duomenis su esamais. Bet turite nurodyti, kaip tai daryti:
const { loading, data, fetchMore } = useQuery(GET_POSTS, {
variables: { limit: 10 }
});
const loadMore = () => {
fetchMore({
variables: {
after: data.posts.pageInfo.endCursor
},
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
posts: {
...fetchMoreResult.posts,
edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges]
}
};
}
});
};
Infinite scroll implementuoti dar paprasčiau su IntersectionObserver:
const observerRef = useRef();
const lastPostRef = useCallback(node => {
if (loading) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && data.posts.pageInfo.hasNextPage) {
loadMore();
}
});
if (node) observerRef.current.observe(node);
}, [loading, data]);
Error handling ir retry logika
Klaidos vyksta. Serveriai krenta, internetas dingsta, API keičiasi. Svarbu, kaip su tuo tvarkotės. Apollo Client turi keletą įrankių error handling’ui.
Paprasčiausias būdas – tiesiog patikrinti error objektą:
const { loading, error, data } = useQuery(GET_USERS);
if (error) {
if (error.networkError) {
return <p>Tinklo klaida. Patikrinkite interneto ryšį.</p>;
}
if (error.graphQLErrors) {
return <p>Serverio klaida: {error.graphQLErrors[0].message}</p>;
}
}
Bet tai greitai tampa kartojančiu kodu. Geriau naudoti Apollo Link’us globaliam error handling’ui:
import { onError } from '@apollo/client/link/error';
import { ApolloLink } from '@apollo/client';
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) => {
console.error(`GraphQL klaida: ${message}`);
// Čia galite siųsti į logging servisą
});
}
if (networkError) {
console.error(`Tinklo klaida: ${networkError}`);
// Galbūt rodyti toast notification?
}
});
const client = new ApolloClient({
link: ApolloLink.from([errorLink, httpLink]),
cache: new InMemoryCache()
});
Retry logika taip pat svarbi. Kartais užklausa nepavyksta dėl laikino tinklo sutrikimo. Apollo Client gali automatiškai bandyti iš naujo:
import { RetryLink } from '@apollo/client/link/retry';
const retryLink = new RetryLink({
delay: {
initial: 300,
max: Infinity,
jitter: true
},
attempts: {
max: 5,
retryIf: (error, _operation) => !!error
}
});
Vienas dalykas, kurį išmokau sunkiu būdu – būkite atsargūs su retry logika mutations’uose. Jei vartotojas spaudžia „Ištrinti” mygtuką, o užklausa nepavyksta ir automatiškai bandoma iš naujo 5 kartus, gali atsitikti keisti dalykai. Geriau retry naudoti tik read operacijoms arba idempotent mutations’ams.
Realaus laiko duomenys su subscriptions
Vienas iš galingiausių Apollo Client feature’ų – subscriptions. Tai leidžia gauti realaus laiko atnaujinimus iš serverio naudojant WebSockets.
Pirmiausia reikia sukonfigūruoti WebSocket link’ą:
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
const httpLink = new HttpLink({
uri: 'https://jūsų-api.com/graphql'
});
const wsLink = new GraphQLWsLink(createClient({
url: 'ws://jūsų-api.com/graphql'
}));
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
httpLink
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
});
Dabar galite naudoti subscriptions:
import { useSubscription, gql } from '@apollo/client';
const MESSAGE_SUBSCRIPTION = gql`
subscription OnMessageAdded {
messageAdded {
id
content
user {
name
}
createdAt
}
}
`;
function ChatMessages() {
const { data, loading } = useSubscription(MESSAGE_SUBSCRIPTION);
if (loading) return <p>Jungiamasi...</p>;
return (
<div>
{data && <Message message={data.messageAdded} />}
</div>
);
}
Dažnai norite kombinuoti query su subscription, kad gautumėte pradinius duomenis ir tada klausytumėte atnaujinimų:
function ChatRoom() {
const { data: initialData } = useQuery(GET_MESSAGES);
useSubscription(MESSAGE_SUBSCRIPTION, {
onSubscriptionData: ({ client, subscriptionData }) => {
const newMessage = subscriptionData.data.messageAdded;
client.cache.modify({
fields: {
messages(existingMessages = []) {
const newMessageRef = client.cache.writeFragment({
data: newMessage,
fragment: gql`
fragment NewMessage on Message {
id
content
user {
name
}
createdAt
}
`
});
return [...existingMessages, newMessageRef];
}
}
});
}
});
return <MessageList messages={initialData?.messages} />;
}
Subscriptions puikiai tinka chat aplikacijoms, realaus laiko dashboard’ams, notification sistemoms. Bet atminkite – WebSocket connections naudoja daugiau resursų nei paprastos HTTP užklausos. Nenaudokite jų ten, kur pakanka paprastos polling strategijos.
Kas toliau: advanced patternai ir geriausia praktika
Kai jau įvaldėte pagrindus, laikas pažvelgti į keletą advanced patternų, kurie gali iškelti jūsų Apollo Client naudojimą į kitą lygį.
**Local state management** – Apollo Client gali valdyti ne tik serverio duomenis, bet ir lokalią būseną. Tai leidžia turėti vieną šaltinį tiesai visai aplikacijai:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isLoggedIn: {
read() {
return localStorage.getItem('token') !== null;
}
}
}
}
}
});
// Naudojimas komponente
const IS_LOGGED_IN = gql`
query IsUserLoggedIn {
isLoggedIn @client
}
`;
**Batch užklausos** – jei turite daug mažų užklausų, galite jas sugrupuoti į vieną network request’ą:
import { BatchHttpLink } from '@apollo/client/link/batch-http';
const batchLink = new BatchHttpLink({
uri: 'https://jūsų-api.com/graphql',
batchMax: 10, // Maksimalus užklausų skaičius batch'e
batchInterval: 20 // Laukimo laikas ms
});
**Persisted queries** – siųskite tik query hash’ą vietoj viso query string’o. Tai sumažina payload dydį:
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const persistedLink = createPersistedQueryLink({ sha256 });
**Testing** – Apollo Client turi puikų testing palaikymą su MockedProvider:
import { MockedProvider } from '@apollo/client/testing';
const mocks = [
{
request: {
query: GET_USERS,
},
result: {
data: {
users: [{ id: '1', name: 'Jonas' }]
}
}
}
];
test('renders users', async () => {
render(
<MockedProvider mocks={mocks}>
<UserList />
</MockedProvider>
);
// Jūsų test'ai čia
});
Keletas patarimų iš realių projektų:
1. Organizuokite queries ir mutations – laikykite juos atskiruose failuose pagal feature’us, ne komponentus. Tai leidžia lengviau perpanaudoti ir testuoti.
2. Naudokite TypeScript – Apollo Client turi puikų TypeScript palaikymą. Su codegen galite automatiškai generuoti tipus iš jūsų schema.
3. Monitorinkite cache dydį – dideliuose projektuose cache gali išaugti. Naudokite cache.gc() periodiškai išvalyti nenaudojamus duomenis.
4. Būkite atsargūs su nested queries – lengva sukurti N+1 problemą. Geriau naudokite DataLoader backend’e.
5. Devtools yra jūsų draugas – Apollo Client DevTools extension Chrome’ui leidžia matyti visas užklausas, cache būseną, ir net modifikuoti duomenis development metu.
Dirbu su Apollo Client jau kelerius metus įvairiuose projektuose – nuo mažų startup’ų iki didelių enterprise aplikacijų. Tai tikrai subrendusi ir patikima biblioteka. Taip, yra learning curve, ypač kai pradedi gilinties į cache valdymą ir advanced feature’us. Bet kai įvaldai pagrindus, produktyvumas išauga eksponenciškai.
Svarbiausias patarimas – pradėkite paprastai. Nenaudokite visų feature’ų iš karto. Pradėkite nuo paprastų queries ir mutations, paskui pridėkite cache strategijas, optimistinį UI, subscriptions. Leiskite savo komandai įprasti prie naujų patternų palaipsniui. GraphQL ir Apollo Client keičia mąstymą apie duomenų valdymą front-end’e, ir tam reikia laiko.
Bendruomenė aplink Apollo Client yra aktyvi ir draugiška. Dokumentacija nuolat tobulėja, yra daug pavyzdžių ir tutorial’ų. Jei užstrigsite, greičiausiai kažkas jau yra susidūręs su panašia problema ir pasidalinęs sprendimu GitHub issues ar Stack Overflow.

