使用 Next.js 设置 Redux Toolkit
- 如何在 Next.js 框架 中设置和使用 Redux Toolkit
- 熟悉 ES6 语法和特性
- 了解 React 术语:JSX、状态、函数组件、Props 和 Hooks
- 理解 Redux 术语和概念
- 建议完成 快速入门教程 和 TypeScript 快速入门教程,理想情况下,还应完成完整的 Redux Essentials 教程
简介
Next.js 是一个流行的 React 服务器端渲染框架,它在正确使用 Redux 时带来了一些独特的挑战。这些挑战包括
- 每次请求安全的 Redux store 创建:Next.js 服务器可以同时处理多个请求。这意味着 Redux store 应该为每个请求创建,并且 store 不应该在请求之间共享。
- SSR 友好的 store 水合:Next.js 应用程序渲染两次,第一次在服务器上,第二次在客户端上。如果客户端和服务器上的页面内容渲染不一致,会导致“水合错误”。因此,Redux store 需要在服务器上初始化,然后在客户端上使用相同的数据重新初始化,以避免水合问题。
- SPA 路由支持:Next.js 支持客户端路由的混合模型。客户的第一个页面加载将从服务器获取 SSR 结果。后续页面导航将由客户端处理。这意味着,使用在布局中定义的单例 store,需要在路由导航时有选择地重置特定于路由的数据,同时需要在 store 中保留非特定于路由的数据。
- 服务器缓存友好:最新版本的 Next.js(特别是使用 App Router 架构的应用程序)支持积极的服务器缓存。理想的 store 架构应该与这种缓存兼容。
Next.js 应用程序有两种架构:Pages Router 和 App Router。
页面路由器是 Next.js 的原始架构。如果您使用页面路由器,Redux 设置主要通过使用 next-redux-wrapper
库 来处理,该库将 Redux 存储与页面路由器数据获取方法(如 getServerSideProps
)集成在一起。
本指南将重点介绍 App 路由器架构,因为它是在 Next.js 中新的默认架构选项。
如何阅读本指南
本页面假设您已经拥有基于 App 路由器架构的现有 Next.js 应用程序。
如果您想跟随操作,可以使用 npx create-next-app my-app
创建一个新的空 Next 项目 - 默认提示将使用启用了 App 路由器的项目设置一个新项目。然后,将 @reduxjs/toolkit
和 react-redux
添加为依赖项。
您还可以使用 npx create-next-app --example with-redux my-app
创建一个新的 Next+Redux 项目,其中包含本页面中描述的初始设置部分。
App 路由器架构和 Redux
Next.js App 路由器的主要新功能是增加了对 React 服务器组件 (RSC) 的支持。RSC 是一种特殊的 React 组件,它只在服务器上渲染,而不是在客户端和服务器上都渲染的“客户端”组件。RSC 可以定义为 async
函数,并在渲染期间返回 Promise,因为它们会对数据进行异步请求以进行渲染。
RSC 能够阻塞数据请求意味着使用 App 路由器,您不再需要 getServerSideProps
来获取数据以进行渲染。树中的任何组件都可以对数据进行异步请求。虽然这非常方便,但也意味着如果您定义全局变量(如 Redux 存储),它们将在请求之间共享。这是一个问题,因为 Redux 存储可能会被来自其他请求的数据污染。
根据 App 路由器的架构,我们对 Redux 的适当使用有以下一般建议
- 没有全局存储 - 由于 Redux 存储在请求之间共享,因此不应将其定义为全局变量。相反,应为每个请求创建存储。
- RSC 不应读取或写入 Redux 存储 - RSC 无法使用钩子或上下文。它们不应具有状态。让 RSC 从全局存储中读取或写入值违反了 Next.js App Router 的架构。
- 存储应仅包含可变数据 - 我们建议您谨慎使用 Redux 来存储旨在全局且可变的数据。
这些建议特定于使用 Next.js App Router 编写的应用程序。单页应用程序 (SPA) 不在服务器上执行,因此可以将存储定义为全局变量。SPA 不需要担心 RSC,因为它们不存在于 SPA 中。单例存储可以存储您想要的任何数据。
文件夹结构
Next 应用程序可以创建为将 /app
文件夹放在根目录或嵌套在 /src/app
下。您的 Redux 逻辑应放在一个单独的文件夹中,与 /app
文件夹并排。通常将 Redux 逻辑放在名为 /lib
的文件夹中,但不是必需的。
该 /lib
文件夹内的文件和文件夹结构由您决定,但我们通常建议使用 基于“功能文件夹”的结构 来组织 Redux 逻辑。
一个典型的示例可能如下所示
/app
layout.tsx
page.tsx
StoreProvider.tsx
/lib
store.ts
/features
/todos
todosSlice.ts
在本指南中,我们将使用这种方法。
初始设置
与 RTK TypeScript 教程 类似,我们需要为 Redux 存储创建一个文件,以及推断出的 RootState
和 AppDispatch
类型。
但是,Next 的多页架构需要与单页应用程序设置有所不同。
为每个请求创建 Redux 存储
第一个变化是从将存储定义为全局变量,改为定义一个 makeStore
函数,该函数为每个请求返回一个新的存储。
- TypeScript
- JavaScript
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({
reducer: {},
})
}
// Infer the type of makeStore
export type AppStore = ReturnType<typeof makeStore>
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']
import { configureStore } from '@reduxjs/toolkit'
export const makeStore = () => {
return configureStore({
reducer: {},
})
}
现在我们有一个名为 makeStore
的函数,我们可以使用它为每个请求创建存储实例,同时保留 Redux Toolkit 提供的强类型安全性(如果您选择使用 TypeScript)。
我们没有导出 store
变量,但我们可以从 makeStore
的返回值中推断出 RootState
和 AppDispatch
类型。
您还需要创建和导出 预先类型化的 React-Redux hook 版本,以简化以后的使用。
- TypeScript
- JavaScript
import { useDispatch, useSelector, useStore } from 'react-redux'
import type { RootState, AppDispatch, AppStore } 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>()
import { useDispatch, useSelector, useStore } from 'react-redux'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes()
export const useAppSelector = useSelector.withTypes()
export const useAppStore = useStore.withTypes()
提供存储
为了使用这个新的 makeStore
函数,我们需要创建一个新的“客户端”组件,它将创建存储并使用 React-Redux 的 Provider
组件共享它。
- TypeScript
- JavaScript
'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) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore } from '../lib/store'
export default function StoreProvider({ children }) {
const storeRef = useRef()
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
}
return <Provider store={storeRef.current}>{children}</Provider>
}
在这个示例代码中,我们通过检查对该引用的值来确保这个客户端组件是重新渲染安全的,以确保存储只创建一次。这个组件在服务器上每个请求只渲染一次,但在客户端上可能会重新渲染多次,如果树中该组件上方有状态的客户端组件,或者如果该组件还包含导致重新渲染的其他可变状态。
任何与 Redux 存储交互的组件(创建它、提供它、从它读取或写入它)都需要是客户端组件。这是因为 **访问存储需要 React 上下文,而上下文只在客户端组件中可用。**
下一步是 **在使用存储的树的任何地方包含 StoreProvider
**。如果所有使用该布局的路由都需要存储,则可以在布局组件中定位存储。或者,如果存储只在特定路由中使用,则可以在该路由处理程序中创建和提供存储。在树中更下层的任何客户端组件中,您可以像平常一样使用 react-redux
提供的 hook 使用存储。
加载初始数据
如果您需要使用来自父组件的数据初始化商店,则将该数据定义为客户端StoreProvider
组件上的一个道具,并使用 Redux 操作在切片上设置商店中的数据,如下所示。
- TypeScript
- JavaScript
'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>
}
'use client'
import { useRef } 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 storeRef = useRef(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
组件首次渲染时设置商店中的值,这发生在对产品详情路由的任何路由更改时。
- 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 }) {
// 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))}
/>
)
}
'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 }) {
// 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))}
/>
)
}
在这里,我们使用与之前相同的初始化模式,即向商店分派操作,来设置路由特定数据。initialized
引用用于确保商店在每次路由更改时只初始化一次。
值得注意的是,使用useEffect
初始化商店将不起作用,因为useEffect
只在客户端运行。这会导致水合错误或闪烁,因为服务器端渲染的结果与客户端渲染的结果不匹配。
缓存
App Router 有四个独立的缓存,包括fetch
请求和路由缓存。最有可能导致问题的缓存是路由缓存。如果您有一个接受登录的应用程序,您可能有一些路由(例如主页路由,/
)根据用户渲染不同的数据,您需要使用dynamic
从路由处理程序导出来禁用路由缓存。
- TypeScript
- JavaScript
export const dynamic = 'force-dynamic'
export const dynamic = 'force-dynamic'
在进行变异操作后,您还应该通过调用 revalidatePath
或 revalidateTag
来使缓存失效,具体取决于您的情况。
RTK Query
我们建议仅在客户端上使用 RTK Query 进行数据获取。服务器上的数据获取应该使用来自 async
RSC 的 fetch
请求。
您可以在 Redux Toolkit Query 教程 中了解更多关于 Redux Toolkit Query 的信息。
将来,RTK Query 可能能够接收通过 React Server Components 在服务器上获取的数据,但这是一种未来的功能,需要对 React 和 RTK Query 进行更改。
检查您的工作
您应该检查三个关键区域,以确保您已正确设置 Redux Toolkit
- 服务器端渲染 - 检查服务器的 HTML 输出,以确保 Redux 存储中的数据存在于服务器端渲染的输出中。
- 路由更改 - 在同一路由上的页面之间以及不同路由之间导航,以确保路由特定数据正确初始化。
- 变异 - 检查存储是否与 Next.js App Router 缓存兼容,方法是执行变异操作,然后从路由导航到其他路由,再返回到原始路由,以确保数据已更新。
总体建议
App Router 为 React 应用程序提供了一种与 Pages Router 或 SPA 应用程序截然不同的架构。我们建议您根据这种新的架构重新考虑您的状态管理方法。在 SPA 应用程序中,拥有一个包含所有数据(可变和不可变)的大型存储来驱动应用程序并不罕见。对于 App Router 应用程序,我们建议您应该
- 仅将 Redux 用于全局共享的可变数据
- 使用 Next.js 状态(搜索参数、路由参数、表单状态等)、React 上下文和 React 钩子来管理所有其他状态。
你学到了什么
这是一个关于如何在 App Router 中设置和使用 Redux Toolkit 的简要概述。
- 通过将
configureStore
包裹在makeStore
函数中,为每个请求创建一个 Redux 存储。 - 使用“客户端”组件将 Redux 存储提供给 React 应用程序组件。
- **仅在客户端组件中与 Redux 存储交互**,因为只有客户端组件才能访问 React 上下文。
- 使用 React-Redux 提供的钩子,像平常一样使用存储。
- 你需要考虑在布局中全局存储中存在每个路由状态的情况。
下一步
我们建议你学习Redux 核心文档中的“Redux Essentials”和“Redux Fundamentals”教程,这些教程将让你全面了解 Redux 的工作原理、Redux Toolkit 的作用以及如何正确使用它。