昨天我們透過 Express Generator 快速建立了專案骨架,今天就要在這個基礎上實作一個完整的 TodoList。主要放在 View 層的實作、EJS 模板引擎的基本使用、樣式的部分由 AI 生成,後續再整理到 github 做紀錄。
基於昨天建立的 express-todolist-mvc
專案,今天我們要新增:
Day12/ (express-todolist-mvc)
├── app.js # 主應用檔案
├── package.json # 專案依賴設定
├── package-lock.json # 依賴版本鎖定
├── bin/
│ └── www # 伺服器啟動腳本
├── models/
│ └── todo.js # Todo 資料模型 (M)
├── controllers/
│ └── todoController.js # Todo 控制器 (C)
├── routes/
│ ├── index.js # 首頁路由 (路由配置)
│ └── todos.js # Todo 路由 (路由配置)
├── views/
│ ├── layout.ejs # 主佈局模板 (V)
│ ├── index.ejs # 首頁模板 (V)
│ ├── error.ejs # 錯誤頁面模板 (V)
│ ├── todos/
│ │ ├── index.ejs # Todo 列表頁面 (V)
│ │ └── edit.ejs # Todo 編輯頁面 (V)
│ └── partials/
│ └── todo-form.ejs # Todo 表單組件 (V)
├── public/
│ └── stylesheets/
│ └── todos.css # Todo 專用樣式
└── node_modules/ # 依賴套件目錄
首先建立 models/
目錄和 Todo 資料模型:
建立 models/todo.js
:
mkdir models
// 簡單的資料庫模擬
class Todo {
constructor() {
this.todos = [
{
id: 1,
title: '學習 Express Generator',
completed: false,
createdAt: new Date('2024-09-25T10:00:00Z')
},
{
id: 2,
title: '實作 TodoList MVC',
completed: false,
createdAt: new Date('2024-09-25T11:00:00Z')
},
{
id: 3,
title: '練習 EJS 模板引擎',
completed: true,
createdAt: new Date('2024-09-25T12:00:00Z')
}
];
this.nextId = 4;
}
// 取得所有 Todo
findAll() {
return this.todos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
// 根據 ID 查找 Todo
findById(id) {
return this.todos.find(todo => todo.id === parseInt(id));
}
// 建立新 Todo
create(data) {
const newTodo = {
id: this.nextId++,
title: data.title,
completed: data.completed || false,
createdAt: new Date()
};
this.todos.push(newTodo);
return newTodo;
}
// 更新 Todo
update(id, data) {
const index = this.todos.findIndex(todo => todo.id === parseInt(id));
if (index === -1) return null;
this.todos[index] = {
...this.todos[index],
...data,
updatedAt: new Date()
};
return this.todos[index];
}
// 刪除 Todo
delete(id) {
const index = this.todos.findIndex(todo => todo.id === parseInt(id));
if (index === -1) return false;
this.todos.splice(index, 1);
return true;
}
// 切換完成狀態
toggleComplete(id) {
const todo = this.findById(id);
if (!todo) return null;
return this.update(id, { completed: !todo.completed });
}
// 取得統計資訊
getStats() {
const total = this.todos.length;
const completed = this.todos.filter(todo => todo.completed).length;
const pending = total - completed;
return { total, completed, pending };
}
}
// 建立單例實例
const todoModel = new Todo();
module.exports = todoModel;
建立 controllers/todoController.js
:
mkdir controllers
const Todo = require('../models/todo');
class TodoController {
// 顯示 Todo 列表
static async index(req, res, next) {
try {
const todos = Todo.findAll();
const stats = Todo.getStats();
res.render('todos/index', {
title: 'TodoList',
todos: todos,
stats: stats,
success: req.query.success,
error: req.query.error
});
} catch (error) {
next(error);
}
}
// 顯示新增 Todo 表單
static async new(req, res, next) {
try {
res.render('todos/edit', {
title: '新增 Todo',
todo: null,
action: '/todos',
error: req.query.error
});
} catch (error) {
next(error);
}
}
// 建立新 Todo
static async create(req, res, next) {
try {
const { title } = req.body;
if (!title || title.trim().length === 0) {
return res.redirect('/todos/new?error=title_required');
}
Todo.create({ title: title.trim() });
res.redirect('/todos?success=created');
} catch (error) {
next(error);
}
}
// 顯示編輯 Todo 表單
static async edit(req, res, next) {
try {
const todo = Todo.findById(req.params.id);
if (!todo) {
return res.redirect('/todos?error=not_found');
}
res.render('todos/edit', {
title: '編輯 Todo',
todo: todo,
action: `/todos/${todo.id}`,
error: req.query.error
});
} catch (error) {
next(error);
}
}
// 更新 Todo
static async update(req, res, next) {
try {
const { title } = req.body;
if (!title || title.trim().length === 0) {
return res.redirect(`/todos/${req.params.id}/edit?error=title_required`);
}
const updated = Todo.update(req.params.id, { title: title.trim() });
if (!updated) {
return res.redirect('/todos?error=not_found');
}
res.redirect('/todos?success=updated');
} catch (error) {
next(error);
}
}
// 切換 Todo 完成狀態
static async toggle(req, res, next) {
try {
const updated = Todo.toggleComplete(req.params.id);
if (!updated) {
return res.redirect('/todos?error=not_found');
}
res.redirect('/todos?success=toggled');
} catch (error) {
next(error);
}
}
// 刪除 Todo
static async destroy(req, res, next) {
try {
const success = Todo.delete(req.params.id);
if (!success) {
return res.redirect('/todos?error=not_found');
}
res.redirect('/todos?success=deleted');
} catch (error) {
next(error);
}
}
}
module.exports = TodoController;
現在路由只負責路徑配置,業務邏輯交給Controller處理。
建立 routes/todos.js
:
const express = require('express');
const router = express.Router();
const TodoController = require('../controllers/todoController');
// 路由配置 - 將請求委派給Controller處理
router.get('/', TodoController.index); // 顯示 Todo 列表
router.get('/new', TodoController.new); // 顯示新增表單
router.post('/', TodoController.create); // 建立新 Todo
router.get('/:id/edit', TodoController.edit); // 顯示編輯表單
router.post('/:id', TodoController.update); // 更新 Todo
router.post('/:id/toggle', TodoController.toggle); // 切換完成狀態
router.post('/:id/delete', TodoController.destroy); // 刪除 Todo
module.exports = router;
更新 views/layout.ejs
:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof title !== 'undefined' ? title : 'Express TodoList' %></title>
<link rel="stylesheet" href="/stylesheets/style.css">
<link rel="stylesheet" href="/stylesheets/todos.css">
</head>
<body>
<header class="header">
<div class="container">
<nav class="nav">
<a href="/" class="nav-logo">Express TodoList</a>
<div class="nav-links">
<a href="/todos" class="nav-link">TodoList</a>
<a href="/todos/new" class="nav-link nav-link-primary">新增 Todo</a>
</div>
</nav>
</div>
</header>
<main class="main">
<div class="container">
<%- body %>
</div>
</main>
<footer class="footer">
<div class="container">
<p>© 2024 Express TodoList MVC Demo</p>
</div>
</footer>
</body>
</html>
建立 views/todos/
目錄:
mkdir views/todos
建立 views/todos/index.ejs
:
<div class="todo-header">
<h1>我的 TodoList</h1>
<!-- 統計資訊 -->
<div class="stats">
<div class="stat">
<span class="stat-number"><%= stats.total %></span>
<span class="stat-label">總計</span>
</div>
<div class="stat">
<span class="stat-number"><%= stats.pending %></span>
<span class="stat-label">待完成</span>
</div>
<div class="stat">
<span class="stat-number"><%= stats.completed %></span>
<span class="stat-label">已完成</span>
</div>
</div>
<!-- 訊息提示 -->
<% if (typeof success !== 'undefined') { %>
<div class="message message-success">
<% if (success === 'created') { %>
Todo 建立成功!
<% } else if (success === 'updated') { %>
Todo 更新成功!
<% } else if (success === 'deleted') { %>
Todo 刪除成功!
<% } else if (success === 'toggled') { %>
Todo 狀態已更新!
<% } %>
</div>
<% } %>
<% if (typeof error !== 'undefined') { %>
<div class="message message-error">
<% if (error === 'not_found') { %>
找不到指定的 Todo
<% } else { %>
發生錯誤,請稍後再試
<% } %>
</div>
<% } %>
</div>
<!-- Todo 列表 -->
<div class="todo-list">
<% if (todos.length === 0) { %>
<div class="empty-state">
<h3>沒有任何 Todo</h3>
<p>點擊上方的「新增 Todo」開始新增你的第一個任務吧!</p>
</div>
<% } else { %>
<% todos.forEach(function(todo) { %>
<div class="todo-item <%= todo.completed ? 'todo-item-completed' : '' %>">
<div class="todo-content">
<h3 class="todo-title"><%= todo.title %></h3>
<div class="todo-meta">
建立於 <%= new Date(todo.createdAt).toLocaleString('zh-TW') %>
<% if (todo.updatedAt) { %>
• 更新於 <%= new Date(todo.updatedAt).toLocaleString('zh-TW') %>
<% } %>
</div>
</div>
<div class="todo-actions">
<!-- 切換完成狀態 -->
<form method="POST" action="/todos/<%= todo.id %>/toggle" class="inline-form">
<button type="submit" class="btn <%= todo.completed ? 'btn-secondary' : 'btn-primary' %>">
<%= todo.completed ? '取消完成' : '標記完成' %>
</button>
</form>
<!-- 編輯按鈕 -->
<a href="/todos/<%= todo.id %>/edit" class="btn btn-outline">編輯</a>
<!-- 刪除按鈕 -->
<form method="POST" action="/todos/<%= todo.id %>/delete" class="inline-form">
<button type="submit" class="btn btn-danger"
onclick="return confirm('確定要刪除這個 Todo 嗎?')">
刪除
</button>
</form>
</div>
</div>
<% }); %>
<% } %>
</div>
<!-- 浮動新增按鈕 -->
<a href="/todos/new" class="fab">
<span>+</span>
</a>
建立 views/todos/edit.ejs
:
<div class="form-container">
<div class="form-header">
<h1><%= title %></h1>
<a href="/todos" class="btn btn-outline">← 返回列表</a>
</div>
<!-- 錯誤訊息 -->
<% if (typeof error !== 'undefined') { %>
<div class="message message-error">
<% if (error === 'title_required') { %>
請輸入 Todo 標題
<% } else { %>
發生錯誤,請稍後再試
<% } %>
</div>
<% } %>
<!-- Todo 表單 -->
<form method="POST" action="<%= action %>" class="todo-form">
<div class="form-group">
<label for="title" class="form-label">Todo 標題 *</label>
<input
type="text"
id="title"
name="title"
value="<%= todo ? todo.title : '' %>"
placeholder="輸入你想要完成的任務..."
class="form-input"
required
maxlength="200"
>
<small class="form-help">最多 200 個字元</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<%= todo ? '更新' : '建立' %> Todo
</button>
<a href="/todos" class="btn btn-secondary">取消</a>
</div>
</form>
<!-- 如果是編輯模式,顯示額外資訊 -->
<% if (todo) { %>
<div class="todo-info">
<h3>Todo 資訊</h3>
<ul>
<li><strong>ID:</strong> <%= todo.id %></li>
<li><strong>狀態:</strong> <%= todo.completed ? '已完成' : '待完成' %></li>
<li><strong>建立時間:</strong> <%= new Date(todo.createdAt).toLocaleString('zh-TW') %></li>
<% if (todo.updatedAt) { %>
<li><strong>最後更新:</strong> <%= new Date(todo.updatedAt).toLocaleString('zh-TW') %></li>
<% } %>
</ul>
</div>
<% } %>
</div>
更新 app.js
,註冊 todos 路由:
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var expressLayouts = require('express-ejs-layouts');
var indexRouter = require('./routes/index');
var todosRouter = require('./routes/todos');
var app = express();
// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
app.use(logger('dev'));
app.use(expressLayouts);
app.set('layout', 'layout');
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
app.use('/todos', todosRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error', { title: '錯誤頁面' });
});
module.exports = app;
SSR 是伺服器端渲染,指的是在伺服器上生成完整的HTML頁面,然後發送給瀏覽器。這與現代的客戶端渲染(CSR)形成對比。
在我們的TodoList專案中,MVC架構天然支援SSR:
// Controller層處理請求
static async index(req, res, next) {
const todos = Todo.findAll(); // Model層提供資料
res.render('todos/index', { // View層在伺服器端渲染
title: 'TodoList',
todos: todos
});
}
SSR流程:
/todos
特性 | SSR (我們的做法) | CSR (現代SPA) |
---|---|---|
渲染位置 | 伺服器端 | 客戶端 |
首屏載入 | 快(完整HTML) | 慢(需要JS執行) |
SEO友善 | 是 | 需要額外處理 |
伺服器負載 | 高 | 低 |
使用者互動 | 需要頁面刷新 | 即時互動 |
傳統方式(我們學習的):
請求 → Controller → Model → View(SSR) → HTML
現代SPA:
請求 → API → JSON → 客戶端渲染
混合方式(Next.js, Nuxt.js):
請求 → Controller → Model → View(SSR/CSR) → HTML/JSON
<!-- 轉義輸出(安全) -->
<%= title %>
<%= todo.title %>
<!-- 非轉義輸出(原始 HTML) -->
<%- body %>
<% if (typeof error !== 'undefined') { %>
<div class="error"><%= error %></div>
<% } else { %>
<div class="success">No errors!</div>
<% } %>
<% todos.forEach(function(todo) { %>
<div><%= todo.title %></div>
<% }); %>
<%- include('partials/header') %>
models/todo.js
views/todos/
controllers/todoController.js
routes/todos.js
使用者請求 → Routes → Controller → Model → Controller → View → 使用者回應