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'

// Use throughout your app instead of plain `useDispatch` and `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) {
// Create the store instance the first time this renders
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 }) {
// Initialize the store with the product information
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 功能及正确使用方法。