5. 實際開發 ToDo List 案例


讓我們重新回顧過去的內容
從頭來做個簡單的 ToDo List 來練習吧!

建立 React App

安裝 node, yarn 的部分讓我們跳過,不太懂的可以參考《1. 建立良好的開發與執行環境》

yarn create react-app --template typescript demo-app

建立專案架構

通常 React 會需要建立多個頁面,建議放在相同資料夾
所以我們先在 pages 裡面建立一個 HomePage.tsx

// src/pages/HomePage.tsx
import React from 'react'

const HomePage: React.FC = () => {
  return <div>HomePage</div>
}

export default HomePage

接著為了讓 React App 能夠順利實作多頁面
我們引入 react-router(-dom) 的套件(因為沒有內建型別檔案,所以需要額外安裝)

yarn add react-router react-router-dom
yarn add --dev @types/react-router @types/react-router-dom

接下來我們改寫 App.tsx

// src/App.tsx
import React from 'react'
import { Route, Switch } from 'react-router'
import { BrowserRouter } from 'react-router-dom'
import HomePage from '../pages/HomePage'

const App = () => {
  return (
    <BrowserRouter>
      <Switch>
        <Route exact path="/" component={HomePage} />
      </Switch>
    </BrowserRouter>
  )
}

export default App

接著測試看看是否正常執行,執行 yarn start 應該會出現以下畫面
HomePage default

撰寫元件展示

我們接下來撰寫 ToDo List 需要的元件
不過在那之前,我們可以先引用 UI Library 來快速開發
這邊使用的是 Chakra UI(最近很喜歡的一個 UI Library)
你也可以使用 react-bootstrap, @material-ui/core, antd 等等

yarn add @chakra-ui/core @emotion/core @emotion/styled emotion-theming

由於 Chakra 本身有內建型別定義檔案,所以我們不需要再額外安裝
不過這邊需要修改 App.tsx 來設定主題:

// src/App.tsx
import { theme, ThemeProvider } from '@chakra-ui/core'
import React from 'react'
import { Route, Switch } from 'react-router'
import { BrowserRouter } from 'react-router-dom'
import HomePage from '../pages/HomePage'

const App = () => {
  return (
    <ThemeProvider theme={theme}>
      <BrowserRouter>
        <Switch>
          <Route exact path="/" component={HomePage} />
        </Switch>
      </BrowserRouter>
    </ThemeProvider>
  )
}

export default App

接著我們可以開始建立元件了!
首先我們先在 components 的資料夾裡面新增 todo/ 和 shared/ 兩個資料夾
正如之前文章所述,components 內建議以「功能」進行管理
todo/ 用來放置所有跟 todo list 有關的功能
shared/ 用來放置共用的功能
在未來,若是想要新增「提醒功能」,我們則可以直接新增 notification/ 即可

我們先來定義型別,建議建立 types/ 資料夾,並創建 index.d.ts

// types/index.d.ts
type TodoStatus = 'TODO' | 'DOING' | 'DONE'

這邊只有定義 status,可以視情況新增其他型別

再來我們建立 todo item,這邊將 icon 的部分抽到 utility 那邊
另外,為了讓這個元件有更高擴充性,所以我們使用 children 來渲染內容
以及將 remove 的事件交由父層來處理(使用 onRemove props)

// src/components/TodoItem.tsx
import { ListIcon, ListItem } from '@chakra-ui/core'
import React from 'react'
import { getIconFromTodoStatus } from '../../utils'

type TodoItemProps = {
  status: TodoStatus
  onRemove?: () => void
}
const TodoItem: React.FC<TodoItemProps> = ({ children, status, onRemove }) => {
  return (
    <ListItem>
      <ListIcon icon="close" color="black.500" onClick={() => onRemove && onRemove()} />
      <ListIcon icon={getIconFromTodoStatus(status)} color="green.500" />
      {children}
    </ListItem>
  )
}

export default TodoItem

注意一下~ utility 建議為純 TypeScript,不建議在裡面使用 tsx

// src/utils.ts
export const getIconFromTodoStatus = (status: TodoStatus) => {
  switch (status) {
    case 'TODO':
      return 'time'
    case 'DOING':
      return 'spinner'
    case 'DONE':
      return 'check'
    default:
      return 'close'
  }
}

接著,我們來撰寫 Input,這邊我們使用 Controlled Component 寫法
我們將會把 submit 事件交給父層處理(使用外部給的 props)

