folder_open

30 天打造 MERN Stack Boilerplate

arrow_right
article

Day 27 - Example - Todo List App

Day 27 - Example - Todo List App

整個 Boilerplate 包覆了前端 Flux 及後端 MVC,還要注意 API、Pagination 等大大小小的細節,如果直接抓了 Boilerplate 就拿來用,門檻似乎有點太高了,所以今天我想帶各位讀者在 Boilerplate 的規範之下,實作一個的 Todo List App。

建立新 Feature

#

git flow feature start todo-app

建立 Model

#

新建 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);

完整程式碼:src/server/models/Todo.jsopen_in_new

考量 Todo Item 可能會有分頁的需求,因此掛上 Paginate Plugin。建立好 Model 之後,我習慣會在這個 Moment 做一次 Git Commit。

撰寫 API

#

先看 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);
};

完整程式碼:src/server/routes/api.jsopen_in_new

再建立 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({});
}));
},
};

完整程式碼:src/server/controllers/todo.jsopen_in_new

接著是 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}`),
});

完整程式碼:src/common/api/todo.jsopen_in_new

目前為止,我們已經完成 Isomorphic API 了,一樣 Git Commit 一下。

Reducer

#

編輯 ,由於我們需要列出不同頁的 Todo List,同時還要兼顧 Caching,所以在這個 Reducer 內加上 這個需要被分頁的資源:

import Resources from '../constants/Resources';
// ...其他處理細節的 Reducer
let paginate = /* ... */
let paginationReducer = combineReducers({
// ...其他需要分頁的 State
todos: paginate(Resources.TODO),
});
export default paginationReducer;

完整程式碼:src/common/reducers/paginationReducer.jsopen_in_new

Action Creator

#

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]);

完整程式碼:src/common/actions/todoActions.jsopen_in_new

這裡的 Action Creator 主要是將 API 收到的 Response Data 透過 normalizr 正規化成 Store 儲存的資料結構。

到這裡建立完 Reducer 與 Action Creator 等 Redux 相關的程式時,我也會進行一次 Commit。

View

#

建立

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 一下。

Routes

#

建立 Todo List 頁面的路由 ,由於每一頁都是 Lazy Load,所以要使用 動態載入頁面:

export default (store) => ({
path: 'todo',
getComponent(nextState, cb) {
require.ensure([], (require) => {
cb(null, require('../components/pages/todo/ListPage').default);
});
},
});

完整程式碼:src/common/routes/todo.jsopen_in_new

接著編輯 ,加入上面建立好的 todo 路由:

export default (store) => ({
path: '/',
getChildRoutes(location, cb) {
require.ensure([], (require) => {
cb(null, [
// ...其他頁面路由
require('./todo').default(store),
require('./notFound').default(store),
]);
});
},
// ...
});

完整程式碼:src/common/routes/index.jsopen_in_new

至此,我們完整實作了一個 Todo List App,打開瀏覽器就可以操作它了。確保正常運作後就可以 Commit 了!

撰寫測試

#

這個步驟我認為對一個新功能而言是非必要的,畢竟新功能很可能朝令夕改,所以寫不寫測試就看個人需求吧!

而 Todo List 的測試其實在 Day 22 已經有完整程式碼及說明,請參考 Day 22 - Testing - 撰寫 End-To-End API 測試

整理 Commit

#

實際上我很少依序寫完一個部份就 Commit 一次,比較常發生的情況是,整個 Feature 完成大約 90% 時才開始 Commit,開發順序也不會是這麼明確地 Step By Step,反而是 MVC + Redux 整陀一起寫,哪邊缺功能就寫哪邊,遇到 Bug 就修 Bug。當整個 Feature 寫得差不多了,再逐漸挑檔案進行 Commit。

通常在我 Commit 下去的那一瞬間,總是會發現程式碼裡面有 Typo,或者是突然找到 Bug,有時甚至是漏了一些功能。如此一來,後面為了修正程式所作的 Commit 會把 Commit History 弄得非常凌亂,這種時候我通常是透過 指令來整理 Commit History。不過 Git 不屬於我們的討論範圍,而且也是我要求各位讀者閱讀本系列文章的先備技能,所以如果不懂的話還是請 Google 吧!

跑測試 & 完成 Feature

#

確定完成整個 Feature 之前,記得要跑一下測試:

npm test

如果有問題,就持續修正,然後回到上一步整理 Commit,直到測試通過為止,就能執行以下指令把新功能併入 develop branch 了:

git flow feature finish todo-app