整個 Boilerplate 包覆了前端 Flux 及後端 MVC,還要注意 API、Pagination 等大大小小的細節,如果直接抓了 Boilerplate 就拿來用,門檻似乎有點太高了,所以今天我想帶各位讀者在 Boilerplate 的規範之下,實作一個商業版
的 Todo List App。
git flow feature start todo-app
新建 Mongoose Model src/server/models/Todo.js
:
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 的部分,編輯 src/server/routes/api.js
,補上 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);
};
完整程式碼:src/server/routes/api.js
再建立 Controller src/server/controllers/todo.js
,實作 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。建立 src/common/api/todo.js
:
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}`),
});
完整程式碼:src/common/api/todo.js
目前為止,我們已經完成 Isomorphic API 了,一樣 Git Commit 一下。
編輯 src/common/reducers/paginationReducer.js
,由於我們需要列出不同頁的 Todo List,同時還要兼顧 Caching,所以在這個 Reducer 內加上 todos
這個需要被分頁的資源:
import Resources from '../constants/Resources';
// ...其他處理細節的 Reducer
let paginate = /* ... */
let paginationReducer = combineReducers({
// ...其他需要分頁的 State
todos: paginate(Resources.TODO),
});
export default paginationReducer;
Store 中儲存 Data 的資料結構確定以後,就可以撰寫 Action Creator 來操作 Store 了。請建立 src/common/actions/todoActions.js
:
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。
建立 src/common/components/pages/todo/ListPage.js
:
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);
這個 Component 最終串起 API 與 Redux,TodoItem 的 Code 有點瑣碎,所以就省略了。到這裡建立完 Components 也要記得 Commit 一下。
建立 Todo List 頁面的路由 src/common/routes/todo.js
,由於每一頁都是 Lazy Load,所以要使用 getComponent
和 require.ensure
動態載入頁面:
export default (store) => ({
path: 'todo',
getComponent(nextState, cb) {
require.ensure([], (require) => {
cb(null, require('../components/pages/todo/ListPage').default);
});
},
});
接著編輯 src/common/routes/index.js
,加入上面建立好的 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 弄得非常凌亂,這種時候我通常是透過 git rebase -i
指令來整理 Commit History。不過 Git 不屬於我們的討論範圍,而且也是我要求各位讀者閱讀本系列文章的先備技能,所以如果不懂的話還是請 Google 吧!
確定完成整個 Feature 之前,記得要跑一下測試:
npm test
如果有問題,就持續修正,然後回到上一步整理 Commit,直到測試通過為止,就能執行以下指令把新功能併入 develop branch 了:
git flow feature finish todo-app