iT邦幫忙

2022 iThome 鐵人賽

DAY 29
0
Modern Web

新手進化日記,從React至Redux Saga系列 第 29

Day 29 - 完善功能與Saga介紹

  • 分享至 

  • xImage
  •  
tags: iThome 鐵人賽 30天

來到第29天啦!今天寫完剩下最後一天,沒想到居然可以撐到要玩賽了~先來快速帶過把所有的基礎功能給完整做完,之後就來先介紹一下Saga。沒錯,最後一天不只完賽感言,還要教學到滿~

完善功能

那就開始今天的還債時間。

首頁新增影片

利用Dialog彈跳視窗來填寫表單新增影片,.\src\components\elements\AddVideoForm.jsx

import React, { useState } from 'react'
import {
  Button, TextField, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle
} from '@mui/material';


export default function AddVideoForm(props) {
  const { handleClose, open, handleSummit } = props

  const [form, setForm] = useState({ v: null, title: null })

  const onChangeForm = (name, value) => {
    setForm({ ...form, [name]: value })
  }

  return (
    <Dialog open={open} onClose={handleClose}>
      <DialogTitle>新增影片</DialogTitle>
      <DialogContent>
        <DialogContentText>
          請輸入YouTube影片的ID與影片標題
        </DialogContentText>
        <TextField
          autoFocus
          margin="dense"
          id="v"
          label="YouTube ID (v)"
          type="text"
          fullWidth
          variant="standard"
          onChange={event => onChangeForm('v', event.target.value)}
        />
        <TextField
          autoFocus
          margin="dense"
          id="title"
          label="標題"
          type="text"
          fullWidth
          variant="standard"
          onChange={event => onChangeForm('title', event.target.value)}
        />
      </DialogContent>
      <DialogActions>
        <Button onClick={handleClose}>取消</Button>
        <Button onClick={() => handleSummit(form)} disabled={!form.v || !form.title}>確認</Button>
      </DialogActions>
    </Dialog>
  )
}

Header來呼叫視窗,.\src\components\Header.jsx

const Header = (props) => {

  const [open, setOpen] = React.useState(false);

  const handleClickOpen = () => {
    setOpen(true);
  };

  const handleClose = () => {
    setOpen(false);
  };

  const addVideo = async (form) => {
    const { v, title } = form
    const data = await fetch(`http://localhost:5000/api/Note`, {
      body: JSON.stringify({ v, title }),
      method: "POST",
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
    }).then(res => res.json())
    handleClose()
  }

  return (
    <Box color="inherit">
      <AppBar position="static" sx={{ backgroundColor: "#000" }}>
        <Toolbar>
          <Typography
            variant="h6"
            noWrap
            component="div"
            sx={{ flexGrow: 1 }}
          >
            <Link className="reset white" to="/">
              {props.title}
            </Link>
          </Typography>
          <Search>
            <SearchIconWrapper>
              <SearchIcon />
            </SearchIconWrapper>
            <StyledInputBase
              placeholder="Search…"
              inputProps={{ 'aria-label': 'search' }}
            />
          </Search>
          <Box sx={{ flexGrow: 1 }} />
          <Button variant="contained" color="success" startIcon={<AddIcon />} onClick={handleClickOpen}>
            新增影片
          </Button>
        </Toolbar>
      </AppBar>
      <AddVideoForm open={open} handleClose={handleClose} handleSummit={addVideo} />
    </Box >
  )
}

export default Header

新增後目前還不會即時更新,因為如果要有即時更新效果要在Header再寫一次呼叫API,所以之後介紹的Saga可以讓呼叫API的動作共用,不用重複寫程式碼,先以重新整理看資料有無成功匯入。

筆記編輯、新增、刪除

其實昨天就偷偷把這幾個功能給完工了,改動主要是在.\src\components\Main\WatchPage.jsx.\src\components\Main\MarkPage.jsx,稍微看一下下改動甚麼,有不懂的地方也都歡迎下方留言討論。

.\src\components\Main\MarkPage.jsx

import React, { useState, useEffect, useRef } from 'react'
import MarkView from '../elements/MarkView'
import MDEditor from '@uiw/react-md-editor'
import { Button, Box } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'

export default function MarkPage(props) {
  const { v, mark, setMark, getCurrentTime, seekTo } = props
  const [isEdit, setEdit] = useState(false)
  const [mdInfo, setMdInfo] = useState({ content: null, id: null, sec: null })

  // 當按下esc離開編輯器
  const handleKeydown = (e) => {
    if (e.keyCode == 27) {
      setEdit(false)
      editMark(v, mdInfo.id, mdInfo.sec, mdInfo.content)
    }
  }

  useEventListener('keydown', handleKeydown)

  useEffect(() => {
    getMark(v)
  }, [v])

  const getMark = async (v) => {
    const data = await fetch(`http://localhost:5000/api/Note/${v}`).then(res => res.json())
    setMark(data)
  }

  const editMark = async (v, id, sec, content) => {
    const data = await fetch(`http://localhost:5000/api/Note/${v}`, {
      body: JSON.stringify({ id, sec, content }),
      method: "PUT",
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
    }).then(res => res.json())
    getMark(v)
  }

  const addMark = async (v, sec, content = null) => {
    if (!!v && !!sec) {
      const data = await fetch(`http://localhost:5000/api/Note/${v}`, {
        body: JSON.stringify({ sec, content }),
        method: "POST",
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
      }).then(res => res.json())
      getMark(v)
    }
  }

  const delMark = async (v, id) => {
    const data = await fetch(`http://localhost:5000/api/Note/${v}`, {
      body: JSON.stringify({ id }),
      method: "DELETE",
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
    }).then(res => res.json())
    getMark(v)
  }

  return (
    <>
      {!!isEdit &&
        <MDEditor
          value={mdInfo.content || undefined}
          onChange={value => setMdInfo({ ...mdInfo, content: value })}
          style={{ position: "absolute", zIndex: "1" }}
          height="100%"
          width="100%"
          className='md_editor'
          autoFocus
        />
      }
      {
        !!mark.mark && mark.mark.map((m, idx) =>
          <MarkView
            key={idx}
            sec={m.sec}
            content={m.content}
            onDoubleClick={e => (setEdit(!isEdit), setMdInfo({ content: m.content, id: m.id, sec: m.sec }))}
            handleDel={() => delMark(v, m.id)}
            seekTo={seekTo}
          />
        )
      }
      <Box className="flex jcc" sx={{ p: 1, mt: 1 }}>
        <Button variant="contained" color="success" startIcon={<AddIcon />} onClick={() => addMark(v, getCurrentTime())}>
          新增標記
        </Button>
      </Box>
    </>
  )
}

