\n \n\n
整個 Boilerplate 包覆了前端 Flux 及後端 MVC,還要注意 API、Pagination 等大大小小的細節,如果直接抓了 Boilerplate 就拿來用,門檻似乎有點太高了,所以今天我想帶各位讀者在 Boilerplate 的規範之下,實作一個的 Todo List App。
git flow feature start todo-app
新建 Mongoose Model :
import mongoose from 'mongoose';import paginatePlugin from './plugins/paginate';
let Todo = new mongoose.Schema({ text: String,}, { versionKey: false, timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt', },});
Todo.plugin(paginatePlugin);
export default mongoose.model('Todo', Todo);
考量 Todo Item 可能會有分頁的需求,因此掛上 Paginate Plugin。建立好 Model 之後,我習慣會在這個 Moment 做一次 Git Commit。
先看 API Server 的部分,編輯 ,補上 Todo Item 的 REST API 路由(由於我們不需要讀取單一 Todo Item 的內容,所以只有 List、Create、Update、Remove,而沒有 Read):
import bodyParser from '../middlewares/bodyParser';import todoController from '../controllers/todo';
export default ({ app }) => { // ...其他路由 app.get('/api/todos', todoController.list); app.post('/api/todos', bodyParser.json, todoController.create); app.put('/api/todos/:id', bodyParser.json, todoController.update); app.delete('/api/todos/:id', todoController.remove);};
再建立 Controller ,實作 Todo Model 的 CRUD 操作:
import assign from 'object-assign';import { handleDbError } from '../decorators/handleError';import filterAttribute from '../utils/filterAttribute';import Todo from '../models/Todo';
export default { list(req, res) { Todo.paginate({ page: req.query.page, perPage: 5, }, handleDbError(res)((page) => { Todo .find({}, null, { limit: page.limit, skip: page.skip < 0 ? 0 : page.skip, sort: { createdAt: 'desc' }, }) .then((todos) => { res.json({ todos: todos, page: page, }); }); })); },
create(req, res) { const todo = Todo({ text: req.body.text, });
todo.save(handleDbError(res)((todo) => { res.json({ todo: todo, }); })); },
update(req, res) { let modifiedTodo = filterAttribute(req.body, [ 'text', ]);
Todo.findById(req.params.id, handleDbError(res)((todo) => { todo = assign(todo, modifiedTodo); todo.save(handleDbError(res)(() => { res.json({ originAttributes: req.body, updatedAttributes: todo, }); })); })); },
remove(req, res) { Todo.remove({_id: req.params.id}, handleDbError(res)(() => { res.json({}); })); },};
接著是 Common Side 的部分,要來做 API 的 Mapping。建立 :
export default (apiEngine) => ({ list: ({ page }) => apiEngine.get('/api/todos', { params: { page } }), create: (todo) => apiEngine.post('/api/todos', { data: todo }), update: (id, todo) => apiEngine.put(`/api/todos/${id}`, { data: todo }), remove: (id) => apiEngine.del(`/api/todos/${id}`),});
目前為止,我們已經完成 Isomorphic API 了,一樣 Git Commit 一下。
編輯 ,由於我們需要列出不同頁的 Todo List,同時還要兼顧 Caching,所以在這個 Reducer 內加上 這個需要被分頁的資源:
import Resources from '../constants/Resources';
// ...其他處理細節的 Reducer
let paginate = /* ... */
let paginationReducer = combineReducers({ // ...其他需要分頁的 State todos: paginate(Resources.TODO),});
export default paginationReducer;
Store 中儲存 Data 的資料結構確定以後,就可以撰寫 Action Creator 來操作 Store 了。請建立 :
import { normalize, arrayOf } from 'normalizr';import { todoSchema } from '../schemas';import Resources from '../constants/Resources';import { setEntities, removeEntities } from './entityActions';import { setPages, prependEntitiesIntoPage } from './pageActions';
export const setTodos = (res) => (dispatch, getState) => { let normalized = normalize(res.todos, arrayOf(todoSchema));
dispatch(setEntities(normalized)); dispatch(setPages(Resources.TODO, res.page, normalized.result));};
export const addTodo = (todo) => (dispatch, getState) => { let normalized = normalize([todo], arrayOf(todoSchema));
dispatch(prependEntitiesIntoPage( Resources.TODO, normalized, 1 ));};
export const removeTodo = (id) => removeEntities(Resources.TODO, [id]);
這裡的 Action Creator 主要是將 API 收到的 Response Data 透過 normalizr 正規化成 Store 儲存的資料結構。
到這裡建立完 Reducer 與 Action Creator 等 Redux 相關的程式時,我也會進行一次 Commit。
建立 :
import React, { Component } from 'react';import { connect } from 'react-redux';import { push } from 'react-router-redux';import Resources from '../../../constants/Resources';import todoAPI from '../../../api/todo';import { pushErrors } from '../../../actions/errorActions';import { setCrrentPage } from '../../../actions/pageActions';import { setTodos, addTodo, removeTodo,} from '../../../actions/todoActions';import PageLayout from '../../layouts/PageLayout';import Pager from '../../utils/BsPager';
class TodoItem extends Component { // ...}
class ListPage extends Component { constructor() { super(); this.handlePageChange = this._handlePageChange.bind(this); this.fetchTodos = this._fetchTodos.bind(this); this.handleAddClick = this._handleAddClick.bind(this); }
componentDidMount() { let { location } = this.props;
this.fetchTodos(location.query.page || 1); }
componentDidUpdate(prevProps) { let { page, todos } = this.props;
if (todos.length === 0 && prevProps.page.current !== page.current) { this.fetchTodos(page.current); } }
_handlePageChange(pageId) { let { dispatch } = this.props;
dispatch(setCrrentPage(Resources.TODO, pageId)); }
_fetchTodos(page) { let { dispatch, apiEngine, location } = this.props;
todoAPI(apiEngine) .list({ page }) .catch((err) => { dispatch(pushErrors(err)); throw err; }) .then((json) => { dispatch(setTodos(json)); dispatch(push({ pathname: location.pathname, query: { page: json.page.current }, })); }); }
_handleAddClick() { let { dispatch, apiEngine } = this.props; let text = this.refs.todotext.value;
todoAPI(apiEngine) .create({ text }) .catch((err) => { dispatch(pushErrors(err)); throw err; }) .then((json) => { dispatch(addTodo(json.todo)); this.refs.todotext.value = ''; }); }
handleSaveClick(id, newText) { let { dispatch, apiEngine } = this.props;
return todoAPI(apiEngine) .update(id, { text: newText }) .catch((err) => { dispatch(pushErrors(err)); throw err; }) .then((json) => { this.fetchTodos(); }); }
handleRemoveClick(id) { let { dispatch, apiEngine } = this.props;
todoAPI(apiEngine) .remove(id) .catch((err) => { dispatch(pushErrors(err)); throw err; }) .then((json) => { dispatch(removeTodo(id)); }); }
render() { let { page } = this.props;
return ( <PageLayout> <input disabled={page.current !== 1} type="text" ref="todotext" /> <button disabled={page.current !== 1} onClick={this.handleAddClick} > Add Todo </button>
<ul> {this.props.todos.map((todo) => <TodoItem key={todo._id} onRemoveClick={this.handleRemoveClick.bind(this, todo._id)} onSaveClick={this.handleSaveClick.bind(this, todo._id)} text={todo.text} /> )} </ul> <Pager page={page} onPageChange={this.handlePageChange} /> </PageLayout> ); }};
export default connect(({ apiEngine, pagination, entity }) => { let { page } = pagination.todos; let todoPages = pagination.todos.pages[page.current] || { ids: [] }; let todos = todoPages.ids.map(id => entity.todos[id]);
return { apiEngine, todos, page, };})(ListPage);
完整程式碼:src/common/components/pages/todo/ListPage.jsopen_in_new
這個 Component 最終串起 API 與 Redux,TodoItem 的 Code 有點瑣碎,所以就省略了。到這裡建立完 Components 也要記得 Commit 一下。
建立 Todo List 頁面的路由 ,由於每一頁都是 Lazy Load,所以要使用 和 動態載入頁面:
export default (store) => ({ path: 'todo', getComponent(nextState, cb) { require.ensure([], (require) => { cb(null, require('../components/pages/todo/ListPage').default); }); },});
接著編輯 ,加入上面建立好的 todo 路由:
export default (store) => ({ path: '/', getChildRoutes(location, cb) { require.ensure([], (require) => { cb(null, [ // ...其他頁面路由 require('./todo').default(store), require('./notFound').default(store), ]); }); }, // ...});
至此,我們完整實作了一個 Todo List App,打開瀏覽器就可以操作它了。確保正常運作後就可以 Commit 了!
這個步驟我認為對一個新功能而言是非必要的,畢竟新功能很可能朝令夕改,所以寫不寫測試就看個人需求吧!
而 Todo List 的測試其實在 Day 22 已經有完整程式碼及說明,請參考 Day 22 - Testing - 撰寫 End-To-End API 測試。
實際上我很少依序寫完一個部份就 Commit 一次,比較常發生的情況是,整個 Feature 完成大約 90% 時才開始 Commit,開發順序也不會是這麼明確地 Step By Step,反而是 MVC + Redux 整陀一起寫,哪邊缺功能就寫哪邊,遇到 Bug 就修 Bug。當整個 Feature 寫得差不多了,再逐漸挑檔案進行 Commit。
通常在我 Commit 下去的那一瞬間,總是會發現程式碼裡面有 Typo,或者是突然找到 Bug,有時甚至是漏了一些功能。如此一來,後面為了修正程式所作的 Commit 會把 Commit History 弄得非常凌亂,這種時候我通常是透過 指令來整理 Commit History。不過 Git 不屬於我們的討論範圍,而且也是我要求各位讀者閱讀本系列文章的先備技能,所以如果不懂的話還是請 Google 吧!
確定完成整個 Feature 之前,記得要跑一下測試:
npm test
如果有問題,就持續修正,然後回到上一步整理 Commit,直到測試通過為止,就能執行以下指令把新功能併入 develop branch 了:
git flow feature finish todo-app
Day 27 - Example - Todo List App
Day 27 - Example - Todo List App