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:5173sang:8000, giả làm same-origin → không cần bật CORS ở server.- Prod:
vite build→ outputadmin/src/static/. FastAPI dùngStaticFilesserve 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 quaStaticFiles, 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
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:
- Data fetching — nói chuyện với server
- State management — giữ data và derive state
- 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() là 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?