Skip to main content

使用 Next.js 配置 Redux Toolkit

您将学到的内容
前置知识

介绍

Next.js 是一个流行的 React 服务器端渲染框架,正确使用 Redux 需要克服一些独特挑战。这些挑战包括:

  • 按请求安全地创建 Redux store:Next.js 服务器可以同时处理多个请求。这意味着 Redux store 应该为每个请求单独创建,而不应在请求间共享。
  • 支持 SSR 的 store 水合(hydration):Next.js 应用先在服务器渲染一次,然后在客户端再次渲染。如果客户端和服务器呈现的页面内容不一致,将导致“水合错误”。因此,Redux store 需要在服务器侧初始化,然后在客户端使用相同数据重新初始化以避免水合问题。
  • 支持 SPA 路由:Next.js 支持客户端路由的混合模式。用户首次页面加载会从服务器获得 SSR 结果,后续页面导航由客户端处理。这意味着如果在布局中定义了单例 store,路由特定的数据需要在路由切换时选择性重置,而非路由特定的数据需保留。
  • 支持服务器缓存:Next.js 的新版本(尤其是采用 App Router 架构的应用)支持积极的服务器缓存。理想的 store 架构应兼容此类缓存。

Next.js 应用有两种架构:Pages RouterApp Router

Pages Router 是 Next.js 的原始架构。如果你使用 Pages Router,Redux 配置主要通过 next-redux-wrapper 实现,它将 Redux store 集成到 Pages Router 的数据获取方法(如 getServerSideProps)中。

本指南聚焦于 App Router 架构,因为它是 Next.js 的新默认架构选项。

如何阅读本指南

本页假定你已有基于 App Router 架构的 Next.js 应用。

如果想跟着做,可以用 npx create-next-app my-app 创建一个新的空 Next 项目,默认配置即启用 App Router。然后,添加 @reduxjs/toolkitreact-redux 作为依赖。

你也可以使用 npx create-next-app --example with-redux my-app 创建一个包含本页所述初始配置步骤的 Next+Redux 项目。

App Router 架构与 Redux

Next.js App Router 的主要新特性是支持 React Server Components(RSCs)。RSC 是一种只在服务器端渲染的 React 组件,与在客户端和服务器端都渲染的“客户端”组件不同。RSC 可以定义为 async 函数,并在渲染时返回 Promise,以异步请求渲染所需数据。

RSC 的数据请求阻塞能力意味着在 App Router 中,你不再拥有 getServerSideProps 来为渲染获取数据。树中的任何组件都可以发起异步数据请求。虽然这非常方便,但也意味着如果你定义了全局变量(例如 Redux store),它们会在请求之间共享。这是个问题,因为 Redux store 可能会被其他请求的数据污染。

基于 App Router 架构,针对 Redux 的一般建议有:

  • 不要使用全局 store — 由于 Redux store 会跨请求共享,不应定义为全局变量。应为每个请求创建新的 store。
  • RSC 不应读写 Redux store — RSC 不能使用 hooks 或 context,也不应有状态。让 RSC 读写全局 store 违背了 Next.js App Router 的架构设计。
  • store 应仅包含可变数据 — 推荐将 Redux 限用于全局共享且可变的数据。

这些建议针对使用 Next.js App Router 实现的应用。单页应用(SPA)不在服务器执行,可将 store 定义为全局变量。SPA 无需考虑 RSC,因此单例 store 可存储任意数据。

目录结构

Next 应用的 /app 文件夹可位于根目录或 /src/app 下。你的 Redux 相关逻辑应放在与 /app 同级的独立文件夹,常见命名如 /lib,但非强制。

/lib 内的文件和文件夹结构自行决定,但通常推荐 基于“特性文件夹”的结构 编写 Redux 逻辑。

典型示例结构如下:

/app
layout.tsx
page.tsx
StoreProvider.tsx
/lib
store.ts
/features
/todos
todosSlice.ts

本指南将采用此方案。

初始配置

类似于RTK TypeScript 教程,需要创建一个 Redux store 文件,以及推断的 RootStateAppDispatch 类型。

不过,由于 Next 的多页面架构,需要与单页应用不同的处理方式。

为每个请求创建 Redux Store

第一点变化是,不再将 store 设为全局或模块单例变量,而是定义一个 makeStore 函数,为每个请求返回一个新 store:

lib/store.ts
import { configureStore } from '@reduxjs/toolkit'

export const makeStore = () => {
return configureStore({
reducer: {}
})
}

// 推断 makeStore 的返回类型
export type AppStore = ReturnType<typeof makeStore>
// 从 store 本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']

现在我们有了 makeStore 函数,可用来为每个请求创建 store 实例,同时保留 Redux Toolkit(若使用 TypeScript)的强类型安全。

我们没导出 store 变量,但可通过 makeStore 返回类型推断 RootStateAppDispatch

你还可以创建并导出经过预设类型的 React-Redux hooks(参见定义类型化的 hooks),便于后续使用:

lib/hooks.ts
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { AppDispatch, AppStore, RootState } from './store'

// 在整个应用中使用这些,而不是直接使用 `useDispatch` 和 `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()

提供 Store

要使用新 makeStore,需要创建一个“客户端”组件,负责创建 store 并用 React-Redux 的 Provider 共享:

app/StoreProvider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from '../lib/store'

export default function StoreProvider({
children
}: {
children: React.ReactNode
}) {
const storeRef = useRef<AppStore | null>(null)
if (!storeRef.current) {
// 第一次渲染时创建 store 实例
storeRef.current = makeStore()
}

return <Provider store={storeRef.current}>{children}</Provider>
}

