iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 27
1
Modern Web

30 天打造 MERN Stack Boilerplate系列 第 27

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

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

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

撰寫 API

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

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

接著是 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 一下。

Reducer

編輯 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;

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

Action Creator

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

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

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

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

View

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

完整程式碼:src/common/components/pages/todo/ListPage.js

這個 Component 最終串起 API 與 Redux,TodoItem 的 Code 有點瑣碎,所以就省略了。到這裡建立完 Components 也要記得 Commit 一下。

Routes

建立 Todo List 頁面的路由 src/common/routes/todo.js,由於每一頁都是 Lazy Load,所以要使用 getComponentrequire.ensure 動態載入頁面:

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

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

接著編輯 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),
      ]);
    });
  },
  // ...
});

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

至此,我們完整實作了一個 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 弄得非常凌亂,這種時候我通常是透過 git rebase -i 指令來整理 Commit History。不過 Git 不屬於我們的討論範圍,而且也是我要求各位讀者閱讀本系列文章的先備技能,所以如果不懂的話還是請 Google 吧!

跑測試 & 完成 Feature

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

npm test

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

git flow feature finish todo-app

上一篇
Day 26 - Example - Start a new project
下一篇
Day 28 - Case Study - Customization for your own app
系列文
30 天打造 MERN Stack Boilerplate30

尚未有邦友留言

立即登入留言