Bài 6: Vite + Vanilla JS — Frontend Không Framework¶
Tham khảo trong project:
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 thực chất là gì? (Nó là Build Tool, không phải Framework UI)¶
Nhiều bạn mới hay nhầm lẫn Vite với các tên tuổi như React, Vue hay Angular. Thực ra, React/Vue là các Framework UI — chúng cung cấp cho bạn mô hình component, quản lý trạng thái (state) và cơ chế Virtual DOM để vẽ giao diện.
Còn Vite thì khác hoàn toàn: nó chỉ đơn thuần là một công cụ đóng gói (Build Tool). Nó chả quan tâm bạn code bằng React, Vue hay JavaScript thuần (Vanilla JS), việc của nó là:
- Lúc code (Dev mode): Chạy server cực nhanh, hỗ trợ module native của ES, sửa code là trình duyệt cập nhật tức thì (HMR).
- Lúc xuất xưởng (Prod mode): Gom tất cả code JS/CSS lại, nén cho nhẹ (minify), vứt bỏ code thừa (tree-shake), và đính thêm mã băm (hash) vào tên file để chống bộ nhớ đệm (cache).
admin/src/ui/src/ ← Nơi bạn hì hục gõ code
main.js
modules/audio.js
...
$ vite build ↓ (Hô biến!)
admin/src/static/ ← Sản phẩm đầu ra (Ném cho FastAPI phục vụ)
index.html
assets/
index-BtHFploo.js ← Tên file có dính mã hash = tự động dọn cache trình duyệt
index-DkC77ldt.css
Cấu hình Vite "chuẩn bài"¶
// admin/src/ui/vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
root: ".", // Chỗ chứa file index.html
build: {
outDir: "../static", // Nhổ output thẳng vào thư mục static của FastAPI
emptyOutDir: true, // Nhớ dọn rác (xóa output cũ) trước khi build
},
server: {
proxy: {
"/api": "http://localhost:8000", // Dev mode: Bắt luồng gọi API đẩy sang cho FastAPI xử lý
},
},
});
💡 Sự tích cái Proxy (Góc chú ý cho newbie):
- Lúc Dev: Giao diện chạy trên port
:5173, còn API chạy trên:8000. Vì lệch port nên trình duyệt sẽ chặn đứng lại (lỗi CORS). Cụcproxyở trên giúp "lừa" trình duyệt rằng giao diện đang gọi API trên cùng một nhà:5173, rồi âm thầm đẩy request đó sang:8000. Bạn không cần phải đau đầu config CORS ở phía server nữa.- Lúc Prod: Khi chạy lệnh
vite build, toàn bộ giao diện đã biến thành file tĩnh và được FastAPI ôm trọn (quaStaticFiles) để chạy chung nhà ở:8000. Lúc này cả UI và API đã quy về một mối, cái proxy kia tự động trở nên vô dụng (và cũng chả cần thiết nữa).Rất nhiều bạn kêu ca: "Code lúc dev thì chạy phà phà, build xong đem lên host thì UI vẫn lên mà API thì chết ngắc báo 404". Nguyên nhân 99% là do bạn chưa gắn đúng thư mục static vào FastAPI, hoặc bạn để dòng lệnh mount file tĩnh đè lấp lên các dòng route API.
2. Hệ thống Module — Kỷ nguyên ES Modules¶
Đã qua rồi cái thời bạn phải viết JS vào một cái file khổng lồ hay chèn cả tá thẻ <script> vào HTML. Vanilla JS hiện đại hỗ trợ ES modules y chang cách bạn dùng import/export trong 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";
// ↑ Gọi đồ từ nhà hàng xóm — Lúc build, Vite sẽ tự động gom chúng lại đúng thứ tự
Quên require() của Node.js đi. ES modules giờ là tiêu chuẩn quốc dân của trình duyệt rồi.
3. Tầng API — Quy về một mối (Centralized HTTP Client)¶
Thay vì chỗ nào cần data cũng gọi fetch() một cách vung vãi, hãy bắt tất cả chui qua một "trạm thu phí" duy nhất:
// admin/src/ui/src/modules/api.js
export async function api(method, path, body = null) {
const options = {
method,
credentials: "include", // ← Chìa khóa vàng: Tự động đính kèm cookie auth vào mọi request
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : null,
};
const res = await fetch(`/api${path}`, options);
// Bị đá mã 401 (Hết phiên đăng nhập) → Tự động tống cổ về trang chủ
if (res.status === 401) {
window.location.href = "/";
return;
}
// Xóa thành công (204 No Content) thì chả có data gì để đọc
if (res.status === 204) return null;
const json = await res.json();
// Server cằn nhằn (có lỗi) → Quăng lỗi ra cho các module khác tự bắt và hiển thị
if (!res.ok) throw new ApiError(json.error, res.status);
// Bóc vỏ quà: Server luôn trả về định dạng {"data": ...}, mình móc lấy lõi thôi
return json.data ?? json;
}
// Hàm Upload phải viết riêng vì nó xài FormData (Cần trình duyệt tự nhận diện 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,
// TUYỆT ĐỐI KHÔNG tự set Content-Type ở đây, để trình duyệt lo
});
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;
}
Lợi ích cực lớn: Ngày đẹp trời, sếp bảo đổi base URL, thay cách chứng thực token, hay muốn gắn thêm tracking vào mỗi request... Bạn chỉ cần mở đúng 1 file api.js ra sửa là xong!
4. Quản lý trạng thái (State) — Tuyệt chiêu Closure¶
Không có state của React hay ref của Vue, Vanilla JS xài chiêu gì? Đơn giản thôi: dùng biến cấp module (Module-level variables).
// admin/src/ui/src/modules/audio.js
// Khai báo biến "mật" (Không gắn chữ export nên file khác mù tịt)
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;
// Hàm "công khai" (Cho phép các file khác xài)
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(); // Kéo data xong thì tự động vẽ lại giao diện
}
export function getAudioList() {
return audioList; // Cho người ngoài "ngó" list nhạc, nhưng cấm sửa
}
Pattern này rất rõ ràng: Trạng thái (state) được giấu kín. Ai muốn đổi data thì bắt buộc phải gọi qua các hàm công khai. Nó giống như một phiên bản tinh gọn của Redux vậy.
5. Vẽ giao diện — Xài String Template cho lẹ¶
Không có Virtual DOM thần thánh, ta xài cách nguyên thủy nhưng chạy siêu mượt: Bơm cục HTML thuần bằng innerHTML.
// admin/src/ui/src/modules/audio.js
function render() {
const el = document.getElementById(containerId);
if (!el) return;
// Lắp ráp một cục HTML to đùng
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(); // Vẽ xong thì phải đi gắn lại các cục nam châm (sự kiện click)
}
function renderAudioCard(audio) {
// Để ý hàm esc() — Đây là lá chắn thép chống 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>
`;
}
Quy tắc sinh tử phòng chống XSS (Hack chèn mã độc):
// admin/src/ui/src/modules/util.js
export function esc(str) {
// Hàm này giúp mã hóa các ký tự nhạy cảm thành HTML entities
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Bất cứ chữ nghĩa nào do người dùng gõ vào (tên bài, mô tả...) khi muốn nhét vào innerHTML thì PHẢI đi qua bộ lọc esc(). Không có ngoại lệ.
6. Gắn sự kiện (Event Delegation) — Gắn một bắt mười¶
Hãy tưởng tượng bạn có 100 bài hát, mỗi bài có một nút Xóa.
function bindListeners() {
const el = document.getElementById(containerId);
// ❌ Sai lầm chết người: Gắn sự kiện cho từng cái nút
// Cứ mỗi lần hàm render() chạy là bạn phải gắn lại 100 cái tai nghe này. Cực kỳ tốn RAM!
// document.querySelectorAll(".btn-delete").forEach(btn => {
// btn.addEventListener("click", handleDelete);
// });
// ✅ Đỉnh cao thực chiến: Ủy quyền sự kiện (Event Delegation)
// Chỉ đeo đúng 1 cái tai nghe cho thằng thẻ cha ngoài cùng. Thằng con nào bị click thì nó sẽ soi xem là ai.
el.addEventListener("click", async (e) => {
const btn = e.target.closest("[data-id]");
if (!btn) return; // Click trật ra ngoài thì bỏ qua
const id = parseInt(btn.dataset.id);
if (btn.classList.contains("btn-delete")) {
await handleDelete(id);
} else if (btn.classList.contains("btn-edit")) {
openEditModal(id);
}
});
}
Mẹo này giúp hệ thống chạy êm ru dù bạn có render thêm bao nhiêu cục HTML đi chăng nữa.
7. Tuyệt chiêu "Lưu nháp & Hoàn tác" (Optimistic Rollback)¶
// admin/src/ui/src/modules/schedule.js
let schedule = [];
let saving = false; // Biến cờ: Đang lưu thì cấm bấm thêm
async function persistSchedule() {
const payload = schedule.map(toBackendShape);
const result = await api("POST", "/schedule", payload);
overlapWarnings = result?.warnings ?? [];
await loadSchedule(); // Đồng bộ lại ID xịn từ server
return result;
}
// Bọc mọi hành động sửa đổi vào hàm này
async function saveWithRollback(mutateFn) {
if (saving) return; // Cản trò spam click
// 1. Chụp hình lại trạng thái lúc bình yên
const snapshot = schedule.map(e => ({ ...e }));
// 2. Chơi bạo: Cập nhật giao diện LUÔN CHO NÓNG (Người dùng thấy ăn liền)
mutateFn();
render();
// 3. Âm thầm gửi lên server
saving = true;
try {
await persistSchedule();
} catch (err) {
// 4. Nếu server chửi (Lỗi data) → Khôi phục lại bức hình vừa chụp
schedule = snapshot;
render();
notify(err.message, "error");
} finally {
saving = false;
}
}
// Ví dụ: Bấm thêm bài mới
async function handleFormSubmit(formData) {
await saveWithRollback(() => {
schedule.push({ ...formData, id: nextLocalId-- }); // Thêm đại bằng ID ảo trước
});
}
// Ví dụ: Bấm xóa bài
async function handleDelete(entryId) {
await saveWithRollback(() => {
schedule = schedule.filter(e => e.id !== entryId); // Xóa bay khỏi màn hình ngay lập tức
});
}
Tại sao phải cực thế? Vì UX (trải nghiệm người dùng)! Bạn cập nhật giao diện ngay lập tức sẽ tạo cảm giác web chạy nhanh như điện. Chẳng may API xịt thì mình lùi lại (Rollback). Pattern này xịn không kém gì các framework khủng đâu.
8. Hãm phanh (Debounce) — Đừng hành hạ Server¶
// admin/src/ui/src/modules/audio.js
let searchDebounce = null;
function onSearchInput(value) {
// Xóa ngay cái hẹn giờ cũ nếu user vẫn đang hí hoáy gõ
clearTimeout(searchDebounce);
// Hẹn giờ: Chỉ khi user DỪNG gõ khoảng 300ms thì mới bóp cò gọi API
searchDebounce = setTimeout(async () => {
await loadAudio({ q: value || null });
}, 300);
}
Nếu không có trò này, user gõ chữ K H I → Server sẽ phải hứng trọn 3 cú API call liên tiếp. Có Debounce, server chỉ phải phục vụ 1 cú call cuối cùng.
9. CustomEvent — Cây loa phát thanh liên Module¶
Làm sao để module A báo tin cho module B mà không phải import lẫn nhau (tránh lỗi vòng lặp import luẩn quẩn)? Dùng loa phát thanh của trình duyệt!
// audio.js — Vừa xóa nhạc xong, phát loa thông báo!
function emitChange() {
window.dispatchEvent(new CustomEvent("audio:changed"));
}
export async function deleteAudio(id) {
await api("DELETE", `/audio/${id}`);
await loadAudio();
emitChange(); // Alo alo, nhà tui vừa có thay đổi nha
}
// playlist.js — Lắng nghe loa phường
window.addEventListener("audio:changed", async () => {
// Có file bị xóa rồi, rà soát lại danh sách Playlist ngay kẻo dính link chết!
await loadPlaylists();
});
10. Tại sao không nã React/Vue vào đây?¶
| Bàn cân | React/Vue | Vanilla JS (Dự án này) |
|---|---|---|
| Cục nợ (Bundle size) | Gánh thêm ~40-100KB | ~0KB (Nhẹ tựa lông hồng) |
| Độ rườm rà | Setup Component, Hooks, JSX | File JS thuần, gõ là chạy |
| Reactivity | Tự động (Virtual DOM) | Chạy tay (render()) |
| Khả năng phình to | Cực tốt cho app siêu to khổng lồ | Sẽ vỡ trận nếu Web > 20 màn hình |
| Phù hợp nhất khi | App dùng cho đại chúng, tương tác dồn dập | Admin panel nội bộ, nhỏ gọn |
Kết luận: Đừng lấy dao mổ trâu đi giết gà. Nếu hệ thống Admin của bạn chỉ loay hoay < 20 màn hình, ít tương tác phức tạp, team lại nhỏ... thì Vanilla JS kết hợp Vite là sự lựa chọn vẹn cả đôi đường về cả tốc độ phát triển lẫn trải nghiệm sử dụng.
Nguyên lý tổng quát¶
| Kỹ thuật trong bài | Nguyên lý (Bài 0) | Chuyển giao đi nơi khác |
|---|---|---|
Gom API vào api.js |
DRY (có kỷ luật) | Sang React có xài axios instance thì tư duy cũng y hệt. |
| Giấu State vào nội bộ module | SoC (Tách biệt mối quan tâm) | Biến nội bộ, sửa qua hàm export. Rất giống tư tưởng Redux. |
Luôn gọi esc() trước khi render |
Defense in depth (Phòng thủ nhiều lớp) | React/Vue nó làm hộ bạn vụ này, nhưng làm dev thì phải hiểu tại sao có nó. |
| Ủy quyền sự kiện (Event delegation) | DRY ở cấp độ DOM | Dù code jQuery hay JavaScript thuần, đừng bao giờ bind hàng tá event lặp lại. |
| Debounce 300ms | POLS + Tiết kiệm tài nguyên | Chống spam ở Frontend cũng là bảo vệ Backend. |
| Lưu nháp và hoàn tác | Fail Soft (với khả năng khôi phục) | React Query có tính năng optimistic update cũng dùng triết lý này. |
XSS — Hãy phòng thủ nhiều lớp¶
Cái hàm esc() chỉ là 1 lớp áo giáp. Hệ thống thực chiến cần ráp thêm:
- Lớp 2: Content Security Policy (CSP) (Cấm chạy script lạ trên header).
- Lớp 3: HttpOnly cookie (JS không đọc được token, lỡ bị XSS cũng không lộ JWT).
- Lớp 4: Chặn ký tự độc hại ngay tại Backend.
Nguyên lý: Cứ giả định lớp ngoài cùng sẽ bị chọc thủng, hãy thiết lập thêm lớp rào chắn phía sau.
Bài tập áp dụng¶
Thử sức: Tự viết một module quản lý danh sách các Thẻ (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>
`;
// Thử thách: Bạn hãy tự viết thân hàm bindListeners() nhé!
}
Hãy tự nhẩm trong đầu:
- Tham số
credentials: "include"trong cái hàmfetchđóng vai trò sinh tử gì? - Tại sao khi nhồi cục file âm thanh đẩy lên server, ta KHÔNG ĐƯỢC phép tự tay gắn
Content-Type: multipart/form-data? - Cái hàm
esc()sinh ra để chặn trò bẩn nào của Hacker? Khi nào thì bắt buộc phải dùng tới nó? - Đeo 1 cái tai nghe cho thằng thẻ Cha (Event Delegation) ngon hơn đeo 100 cái tai nghe cho 100 thằng thẻ Con ở điểm nào?