跳至主要内容

Redux Toolkit 与 Next.js 设置

您将学到什么
先决条件

简介

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/toolkitreact-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 创建一个文件,以及推断出的RootStateAppDispatch类型。

但是,Next 的多页面架构需要与单页面应用程序设置有所不同。

为每个请求创建 Redux Store

第一个变化是从将store定义为全局或模块单例变量,改为定义一个makeStore函数,该函数为每个请求返回一个新的 store。

lib/store.ts
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']

现在我们有一个函数makeStore,我们可以使用它为每个请求创建 store 实例,同时保留 Redux Toolkit 提供的强类型安全性(如果您选择使用 TypeScript)。

我们没有导出store变量,但我们可以从makeStore的返回类型推断出RootStateAppDispatch类型。

您还需要创建和导出预先类型化的 React-Redux 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>()
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 操作将数据设置在存储中,如下所示。

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组件首次渲染时设置存储中的值,这发生在对产品详情路由的任何路由更改时。

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))}
/>
)
}

这里我们使用与之前相同的初始化模式,即向商店分派操作,以设置特定于路由的数据。initialized ref 用于确保商店在每次路由更改时只初始化一次。

值得注意的是,使用 useEffect 初始化商店将不起作用,因为 useEffect 仅在客户端运行。这会导致水合错误或闪烁,因为服务器端渲染的结果与客户端渲染的结果不匹配。

缓存

App Router 有四个独立的缓存,包括 fetch 请求和路由缓存。最有可能导致问题的缓存是路由缓存。如果您有一个接受登录的应用程序,您可能拥有根据用户渲染不同数据的路由(例如主页路由,/),您将需要使用 路由处理程序中的 dynamic 导出 来禁用路由缓存。

export const dynamic = 'force-dynamic'

在进行变异后,您还应该通过调用 revalidatePathrevalidateTag 来使缓存失效,具体取决于情况。

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 的功能以及如何正确使用它。