React + Redux + Redux-Saga
勉強会 ③ Redux-Saga編
2019/8/5
tashxii@tis
https://fintan.jp/
React / Redux / Redux-Saga
• React … UIライブラリ
• Redux … React の状態管理ライブラリ
• Redux-Saga … 非同期処理を扱うReduxのミドルウェア
2
https://ja.reactjs.org/
https://Redux-Saga.js.org/
https://redux.js.org/
Skip可:前回と同じスライド
題材に使うアプリケーション
• タスク管理アプリ
• イメージ(gif)
• 機能
• サインアップ
• ログイン
• ボード管理
• ユーザー管理
• タスク管理
• ドラッグ&ドロップ
• Push通知
3
Skip可:前回と同じスライド
題材に使うアプリケーション
• Websocketを使ったPush通知
• イメージ(gif)
• 他のクライアントの
操作をサーバー
経由で伝達
4
Skip可:前回と同じスライド
題材のアプリで使用しているライブラリ
• Redux 状態管理ライブラリ
• Redux-Saga 非同期処理用ライブラリ
• styled-component コンポーネントのスタイル管理
• Ant design コンポーネントライブラリ
• react-beautiful-dnd ドラッグ&ドロップコンポーネント
• react-router-dom URL遷移
• Font awesome アイコンライブラリ
5
Skip可:前回と同じスライド
ソースコード
• Front-end (React)
https://github.com/tashxii/taskboard-react
• Back-end (Go)
https://github.com/tashxii/taskboard-api-go
git clone https://github.com/tashxii/taskboard-react.git
cd taskboard-react
yarn install
git clone https://github.com/tashxii/taskboard-api-go.git
cd taskboard-api-go
dep ensure
go build
6
Skip可:前回と同じスライド
Reactとは(ダイジェスト)
7
Skip可:前回と同じスライド
Reactを使うアプリケーションの構成
https://ja.reactjs.org/8
• Reactは、Viewを提供するライブラリであり、それ以外の技術スタックを
組み合わせて使うことが前提
以下のような組み合わせで使う
• Redux … アプリケーションの状態管理フレームワークを使用したり、
• Redux-Saga … 非同期処理を扱うライブラリを使ったり、
• Back-end サーバー(RESTやSOAPサーバー)と組み合わせて構成する
ここを取り上げます
非同期処理 - Redux-Saga
9
Redux-Sagaとは?
• 非同期処理を扱うReduxのmiddleware
• 非同期処理をコールバック等を使わず
手続的に記述することができる
10
https://redux-saga.js.org/
同期処理との違い
• 同期処理では、「ログイン」は、ログインボタン押下⇒待ち⇒ログイン後の
処理の継続のような流れになる
• 非同期処理は、「開始」、「成功」、「失敗」の三つに分岐される
• 「開始」後にコントロールがユーザーに返されるため、二重サブミット防止や、処理
中が分かるような表示へのフィードバックなどをする必要がある
11
export const UPDATE_LOGIN_USER_START_EVENT = "UPDATE_LOGIN_USER_START_EVENT"
export const UPDATE_LOGIN_USER_SUCCESS_EVENT = "UPDATE_LOGIN_USER_SUCCESS_EVENT"
export const UPDATE_LOGIN_USER_FAILURE_EVENT = "UPDATE_LOGIN_USER_FAILURE_EVENT“
export const LOGOUT_START_EVENT = "LOGOUT_START_EVENT"
export const LOGOUT_SUCCESS_EVENT = "LOGOUT_SUCCESS_EVENT"
export const LOGOUT_FAILURE_EVENT = "LOGOUT_FAILURE_EVENT"
Saga Middlewareの登録
• createSagaMiddleware を使用して
ReduxのMiddlewareに登録
12
import React from "react"
import { render } from "react-dom"
import { Provider } from "react-redux"
import { createStore, applyMiddleware } from "redux"
import createSagaMiddleware from "redux-saga"
import App from "./components/App"
import appState from "./reducers"
import rootSaga from "./sagas/saga"
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
appState,
applyMiddleware(sagaMiddleware)
)
sagaMiddleware.run(rootSaga)
render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
)
forkした
関数をrun
forkを使用してハンドラを登録
• fork を使用してハンドラ
関数を登録
13
import { fork } from "redux-saga/effects"
import UserSagas from "./userSagas"
import BoardSagas from "./boardSaga"
import TaskSagas from "./taskSaga"
import WsSaga from "./wsSaga"
export default function* rootSaga() {
let sagaFunctions = []
sagaFunctions = sagaFunctions.concat(UserSagas.sagaFunctions())
sagaFunctions = sagaFunctions.concat(BoardSagas.sagaFunctions())
sagaFunctions = sagaFunctions.concat(TaskSagas.sagaFunctions())
sagaFunctions = sagaFunctions.concat(WsSaga.sagaFunctions())
for (let i = 0; i < sagaFunctions.length; i++) {
yield fork(sagaFunctions[i])
}
}
function* handleListUsers() {
yield takeEvery(LIST_USERS_START_EVENT, listUsers)
}
function* listUsers(/*action*/) {
const { users, error } = yield call(UserService.listAsync)
if (!error) {
yield put(listUsersSuccessEvent(users))
} else {
yield put(listUsersFailureEvent(error))
}
}
export default class UserSagas {
static sagaFunctions = () => {
return [
handleLogin,
handleSignUp,
handleLogout,
handleUpdateLoginUser,
handleListUsers,
]
}
}
ハンドラ関数
fork
基本的な流れ
• takeEveryでユーザーの操作から
呼ばれるイベントを待ち受ける
• callで非同期処理を呼び出す
• putで次のイベントを起こす
14
function* handleLogin() {
yield takeEvery(LOGIN_START_EVENT, login)
}
function* login(action) {
const payload = action.payload
const { user, error } = yield call(
UserService.loginAsync,
payload.name,
payload.password)
if (!error) {
yield put(loginSuccessEvent(user))
} else {
yield put(loginFailureEvent(error))
}
}
イベント
待ち受け
非同期
呼び出し
次の
イベント
クリック
イベントを
待つ
双方向通信(Push通知)
• eventChannel を作成し、
• サーバーからのメッセージに
対応したイベントを emit する
• クライアントのイベントは、takeで
監視する
• サーバーのイベントはチャンネルを
作り、それを take で監視する
15
const createWebsocketChannel = async loginUser => {
return eventChannel(emit => {
const ws = ApiCommon.createWebsocket(loginUser)
ws.onmessage = (msg) => {
const params = msg.data.split(" ")
if (params.length >= 1) {
const type = params[0]
let ids = params.slice(1)
switch (type) {
case "UPDATE_TASKBOARDS": {
ids.forEach(boardId => {
emit(listTasksStartEvent(boardId))
})
break
}
case "UPDATE_BOARDS":
break
default:
break
}
}
}
return () => {
// Nothing
}
})
}
イベント
チャンネル
サーバーからのメッ
セージに対応した
イベントをemit
双方向通信(Push通知)
• サーバーからのメッセージを eventChannel で受け付け、
• channel を take し、そのアクションを put する
16
function* handleWebsocketMessage() {
yield takeEvery(LOGIN_SUCCESS_EVENT, watchWebsocketMessage)
}
function* watchWebsocketMessage(action) {
const channel = yield call(createWebsocketChannel, action.payload.user)
while (true) {
const pushedAction = yield take(channel)
yield put(pushedAction)
}
}
Channelをcall
で作成する
channelをtakeして
そのイベントをputする
レイヤー設計
17
レイヤー設計
• Redux, Redux-Sagaを使う場合、ビジネスロジック=状態遷移はreducers,
saga関数内で実装される
• 何もしないでロジックを書いていくとreducers, sagaが肥大化していって
しまう
• 以下のようなレイヤー設計を導入して対応する方法がある
• 表示用のmodel層を作成する
• ロジックを集約するservice層を設ける
• API層を作成し、APIのリクエストとレスポンスをmodelと切り分ける
• “Converter”を用いて、modelとAPIのリクエストとレスポンスを変換する
18
View
レイヤー設計(全体)
19
Reducer/Saga
Service Layer
API Layer
Component
Model
Store
Converter
Model
Request/
Response
API以外の
全てから参照
Serviceを
呼び出す
Modelと
Request/Response
を変換する
Request/
Response
レイヤー設計(Model)
• ES2015のclassとして作成
• StoreやComponentで参照する
20
export default class Board {
constructor(
id, name, dispOrder,
isSystem, isClosed, createdDate,
version, tasks,
) {
this.id = id
this.name = name
this.dispOrder = dispOrder
this.isSystem = isSystem
this.isClosed = isClosed
this.createdDate = createdDate
this.version = version
this.tasks = tasks
}
}
export default class Task {
constructor(
id, name, description,
assigneeUserId, boardId, dispOrder,
createdDate, isClosed, estimateSize,
version,
) {
this.id = id
this.name = name
this.description = description
this.assigneeUserId = assigneeUserId
this.boardId = boardId
this.dispOrder = dispOrder
this.createdDate = createdDate
this.isClosed = isClosed
this.estimateSize = estimateSize
this.version = version
}
}
export default class User {
constructor(id, name, avatar, version) {
this.id = id
this.name = name
this.avatar = avatar
this.version = version
this.newPassword = ""
}
}
User
Task Board
レイヤー設計(Saga)
• Serviceを呼び出すのみ
21
function* handleCreateTask() {
yield takeEvery(CREATE_TASK_START_EVENT, createTask)
}
function* createTask(action) {
const payload = action.payload
const { task, error } = yield call(TaskService.createAsync, payload.task)
if (!error) {
yield put(createTaskSuccessEvent(task))
} else {
yield put(createTaskFailureEvent(error))
}
}
Serviceの
呼び出し
タスクの作成
レイヤー設計(Service)
• APIを呼び出す
• Converterを使い、RequestとResponse ⇔ Model の変換を行う
22
export default class TaskService {
static createAsync = async (taskCreateRequest) => {
const request = TaskConverter.convertCreateRequest(taskCreateRequest)
return await TaskApi.create(request)
.then((res) => {
if (res.ok) {
return { task: TaskConverter.getTaskByTaskResponse(res.json) }
} else {
return {
error: ApiErrorConverter.createByApiError(res, I18n.get("タスクの登録に失敗しました"))
}
}
})
.catch((error) => {
return {
error: ApiErrorConverter.createSystemError(error)
}
})
}
Task ⇒ TaskCreateRequest
変換
TaskService.js
Response ⇒ Task 変換
レイヤー設計(Converter)
• ModelをResponseとRequestに変換
(REST API ならJSONをclassにする)
• APIの変更を吸収する
23
export default class TaskConverter {
static getTaskByTaskResponse = (response) => {
return new Task(
response.id,
response.name,
response.description,
response.assigneeUserId,
response.boardId,
response.dispOrder,
response.createdDate,
response.isClosed,
response.estimateSize,
response.version,
)
}
static convertCreateRequest = (task) => {
return {
name: task.name,
description: task.description,
assigneeUserId: task.assigneeUserId,
boardId: task.boardId,
dispOrder: task.dispOrder,
isClosed: task.isClosed,
estimateSize: task.estimateSize,
}
}
TaskConverter.js
Response ⇒ Task 変換
Task ⇒ TaskCreateRequest
変換
レイヤー設計(API)
• Httpメソッドを呼び出す(fetch API使用)
24
export default class TaskApi {
static create = async (request) => {
return await ApiCommon.post("/tasks", request)
}
static list = async (boardId) => {
return await ApiCommon.get(`/tasks?boardid=${boardId}`)
}
static get = async (taskId) => {
return await ApiCommon.get(`/tasks/${taskId}`)
}
static update = async (taskId, request) => {
return await ApiCommon.put(`/tasks/${taskId}`, request)
}
static delete = async (taskId) => {
return await ApiCommon.delete(`/tasks/${taskId}`, {})
}
}
TaskAPI.js
GET,
POST,
PUT,
DELETE
を呼び出す
export default class ApiCommon {
static async get(path) {
return doFetch(
getApiUrl(path), getOption()
)
}
static async post(path, request) {
return doFetch(
getApiUrl(path),
getUpdateOption(ApiCommon.Method.POST, request)
)
}
static async put(path, request) {
return doFetch(
getApiUrl(path),
getUpdateOption(ApiCommon.Method.PUT, request)
)
}
static async delete(path, request) {
return doFetch(
getApiUrl(path),
getUpdateOption(ApiCommon.Method.DELETE, request)
)
}
}
const doFetch = async (path, option) => {
let ok = false
let status = -1
return await fetch(path, option)
.then(response => {
ok = response.ok
status = response.status
return response.text()
})
.then(text => {
const json = text !== "" ? JSON.parse(text) : {}
return { ok, status, json }
})
.catch(error => {
throw error
})
}
} APICommon.js
覚えていてほしいこと
• Redux-Sagaでの基本的な流れ
• takeEvery … イベントの待ち受け
• call … 非同期処理の呼び出し
• put … イベントの発行
• Redux-SagaでのPush通知
• eventChannel と emit
• eventChannel のtake
• レイヤー化設計があること
• Serviceにロジックを集約する
• APIとビューを疎結合にする
25

React+redux+saga 03