跳至主要內容

Redux 基礎知識,第 6 部分:非同步邏輯和資料擷取

Redux 基礎知識,第 6 部分:非同步邏輯和資料擷取

你將學到
  • Redux 資料流程如何與非同步資料搭配運作
  • 如何使用 Redux 中介軟體進行非同步邏輯
  • 處理非同步請求狀態的模式
先備知識
  • 熟悉使用 AJAX 請求從伺服器擷取和更新資料
  • 了解 JS 中的非同步邏輯,包括 Promise

簡介

第 5 部分:UI 和 React 中,我們瞭解如何使用 React-Redux 函式庫讓 React 元件與 Redux 儲存體互動,包括呼叫 useSelector 來讀取 Redux 狀態、呼叫 useDispatch 來存取 dispatch 函式,以及將我們的應用程式包覆在 <Provider> 元件中,讓這些鉤子存取儲存體。

到目前為止,我們處理的所有資料都直接在我們的 React+Redux 應用程式中。然而,大多數實際應用程式需要透過對 HTTP API 進行呼叫來擷取和儲存項目,才能處理來自伺服器的資料。

在本節中,我們將更新我們的待辦事項應用程式,從 API 擷取待辦事項,並透過將它們儲存到 API 來新增新的待辦事項。

注意

請注意,本教學課程故意顯示舊式的 Redux 邏輯模式,這些模式需要比我們現在教授的「現代 Redux」模式(使用 Redux Toolkit)更多的程式碼來建置 Redux 應用程式,目的是為了說明 Redux 背後的原則和概念。它並非旨在成為一個可供生產的專案。

請參閱這些頁面,瞭解如何使用 Redux Toolkit 來使用「現代 Redux」

提示

Redux Toolkit 包含 RTK Query 資料擷取和快取 API。RTK Query 是專為 Redux 應用程式打造的資料擷取和快取解決方案,可以消除編寫任何 thunk 或 reducer 來管理資料擷取的需要。我們特別將 RTK Query 教導為資料擷取的預設方法,而 RTK Query 是建立在這個頁面所示的相同模式之上。

Redux Essentials,第 7 部分:RTK Query 基礎 中了解如何使用 RTK Query 進行資料擷取。

範例 REST API 和 Client

為了讓範例專案既獨立又符合實際,初始專案設定已經包含一個假的記憶體中 REST API,用於我們的資料(使用 Mirage.js 模擬 API 工具 進行設定)。API 使用 /fakeApi 作為端點的基本 URL,並支援 /fakeApi/todos 的典型 GET/POST/PUT/DELETE HTTP 方法。它定義在 src/api/server.js 中。

專案還包含一個小型 HTTP API Client 物件,它公開 client.get()client.post() 方法,類似於 axios 等熱門 HTTP 函式庫。它定義在 src/api/client.js 中。

我們將使用 client 物件對我們記憶體中的假 REST API 進行 HTTP 呼叫,以進行此部分的說明。

Redux 中介軟體和副作用

Redux 儲存本身對非同步邏輯一無所知。它只知道如何同步發送動作、透過呼叫根部簡化器函數來更新狀態,並通知使用者介面某些事項已變更。任何非同步性都必須發生在儲存之外。

稍早,我們提到 Redux 簡化器絕不可包含「副作用」。「副作用」是指在函數傳回值之外,任何可見的狀態或行為變更。以下是常見的幾種副作用

  • 將值記錄到主控台
  • 儲存檔案
  • 設定非同步計時器
  • 建立 AJAX HTTP 要求
  • 修改函數外存在的某些狀態,或變異函數引數
  • 產生亂數或唯一的亂數 ID(例如 Math.random()Date.now()

然而,任何實際的應用程式都需要在某處執行這些動作。因此,如果我們無法在簡化器中放入副作用,可以在哪裡放入呢?

Redux 中介軟體旨在編寫具有副作用的邏輯.

正如我們在第 4 部分中所述,當 Redux 中介軟體看到發送的動作時,可以執行任何動作:記錄某些事項、修改動作、延遲動作、建立非同步呼叫等。此外,由於中介軟體會在實際的 store.dispatch 函數周圍形成一個管線,這也表示我們實際上可以傳遞不是純粹動作物件的值給 dispatch,只要有中介軟體攔截該值並讓它不會傳遞到簡化器即可。

中介軟體也可以存取 dispatchgetState。這表示您可以在中介軟體中編寫一些非同步邏輯,並仍然能夠透過發送動作與 Redux 儲存互動。

使用中介軟體啟用非同步邏輯

我們來看幾個中介軟體如何讓我們編寫與 Redux 儲存互動的非同步邏輯的範例。

一種可能性是編寫一個尋找特定動作類型,並在看到這些動作時執行非同步邏輯的中介軟體,例如這些範例

import { client } from '../api/client'

const delayedActionMiddleware = storeAPI => next => action => {
if (action.type === 'todos/todoAdded') {
setTimeout(() => {
// Delay this action by one second
next(action)
}, 1000)
return
}

return next(action)
}

const fetchTodosMiddleware = storeAPI => next => action => {
if (action.type === 'todos/fetchTodos') {
// Make an API call to fetch todos from the server
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
storeAPI.dispatch({ type: 'todos/todosLoaded', payload: todos })
})
}

