[Next] 使用 redux-saga管理非同步行為

張庭瑋
11 min readNov 5, 2020

--

next為了給 fetch來的資料提供優化 SEO解決的方案,提供了

當然有時候狀態管理較複雜時,會需要使用 redux,由於最近寫 react使用的都是 redux-saga,這次就試著在 next中使用 redux-saga

yarn add redux react-redux redux-saga redux-devtools-extension
next-redux-wrapper
yarn add -D @types/react-redux

原本 getStaticProps等方法是在 NextPage內取得資料後以props的方式向元件內傳遞資料,如果在next中使用redux的話,可以用 useSelector向 store取得資料後進行渲染

我已經預先在同一個專案底下寫了個簡單的 API,只要使用 get方法請求 /apitest/test就能取得簡單的測試資料

[{ id: 0, name: 'xx'}, .....]

Action

// redux/actions/testAction.ts
import { Action } from 'redux'
export interface TestContent {
[key: string]: string
}
export interface TestAction extends Action {
type: string
payload?: Array<TestContent>
}
export const GET_TESTS_BEGIN = 'GET_TESTS_BEGIN'
export const getTestsBegin = (): {
type: string
} => ({
type: GET_TESTS_BEGIN
})
export const GET_TESTS_SUCCESS = 'GET_TESTS_SUCCESS'
export const getTestsSuccess = (tests: Array<TestContent>): TestAction => ({
type: GET_TESTS_SUCCESS,
payload: tests
})

Reducer

// redux/reducers/testReducer.ts
import { HYDRATE } from 'next-redux-wrapper'
import {
FAILURE,
GET_TESTS_SUCCESS,
TestAction,
TestContent
} from 'redux/actions/testAction'
export interface RootStateType {
tests?: Array<TestContent> | null
}
const initialState: RootStateType = {
tests: null
}
const testReducer = (
state = initialState,
action: TestAction
): RootStateType => {
switch (action.type) {
case HYDRATE:
return { ...state, ...action.payload }
case GET_TESTS_SUCCESS:
return {
...state,
...{ tests: action.payload }
}
default:
return state
}
}
export default testReducer

這裡有兩點不同的

  1. 由於 next-redux-wrapper會派發特殊的 Action:HYDRATE,要記得處理
  2. 資料型態為陣列或物件的初始值需為 null

第2點我實在是很疑惑,但如果使用[]做初始值,就會看到網頁直接在那邊無限轉圈圈

如果想要使用 redux中的 combineReducers時需要把 HYDRATE抽到 root層級

import { HYDRATE } from 'next-redux-wrapper'
import { combineReducers, Action } from 'redux'
import testReducer, { TestStateType } from './testReducer'
import { TestContent } from '../actions/testAction'
export interface RootStateType {
tests: TestStateType | null
}
interface RootAction extends Action {
type: string
payload?: TestContent[] | undefined
}
const initialRootState: RootStateType = {
tests: null
}
const rootReducer = (
state = initialRootState,
action: RootAction
): RootStateType => {
if (action.type === HYDRATE) {
return { ...state, ...action.payload }
}
return combineReducers({ tests: testReducer })(state, action)
}
export default rootReducer

但是初始值需要是 null這點實在是很大的困擾,之後可能要想方法找出問題在哪……

Sagas

// redux/sagas.ts
import { all, AllEffect, ForkEffect, put, takeEvery } from 'redux-saga/effects'
import axios from 'axios'
import { getTestsSuccess, GET_TESTS_BEGIN } from './actions/testAction'function* getSelfTests() {
try {
const res = yield axios.get('http://localhost:3000/apitest/test')
yield put(getTestsSuccess(res.data))
} catch (err) {
...
}
}
function* testSaga(): Iterator<ForkEffect<never>> {
yield takeEvery(GET_TESTS_BEGIN, getSelfTests)
}
function* rootSaga(): Iterator<
AllEffect<Iterator<ForkEffect<never>, any, undefined>>
> {
yield all([testSaga()])
}
export default rootSaga

Store

// redux/store.ts
import { applyMiddleware, createStore } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { createWrapper, MakeStore } from 'next-redux-wrapper'
import { composeWithDevTools } from 'redux-devtools-extension'
import rootReducer from './reducers/testReducer'
import rootSaga from './sagas'
export const makeStore: MakeStore = () => {
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(sagaMiddleware))
)
store.sagaTask = sagaMiddleware.run(rootSaga)return store
}
export const wrapper = createWrapper(makeStore, { debug: true })

這裡跟 react中不同,react是直接 return store給 Provider使用,而這裡是包成一個 HOC

另外就是

store.sagaTask = sagaMiddleware.run(rootSaga)
// Property 'sagaTask' does not exist on type 'Store<RootStateType, TestAction> & { dispatch: unknown; }'.

如果不做處理的話 ts會跳出警告

這時可以定義sagaTask來解決

// redux/redux.d.ts
import { Task } from 'redux-saga'
declare module 'redux' {
export interface Store {
sagaTask: Task
}
}

最後把 _app.tsxwrapper包裹起來

// pages/_app.tsx
import { FC, ReactNode } from 'react'
import { wrapper } from 'redux/store'
import '../styles/globals.css'
import '../styles/test.scss'
interface MyAppProps {
Component: FC
pageProps: JSX.IntrinsicAttributes & { children?: ReactNode }
}
const MyApp = ({ Component, pageProps }: MyAppProps) => (
<Component {...pageProps} />
)
export default wrapper.withRedux(MyApp)

最後只要在任意的 NextPage裡使用 useSelector就可以了

import { GetServerSideProps, NextPage } from 'next'
import { useSelector } from 'react-redux'
import { RootStateType } from 'redux/reducers/testReducer'
import { wrapper } from 'redux/store'
import { END } from 'redux-saga'
import { getTestsBegin } from 'redux/actions/testAction'
import SideMenu from '../components/SideMenu'
...
export const getServerSideProps: GetServerSideProps = wrapper.getServerSideProps(
async ({ store }) => {
if (!store.getState().tests) {
store.dispatch(getTestsBegin())
store.dispatch(END)
}
await store.sagaTask.toPromise()
}
)
const Home: NextPage = () => {
const tests = useSelector((state: RootStateType) => state.tests)
return (
<BaseLayout title='Home'>
<div className='container home-page'>
<div className='row'>
<div className='col-lg-3'>
<SideMenu sideTitle={tests} />
</div>
...

除了不使用 Provider而是自己包成 wrapper,以及使用時還需要呼叫一次 wrapper外,整體來說跟 react中的使用並沒有很大差別,當然也是可以在 useEffect裡面呼叫 useSelector,不過那樣就不能享受 next帶來的 SEO的優化了。

參考:

--

--