function useEventListener(eventName, handler, element = window) {
  const savedHandler = useRef();

  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);
  useEffect(() => {
    const isSupported = element && element.addEventListener;
    if (!isSupported) return;
    const eventListener = (event) => savedHandler.current(event);
    element.addEventListener(eventName, eventListener);
    return () => {
      element.removeEventListener(eventName, eventListener);
    };
  }, [eventName, element]);
}

.\src\components\Main\WatchPage.jsx

import React, { useState } from 'react'
import YouTubeIframe from '../elements/YouTubeIframe'
import { Box } from '@mui/material'
import MarkPage from '../../containers/Main/MarkPage'
import { useLocation } from 'react-router-dom'
const queryString = require('query-string')

export default function WatchPage(props) {
  const [player, setPlayer] = useState(null)
  const location = useLocation()
  const { v } = queryString.parse(location.search)

  const getCurrentTime = () => {
    let sec = 0;
    if (!!player)
      sec = player.getCurrentTime()
    return sec;
  }

  const onPlayerReady = (event) => {
    event.target.playVideo()
  }

  const seekTo = (sec) => {
    if (!!player) {
      player.seekTo(sec)
      player.playVideo()
    }
  }

  return (
    <Box sx={{ display: "flex", height: '100%', overflow: "auto" }} >
      <Box sx={{ width: "70%" }} className="watch_wrapper">
        <div className='video_wrapper'>
          <div className='video_container'>
            <div id="player" width="100%"></div>
          </div>
        </div>
        <YouTubeIframe
          v={v}
          t={0}
          playerid="player"
          player={player}
          setPlayer={pl => setPlayer(pl)}
          onPlayerReady={onPlayerReady}
        />
      </Box>
      <Box sx={{ width: "30%", position: "relative" }} className="mark_container">
        <MarkPage v={v} getCurrentTime={getCurrentTime} seekTo={seekTo} />
      </Box>
    </Box>
  )
}

功能補齊的差不多了,接下來就是進入最後的重頭戲,Saga!

Saga

雖然React Redux可以讓UI Component共用Redux取得資料並設定資料,但是UI Component還是需要負責呼叫API取得資料。這時候Saga就可以來解決這個問題了,透過Saga來呼叫API可以讓Component專心負責呈現資料,而不需要把除了渲染資料的工作放在一起,讓Component更加專一!

不免俗地也要來安裝一下環境redux-saga套件:

npm i redux-saga

流程

原本Reducer中是透過connect把store的資料與action傳遞給component再去呼叫,而Redux-Saga也是一樣,但會在做動作action時先觸發saga對應的事件,才到reducer去設定資料。

也就是說可以把saga當成是一個middleware,每個action都會先經過saga去處理,而saga是進行非同步的呼叫,所以不需要考慮async/await。

分工

  • Component: 處理畫面渲染
  • Saga: 事件處理(API…)
  • Reducer: State控管
  • Action: 事件宣告

Yield

  • 當一個Promise被yield到middleware,middleware將暫停Saga,直到 Promise完成
  • yield用來處理Saga非同步事件,來達到非同步
  • function*
    • function* 宣告式 (function 關鍵字後面跟著一個星號) 定義了一個生成器函式 (generator function),他會回傳一個生成器 (Generator) 物件
    • 所以Saga function要加上*號

yield*的範例

function* anotherGenerator(i) {
  yield i + 1;
  yield i + 2;
  yield i + 3;
}

function* generator(i) {
  yield i;
  yield anotherGenerator(i);
  yield i + 10;
}

var gen = generator(10);

console.log(gen.next().value); // 10
console.log(gen.next().value); // 11
console.log(gen.next().value); // 12
console.log(gen.next().value); // 13
console.log(gen.next().value); // 20

5種Saga Effect

effect是一個JavaScript物件,用來描述Redux-saga要如何執行的說明

  • take: 等待一個action事件,事件呼叫後才會繼續執行
  • call: 呼叫一個 function,(fn, …args)
  • put : 呼叫一個 action,(action)
  • fork: 建立一個Effect描述,指示middleware在fn執行一個非阻塞呼叫
  • select: 從store取出資料,yield select(store => store.object)

takeEvery

每次dispatch的action符合 pattern時,產生一個saga,(pattern, saga)


後記

明天就是最後一個篇章了,會把大致saga的建置流程寫出來,並把一些資料流API呼叫都套用在saga上,來完成這次30天鐵人賽的專案。

附上專案:

對資安或Mapbox有興趣的話也可以觀看我們團隊的鐵人發文喔~


上一篇
Day 28 - React Redux Container 實際運用 (取得store與執行action)
下一篇
Day 30 - 影片標記系統最終章:Saga整合 / 完賽感言
系列文
新手進化日記,從React至Redux Saga30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言