Next.js 中整合 Apollo Client 与 Redux:with-apollo-and-redux 示例深度解析
本文围绕 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-apollo 与 with-redux 两个示例的合体,目的是证明两种状态管理方案可以在 Next.js 中平滑共存、互不干扰。
对应到代码,示例中的应用组件由 pages、lib、components 三部分构成,结构清晰:
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 dev,npm 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 实现的简单帖子服务,提供 allPosts、createPost、votePost 等查询与变更。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>
);
}
实现要点有两个:
- 两套 context 独立嵌套:React-Redux 的
<Provider>负责下发 store,@apollo/client的<ApolloProvider>负责下发client。App Router 下所有组件即可同时使用useSelector/useDispatch与useQuery/useMutation,互不遮蔽。 - 页面级注水数据:
pageProps.initialReduxState与pageProps.initialApolloState由页面在getStaticProps/getServerSideProps阶段生成(详见第五节),App 拿到后分别交给useStore与useApollo,完成服务端状态向客户端的“水合”。
四、双 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。自定义的 arrayMerge 用 isEqual 做对象级去重,语义上类似把数组当集合处理,防止分页数据重复。最后同样遵守「服务端每次新建、客户端单例复用」的铁律,并封装 useApollo 用 useMemo 缓存初始化结果。
4.3 定时器 Hook:lib/useInterval.js
lib/useInterval.js 是时钟页面的驱动源,采用 Dan Abramov 的“声明式 setInterval”模式:用 useRef 保存最新回调,useEffect 只在 delay 变化时重建定时器,避免因闭包捕获过期状态而出现计时错误,当 delay 为 null 时自动清理定时器。
五、数据预取与水合: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,即前一节看到的initialReduxState与initialApolloState,在客户端完成水合; 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 中的两个注水字段都是可选的(undefined 时 useStore/useApollo 走默认分支),纯 Apollo 页可以完全不传 initialReduxState,纯 Redux 页也可以不传 initialApolloState,充分体现两套状态层在初始化函数上的“按需装配、松耦合”设计。页面间通过 components/Nav.js 基于 next/link 与 useRouter 导航,并根据当前 pathname 高亮激活链接。
六、组件层:本地状态与 GraphQL 数据的典型用法
6.1 Redux 侧组件:Clock 与 Counter
Clock.js 示范了如何用 React-Redux hooks 订阅本地状态:它用 useSelector 选取 lastUpdate 与 light,并通过 shallowEqual 做浅比较,避免每次 TICK 都因返回新对象而触发无谓重渲染;再把时间戳裁剪为 hh:mm:ss 显示。
Counter.js 则示范 useSelector + useDispatch 的组合:把 count 与 increment/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 节中 InMemoryCache 里 allPosts: 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 负责把 useQuery 的 error 渲染为红色提示条;Layout.js 通过全局 styled-jsx 统一了等宽字体、链接色与按钮样式。
七、同构水合的工程要点与常见陷阱
把上面几节串起来,本示例沉淀出的可复用经验可以总结为四条:
-
服务端每次新建实例、客户端单例复用:Redux 的
store、Apollo 的client若在模块顶层缓存并被服务端共享,会引发跨请求状态串扰;两个lib文件统一用typeof window === "undefined"判断分支处理,是 Next.js 数据获取函数中创建全局状态的标准姿势(由 lib/redux.js 与 lib/apollo.js 可以互相印证同一模式)。 -
服务端状态必须“导出再导入”:Redux 用
store.getState()、Apollo 用cache.extract()序列化,统一经pageProps交给_app.js,再由useStore/useApollo在客户端restore/merge,完成双轨水合。Apollo 侧因为存在“服务端快照 vs 客户端已有缓存”两个来源,需要deepmerge+ 去重数组的合并策略。 -
选择状态归属是关键设计决策:凡是 GraphQL 返回的数据,如帖子、票数,一律留在 Apollo 缓存并由其规范化;凡是设备/交互本地状态,如时钟、计数、开关,则留在 Redux。二者通过 Provider 树并存但职责边界清晰,这是迁移期“不全量接管”的务实中间态。
-
开启 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.js、lib/redux.js、pages/_app.js)就是一份可以直接照搬、裁剪并落地的参考实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00