首页
/ Next.js 中整合 Apollo Client 与 Redux:with-apollo-and-redux 示例深度解析

Next.js 中整合 Apollo Client 与 Redux:with-apollo-and-redux 示例深度解析

2026-09-06 18:13:09作者:余洋婵Anita

本文围绕 Next.js 官方仓库中的 with-apollo-and-redux 示例展开,讲解在同一个 Next.js 应用中同时使用 Apollo Client 3.x(管理 GraphQL 服务端数据)与 Redux(管理本地 UI 状态)的完整工程实践,涵盖双 Store 的初始化、SSR/SSG 数据预取与注水、缓存合并、分页与乐观更新等核心环节。读完本文,你将掌握如何把 GraphQL 数据层与本地状态层解耦并存,并能够独立搭建一套可在生产环境扩展的同构双状态管理应用。

一、示例定位:为什么要同时保留 Redux 与 Apollo

这个示例最初源自一个非常具体的迁移场景:正在使用 Apollo 1.X + Redux 的项目,向 Apollo 3.x 迁移,但开发者并不希望把整个应用状态都收进 Apollo 的 apollo-link-state。也就是说,应用里同时存在两种性质截然不同的状态:

  • 服务端数据:来自 GraphQL API 的帖子列表、投票数等,交给 Apollo Client 的 InMemoryCache 统一管理;
  • 本地 UI 状态:计数器、时钟刷新时间、主题灯色这类纯客户端状态,继续留在 Redux store 中。

从 Apollo 3.0.0 起,Apollo 官方开箱即用地支持了用自身状态管理能力替代 Redux,但“能替代”不等于“必须替代”。本示例就是一个融合方案,可视为官方 with-apollowith-redux 两个示例的合体,目的是证明两种状态管理方案可以在 Next.js 中平滑共存、互不干扰。

对应到代码,示例中的应用组件由 pageslibcomponents 三部分构成,结构清晰:

examples/with-apollo-and-redux/
├── components/        # Clock / Counter / Nav / Submit / PostList 等 UI 组件
├── lib/
│   ├── apollo.js      # Apollo Client 的创建与初始化(含 SSR 处理)
│   ├── redux.js       # Redux store 的创建与初始化(含 SSR 处理)
│   └── useInterval.js # 复用 Dan Abramov 方案的定时器 Hook
├── pages/
│   ├── _app.js        # 顶层同时注入 Redux Provider 与 ApolloProvider
│   ├── index.js       # 混合页:Redux 组件 + Apollo 组件同屏
│   ├── apollo.js      # 纯 Apollo 示例页
│   └── redux.js       # 纯 Redux 示例页
└── package.json

二、环境准备与快速启动

package.json 中,示例声明的关键依赖体现了“双轨并存”的选型:

