使用 Next.js 配置 Redux Toolkit
- 如何使用 Next.js 框架 设置和使用 Redux Toolkit
- 熟悉 ES2015 语法和特性。
- 了解 React 术语:JSX、State、函数组件和 Props以及 Hooks。
- 理解 Redux 术语和概念。
- 建议先完成快速开始教程,最好也完整学习 Redux Essentials 教程。
介绍
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 Router 和 App 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/toolkit 和 react-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
本指南将采用此方案。
初始配置
与快速开始教程类似,我们需要创建 Redux store 文件,并推断 RootState 和 AppDispatch 类型。
不过,由于 Next 的多页面架构,需要与单页应用不同的处理方式。
为每个请求创建 Redux Store
第一点变化是,不再将 store 设为全局或模块单例变量,而是定义一个 makeStore 函数,为每个请求返回一个新 store:
- TypeScript
- JavaScript
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']
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({ reducer: {} })
}
现在我们有了 makeStore 函数,可用来为每个请求创建 store 实例,同时保留 Redux Toolkit(若使用 TypeScript)的强类型安全。
我们没导出 store 变量,但可通过 makeStore 返回类型推断 RootState 和 AppDispatch。
为了简化后续使用,也需要创建并导出预设类型的 React-Redux hooks:
- TypeScript
- JavaScript
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>()
import { useDispatch, useSelector, useStore } from 'react-redux'
// 在整个应用中使用这些,而不是直接使用 `useDispatch` 和 `useSelector`
export const useAppDispatch = useDispatch.withTypes()
export const useAppSelector = useSelector.withTypes()
export const useAppStore = useStore.withTypes()
提供 Store
要使用新 makeStore,需要创建一个“客户端”组件,负责创建 store 并用 React-Redux 的 Provider 共享:
- TypeScript
- JavaScript
'use client'
import { useState } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore } from '../lib/store'
export default function StoreProvider({
children
}: {
children: React.ReactNode
}) {
// Create the store instance the first time this renders
const [store] = useState<AppStore>(makeStore)
return <Provider store={store}>{children}</Provider>
}
'use client'
import { useState } from 'react'
import { Provider } from 'react-redux'
import { makeStore } from '../lib/store'
export default function StoreProvider({ children }) {
// Create the store instance the first time this renders
const [store] = useState(makeStore)
return <Provider store={store}>{children}</Provider>
}
在此示例中,我们确保该客户端组件可安全重复渲染。传给 useState 的惰性初始化函数只会在首次渲染时运行,因此 store 只会创建一次。服务器端每个请求只会渲染该组件一次;但在客户端,如果组件树中它的上层存在带状态的客户端组件,或它本身还包含其他会导致重新渲染的可变状态,就可能重复渲染多次。
任何与 Redux store 交互(创建、提供、读取或写入)的组件都必须是客户端组件。因为 访问 store 需要 React context,而 context 仅在客户端组件中可用。
下一步就是 在组件树中 store 使用位置之上的任何地方包裹 StoreProvider。如若所有使用该布局的路由都需要 store,可放在布局组件中。如果仅特定路由使用 store,亦可在该路由处理器中创建和提供。在后续所有客户端组件中,可以照常使用 react-redux 提供的 hooks 使用 store。
加载初始数据
如果需要用父组件的初始数据初始化 store,则在客户端的 StoreProvider 组件中定义该数据为 prop,并通过 slice 的 Redux action 设置到 store 中,如下示例:
- TypeScript
- JavaScript
'use client'
import { useState } 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 [store] = useState<AppStore>(() => {
const newStore = makeStore()
newStore.dispatch(initializeCount(count))
return newStore
})
return <Provider store={store}>{children}</Provider>
}
'use client'
import { useState } from 'react'
import { Provider } from 'react-redux'
import { makeStore } from '../lib/store'
import { initializeCount } from '../lib/features/counter/counterSlice'
export default function StoreProvider({ count, children }) {
const [store] = useState(() => {
const newStore = makeStore()
newStore.dispatch(initializeCount(count))
return newStore
})
return <Provider store={store}>{children}</Provider>
}
其他配置
按路由状态
如果使用 Next.js 的客户端 SPA 风格导航(使用 next/navigation),用户在页面间导航时,仅路由组件会重新渲染。这意味着如果在布局组件中创建并提供了同一个 Redux store,路由切换时该 store 会保留下来。
下面的 ProductName 示例组件使用 Redux store 管理可变的商品名称。ProductName 属于商品详情路由。为确保 store 中的名称正确,每当 ProductName 首次渲染时都需要将值设置到 store 中;每次导航到商品详情路由时都会发生这种首次渲染。
- TypeScript
- JavaScript
'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))}
/>
)
}
'use client'
import { useRef } from 'react'
import { useAppSelector, useAppDispatch, useAppStore } from '../lib/hooks'
import {
initializeProduct,
setProductName
} from '../lib/features/product/productSlice'
export default function ProductName({ 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 配置 禁用路由缓存:
- TypeScript
- JavaScript
export const dynamic = 'force-dynamic'
export const dynamic = 'force-dynamic'
执行变更后,使用 revalidatePath 或 revalidateTag 适时地使缓存失效。
RTK Query
建议仅 在客户端 使用 RTK Query 进行数据请求。服务器端的数据请求应该用异步的 React Server Components 中的 fetch。
有关 Redux Toolkit Query 的更多信息,请参阅 Redux Toolkit Query 教程。
未来,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 功能及正确使用方法。