示例中,我们用一个引用变量确保该客户端组件的重渲染安全,store 只创建一次。此组件在服务器端每个请求只渲染一次,但在客户端可能因上层有状态组件或自身有可变状态,导致多次重渲染。

为什么是客户端组件?

任何与 Redux store 交互(创建、提供、读取或写入)的组件都必须是客户端组件。因为 访问 store 需要 React context,而 context 仅在客户端组件中可用。

下一步就是 在组件树中 store 使用位置之上的任何地方包裹 StoreProvider。如若所有使用该布局的路由都需要 store,可放在布局组件中。如果仅特定路由使用 store,亦可在该路由处理器中创建和提供。在后续所有客户端组件中,可以照常使用 react-redux 提供的 hooks 使用 store。

加载初始数据

如果需要用父组件的初始数据初始化 store,则在客户端的 StoreProvider 组件中定义该数据为 prop,并通过 slice 的 Redux action 设置到 store 中,如下示例:

app/StoreProvider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from '../lib/store'
import { initializeCount } from '../lib/features/counter/counterSlice'

export default function StoreProvider({
count,
children
}: {
count: number
children: React.ReactNode
}) {
const storeRef = useRef<AppStore | null>(null)
if (!storeRef.current) {
storeRef.current = makeStore()
storeRef.current.dispatch(initializeCount(count))
}

return <Provider store={storeRef.current}>{children}</Provider>
}

其他配置

按路由状态

如果使用 Next.js 的客户端 SPA 风格导航(使用 next/navigation),用户在页面间导航时,仅路由组件会重新渲染。这意味着如果在布局组件中创建并提供了同一个 Redux store,路由切换时该 store 会保留下来。

这对只存储全局可变数据没问题,但如果将路由特定数据存储在 store 中,则需要在路由切换时重置这些数据。

以下示例展示一个 ProductName 组件,它通过 Redux store 管理产品的可变名称。此组件是产品详情路由的一部分。为了保证 store 中名称的正确性,需要在 ProductName 初次渲染时(每次路由切换到此路由时)设置 store 的值。

app/ProductName.tsx
'use client'
import { useRef } from 'react'
import { useAppSelector, useAppDispatch, useAppStore } from '../lib/hooks'
import {
initializeProduct,
setProductName,
Product
} from '../lib/features/product/productSlice'

export default function ProductName({ product }: { product: Product }) {
// 使用产品信息初始化 store
const store = useAppStore()
const initialized = useRef(false)
if (!initialized.current) {
store.dispatch(initializeProduct(product))
initialized.current = true
}
const name = useAppSelector(state => state.product.name)
const dispatch = useAppDispatch()

return (
<input
value={name}
onChange={e => dispatch(setProductName(e.target.value))}
/>
)
}

这里使用了和之前相同的初始化模式,即派发 action 向 store 设置路由特定数据。initialized 引用用来保证每次路由切换后只初始化一次。

注意,用 useEffect 初始化 store 不起作用,因为 useEffect 只在客户端执行。这样会导致水合错误或页面闪烁,因为服务器端渲染和客户端渲染的结果不一致。

缓存

App Router 有四种缓存,包括 fetch 请求缓存和路由缓存。最容易出问题的是路由缓存。

如果你的应用需要登录,某些路由(如首页 /)可能基于用户渲染不同数据,则需要在路由处理器中通过导出 dynamic 配置 禁用路由缓存:

export const dynamic = 'force-dynamic'

执行变更后,使用 revalidatePathrevalidateTag 适时地使缓存失效。

RTK Query

建议仅 在客户端 使用 RTK Query 进行数据请求。服务器端的数据请求应该用异步的 React Server Components 中的 fetch

你可以在 Redux Toolkit Query 教程 学习更多。

note

未来,RTK Query 可能支持通过 React Server Components 接收服务器端获取的数据,但这需要 React 和 RTK Query 双方的进一步更改,是未来的功能。

检查你的成果

确保你的 Redux Toolkit 配置正确,需要重点检查以下三点:

  • 服务器端渲染 — 检查服务器输出的 HTML,确保 Redux store 中的数据已经存在于 SSR 内容中。
  • 路由切换 — 访问同一路由下不同页面以及不同路由页面,确保路由特定数据被正确初始化。
  • 变更 — 检查 store 与 Next.js App Router 缓存兼容性,执行数据变更后离开路由再返回,确认数据得到更新。

综合建议

App Router 相较于 Pages Router 或 SPA,引入了截然不同的 React 应用架构设计。我们建议结合此新架构重新思考状态管理策略。

SPA 中常见将全部需要驱动应用的可变及不可变数据装入大型 store。但对 App Router 应用,推荐:

  • 仅使用 Redux 管理全局共享且可变数据
  • 对于其他状态管理,结合 Next.js 状态(如搜索参数、路由参数、表单状态等)、React context 和 React hooks 使用

你已学到的内容

以上是如何使用 App Router 设置和使用 Redux Toolkit 的简要介绍:

总结
  • 利用封装在 makeStore 函数中的 configureStore,为每个请求创建 Redux store
  • 使用“客户端”组件将 Redux store 提供给 React 组件
  • 仅在客户端组件中与 Redux store 交互,因为只有客户端组件能访问 React context
  • 使用 React-Redux 提供的 hooks 按常规方式使用 store
  • 处理全局 store 中的按路由状态,确保路由相关数据正确初始化

接下来做什么?

推荐完成 Redux 核心文档中的“Redux Essentials”及“Redux Fundamentals”教程,它们将全面讲解 Redux 的工作原理、Redux Toolkit 功能及正确使用方法。