依赖 版本 职责
next latest React 框架本体
react / react-dom ^18.2.0 视图层
@apollo/client ^3.0.0 GraphQL 客户端(含缓存、useQuery/useMutation
graphql 16.13.1 GraphQL 语法解析
redux ^4.0.1 本地状态容器
react-redux ^7.1.1 Redux 与 React 的桥接(Provider、hooks)
redux-devtools-extension 2.13.8 Redux DevTools 中间件
deepmerge / lodash ^4.x 服务端缓存与客户端缓存合并的工具库

可用脚本与官方示例保持一致:next 对应 npm run devnpm run build 执行生产构建,npm run start 启动生产服务。

2.1 通过 create-next-app 一键拉取

示例 README 推荐使用 create-next-app 配合 --example 参数直接引导出一个可运行项目,三种包管理器命令等价:

npx create-next-app --example with-apollo-and-redux with-apollo-and-redux-app
yarn create next-app --example with-apollo-and-redux with-apollo-and-redux-app
pnpm create next-app --example with-apollo-and-redux with-apollo-and-redux-app

上述命令会以本仓库 examples/with-apollo-and-redux 为模板,在本地生成名为 with-apollo-and-redux-app 的项目,之后进入目录执行 npm install && npm run dev 即可访问。

2.2 示例依赖的演示 API

为了便于直接体验,示例中两个 Apollo Client 与查询都指向同一个远端 GraphQL API(位于 https://nextjs-graphql-with-prisma-simple-foo.vercel.app/api)。这是一个基于 Prisma 实现的简单帖子服务,提供 allPostscreatePostvotePost 等查询与变更。README 中提示可以借助该 API 的 web IDE 直观查看与调试 GraphQL schema。需要说明的是:该地址是示例内置的演示服务端点,仅供体验代码使用,接入自己项目时应将其替换为真实的绝对地址 GraphQL 端点。

三、顶层整合:_app.js 中注入双 Provider

两类状态能否和谐共处,关键在于顶层入口把两个上下文同时挂在组件树上。查看 pages/_app.js

import { ApolloProvider } from "@apollo/client";
import { Provider } from "react-redux";
import { useStore } from "../lib/redux";
import { useApollo } from "../lib/apollo";

export default function App({ Component, pageProps }) {
  const store = useStore(pageProps.initialReduxState);
  const apolloClient = useApollo(pageProps.initialApolloState);

  return (
    <Provider store={store}>
      <ApolloProvider client={apolloClient}>
        <Component {...pageProps} />
      </ApolloProvider>
    </Provider>
  );
}

实现要点有两个:

  1. 两套 context 独立嵌套:React-Redux 的 <Provider> 负责下发 store,@apollo/client<ApolloProvider> 负责下发 client。App Router 下所有组件即可同时使用 useSelector/useDispatchuseQuery/useMutation,互不遮蔽。
  2. 页面级注水数据pageProps.initialReduxStatepageProps.initialApolloState 由页面在 getStaticProps/getServerSideProps 阶段生成(详见第五节),App 拿到后分别交给 useStoreuseApollo,完成服务端状态向客户端的“水合”。

四、双 Store 的创建与 SSR 安全初始化

4.1 Redux 侧:lib/redux.js

lib/redux.js 是一个精简但完整的 Redux 模块,定义了初始状态、reducer 与安全的 store 工厂函数。它管理的都是纯本地状态:

const initialState = {
  lastUpdate: 0,
  light: false,
  count: 0,
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case "TICK":
      return { ...state, lastUpdate: action.lastUpdate, light: !!action.light };
    case "INCREMENT":
      return { ...state, count: state.count + 1 };
    case "DECREMENT":
      return { ...state, count: state.count - 1 };
    case "RESET":
      return { ...state, count: initialState.count };
    default:
      return state;
  }
};

function initStore(preloadedState = initialState) {
  return createStore(
    reducer,
    preloadedState,
    composeWithDevTools(applyMiddleware()),
  );
}

TICK 由页面里的定时器每秒钟派发一次,驱动时钟与灯色刷新;INCREMENT/DECREMENT/RESET 驱动计数器。这与 Apollo 数据完全无关,构成“本地状态不进入 Apollo 缓存”的示范。

关键的工厂函数 initializeStore 处理了 SSR 场景下的经典陷阱:

export const initializeStore = (preloadedState) => {
  let _store = store ?? initStore(preloadedState);

  // 导航到带初始 Redux 状态的页面后,把该状态与当前 store 合并,再新建 store
  if (preloadedState && store) {
    _store = initStore({ ...store.getState(), ...preloadedState });
    store = undefined; // 重置当前 store
  }

  // SSG/SSR 场景总是新建 store,避免跨请求共享
  if (typeof window === "undefined") return _store;
  // 客户端只创建一次,后续复用单例
  if (!store) store = _store;

  return _store;
};

这里的逻辑分三层:

  • 服务端(typeof window === "undefined":每次数据获取都新建 store,防止多个请求/多次预渲染共享一份可变状态;
  • 客户端:通过模块级变量 store 持有单例,只在第一次时创建;
  • 客户端后续导航:若某个页面带回了新的 preloadedState(例如重新执行了数据获取),则用「当前 state 展开 + 新 preloadedState 展开」的方式合并出一个新 store,并重置模块级单例,避免新旧状态互相污染。

4.2 Apollo 侧:lib/apollo.js

lib/apollo.js 遵循同样的 SSR 安全思路,但在细节上更强调“缓存合并”。

let apolloClient;

function createApolloClient() {
  return new ApolloClient({
    ssrMode: typeof window === "undefined",
    link: new HttpLink({
      uri: "https://nextjs-graphql-with-prisma-simple-foo.vercel.app/api", // Server URL (must be absolute)
      credentials: "same-origin", // Additional fetch() options like `credentials` or `headers`
    }),
    cache: new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            allPosts: concatPagination(),
          },
        },
      },
    }),
  });
}

