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) - 基本介紹
系列文
欸欸!! 這是我的學習筆記16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言