return next(action)
}
資訊

有關 Redux 為何及如何使用中間件進行非同步邏輯的更多詳細資訊,請參閱 Redux 建立者 Dan Abramov 在 StackOverflow 的這些解答

撰寫非同步函式中間件

上一節的兩個中間件都非常特定,只做一件事。如果我們能有一種方法,讓我們能事先撰寫任何非同步邏輯,與中間件本身分開,但仍能存取 dispatchgetState,以便與儲存體互動,那就太好了。

如果我們撰寫一個中間件,讓我們能將函式傳遞給 dispatch,而不是動作物件,會怎樣?我們可以讓我們的中間件檢查「動作」實際上是否是一個函式,如果是函式,就立即呼叫該函式。這將讓我們能以獨立的函式撰寫非同步邏輯,在中間件定義之外。

以下是如何撰寫該中間件

非同步函式中間件範例
const asyncFunctionMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

然後我們可以像這樣使用該中間件

const middlewareEnhancer = applyMiddleware(asyncFunctionMiddleware)
const store = createStore(rootReducer, middlewareEnhancer)

// Write a function that has `dispatch` and `getState` as arguments
const fetchSomeData = (dispatch, getState) => {
// Make an async HTTP request
client.get('todos').then(todos => {
// Dispatch an action with the todos we received
dispatch({ type: 'todos/todosLoaded', payload: todos })
// Check the updated store state after dispatching
const allTodos = getState().todos
console.log('Number of todos after loading: ', allTodos.length)
})
}

// Pass the _function_ we wrote to `dispatch`
store.dispatch(fetchSomeData)
// logs: 'Number of todos after loading: ###'

再次注意,這個「非同步函式中間件」讓我們能將函式傳遞給 dispatch在該函式內,我們能夠撰寫一些非同步邏輯(HTTP 要求),然後在要求完成時傳遞一個正常的動作物件。

Redux 非同步資料流程

那麼中間件和非同步邏輯如何影響 Redux 應用程式的整體資料流程?

就像一般的動作一樣,我們首先需要處理應用程式中的使用者事件,例如按一下按鈕。然後,我們呼叫 dispatch(),並傳入某個東西,無論是純粹的動作物件、函式或其他中間件可以尋找的值。

一旦傳遞的值到達中間件,它就可以進行非同步呼叫,然後在非同步呼叫完成時傳遞一個真正的動作物件。

稍早,我們看過 一張圖表,代表正常的同步 Redux 資料流程。當我們在 Redux 應用程式中加入非同步邏輯時,我們會加入一個額外的步驟,讓中間件可以執行像 AJAX 要求的邏輯,然後發送動作。這讓非同步資料流程看起來像這樣

Redux async data flow diagram

使用 Redux Thunk 中間件

事實上,Redux 已經有一個官方版本的「非同步函式中間件」,稱為 Redux 「Thunk」中間件。Thunk 中間件允許我們撰寫函式,取得 dispatchgetState 作為參數。Thunk 函式可以在其中放入我們想要的任何非同步邏輯,而且該邏輯可以視需要發送動作和讀取儲存狀態。

將非同步邏輯寫成 thunk 函式,讓我們可以在不知道我們事先使用哪個 Redux 儲存的情況下,重複使用該邏輯.

資訊

