Redux Toolkit 与 Next.js 设置
- 如何使用 Next.js 框架 设置和使用 Redux Toolkit
- 熟悉 ES2015 语法和特性
- 了解 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 应用程序有两种架构:页面路由器 和 应用程序路由器。
页面路由器是 Next.js 的原始架构。如果您使用页面路由器,Redux 设置主要通过使用 next-redux-wrapper
库 来处理,该库将 Redux 存储与页面路由器数据获取方法(如 getServerSideProps
)集成在一起。
本指南将重点介绍应用程序路由器架构,因为它是 Next.js 的新默认架构选项。
如何阅读本指南
本页面假设您已经拥有基于应用程序路由器架构的现有 Next.js 应用程序。
如果您想跟着做,可以使用 npx create-next-app my-app
创建一个新的空 Next 项目 - 默认提示将使用启用应用程序路由器的项目设置一个新项目。然后,添加 @reduxjs/toolkit
和 react-redux
作为依赖项。
您还可以使用 npx create-next-app --example with-redux my-app
创建一个新的 Next+Redux 项目,其中包含本页面中描述的初始设置部分。
应用程序路由器架构和 Redux
Next.js 应用程序路由器的主要新功能是增加了对 React 服务器组件 (RSC) 的支持。RSC 是一种特殊的 React 组件,它只在服务器上渲染,而不是在客户端和服务器上都渲染的“客户端”组件。RSC 可以定义为 async
函数,并在渲染期间返回承诺,因为它们对数据进行异步请求以进行渲染。
RSC 能够阻塞数据请求意味着使用应用程序路由器时,您不再需要 getServerSideProps
来获取数据以进行渲染。树中的任何组件都可以对数据进行异步请求。虽然这非常方便,但也意味着如果您定义全局变量(如 Redux 存储),它们将在请求之间共享。这是一个问题,因为 Redux 存储可能会被来自其他请求的数据污染。
根据应用程序路由器的架构,我们对 Redux 的适当使用有以下一般建议
- 没有全局存储 - 由于 Redux 存储在请求之间共享,因此不应将其定义为全局变量。相反,应该为每个请求创建存储。
- RSC 不应该读取或写入 Redux 存储 - RSC 不能使用钩子或上下文。它们不应该是状态化的。让 RSC 从全局存储中读取或写入值违反了 Next.js 应用程序路由器的架构。
- 存储应该只包含可变数据 - 我们建议您谨慎使用 Redux 来存储旨在全局和可变的数据。
这些建议特定于使用 Next.js 应用程序路由器编写的应用程序。单页应用程序 (SPA) 不在服务器上执行,因此可以将存储定义为全局变量。SPA 不需要担心 RSC,因为它们不存在于 SPA 中。单例存储可以存储您想要的任何数据。
文件夹结构
接下来创建的应用程序可以将/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 store 创建一个文件,以及推断出的RootState
和AppDispatch
类型。
但是,Next 的多页面架构需要与单页面应用程序设置有所不同。
为每个请求创建 Redux Store
第一个变化是从将store
定义为全局或模块单例变量,改为定义一个makeStore
函数,该函数为每个请求返回一个新的 store。
- 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
,我们可以使用它为每个请求创建 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'
// 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()
提供 Store
要使用这个新的makeStore
函数,我们需要创建一个新的“客户端”组件,它将创建 store 并使用 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>
}
在这个示例代码中,我们确保这个客户端组件是重新渲染安全的,方法是检查对该引用的值,以确保 store 只创建一次。这个组件在服务器上每个请求只渲染一次,但在客户端上可能会重新渲染多次,如果树中位于该组件上方的组件是状态化的客户端组件,或者如果该组件还包含导致重新渲染的其他可变状态。
任何与 Redux 存储交互的组件(创建、提供、读取或写入)都需要是客户端组件。这是因为**访问存储需要 React 上下文,而上下文仅在客户端组件中可用。**
下一步是**在使用存储的树的上方任何位置包含StoreProvider
**。如果所有使用该布局的路由都需要存储,则可以在布局组件中定位存储。或者,如果存储仅在特定路由中使用,则可以在该路由处理程序中创建和提供存储。在树中更下层的客户端组件中,您可以使用与通常使用react-redux
提供的钩子完全相同的方式使用存储。
加载初始数据
如果您需要使用来自父组件的数据初始化存储,则将该数据定义为客户端StoreProvider
组件上的一个 prop,并使用切片上的 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
ref 用于确保商店在每次路由更改时只初始化一次。
值得注意的是,使用 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 的功能以及如何正确使用它。