可展开的要点包括:

  • ssrMode:在服务端渲染时置为 true,禁用对 useQuery 的重复轮询等仅适用于浏览器的行为,让 SSR 阶段可以一次性取出缓存快照;
  • HttpLink.uri 必须是绝对地址:源码注释特别强调这一点,这是 Next.js 前后端同构场景下的常见坑——相对路径在服务端无法解析;
  • typePolicies + concatPagination():为 Query.allPosts 配置分页合并策略,它源自 @apollo/client/utilities,使后续 fetchMore 拉取的新一页数据以“拼接”方式写入缓存,而不是覆盖旧列表(详见第六节)。

initializeApollo 的缓存合并逻辑体现了同构数据水合的核心难点:

export function initializeApollo(initialState = null) {
  const _apolloClient = apolloClient ?? createApolloClient();

  // 若页面在 getStaticProps/getServerSideProps 中已用 Apollo 取过数据,
  // 则在这里把 initial state 注水进缓存
  if (initialState) {
    const existingCache = _apolloClient.extract(); // 客户端已有的缓存快照
    const data = merge(initialState, existingCache, {
      // 数组按对象相等性合并(类似集合语义),避免重复项
      arrayMerge: (destinationArray, sourceArray) => [
        ...sourceArray,
        ...destinationArray.filter((d) =>
          sourceArray.every((s) => !isEqual(d, s)),
        ),
      ],
    });
    _apolloClient.cache.restore(data);
  }

  // SSG/SSR 总是创建新客户端
  if (typeof window === "undefined") return _apolloClient;
  // 客户端只创建一次
  if (!apolloClient) apolloClient = _apolloClient;

  return _apolloClient;
}

这里 deepmerge 负责把 SSR 阶段由 cache.extract() 产出的序列化快照客户端可能已存在的内存缓存 逐层合并:因为有些数据是在客户端导航中实时拉取的(existingCache),而 initialState 是服务端较新的快照,直接 restore 会覆盖新数据,因此先合并再 restore。自定义的 arrayMergeisEqual 做对象级去重,语义上类似把数组当集合处理,防止分页数据重复。最后同样遵守「服务端每次新建、客户端单例复用」的铁律,并封装 useApollouseMemo 缓存初始化结果。

4.3 定时器 Hook:lib/useInterval.js

lib/useInterval.js 是时钟页面的驱动源,采用 Dan Abramov 的“声明式 setInterval”模式:用 useRef 保存最新回调,useEffect 只在 delay 变化时重建定时器,避免因闭包捕获过期状态而出现计时错误,当 delaynull 时自动清理定时器。

五、数据预取与水合:getStaticProps 中的双预取

示例最精彩的部分在页面级数据获取函数:同一个 getStaticProps 里同时驱动 Redux dispatch 与 Apollo query,并把两份结果都塞进 props,配合 revalidate: 1 使用 ISR(增量静态再生成)。以混合首页 pages/index.js 为例:

