Bỏ qua

Bài 6: Vite + Vanilla JS — Frontend Không Framework

Project reference: admin/src/ui/src/main.js, admin/src/ui/src/modules/api.js, admin/src/ui/src/modules/audio.js, admin/src/ui/src/modules/schedule.js


1. Vite là gì — Build Tool, không phải Framework UI

React, Vue, Angular là UI frameworks — chúng cung cấp component model, reactive state, và virtual DOM.

Vite thì khác: nó là build tool, không quan tâm bạn đang dùng React hay Vanilla JS: - Dev mode: serve ES modules native, không bundle → HMR cực nhanh - Prod mode: Rollup bundler → minify, tree-shake, hash filenames

admin/src/ui/src/          ← source (bạn viết ở đây)
    main.js
    modules/audio.js
    ...

        $ vite build ↓

admin/src/static/           ← output (FastAPI serve tĩnh)
    index.html
    assets/
        index-BtHFploo.js   ← hash trong tên = cache-busting tự động
        index-DkC77ldt.css

Cấu hình Vite

// admin/src/ui/vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  root: ".",                           // index.html ở đây
  build: {
    outDir: "../static",               // output vào FastAPI static folder
    emptyOutDir: true,                 // xóa output cũ trước build
  },
  server: {
    proxy: {
      "/api": "http://localhost:8000", // dev: proxy API requests → FastAPI
    },
  },
});

