Khởi tạo dự án với Apollo và NextJS SSR

Gần đây mình có làm việc với 1 dự án sử dụng NextJS và Apollo, mọi việc đang rất yên bình cho đến một ngày khách hàng yêu cầu chuyển đổi từ CSR sang SSR. Kiếp nạn này mình đã trải qua rồi nên cũng tự tin chút cho đến khi nhận ra Apollo (GraphQL) khác với REST API mình đã từng làm.

Nghe có vẻ ổn phết nhưng đội ngũ NextJS nói không. getDataFromTree được đánh giá là anti-pattern đối với NextJS vì getDataFromTree không được tích hợp vào các phương thức mà NextJS đã cung cấp cho việc fetch dữ liệu như getStaticPropsgetServerSideProps. Việc getDataFromTree duyệt page tree một lần nữa để tìm kiếm các useQuery cũng ảnh hưởng đến hiệu suất render trang. Và cuối cùng là mình gặp lỗi tích hợp getDataFromTree vào version của NextJS hiện tại của dự án : D.

Thật may thì cuối cùng NextJS cũng đưa ra một phương án khác đối với Apollo vẫn tận dụng được các phương thức SSR của NextJS đó là tận dụng sức mạnh của Apollo cache.

Apollo cache

Apollo Client sử dụng một hệ thống cache để tối ưu hóa việc quản lý và truy cập dữ liệu trong ứng dụng của bạn. Cache trong Apollo Client là nơi lưu trữ tạm thời kết quả của các truy vấn GraphQL, cho phép ứng dụng của bạn tái sử dụng dữ liệu này mà không cần phải gửi lại truy vấn đến server. Nó giúp giảm thiểu số lượng yêu cầu mạng, giảm độ trễ và cải thiện hiệu suất ứng dụng.

Ý tưởng của mình sẽ là fetch data thông qua getServerSideProps. Sau khi đã fetch tất cả các dữ liệu FE server sẽ gen ra một file html đầy đủ và trả về thêm dữ liệu từ Apollo thông qua props cho client. Việc trả về thêm Apollo data giúp chúng ta tận dụng được các thuộc tính data, loading và các phương thức như refetch của Apollo client và đồng bộ dữ liệu giữa client và FE server lúc khởi tạo trang thông qua Apollo cache

Bắt đầu thôi

  • Khởi đầu với tạo dự án NextJS
npx create-next-app
  • Cài các package liên quan đến Apollo client. Ở đây mình cài thêm graphql-codegen để tự động gen các hook React từ các document và schema
npm i graphql @apollo/client @graphql-codegen/typescript-react-apollo @graphql-codegen/cli
  • Cài vài package dùng để xử lí logic và tạo proxy ẩn danh
npm i http-proxy deepmerge lodash.isequal @types/lodash.isequal @types/http-proxy
  • Tạo file .env để chứ API_URL
NEXT_PUBLIC_API_URL=https://beta.pokeapi.co/graphql/v1beta
  • Tạo file index.ts trong thư mục src/api để tạo 1 http proxy
import httpProxy from 'http-proxy';
import type { NextApiRequest, NextApiResponse } from 'next';

// You can export a config variable from any API route in Next.js.
// We'll use this to disable the bodyParser, otherwise Next.js
// would read and parse the entire request body before we
// can forward the request to the API. By skipping the bodyParser,
// we can just stream all requests through to the actual API.
export const config = {
  api: {
    bodyParser: false,
  },
};

const proxy = httpProxy.createProxyServer();

export default async function handler(req: NextApiRequest, res: NextApiResponse<unknown>) {
  // Return a Promise to let Next.js know when we're done
  // processing the request:
  return new Promise(async (resolve, reject) => {
    // Rewrite the URL: strip out the leading '/api'.
    // For example, '/api/login' would become '/login'.
    // ️You might want to adjust this depending
    // on the base path of your API.
    req.url = req?.url?.replace(/^\/api/, '');

    // // Handle error
    proxy.once('error', () => {
      reject();
    });

    proxy.once('proxyRes', () => {
      resolve(true);
    });

    // Forward the request to the API
    proxy.web(req, res, {
      target: process.env.NEXT_PUBLIC_API_URL,
      autoRewrite: false,
      changeOrigin: true,
      selfHandleResponse: false,
    });
  });
}
  • Config file codegen bằng lệnh npx graphql-code-generator init hoặc tạo file codegen.ts ở ngoài cùng thư mục dự án và copy paste đoạn config dưới này
import type { CodegenConfig } from '@graphql-codegen/cli';
import { loadEnvConfig } from '@next/env';

loadEnvConfig(process.cwd());

const config: CodegenConfig = {
  overwrite: true,
  schema: process.env.NEXT_PUBLIC_API_URL,
  documents: ['./src/graphql/**/*.{gql,graphql}'],
  ignoreNoDocuments: true, // for better experience with the watcher
  generates: {
    'src/__generated__/graphql.tsx': {
      plugins: ['typescript', 'typescript-operations', 'typescript-react-apollo'],
    },
  },
};

export default config;
  • Định nghĩa một đối tượng Apollo client và hàm addApolloState ở file src/lib/apolloClient.ts
import {
  ApolloClient,
  InMemoryCache,
  type NormalizedCacheObject,
} from '@apollo/client';
import merge from 'deepmerge';
import isEqual from 'lodash.isequal';


const COUNTRIES_API = 'https://countries.trevorblades.com';

export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__';

let apolloClient: ApolloClient<NormalizedCacheObject> | null;

