iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
佛心分享-IT 人自學之術

欸欸!! 這是我的學習筆記系列 第 13 篇

Day13 - Express Generator(2)

  • 分享至 

  • xImage
  •  

前言

昨天我們透過 Express Generator 快速建立了專案骨架,今天就要在這個基礎上實作一個完整的 TodoList。主要放在 View 層的實作、EJS 模板引擎的基本使用、樣式的部分由 AI 生成,後續再整理到 github 做紀錄。

專案規劃與準備

基於昨天建立的 express-todolist-mvc 專案,今天我們要新增:

目標功能

  • 顯示 TodoList
  • 新增 Todo
  • 編輯 Todo
  • 刪除 Todo
  • 標記完成/未完成

檔案結構規劃

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/             # 依賴套件目錄

建立 Todo 模型

首先建立 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;

建立 Todo 控制器 (Controller)

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

建立 Todo 路由 (Routes)

現在路由只負責路徑配置,業務邏輯交給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;

建立 EJS 模板

更新主佈局

更新 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>&copy; 2024 Express TodoList MVC Demo</p>
      </div>
    </footer>
  </body>
</html>

建立 TodoList 頁面

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

MVC架構與SSR的關係

什麼是SSR(Server-Side Rendering)?

SSR 是伺服器端渲染,指的是在伺服器上生成完整的HTML頁面,然後發送給瀏覽器。這與現代的客戶端渲染(CSR)形成對比。

MVC架構如何支援SSR?

在我們的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流程:

  1. 使用者訪問 /todos
  2. Controller接收請求
  3. Model提供Todo資料
  4. 伺服器端渲染EJS模板
  5. 返回完整的HTML頁面

SSR vs CSR 比較

特性 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

EJS 模板語法

變數輸出

<!-- 轉義輸出(安全) -->
<%= 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') %>

總結 MVC 架構

各層職責分工

Model 層 (M)

  • 檔案位置:models/todo.js
  • 職責:
    • 資料存取和操作
    • 業務邏輯處理
    • 資料驗證和格式化
  • 特點:獨立於視圖和控制器,可重複使用

View 層 (V)

  • 檔案位置:views/todos/
  • 職責:
    • 使用者介面呈現
    • 資料顯示格式化
    • 使用者輸入表單
  • 特點:只負責顯示,不包含業務邏輯

Controller 層 (C)

  • 檔案位置:controllers/todoController.js
  • 職責:
    • 處理使用者請求
    • 協調Model和View
    • 控制應用程式流程
    • 錯誤處理和回應
  • 特點:作為Model和View的橋樑

Routes 層

  • 檔案位置:routes/todos.js
  • 職責:
    • URL路徑配置
    • HTTP方法對應
    • 將請求導向對應的Controller方法
  • 特點:只做路由配置,不包含業務邏輯

MVC 資料流程

使用者請求 → Routes → Controller → Model → Controller → View → 使用者回應
  1. 使用者發送請求 → HTTP請求到達伺服器
  2. Routes接收請求 → 根據URL和HTTP方法找到對應的Controller方法
  3. Controller處理請求 → 解析請求參數,呼叫Model處理資料
  4. Model操作資料 → 執行資料庫操作或業務邏輯
  5. Controller取得結果 → 接收Model處理結果
  6. Controller選擇View → 決定使用哪個模板渲染結果
  7. View渲染頁面 → 生成HTML回應給使用者

上一篇
Day12 - Express Generator(1)
下一篇
Day14 - TypeScript (1) - 基本介紹
系列文
欸欸!! 這是我的學習筆記 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言