export async function getStaticProps() {
  const reduxStore = initializeStore();
  const apolloClient = initializeApollo();
  const { dispatch } = reduxStore;

  dispatch({ type: "TICK", light: true, lastUpdate: Date.now() });

  await apolloClient.query({
    query: ALL_POSTS_QUERY,
    variables: allPostsQueryVars,
  });

  return {
    props: {
      initialReduxState: reduxStore.getState(),
      initialApolloState: apolloClient.cache.extract(),
    },
    revalidate: 1,
  };
}

值得注意的细节:

  • 状态提取方式不同:Redux 直接 reduxStore.getState() 取出最新状态对象;Apollo 则调用 apolloClient.cache.extract() 把整个 InMemoryCache 序列化成可传输的普通对象快照;
  • 两份快照通过 props 进入 _app.js,即前一节看到的 initialReduxStateinitialApolloState,在客户端完成水合;
  • revalidate: 1 使页面启用 ISR:首次构建后,页面仍会在后台最多每 1 秒按需重新生成,保证服务端数据不会永久陈旧——这是本示例对比纯 CSR 方案在生产价值上的关键差异。

dispatch 是同步操作,而 query 需要 await;把 TICK 派发放在 query 之前,确保 getState() 捕获到的是已经包含时钟初始值的状态。

5.1 三种页面的分工对照

为了演示“同一套基础设施在不同页面上的裁剪方式”,示例提供了三个页面:

页面 数据获取函数 预取的 store 渲染内容
pages/index.js getStaticProps Redux + Apollo 同时预取 Clock、Counter(Redux)与 Submit、PostList(Apollo)同屏
pages/apollo.js getStaticProps 仅 Apollo 只展示 GraphQL 的提交与列表
pages/redux.js getStaticProps 仅 Redux 只展示时钟与计数器

由于 pageProps 中的两个注水字段都是可选的(undefineduseStore/useApollo 走默认分支),纯 Apollo 页可以完全不传 initialReduxState,纯 Redux 页也可以不传 initialApolloState,充分体现两套状态层在初始化函数上的“按需装配、松耦合”设计。页面间通过 components/Nav.js 基于 next/linkuseRouter 导航,并根据当前 pathname 高亮激活链接。

六、组件层:本地状态与 GraphQL 数据的典型用法

6.1 Redux 侧组件:Clock 与 Counter

Clock.js 示范了如何用 React-Redux hooks 订阅本地状态:它用 useSelector 选取 lastUpdatelight,并通过 shallowEqual 做浅比较,避免每次 TICK 都因返回新对象而触发无谓重渲染;再把时间戳裁剪为 hh:mm:ss 显示。

Counter.js 则示范 useSelector + useDispatch 的组合:把 countincrement/decrement/reset 封装成一个自定义 hook useCounter,UI 层保持纯净——这正是“本地 UI 状态放 Redux”的教科书式写法。

6.2 Apollo 侧组件:PostList 与分页

PostList.js 定义了示例的 GraphQL 文档与分页逻辑:

export const ALL_POSTS_QUERY = gql`
  query allPosts($first: Int!, $skip: Int!) {
    allPosts(orderBy: { createdAt: desc }, first: $first, skip: $skip) {
      id
      title
      votes
      url
      createdAt
    }
    _allPostsMeta {
      count
    }
  }
`;
export const allPostsQueryVars = { skip: 0, first: 10 };

组件中使用 useQuery 并开启 notifyOnNetworkStatusChange: true,以感知 fetchMore 触发的网络状态变化:

const { loading, error, data, fetchMore, networkStatus } = useQuery(ALL_POSTS_QUERY, {
  variables: allPostsQueryVars,
  notifyOnNetworkStatusChange: true,
});

const loadingMorePosts = networkStatus === NetworkStatus.fetchMore;
const loadMorePosts = () => {
  fetchMore({ variables: { skip: allPosts.length } });
};
const areMorePosts = allPosts.length < _allPostsMeta.count;