import { Button, Input, InputGroup, InputRightElement } from '@chakra-ui/core'
import React, { useState } from 'react'

type TodoInputProps = {
  onSubmit?: (title: string) => void
}
const TodoInput: React.FC<TodoInputProps> = ({ onSubmit }) => {
  const [value, setValue] = useState('')
  const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = e => setValue(e.currentTarget.value)

  return (
    <InputGroup>
      <Input value={value} onChange={handleInputChange} type="text" placeholder="我等等要做..." />
      <InputRightElement width="4.5rem">
        <Button isDisabled={!value} h="1.75rem" size="sm" onClick={() => onSubmit && onSubmit(value)}>
          送出
        </Button>
      </InputRightElement>
    </InputGroup>
  )
}

export default TodoInput

撰寫資料處理

為了專案可以擴充,我們這邊會以較大專案的視角來撰寫
資料部分我們使用 localstorage 的暫存

有了 todo item 之後,我們必須要拿到所有 item 的資料才行
因此,我們使用 custom hook 來完成資料的處理
一樣是放到 hooks/ 資料夾,使用功能來命名
注意:custom hook 也是純 TypeScript,不要使用 tsx

// hooks/todo.ts
import { useState, useCallback, useEffect } from 'react'

export type TodoItem = {
  id: string
  title: string
  status: TodoStatus
}
export type TodoList = TodoItem[]

// fake data hook
export const useTodoList = () => {
  const [todoList, setTodoList] = useState<TodoList>([])
  const fetchTodoList = useCallback(() => {
    const data = localStorage.getItem('todoList')
    const parsedData: TodoList = data ? JSON.parse(data) : []
    setTodoList(parsedData)
  }, [])

  const addTodoItem = useCallback(
    (title: string, status: TodoStatus) => {
      const newTodoItem = {
        id: Date.now().toString(),
        title,
        status,
      }
      const newTodoList = [...todoList, newTodoItem]
      localStorage.setItem('todoList', JSON.stringify(newTodoList))
      setTodoList(newTodoList)
    },
    [todoList],
  )
  const removeTodoItem = useCallback(
    (todoItemId: string) => {
      const idx = todoList.findIndex(todoItem => todoItem.id === todoItemId)
      const newTodoList = [...todoList.slice(0, idx), ...todoList.slice(idx + 1)]
      localStorage.setItem('todoList', JSON.stringify(newTodoList))
      setTodoList(newTodoList)
    },
    [todoList],
  )

  useEffect(() => fetchTodoList(), [fetchTodoList])
  return {
    todoList,
    fetchTodoList,
    addTodoItem,
    removeTodoItem,
  }
}

我們將資料狀態存在這個 custom hook 裡面
並且提供 addTodoItem, removeTodoItem 來讓外部使用

完成後我們就可以組裝到 HomePage
這邊會使用 hook 裡面的資料以及函式

import { List } from '@chakra-ui/core'
import React from 'react'
import TodoInput from '../components/todo/TodoInput'
import TodoItem from '../components/todo/TodoItem'
import { useTodoList } from '../hooks/todo'

const HomePage: React.FC = () => {
  const { todoList, addTodoItem, removeTodoItem } = useTodoList()
  return (
    <div>
      <TodoInput onSubmit={title => addTodoItem(title, 'TODO')} />
      <List>
        {todoList.map((todoItem, idx) => (
          <TodoItem
            key={idx}
            status={todoItem.status}
            onRemove={() => {
              removeTodoItem(todoItem.id)
            }}
          >
            {todoItem.title}
          </TodoItem>
        ))}
      </List>
    </div>
  )
}

export default HomePage

這樣簡易版的 Todo List 就完成了!
這邊需要提醒一下,因為我們的 todoList 狀態是在 custom hook 維護
如果在 HomePage 以外的其他地方也使用 const { todoList} = useTodoList()
那麼將不會更新到最新的狀態,除非重新 fetchTodoList()

結語

這篇實在是好難寫,真心佩服能把 step by step 寫得很詳細的人
這邊附上此 demo 的 github 連結:https://github.com/kkshyu/demo-app
裡面用上許多我們嘗試了很多次才使用的 pattern(像是 App.tsx 應該放在 components/ 裡面)
雖然沒有說明的很仔細,希望之後能開個分享會來分享






Typescript 開發 React 的寫法百百種,提供讀者較佳的程式撰寫方式以及專案架構。

留言討論