Chapter 10

全栈实战:Next.js + GraphQL

综合运用所学知识,构建类型安全的全栈 GraphQL 应用

项目结构

my-app/
├── app/
│   ├── api/graphql/route.ts   # Next.js API Route (Apollo Server)
│   ├── posts/page.tsx          # 服务端组件(SSR 查询)
│   └── layout.tsx              # ApolloProvider 包装
├── graphql/
│   ├── schema.graphql          # SDL Schema 定义
│   ├── queries.graphql         # 客户端查询定义
│   └── generated/              # GraphQL Codegen 生成的类型
├── lib/
│   └── apollo-client.ts        # Apollo Client 配置
└── codegen.ts                  # GraphQL Codegen 配置

Next.js API Route 中的 Apollo Server

// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server';
import { startServerAndCreateNextHandler } from '@as-integrations/next';
import { typeDefs, resolvers } from '@/graphql/schema';

const server = new ApolloServer({ typeDefs, resolvers });
const handler = startServerAndCreateNextHandler(server, {
  context: async (req) => ({
    user: await getUserFromRequest(req),
    db,
  }),
});

export { handler as GET, handler as POST };

Apollo Client 配置

// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

const httpLink = new HttpLink({ uri: '/api/graphql' });

const wsLink = new GraphQLWsLink(createClient({
  url: 'ws://localhost:3000/api/graphql',
}));

// Query/Mutation 走 HTTP,Subscription 走 WebSocket
const splitLink = split(
  ({ query }) => {
    const def = getMainDefinition(query);
    return def.kind === 'OperationDefinition' && def.operation === 'subscription';
  },
  wsLink,
  httpLink,
);

export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

GraphQL Codegen 类型生成

// codegen.ts
import { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: './graphql/schema.graphql',
  documents: ['./graphql/**/*.graphql', './app/**/*.tsx'],
  generates: {
    './graphql/generated/index.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-react-apollo',
      ],
      config: {
        withHooks: true,     // 生成 useQuery/useMutation hooks
        withResultType: true,
      },
    },
  },
};
export default config;
# 生成类型和 hooks
npx graphql-codegen --config codegen.ts

# 监视模式(开发时自动重新生成)
npx graphql-codegen --config codegen.ts --watch

客户端使用生成的 Hooks

// graphql/queries.graphql 中定义的查询
// query GetPosts { posts { id title author { name } } }

// 使用 Codegen 生成的类型安全 Hook
import { useGetPostsQuery, useCreatePostMutation } from '@/graphql/generated';

function PostList() {
  const { data, loading, error } = useGetPostsQuery();
  const [createPost, { loading: creating }] = useCreatePostMutation({
    // 乐观更新:立即更新 UI,等服务器确认
    optimisticResponse: ({ input }) => ({
      createPost: {
        __typename: 'Post',
        id: 'temp-id',
        title: input.title,
        author: { name: 'You' },
      },
    }),
    // 成功后更新缓存
    update(cache, { data }) {
      cache.modify({
        fields: {
          posts(existingPosts = []) {
            return [...existingPosts, data?.createPost];
          },
        },
      });
    },
  });

  if (loading) return <Loading />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      {data?.posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}
Codegen 的最大价值

GraphQL Codegen 基于 Schema 和你写的查询自动生成 TypeScript 类型和 React Hooks。当 Schema 变化时,只需重新运行 Codegen,TypeScript 编译器会立即告诉你哪些地方需要更新——这是 GraphQL 类型安全的精髓所在。

Apollo Client 缓存策略

InMemoryCache 规范化缓存
Apollo Client 默认使用规范化缓存:将每个对象按 __typename:id(如 Post:123)存储为独立条目。相同 ID 的对象在多个查询中共享同一缓存条目,Mutation 更新一个对象后,所有显示该对象的组件自动刷新。
缓存更新策略
有三种方式更新缓存:1) 重新查询(refetch)——最简单但有网络请求;2) cache.modify——精确修改某个字段;3) 乐观更新(optimisticResponse)——立即更新 UI,等服务器确认后再替换,提升感知响应速度。
fetchPolicy 控制
cache-first(默认,优先缓存)、network-only(总是请求服务器)、cache-and-network(先返回缓存,再用网络结果更新)、no-cache(不使用也不写入缓存)。根据数据实时性要求选择。

服务端渲染(RSC)与 GraphQL

// app/posts/page.tsx(React Server Component)
// 服务端直接调用 GraphQL,无需 Apollo Client
import { request, gql } from 'graphql-request';

const GET_POSTS = gql`
  query GetPosts {
    posts(first: 10) {
      edges {
        node { id title author { name } }
      }
    }
  }
`;

// Next.js 会在构建时或每次请求时执行此函数
export default async function PostsPage() {
  // 服务端直接 fetch,利用 Next.js 的 fetch 缓存
  const data = await request('http://localhost:3000/api/graphql', GET_POSTS);

  return (
    <main>
      <h1>帖子列表</h1>
      {data.posts.edges.map(({ node }) => (
        <article key={node.id}>
          <h2>{node.title}</h2>
          <p>作者:{node.author.name}</p>
        </article>
      ))}
    </main>
  );
}

// 使用 Next.js 15 的 fetch 缓存控制
async function fetchPosts() {
  const res = await fetch('/api/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: GET_POSTS }),
    // 60 秒内复用缓存结果(ISR)
    next: { revalidate: 60 },
  });
  return res.json();
}

错误处理:GraphQL vs HTTP 错误

// GraphQL 响应结构:即使有错误,HTTP 状态码仍然是 200
// {
//   "data": { "post": null },
//   "errors": [{ "message": "Post not found", "extensions": { "code": "NOT_FOUND" } }]
// }

// 客户端统一错误处理
import { onError } from '@apollo/client/link/error';

const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors) {
    for (const { message, extensions } of graphQLErrors) {
      switch (extensions?.code) {
        case 'UNAUTHENTICATED':
          // Token 过期:刷新 Token 并重试
          return fromPromise(refreshToken()).flatMap(() => forward(operation));
        case 'FORBIDDEN':
          // 权限不足:显示提示并跳转
          router.push('/unauthorized');
          break;
        default:
          console.error(`GraphQL error: ${message}`);
      }
    }
  }
  if (networkError) {
    console.error(`Network error: ${networkError}`);
  }
});

const client = new ApolloClient({
  // 错误处理链接放在最前面
  link: errorLink.concat(splitLink),
  cache: new InMemoryCache(),
});

本章小结

本章核心要点