「Thunk」這個字是一個程式設計術語,意思是 「一段執行延遲工作的程式碼」。有關如何使用 thunk 的更多詳細資訊,請參閱 thunk 使用指南頁面

以及這些文章

設定儲存

Redux thunk 中間件在 NPM 上以稱為 redux-thunk 的套件提供。我們需要安裝該套件才能在我們的應用程式中使用它

npm install redux-thunk

安裝後,我們可以更新待辦事項應用程式中的 Redux 儲存,以使用該中間件

src/store.js
import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducer'

const composedEnhancer = composeWithDevTools(applyMiddleware(thunkMiddleware))

// The store now has the ability to accept thunk functions in `dispatch`
const store = createStore(rootReducer, composedEnhancer)
export default store

從伺服器擷取待辦事項

現在我們的待辦事項條目只能存在於客戶端的瀏覽器中。我們需要一種方法,在應用程式啟動時從伺服器載入待辦事項清單。

我們將從撰寫一個 thunk 函式開始,該函式對我們的 /fakeApi/todos 端點進行 AJAX 呼叫,以要求一個待辦事項物件陣列,然後發送一個包含該陣列作為酬載的動作。由於這與一般的待辦事項功能有關,我們將在 todosSlice.js 檔案中撰寫 thunk 函式

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
// omit reducer logic
}

// Thunk function
export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

我們只想在應用程式第一次載入時進行一次這個 API 呼叫。有幾個地方我們可以放置它

  • <App> 元件中,在 useEffect 勾子中
  • <TodoList> 元件中,在 useEffect 勾子中
  • 直接在 index.js 檔案中,在我們匯入儲存庫之後

現在,讓我們嘗試直接將其放入 index.js

src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import './index.css'
import App from './App'

import './api/server'

import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos)

ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)

如果我們重新載入頁面,UI 中沒有可見的變更。但是,如果我們開啟 Redux DevTools 擴充功能,我們現在應該會看到已發送一個 'todos/todosLoaded' 動作,它應該包含一些由我們的假伺服器 API 生成的待辦事項物件

Devtools - todosLoaded action contents

請注意,即使我們已發送動作,也仍然沒有任何事情會發生以變更狀態。我們需要在我們的 todos 減速器中處理此動作,才能更新狀態。

讓我們在減速器中新增一個案例,以將此資料載入儲存庫中。由於我們從伺服器擷取資料,因此我們想要完全取代任何現有的待辦事項,所以我們可以傳回 action.payload 陣列,使其成為新的待辦事項 state

src/features/todos/todosSlice.js
import { client } from '../../api/client'

const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other reducer cases
case 'todos/todosLoaded': {
// Replace the existing state entirely by returning the new value
return action.payload
}
default:
return state
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch({ type: 'todos/todosLoaded', payload: response.todos })
}

由於發送動作會立即更新儲存庫,我們也可以在 thunk 中呼叫 getState,以便在發送之後讀取更新的狀態值。例如,我們可以在發送 'todos/todosLoaded' 動作之前和之後,將待辦事項的總數記錄到主控台中

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')

const stateBefore = getState()
console.log('Todos before dispatch: ', stateBefore.todos.length)

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

const stateAfter = getState()
console.log('Todos after dispatch: ', stateAfter.todos.length)
}

儲存待辦事項項目

每當我們嘗試建立新的待辦事項項目時,我們也需要更新伺服器。我們不應該立即發送 'todos/todoAdded' 動作,我們應該使用初始資料對伺服器進行 API 呼叫,等待伺服器傳回新儲存的待辦事項項目的副本,然後發送包含該待辦事項項目的動作。

但是,如果我們開始嘗試將此邏輯寫成 thunk 函式,我們會遇到一個問題:由於我們將 thunk 寫成 todosSlice.js 檔案中的個別函式,因此進行 API 呼叫的程式碼不知道新的待辦事項文字應為何

src/features/todos/todosSlice.js
async function saveNewTodo(dispatch, getState) {
// ❌ We need to have the text of the new todo, but where is it coming from?
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}

我們需要一種方法來撰寫一個函式,它接受 text 作為其參數,然後建立實際的 thunk 函式,以便它可以使用 text 值進行 API 呼叫。我們的外部函式應隨後傳回 thunk 函式,以便我們可以在我們的元件中傳遞給 dispatch

