使用 Next.js 设置 Redux Toolkit
- 如何使用 Next.js 框架 设置并使用 Redux Toolkit
- 熟悉 ES6 语法和特性
- 了解 React 术语:JSX,State,函数组件,Props 和 Hooks
- 理解 Redux 术语和概念
- 推荐完成 快速开始教程 和 TypeScript 快速开始教程,理想情况下还应完成完整的 Redux 基础教程
介绍
Next.js 是一个流行的 React 服务器端渲染框架,它对使用 Redux 提出了一些独特的挑战。这些挑战包括:
- 每个请求安全的 Redux 存储创建:Next.js 服务器可以同时处理多个请求。这意味着 Redux 存储应该在每个请求中创建,并且存储不应跨请求共享。
- 支持 SSR 的存储初始化:Next.js 应用程序首先在服务器上渲染,然后在客户端上再次渲染。如果客户端和服务器上渲染的页面内容不同,将导致 "hydration error"。因此,Redux 存储需要在服务器上初始化,然后在客户端上用相同的数据重新初始化,以避免 hydration 问题。
- 支持 SPA 路由:Next.js 支持客户端路由的混合模型。客户的第一次页面加载将从服务器获取 SSR 结果。后续的页面导航将由客户端处理。这意味着如果在布局中定义了单例存储,那么在路由导航时需要选择性地重置特定于路由的数据,而非特定于路由的数据需要在存储中保留。
- 支持服务器缓存:最近版本的 Next.js(特别是使用 App Router 架构的应用程序)支持积极的服务器缓存。理想的存储架构应该与这种缓存兼容。
Next.js 应用程序有两种架构:Pages Router 和 App Router。
Pages Router 是 Next.js 的原始架构。如果你正在使用 Pages Router,Redux 的设置主要通过使用 next-redux-wrapper
库来处理,该库将 Redux 存储与 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) 的支持。RSCs 是一种特殊类型的 React 组件,只在服务器上渲染,而不是在客户端和服务器上都渲染。RSCs 可以定义为 async
函数,并在渲染时返回 promise,因为它们在渲染时进行异步数据请求。
RSCs 能够阻塞数据请求意味着,使用 App Router,你不再需要 getServerSideProps
来获取渲染数据。树中的任何组件都可以进行异步数据请求。虽然这非常方便,但也意味着如果你定义全局变量(如 Redux 存储),它们将在请求之间共享。这是一个问题,因为 Redux 存储可能会被其他请求的数据污染。
基于 App Router 的架构,我们对 Redux 的适当使用有以下一般建议:
- 没有全局存储 - 由于 Redux 存储在请求之间共享,因此不应将其定义为全局变量。相反,存储应该在每个请求中创建。
- RSCs 不应读取或写入 Redux 存储 - RSCs 不能使用 hooks 或 context。它们不应该有状态。让 RSC 从全局存储中读取或写入值违反了 Next.js App Router 的架构。
- 存 储应只包含可变数据 - 我们建议你谨慎使用 Redux 来存储预期为全局和可变的数据。
这些建议特定于使用 Next.js App Router 编写的应用程序。单页应用程序(SPAs)不在服务器上执行,因此可以将存储定义为全局变量。SPAs 不需要担心 RSCs,因为它们在 SPAs 中不存在。并且单例存储可以存储你想要的任何数据。
文件夹结构
Next 应用程序可以创建为在根目录下或在 /src/app
下嵌套的 /app
文件夹。你的 Redux 逻辑应该放在一个单独的文件夹中,与 /app
文件夹并列。通常将 Redux 逻辑放在名为 /lib
的文件夹中,但不是必需的。
该 /lib
文件夹内的文件和文件夹结构由你决定,但我们通常推荐 基于 "feature folder" 的结构 来进行 Redux 逻辑。
一个典型的例子可能看起来像这样:
/app
layout.tsx
page.tsx
StoreProvider.tsx
/lib
store.ts
/features
/todos
todosSlice.ts
我们将在本指南中使用这种方法。
初始设置
与 RTK TypeScript 教程类似,我们需要创建一个 Redux 存储的文件,以及推断的 RootState
和 AppDispatch
类型。
然而,Next 的多页面架构需要与单页面应用设置有所不同。
每个请求创建一个 Redux 存储
第一个改变是将存储从全局定义转移到定义一个 makeStore
函数,该函数为每个请求返回一个新的存储:
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({
reducer: {},
})
}
// 推断 makeStore 的类型
export type AppStore = ReturnType<typeof makeStore>
// 从存储本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
现在我们有一个函数,makeStore
,我们可以用它来创建每个请求的存储实例,同时保留 Redux Toolkit 提供的强类型安全性(如果你选择使用 TypeScript)。
我们没有导出 store
变量,但我们可以从 makeStore
的返回类型推断 RootState
和 AppDispatch
类型。
你还需要创建并导出 React-Redux 钩子的预定义版本,以便后续使用:
// 文件:lib/store.ts noEmit
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({
reducer: {},
})
}
// 推断 makeStore 的类型
export type AppStore = ReturnType<typeof makeStore>
// 从存储本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
/* prettier-ignore */
// 文件:lib/hooks.ts
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { RootState, AppDispatch, AppStore } from './store'
// 在你的应用中使用,而不是普通的 `useDispatch` 和 `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()
提供存储
为了使用这个新的 makeStore
函数,我们需要创建一个新的 "client" 组件,该组件将创建存储并使用 React-Redux 的 Provider
组件共享它。
// 文件:lib/store.ts noEmit
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({
reducer: {},
})
}
// 推断 makeStore 的类型
export type AppStore = ReturnType<typeof makeStore>
// 从存储本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
/* prettier-ignore */
// 文件: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>()
if (!storeRef.current) {
// 第一次渲染时创建存储实例
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}
在这个示例代码中,我们通过检查引用的值来确保这个客户端组件是重新渲染安全的,以确保存储只创建一次。这个组件在服务器上每个请求只渲染一次,但如果在树中这个组件上方有状态的客户端组件,或者这个组件也包含其他可变状态导致重新渲染,那么在客户端可能会重新渲染多次。
任何与 Redux 存储交互的组件(创建它,提供它,从中读取,或写入它)都需要是一个客户端组件。这是因为访问存储需要 React 上下文,而上下文只在客户端组件中可用。
下一步是在树中任何使用存储的地方都包含 StoreProvider
。如果所有使用该布局的路由都需要存储,你可以在布局组件中定位存储。或者,如果存储只在特定路由中使用,你可以在该路由处理器中创建并提供存储。在树中更下方的所有客户端组件中,你可以使用 react-redux
提供的钩子正常使用存储。
加载初始数据
如果你需要用父组件的数据初始化存储,那么在客户端 StoreProvider
组件上定义该数据作为一个 prop,然后使用一个 Redux 动作在切片上设置存储中的数据,如下所示。
// 文件:lib/features/counter/counterSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: {
value: 0,
},
reducers: {
initializeCount: (state, action: PayloadAction<number>) => {
state.value = action.payload
},
},
})
export const { initializeCount } = counterSlice.actions
export default counterSlice.reducer
// 文件:lib/store.ts noEmit
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './features/counter/counterSlice'
export const makeStore = () =>
configureStore({
reducer: {
counter: counterReducer,
},
})
// 推断 makeStore 的类型
export type AppStore = ReturnType<typeof makeStore>
// 从存储本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<AppStore['getState']>
// 推断的类型:{posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = AppStore['dispatch']
/* prettier-ignore */
// 文件: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 存储,那么它将在路由更改时被保留。如果你只是使用存储来存储全局的,可变的数据,这不是问题。然而,如果你使用存储来存储每个路由的数据,那么你需要在路由更改时重置存储中的路由特定数据。
下面展示了一个 ProductName
示例组件,该组件使用 Redux 存储来管理产品的可变名称。ProductName
组件是产品详细信息路由的一部分。为了确保我们在存储中有正确的名称,我们需要在 ProductName
组件首次渲染时(即在任何路由更改到产品详细信息路由时)设置存储中的值。
// 文件: lib/features/product/productSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
export interface Product {
name: string
}
const productSlice = createSlice({
name: 'product',
initialState: {
name: '',
},
reducers: {
initializeProduct: (state, action: PayloadAction<Product>) => {
state.name = action.payload.name
},
setProductName: (state, action: PayloadAction<string>) => {
state.name = action.payload
},
},
})
export const { initializeProduct, setProductName } = productSlice.actions
export default productSlice.reducer
// 文件: lib/store.ts noEmit
import { configureStore } from '@reduxjs/toolkit'
import productReducer from './features/product/productSlice'
export const makeStore = () =>
configureStore({
reducer: {
product: productReducer,
},
})
// 推断 makeStore 的类型
export type AppStore = ReturnType<typeof makeStore>
// 从存储本身推断 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<AppStore['getState']>
// 推断的类型: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = AppStore['dispatch']
// 文件: lib/hooks.ts noEmit
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { RootState, AppDispatch, AppStore } from './store'
// 在你的应用中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
export const useAppStore = useStore.withTypes<AppStore>()
/* prettier-ignore */
// 文件: 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 }) {
// 使用产品信息初始化存储
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))}
/>
)
}
这里我们使用与之前相同的初始化模式,向存储派发操作,以设置路由特定数据。initialized
ref 用于确保每次路由更改时只初始化一次存储。
值得注意的是,使用 useEffect
初始化存储是行不通的,因为 useEffect
只在客户端运行。这将导致 hydration 错误或闪烁,因为服务器端渲染的结果不会匹配客户端渲染的结果。
缓存
应用路由器有四个独立的缓存,包括 fetch
请求和路由缓存。最可能引起问题的是路由缓存。如果你有一个接受登录的应用,你可能有基于用户渲染不同数据的路由(例如,主 页路由,/
),你需要使用路由处理器的 dynamic
导出来禁用路由缓存:
export const dynamic = 'force-dynamic'
在变更后,你还应该通过调用 revalidatePath
或 revalidateTag
来使缓存失效。
RTK 查询
我们建议仅在客户端使用 RTK 查询进行数据获取。服务器上的数据获取应使用 async
RSCs 的 fetch
请求。
你可以在 Redux Toolkit Query 教程中了解更多关于 Redux Toolkit Query 的信息。
在未来,RTK 查询可能能够接收通过 React 服务器组件获取的服务器数据,但这是一个需要对 React 和 RTK 查询进行更改的未来功能。
检查你的工作
有三个关键区域你应该检查以确保你正确地设置了 Redux Toolkit:
- 服务器端渲染 - 检查服务器的 HTML 输出,确保 Redux 存储中的数据存在于服务器端渲染的输出中。
- 路由更改 - 在同 一路由的页面之间以及不同路由之间导航,确保路由特定数据被正确初始化。
- 变更 - 通过执行变更,然后从路由导航离开并返回到原始路由,检查存储是否与 Next.js App Router 缓存兼容,以确保数据已更新。
总体建议
应用路由器为 React 应用提供了与页面路由器或 SPA 应用截然不同的架构。我们建议你在这种新架构的光下重新思考你对状态管理的方法。在 SPA 应用中,拥有一个包含所有驱动应用所需的数据(可变和不可变)的大型存储并不罕见。对于应用路由器应用,我们建议你应该:
- 只使用 Redux 来共享全局的,可变的数据
- 对于所有其他状态管理,使用 Next.js 状态(搜索参数,路由参数,表单状态等),React 上下文和 React 钩子的组合。
你学到了什么
这是一个关于如何使用应用路由器设置和使用 Redux Toolkit 的简要概述:
- 通过使用
configureStore
包装在makeStore
函数中来为每个请求创建一个 Redux 存储 - 使用 "client" 组件将 Redux 存储提供给 React 应用组件
- 只在客户端组件中与 Redux 存储进行交互,因为只有客户端组件可以访问 React 上下文
- 像通常使用 React-Redux 提供的钩子那样使用存储
- 你需要考虑在布局中有每个路由状态的全局存储的情况
下一步是什么?
我们建议你浏览 Redux 核心文档中的 "Redux Essentials" 和 "Redux Fundamentals" 教程,这将给你一个完整的理解 Redux 如何工作,Redux Toolkit 是什么,以及如何正确使用它。