function createApolloClient() {   
  return new ApolloClient({
    ssrMode: typeof window === 'undefined', 
    uri: typeof window === 'undefined' ? `${process.env.NEXT_PUBLIC_API_URL}` : '/api',
    cache: new InMemoryCache(),
  });
}

export function initApolloClient(initialState?: any) {
  const _apolloClient = apolloClient ?? createApolloClient();

  if (initialState) {
    const existingCache = _apolloClient.cache.extract();

    const data = merge(initialState, existingCache, {
      arrayMerge: (destinationArray, sourceArray) => [
        ...sourceArray,
        ...destinationArray.filter((d) =>
          sourceArray.every((s) => !isEqual(d, s))
        ),
      ],
    });
    _apolloClient.cache.restore(data);
  }

  if (typeof window === 'undefined') {
    return _apolloClient;
  }

  if (!apolloClient) {
    apolloClient = _apolloClient;
  }

  return _apolloClient;
}

export function addApolloState(
  client: ApolloClient<NormalizedCacheObject>,
  pageProps: any
) {
  if (pageProps?.props) {
    pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
  }

  return pageProps;
}

Hàm createApolloClient trả về một đối tượng Apollo client: tham số uri định nghĩa API_URL. Khi ở server side call API sẽ gọi trực tiếp đến API_URL của BE, còn phía client side sẽ gọi thông qua proxy mình đã tạo ở trên để tăng tính bảo mật cho API_URL

Hàm initApolloClient đảm bảo trong suốt phiên làm việc của web chỉ có duy nhất 1 đối tượng Apollo client được khởi tạo

Hàm addApolloState sẽ đẩy data đã được fetch từ server side vào 1 prop có tên là __APOLLO_STATE__ sau đó sẽ được gửi đến client side thông qua pageProps là giá trị mặc định trả về của hàm getServerSideProps

  • Tiếp theo ta sẽ tạo một hook useApollo ở file src/hooks/useApollo.ts. Hook này sẽ theo dõi sự thay đổi của prop __APOLLO_STATE__ và sẽ quyết định tạo mới một đối tượng Apollo client nếu chưa có hoặc ngược lại sẽ trả về đối tượng Apollo client đã tồn tại
import { APOLLO_STATE_PROP_NAME, initApolloClient } from '@/lib/apolloClient';
import { useMemo } from 'react';

function useApollo(pageProps: any) {
  const state = pageProps[APOLLO_STATE_PROP_NAME];
  const client = useMemo(() => initApolloClient(state), [state]);

  return client;
}

export default useApollo;

Đến đây mọi thứ đã gần như hoàn thiện rồi việc cuối cùng là tạo một query ví dụ và kiểm tra xem SSR có hoạt động đúng như mong đợi của chúng ta hay không thôi

Thử nghiệm

  • Tạo một query document ở file src/graphql/queries/getPokemons.query.gql. Ở đây mình muốn lấy danh sách tất cả tên các Pokemon nên là câu query sẽ dạng như thế này:
query getPokemons {
  pokemon_v2_pokemonspecies {
    id
    name
  }
}
  • Chạy lệnh npm run codegen để graphql-codegen đọc file schema và các file document để tạo ra các đối tượng document và hook và NextJS có thể đọc được nhé
  • File src/pages/index.tsx mình sửa lại để call hook useGetPokemonsQuery và render data nhé.
import { GetPokemonsDocument, useGetPokemonsQuery } from '@/__generated__/graphql'
import { addApolloState, initApolloClient } from '@/lib/apolloClient'

export default function Home() {
  const { data } = useGetPokemonsQuery()

  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24`}
    >
      <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm flex flex-col">
        {data?.pokemon_v2_pokemonspecies.map((pokemon) => (
          <div key={pokemon.id}>{pokemon.name}</div>
        ))}
      </div>
    </main>
  )
}

Code thì đã chạy ổn đã fetch được data từ Graphql nhưng vẫn chưa đúng với mục tiêu của blog đưa ra là trang này phải chạy ở mode SSR. Vậy thì thêm tiếp dòng code dưới ở src/pages/index.tsx nhé.

export const getServerSideProps = async () => {
  const client = initApolloClient();
  await client.query({ query: GetPokemonsDocument});

  return addApolloState(client, {
    props: {},
  });
};

Đây là kết quả cuối cùng. Ở tab network ta sẽ không còn thấy request /api với payload getPokemons nữa và khi ta bật tab view page source nên sẽ thấy file html gen đầy đủ các thẻ div chứa các tên pokemon

Khi mọi người đọc code sẽ thấy query được gọi 2 lần.

1 lần ở getServerSideProps: await client.query({ query: GetPokemonsDocument});
Và 1 lần nữa ở function component: const { data } = useGetPokemonsQuery(). Sẽ tự hỏi liệu việc gọi lại này có phải sẽ tạo 2 request đến BE không thì câu trả lời là không nhé. Vì gọi lại này nhằm mục đích tận dụng các thuộc tính data, các phương thức refresh do Apollo cung cấp, dễ quản lý dữ liệu ở function component hơn và data lấy từ function component là data được trả về thông qua pageProps mình đã giải thích ở trên nên không ảnh hưởng đến hiệu suất của trang web.

Mn có thể tham khảo code đầy đủ hơn ở: https://github.com/long-la-tomosia/nextjs-ssr-apollo/tree/main

Bài viết dựa trên việc nghiên cứu của 1 cá nhân nên có thể sẽ có 1 vài sai sót mong mọi người góp ý và chia sẻ để hoàn thiện hơn. Cảm ơn mọi người đã theo dõi bài viết : D

0 Shares:
Leave a Reply

Your email address will not be published. Required fields are marked *

You May Also Like