配合 4.2 节中 InMemoryCacheallPosts: concatPagination() 的 typePolicy,“Show More”按钮每次以当前列表长度作为 skip 抓取下一页,Apollo 会在读取时自动把新旧两页拼成一个连续数组。列表旁的 PostUpvoter.js 示范了 useMutation 的乐观更新:通过 optimisticResponse 在服务端响应返回前就假定 votes + 1,界面即时反馈。

6.3 变更与缓存写入:Submit

Submit.js 演示了“变更之后如何把新实体写入规范化缓存”。它先提交 createPost 变更,然后在 update 回调里通过 cache.modify 改写 Query.allPosts 字段:

createPost({
  variables: { title, url },
  update: (cache, { data: { createPost } }) => {
    cache.modify({
      fields: {
        allPosts(existingPosts = []) {
          const newPostRef = cache.writeFragment({
            data: createPost,
            fragment: gql`
              fragment NewPost on allPosts {
                id
                type
              }
            `,
          });
          return [newPostRef, ...existingPosts];
        },
      },
    });
  },
});

因为 Apollo 缓存是按实体规范化存储的,list 字段只保存引用(reference)数组;所以这里用 cache.writeFragment 为新建的帖子生成缓存引用 newPostRef,再把它插到列表头部。若在 createApolloClient 里未配置 concatPagination,或者提交页与列表页不共享同一缓存,这种局部写回便会失效——这正是整个示例把「创建 client 的配置」与「写回缓存的策略」放在一起的原因。

其他辅助组件如 ErrorMessage.js 负责把 useQueryerror 渲染为红色提示条;Layout.js 通过全局 styled-jsx 统一了等宽字体、链接色与按钮样式。

七、同构水合的工程要点与常见陷阱

把上面几节串起来,本示例沉淀出的可复用经验可以总结为四条:

  1. 服务端每次新建实例、客户端单例复用:Redux 的 store、Apollo 的 client 若在模块顶层缓存并被服务端共享,会引发跨请求状态串扰;两个 lib 文件统一用 typeof window === "undefined" 判断分支处理,是 Next.js 数据获取函数中创建全局状态的标准姿势(由 lib/redux.jslib/apollo.js 可以互相印证同一模式)。

  2. 服务端状态必须“导出再导入”:Redux 用 store.getState()、Apollo 用 cache.extract() 序列化,统一经 pageProps 交给 _app.js,再由 useStore/useApollo 在客户端 restore/merge,完成双轨水合。Apollo 侧因为存在“服务端快照 vs 客户端已有缓存”两个来源,需要 deepmerge + 去重数组的合并策略。

  3. 选择状态归属是关键设计决策:凡是 GraphQL 返回的数据,如帖子、票数,一律留在 Apollo 缓存并由其规范化;凡是设备/交互本地状态,如时钟、计数、开关,则留在 Redux。二者通过 Provider 树并存但职责边界清晰,这是迁移期“不全量接管”的务实中间态。

  4. 开启 ISR 让静态页保鲜:三处 getStaticProps 均设置 revalidate: 1,使预渲染页面能在后台按需再生成,让预取到页面里的 Apollo 数据不会停留在一个过时的快照上。

八、小结

with-apollo-and-redux 的价值不在于“炫技式地塞两个 store”,而在于它完整示范了一套 SSR/SSG 安全的双状态初始化模式:Redux 与 Apollo 各自提供模块级工厂函数并遵守“服务端新建、客户端复用”的同构铁律;页面数据获取阶段同时派发本地 action 与执行 GraphQL 查询,并把两份快照经 pageProps 传入 _app.js 完成水合;组件层则按状态性质各取所需,本地 UI 走 useSelector/useDispatch,服务端数据走 useQuery/useMutation。若你在迁移 Apollo 的过程中仍需保留 Redux,或想在同一个 Next.js 应用里让 GraphQL 缓存与本地状态分工协作,这个示例及其源码(lib/apollo.jslib/redux.jspages/_app.js)就是一份可以直接照搬、裁剪并落地的参考实现。

登录后查看全文
热门项目推荐
相关项目推荐