src/features/todos/todosSlice.js
// Write a synchronous outer function that receives the `text` parameter:
export function saveNewTodo(text) {
// And then creates and returns the async thunk function:
return async function saveNewTodoThunk(dispatch, getState) {
// ✅ Now we can use the text value and send it to the server
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch({ type: 'todos/todoAdded', payload: response.todo })
}
}

現在我們可以在我們的 <Header> 元件中使用它

src/features/header/Header.js
import React, { useState } from 'react'
import { useDispatch } from 'react-redux'

import { saveNewTodo } from '../todos/todosSlice'

const Header = () => {
const [text, setText] = useState('')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function with the text the user wrote
const saveNewTodoThunk = saveNewTodo(trimmedText)
// Then dispatch the thunk function itself
dispatch(saveNewTodoThunk)
setText('')
}
}

// omit rendering output
}

由於我們知道我們將立即將 thunk 函式傳遞給元件中的 dispatch,因此我們可以略過建立暫時變數。我們可以呼叫 saveNewTodo(text),並將結果的 thunk 函式直接傳遞給 dispatch

src/features/header/Header.js
const handleKeyDown = e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create the thunk function and immediately dispatch it
dispatch(saveNewTodo(trimmedText))
setText('')
}
}

現在,元件實際上不知道它甚至正在發送 thunk 函式 - saveNewTodo 函式封裝了實際發生的情況。<Header> 元件只知道當使用者按下 Enter 時,它需要發送某個值

撰寫函數來準備傳遞給 dispatch 的這種模式稱為「動作建立器」模式,我們將在 下一節 中詳細說明。

現在我們可以看到已更新的 'todos/todoAdded' 動作已發送

Devtools - async todoAdded action contents

我們在此處需要變更的最後一件事是更新我們的待辦事項 reducer。當我們對 /fakeApi/todos 提出 POST 要求時,伺服器將傳回一個全新的待辦事項物件(包括新的 ID 值)。這表示我們的 reducer 不必計算新的 ID 或填寫其他欄位,它只需要建立一個包含新待辦事項項目的新 state 陣列即可

src/features/todos/todosSlice.js
const initialState = []

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Return a new todos state array with the new todo item at the end
return [...state, action.payload]
}
// omit other cases
default:
return state
}
}

現在新增新的待辦事項將正常運作

Devtools - async todoAdded state diff

提示

Thunk 函數可同時用於非同步同步邏輯。Thunk 提供一種方式來撰寫任何需要存取 dispatchgetState 的可重複使用邏輯。

您已學到的內容

我們現在已成功更新我們的待辦事項應用程式,以便我們可以使用「thunk」函數來擷取待辦事項項目清單並儲存新的待辦事項項目,並向我們的假伺服器 API 提出 AJAX 呼叫。

在此過程中,我們了解到 Redux 中介軟體如何用於讓我們進行非同步呼叫,並透過在非同步呼叫完成後發送動作來與儲存體互動。

以下是目前應用程式的樣貌

摘要
  • Redux 中介軟體旨在編寫具有副作用的邏輯
    • 「副作用」是會變更函數外部狀態/行為的程式碼,例如 AJAX 呼叫、修改函數參數或產生亂數值
  • 中介軟體會在標準 Redux 資料流程中新增一個額外的步驟
    • 中介軟體可以攔截傳遞給 dispatch 的其他值
    • 中介軟體可以存取 dispatchgetState,因此它們可以發送更多動作作為非同步邏輯的一部分
  • Redux「Thunk」中介軟體讓我們可以將函數傳遞給 dispatch
    • 「Thunk」函式讓我們可以預先撰寫非同步邏輯,而無需知道正在使用的 Redux 儲存。
    • Redux thunk 函式接收 `dispatch` 和 `getState` 作為參數,並可以發送動作,例如「這些資料從 API 回應收到」

下一步?

我們現在已經涵蓋了如何使用 Redux 的所有核心部分!您已經看到如何

  • 撰寫根據已發送動作更新狀態的 reducer
  • 使用 reducer、增強器和中間件建立和設定 Redux 儲存
  • 使用中間件撰寫發送動作的非同步邏輯

第 7 部分:標準 Redux 模式 中,我們將探討實際 Redux 應用程式通常使用的多種程式碼模式,以使我們的程式碼更一致,並隨著應用程式的成長而擴充得更好。