Redux Toolkit 與 Next.js 的設定
- 如何設定並使用 Redux Toolkit 與 Next.js 框架
- 熟悉 ES6 語法和功能
- 了解 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 應用程式有兩種架構:Pages Router 和 App 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/toolkit
和 react-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
函式,並在呈現期間傳回承諾,因為它們會針對資料進行非同步要求以呈現。
RSCs 能夠封鎖資料要求,這表示使用 App Router 時,您不再需要 getServerSideProps
來擷取資料以進行呈現。樹狀結構中的任何元件都可以對資料進行非同步要求。雖然這非常方便,但也表示如果您定義了全域變數(例如 Redux 儲存),它們將在要求之間共用。這是一個問題,因為 Redux 儲存可能會受到其他要求的資料污染。
根據 App Router 的架構,我們針對 Redux 的適當使用提出以下一般建議
- 沒有全域儲存 - 由於 Redux 儲存會在要求之間共用,因此不應將其定義為全域變數。相反地,應針對每個要求建立儲存。
- RSCs 不應讀取或寫入 Redux 儲存 - RSCs 無法使用鉤子或內容。它們不應有狀態。讓 RSC 從全域儲存讀取或寫入值會違反 Next.js App Router 的架構。
- 儲存應僅包含可變資料 - 我們建議您謹慎使用 Redux 來處理預計為全域且可變的資料。
這些建議專門針對使用 Next.js 應用程式路由器編寫的應用程式。單頁式應用程式 (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 hooks 的預先輸入版本,以簡化後續使用
- 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()
提供 Store
若要使用這個新的 makeStore
函式,我們需要建立一個新的「用戶端」元件,它將建立 store 並使用 React-Redux Provider
元件分享 store。
- 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 store 互動的元件(建立、提供、讀取或寫入)都需要是用戶端元件。這是因為存取 store 需要 React context,而 context 僅在用戶端元件中可用。
下一步是在 store 被使用的樹狀結構上方任何位置包含 StoreProvider
。如果您使用該版面的所有路由都需要 store,則可以將 store 定位在版面元件中。或者,如果 store 僅在特定路由中使用,則可以在該路由處理程式中建立並提供 store。在樹狀結構中所有進一步往下的用戶端元件中,您可以使用 store,就像您通常使用 react-redux
提供的 hooks 一樣。
載入初始資料
如果您需要使用父元件的資料初始化儲存,請將該資料定義為客戶端 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 對使用 next/navigation
的客戶端 SPA 風格導覽提供支援,則當客戶端在各頁面間導覽時,只有路由元件會重新呈現。這表示如果您在配置元件中建立並提供 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
只會在客戶端執行。這會導致水化錯誤或閃爍,因為伺服器端呈現的結果與客戶端呈現的結果不符。
快取
應用程式路由器有四個獨立的快取,包括 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 應用程式路由器快取相容,方法是執行突變,然後從路由導航到原始路由,以確保資料已更新。
整體建議
應用程式路由器為 React 應用程式提供了一個與頁面路由器或 SPA 應用程式截然不同的架構。我們建議根據這個新架構重新思考你的狀態管理方法。在 SPA 應用程式中,擁有包含所有資料(可變和不可變)的大型儲存庫並不少見,這些資料是驅動應用程式所必需的。對於應用程式路由器應用程式,我們建議你應該
- 僅對全域共用、可變資料使用 Redux
- 對所有其他狀態管理使用 Next.js 狀態(搜尋參數、路由參數、表單狀態等)、React context 和 React hooks 的組合。
你所學到的
這是關於如何使用應用程式路由器設定和使用 Redux Toolkit 的簡要概述
- 使用包覆在
makeStore
函式中的configureStore
,為每個要求建立一個 Redux 儲存庫 - 使用「client」元件將 Redux 儲存庫提供給 React 應用程式元件
- 僅在 client 元件中與 Redux 儲存庫互動,因為只有 client 元件有權存取 React context
- 使用 React-Redux 中提供的 hooks,照常使用儲存庫
- 您需要考慮在配置中位於佈局的全局儲存中擁有每個路由狀態的情況
接下來是什麼?
我們建議您通讀 Redux 核心文件中的「Redux Essentials」和「Redux Fundamentals」教學課程,這將讓您完全了解 Redux 的運作方式、Redux Toolkit 的功能以及如何正確使用它。