Redux Toolkit 與 Next.js 的設定
- 如何設定和使用 Redux Toolkit 與 Next.js 架構
- 熟悉 ES2015 語法和功能
- 了解 React 術語:JSX、狀態、函式元件、Props 和 Hooks
- 了解 Redux 術語和概念
- 建議先完成 快速入門教學 和 TypeScript 快速入門教學,理想情況下也完成完整的 Redux Essentials 教學
簡介
Next.js 是 React 的熱門伺服器端渲染架構,在正確使用 Redux 時會遇到一些獨特的挑戰。這些挑戰包括
- 每個請求安全的 Redux 儲存建立:Next.js 伺服器可以同時處理多個請求。這表示 Redux 儲存應該針對每個請求建立,而且不應該在請求之間共用儲存。
- SSR 友善的儲存水化:Next.js 應用程式會渲染兩次,第一次在伺服器上,第二次在用戶端上。如果無法在用戶端和伺服器上渲染相同的頁面內容,就會導致「水化錯誤」。因此,Redux 儲存必須在伺服器上初始化,然後在用戶端使用相同的資料重新初始化,才能避免水化問題。
- SPA 路由支援:Next.js 支援用戶端路由的混合模式。客戶端第一次載入頁面時,會從伺服器取得 SSR 結果。後續的頁面導覽將由用戶端處理。這表示在配置中定義單例儲存時,路由特定資料需要在路由導覽時選擇性重設,而與路由無關的資料則需要保留在儲存中。
- 伺服器快取友善:最新版本的 Next.js(特別是使用 App Router 架構的應用程式)支援積極的伺服器快取。理想的儲存架構應該與此快取相容。
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 Server Components (RSCs) 的支援。RSCs 是一種特殊的 React 元件,僅在伺服器上呈現,與在客戶端和伺服器上呈現的「客戶端」元件相反。RSCs 可以定義為 async
函式,並在呈現期間傳回承諾,因為它們會針對資料進行非同步請求以呈現。
RSCs 阻擋資料請求的能力表示,使用應用程式路由器時,您不再有 getServerSideProps
來擷取資料以進行呈現。樹狀結構中的任何元件都可以對資料進行非同步請求。雖然這非常方便,但也表示如果您定義了全域變數(例如 Redux 儲存),它們將在請求之間共用。這是個問題,因為 Redux 儲存可能會受到其他請求資料的污染。
根據應用程式路由器的架構,我們針對適當使用 Redux 提出以下一般建議
- 沒有全域儲存 - 由於 Redux 儲存會在請求之間共用,因此不應將其定義為全域變數。相反地,應根據每個請求建立儲存。
- RSCs 不應讀取或寫入 Redux 儲存 - RSCs 無法使用勾子或內容。它們不應有狀態。讓 RSC 從全域儲存讀取或寫入值會違反 Next.js 應用程式路由器的架構。
- 商店應僅包含可變資料 - 我們建議您謹慎使用 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 商店
第一個變更是將 store
從定義為全域或模組單例變數,變更為定義 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 勾子的 預先輸入型別版本,以簡化後續使用
- 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
函式,我們需要建立一個新的「用戶端」元件,它會建立儲存庫並使用 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
提供的掛勾,以完全正常的方式使用儲存庫。
載入初始資料
如果你需要使用父元件中的資料來初始化儲存庫,請在用戶端 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 來擷取資料。伺服器上的資料擷取應該使用來自非同步 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 context 和 React hooks 結合使用,以進行所有其他狀態管理。
您已學到什麼
這是如何使用 App Router 設定和使用 Redux Toolkit 的簡要概述
- 使用包覆在
makeStore
函式中的configureStore
,針對每個要求建立一個 Redux 儲存庫 - 使用「client」元件將 Redux 儲存庫提供給 React 應用程式元件
- 僅在 client 元件中與 Redux 儲存庫互動,因為只有 client 元件可以存取 React 內容
- 使用 React-Redux 中提供的掛勾,像平常一樣使用儲存庫
- 您需要考量在配置於配置中的全域儲存庫中擁有每個路由狀態的情況
接下來?
我們建議您瀏覽 Redux 核心文件中的「Redux Essentials」和「Redux Fundamentals」教學,這將讓您完全了解 Redux 的運作方式、Redux Toolkit 的功能以及如何正確使用它。