💡 Dev vs Prod — proxy chỉ có tác dụng ở dev:

  • Dev: Vite serve UI ở :5173, FastAPI chạy riêng ở :8000. Hai origin khác nhau → browser chặn do CORS. Proxy chuyển request /api/* của :5173 sang :8000, giả làm same-origin → không cần bật CORS ở server.
  • Prod: vite build → output admin/src/static/. FastAPI dùng StaticFiles serve UI cùng host với API → chỉ 1 origin duy nhất → proxy config không chạy, cũng không cần.

Newbie thường nhầm: "dev chạy ngon, deploy xong thì UI load được nhưng mọi call /api/* đều 404" — nguyên nhân là build output chưa được FastAPI mount qua StaticFiles, hoặc thứ tự mount/route không đúng (static mount phải đặt sau các API route để không che khuất).


2. Module System — ES Modules

Vanilla JS hiện đại dùng native ES modules — import/export như Python:

// admin/src/ui/src/modules/api.js
export class ApiError extends Error {
    constructor(message, status) {
        super(message);
        this.status = status;
    }
}

export async function api(method, path, body = null) { ... }
export async function uploadAudio(file, metadata) { ... }
// admin/src/ui/src/modules/audio.js
import { api, uploadAudio as uploadAudioApi } from "./api.js";
import { canEdit } from "./auth.js";
import { getCategories } from "./category.js";
// ↑ Import từ module khác — Vite xử lý dependency graph khi build

Không cần require() (Node.js CommonJS) — ES modules là standard của browser.


3. API Layer — Centralized HTTP Client

Tất cả call HTTP đi qua 1 hàm duy nhất:

// admin/src/ui/src/modules/api.js
export async function api(method, path, body = null) {
    const options = {
        method,
        credentials: "include",   // ← tự gửi cookie (kcds_session) theo mọi request
        headers: body ? { "Content-Type": "application/json" } : {},
        body: body ? JSON.stringify(body) : null,
    };

    const res = await fetch(`/api${path}`, options);

    // 401 = session hết hạn → redirect về login
    if (res.status === 401) {
        window.location.href = "/";
        return;
    }

    // 204 No Content (DELETE thành công)
    if (res.status === 204) return null;

    const json = await res.json();

    // Server trả error → throw (caller catch và hiện toast)
    if (!res.ok) throw new ApiError(json.error, res.status);

    // Unwrap: server luôn trả {"data": ...}
    return json.data ?? json;
}

// Upload riêng vì multipart/form-data cần browser set Content-Type+boundary
export async function uploadAudio(file, metadata) {
    const form = new FormData();
    form.append("file", file);
    Object.entries(metadata).forEach(([k, v]) => {
        if (v !== null && v !== undefined) form.append(k, v);
    });

    const res = await fetch("/api/audio", {
        method: "POST",
        credentials: "include",
        body: form,
        // KHÔNG set Content-Type — browser tự set với boundary
    });

    if (res.status === 401) { window.location.href = "/"; return; }
    const json = await res.json();
    if (!res.ok) throw new ApiError(json.error, res.status);
    return json.data ?? json;
}

Khi cần đổi base URL, thay đổi cách auth, hay thêm logging cho mọi request — chỉ cần sửa duy nhất file api.js.


4. Module State — Closure Pattern

Không có React state hay Vue ref, Vanilla JS dùng module-level variables:

// admin/src/ui/src/modules/audio.js

// Module-level state (private — không export)
let audioList = [];
let activeFilters = { category_id: null, event_id: null, month: null, year: null, q: null };
let groupBy = "category";
let showUpload = false;
let editingId = null;
let containerId = null;
let searchDebounce = null;

// Public API (export)
export async function loadAudio(filters = activeFilters) {
    activeFilters = { ...activeFilters, ...filters };
    const qs = new URLSearchParams(
        Object.entries(activeFilters).filter(([, v]) => v !== null && v !== "")
    ).toString();
    audioList = await api("GET", qs ? `/audio?${qs}` : "/audio");
    render();  // re-render với data mới
}

export function getAudioList() {
    return audioList;  // read-only access từ module khác
}

State được giữ private trong module, chỉ có thể thay đổi thông qua các hàm được export ra ngoài. Về bản chất tương tự Redux store, nhưng không cần thêm thư viện nào.


5. DOM Rendering — String Template

Không có virtual DOM, render bằng innerHTML:

// admin/src/ui/src/modules/audio.js
function render() {
    const el = document.getElementById(containerId);
    if (!el) return;

    // Build HTML string
    el.innerHTML = `
        <div class="audio-header">
            ${canEdit() ? renderUploadButton() : ""}
            ${renderFilters()}
        </div>
        <div class="audio-list">
            ${audioList.length === 0
                ? "<p class='empty'>Chưa có file audio</p>"
                : audioList.map(renderAudioCard).join("")
            }
        </div>
    `;

    bindListeners();  // re-bind events sau mỗi render
}

function renderAudioCard(audio) {
    // esc() = HTML escape để tránh XSS
    return `
        <div class="audio-card" data-id="${audio.id}">
            <h3>${esc(audio.title)}</h3>
            <span class="size">${audio.size_mb} MB</span>
            ${canEdit() ? `<button class="btn-delete" data-id="${audio.id}">Xóa</button>` : ""}
        </div>
    `;
}

XSS prevention — esc() function:

// admin/src/ui/src/modules/util.js
export function esc(str) {
    // Escape HTML entities — LUÔN dùng khi render user-controlled data vào innerHTML
    return String(str ?? "")
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;");
}

Quy tắc bảo mật: Bất cứ data nào đến từ user (title, name, description) → phải qua esc() trước khi đưa vào innerHTML. Data từ server được tin cậy hơn nhưng vẫn nên escape.


6. Event Delegation — Không bind từng element

function bindListeners() {
    const el = document.getElementById(containerId);

    // ❌ Sai: bind event từng card (phải rebind mỗi render)
    // document.querySelectorAll(".btn-delete").forEach(btn => {
    //     btn.addEventListener("click", handleDelete);
    // });

    // ✅ Đúng: 1 listener trên container, check target
    el.addEventListener("click", async (e) => {
        const btn = e.target.closest("[data-id]");
        if (!btn) return;

        const id = parseInt(btn.dataset.id);

        if (btn.classList.contains("btn-delete")) {
            await handleDelete(id);
        } else if (btn.classList.contains("btn-edit")) {
            openEditModal(id);
        }
    });
}

Event delegation: 1 listener trên parent, không phải N listeners trên N children. Tự động work với elements được render dynamic.


7. Auto-save Pattern với Snapshot Rollback

// admin/src/ui/src/modules/schedule.js

let schedule = [];
let saving = false;  // chặn double-submit

async function persistSchedule() {
    const payload = schedule.map(toBackendShape);
    const result = await api("POST", "/schedule", payload);
    overlapWarnings = result?.warnings ?? [];
    await loadSchedule();  // fetch lại để lấy IDs backend + render
    return result;
}

// Dùng trong mọi mutation (add/edit/delete entry)
async function saveWithRollback(mutateFn) {
    if (saving) return;  // chặn concurrent submit

    // 1. Snapshot state hiện tại TRƯỚC khi mutate
    const snapshot = schedule.map(e => ({ ...e }));

    // 2. Optimistic update local state
    mutateFn();
    render();

    // 3. Persist lên server
    saving = true;
    try {
        await persistSchedule();
    } catch (err) {
        // 4. Rollback nếu server reject
        schedule = snapshot;
        render();
        notify(err.message, "error");
    } finally {
        saving = false;
    }
}

// Ví dụ: user submit form thêm entry mới
async function handleFormSubmit(formData) {
    await saveWithRollback(() => {
        schedule.push({ ...formData, id: nextLocalId-- });
    });
}

// Ví dụ: user xóa entry
async function handleDelete(entryId) {
    await saveWithRollback(() => {
        schedule = schedule.filter(e => e.id !== entryId);
    });
}

Pattern này giải quyết: User thấy UI update ngay (tốt cho UX), nhưng nếu server lỗi (bot đang chạy, validation fail) → UI tự động rollback về trạng thái đúng.


8. Debounce — Tránh spam API

// admin/src/ui/src/modules/audio.js
let searchDebounce = null;

function onSearchInput(value) {
    // Hủy call cũ nếu user vẫn đang gõ
    clearTimeout(searchDebounce);

    // Chờ 300ms sau khi user ngừng gõ mới call API
    searchDebounce = setTimeout(async () => {
        await loadAudio({ q: value || null });
    }, 300);  // SEARCH_DEBOUNCE_MS = 300
}

Nếu không dùng debounce, mỗi ký tự gõ tạo một API call — người dùng gõ mười chữ là mười request. Debounce 300ms giải quyết điều đó: API chỉ được gọi sau khi người dùng ngừng gõ 300ms, nên cả câu tìm kiếm chỉ tạo ra một request duy nhất.


9. CustomEvent — Module Communication

Modules communicate qua browser events thay vì direct calls (tránh circular imports):

// audio.js — phát event khi data thay đổi
function emitChange() {
    window.dispatchEvent(new CustomEvent("audio:changed"));
}

export async function deleteAudio(id) {
    await api("DELETE", `/audio/${id}`);
    await loadAudio();
    emitChange();  // thông báo cho các module khác
}

// playlist.js — lắng nghe event từ audio module
window.addEventListener("audio:changed", async () => {
    // Playlist cần refresh vì có thể track bị xóa
    await loadPlaylists();
});

10. Tại sao không React/Vue?

React/Vue Vanilla JS (project này)
Bundle size ~40-100KB runtime ~0KB
Boilerplate Component, hooks, JSX Ít hơn
Reactivity Auto (Virtual DOM) Manual (render())
Scalability Tốt cho app lớn Khó maintain khi > 20 màn hình
Use case Public app, nhiều người dùng Admin panel nội bộ, ít màn hình

Khi nào chọn Vanilla JS: Admin panel nội bộ (< 20 màn hình), team nhỏ, performance critical (embed vào site), không muốn JS runtime overhead.

Khi nào chọn React/Vue: App công khai phức tạp, cần SSR (Next.js/Nuxt), team lớn cần component library, nhiều shared state phức tạp.


Nguyên lý tổng quát

Pattern trong bài Nguyên lý (bài 0) Chuyển giao
api.js centralized DRY có kỷ luật — 1 nơi biết về HTTP React có axios instance, Vue có $http — cùng nguyên tắc
Module state qua closure SoC — state private, mutation qua exported function Tương tự Redux pattern, đơn giản hơn
esc() trước khi innerHTML Defense in depth — XSS prevention ở render layer Framework hiện đại escape mặc định, nhưng bạn vẫn cần hiểu tại sao
Event delegation (1 listener trên parent) DRY ở DOM level — không bind N listener cho N element Pattern chung cho dynamic content, cả jQuery .on() và vanilla
Debounce 300ms cho search POLS + resource efficiency Rate limiting tương tự ở backend (request throttling)
Snapshot rollback (auto-save) Fail Soft với recovery — optimistic UI + rollback khi lỗi Redux có Redux Undo; React Query có optimistic update

Chuyển giao: SoC trong frontend

3 concern cơ bản của UI code:

  1. Data fetching — nói chuyện với server
  2. State management — giữ data và derive state
  3. Rendering — map state → DOM

Bất kỳ framework nào (React, Vue, Svelte, Vanilla) đều phải giải 3 concern này. Khác nhau ở ai quản lý (framework vs developer).

XSS — Defense in Depth

esc()1 layer. Production app nên có nhiều layer:

  • Escape khi render (esc())
  • Content Security Policy (CSP header chặn inline script từ nguồn không tin)
  • HttpOnly cookie (bài 8) — JS không đọc được token dù XSS xảy ra
  • Input validation — reject HTML tag trong field không cần HTML

Không có 1 layer nào "đủ". Nguyên lý: Defense in Depth — giả định mỗi layer có thể bị phá, thêm layer khác.

Khi nào chuyển sang React/Vue

Bài 10 §3 có checklist cụ thể. Dấu hiệu chung: > 20 màn hình, reuse component cao, form phức tạp, SEO quan trọng.


Bài tập áp dụng

Tạo một module quản lý tags:

// modules/tag.js
import { api } from "./api.js";
import { esc } from "./util.js";

let tags = [];
let containerId = null;

export async function loadTags() {
    tags = await api("GET", "/tags");
    render();
}

export function renderTagManager(targetId) {
    containerId = targetId;
    render();
    bindListeners();
}

function render() {
    document.getElementById(containerId).innerHTML = `
        <ul class="tag-list">
            ${tags.map(t => `
                <li data-id="${t.id}">
                    ${esc(t.name)}
                    <button class="btn-delete" data-id="${t.id}">×</button>
                </li>
            `).join("")}
        </ul>
        <form class="tag-form">
            <input name="name" placeholder="Tên tag" required>
            <button type="submit">Thêm</button>
        </form>
    `;
    // TODO: bindListeners()
}

Câu hỏi tự kiểm tra: 1. Tại sao credentials: "include" quan trọng? 2. Khi upload file, tại sao KHÔNG set Content-Type: multipart/form-data thủ công? 3. esc() bảo vệ khỏi tấn công gì? Khi nào cần dùng? 4. Sự khác biệt giữa event delegation và bind từng element?