跳到主要内容

 

使用 Next.js 设置 Redux Toolkit

你将学到什么
先决条件

介绍

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 RouterApp 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/toolkitreact-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 存储的文件,以及推断的 RootStateAppDispatch 类型。

然而,Next 的多页面架构需要与单页面应用设置有所不同。

每个请求创建一个 Redux 存储

第一个改变是将存储从全局定义转移到定义一个 makeStore 函数,该函数为每个请求返回一个新的存储:

lib/store.ts
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 的返回类型推断 RootStateAppDispatch 类型。

你还需要创建并导出 React-Redux 钩子的预定义版本,以便后续使用:

lib/hooks.ts
// 文件: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 组件共享它。

app/StoreProvider.tsx
// 文件: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 动作在切片上设置存储中的数据,如下所示。

src/app/StoreProvider.tsx
// 文件: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 组件首次渲染时(即在任何路由更改到产品详细信息路由时)设置存储中的值。

app/ProductName.tsx
// 文件: 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'

在变更后,你还应该通过调用 revalidatePathrevalidateTag 来使缓存失效。

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 是什么,以及如何正确使用它。