Bài 12 · Nâng cao · 30 phút· Cập nhật 11/06/2026
Dự án: app web nhỏ
Biên soạn bởi Nguyễn Anh Tuấn
Dự án JavaScript cuối khoá: dựng app To-Do hoàn chỉnh - tách lõi khỏi UI, render DOM, event delegation, lưu bằng localStorage + JSON. Chạy được ngay.
Bài cuối: ráp mọi thứ thành một app To-Do chạy được - thử ngay bên dưới (thêm, đánh dấu, lọc, xoá):
- Học closure & this
- Hiểu event loop
- Làm app To-Do
Mỗi việc là một object gồm id, text, done; cả app là một mảng các việc. Nguyên tắc xuyên suốt: tách LÕI (logic thuần) khỏi UI (DOM) - đúng cách mọi công cụ trong khoá được dựng.
mô hình dữ liệu
// Mot viec la mot object:
const task = { id: 1, text: "Hoc JS", done: false };
// Ca app la mot MANG cac viec:
let tasks = []; - ▸App = dữ liệu (mảng task) + lõi + render + sự kiện + lưu trữ.
- ▸Tách lõi (logic thuần) khỏi UI (DOM) để test & bảo trì dễ.
- ▸Vòng lặp một chiều: sự kiện → cập nhật state → render lại.
Toàn bộ logic là hàm thuần: nhận dữ liệu, trả về dữ liệu MỚI (bất biến) - dựng từ map/filter/reduce của Bài 5. Không đụng DOM, nên test được độc lập.
store.js - không có DOM ở đây
function addTask(tasks, text) {
const id = tasks.reduce((m, t) => Math.max(m, t.id), 0) + 1;
return [...tasks, { id, text, done: false }]; // mang MOI
}
function toggleTask(tasks, id) {
return tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
}
function deleteTask(tasks, id) {
return tasks.filter((t) => t.id !== id);
}
function stats(tasks) {
const done = tasks.reduce((n, t) => n + (t.done ? 1 : 0), 0);
return { total: tasks.length, done, active: tasks.length - done };
} - ▸Lõi = hàm thuần: vào dữ liệu → ra dữ liệu MỚI; không phụ thuộc DOM.
- ▸Dùng map/filter/reduce + spread (Bài 5); luôn bất biến.
- ▸Vì thuần nên kiểm thử dễ - đúng tinh thần các công cụ tương tác của khoá.
Render là một hàm: nhận mảng task → vẽ ra màn hình (Bài 9). Dùng textContent cho nội dung người dùng nhập (an toàn, tránh XSS).
ui.js - vẽ từ dữ liệu
const ul = document.querySelector("#ds");
function render(tasks) {
ul.innerHTML = ""; // xoa danh sach cu
for (const t of tasks) {
const li = document.createElement("li");
li.dataset.id = t.id; // nho id de xu ly su kien
li.textContent = (t.done ? "✓ " : "") + t.text; // an toan
const nut = document.createElement("button");
nut.className = "xoa"; // main.js nhan ra qua .matches(".xoa")
nut.dataset.id = t.id;
nut.textContent = "×";
li.append(" ", nut);
ul.append(li);
}
} - ▸render(tasks): nhận dữ liệu → cập nhật DOM (Bài 9).
- ▸textContent an toàn cho dữ liệu người dùng (tránh XSS).
- ▸Cách đơn giản & ít lỗi: vẽ LẠI danh sách sau mỗi thay đổi.
Nối lõi với giao diện bằng sự kiện (Bài 9). Mỗi thao tác đi theo một vòng: sự kiện → cập nhật state bằng lõi → render lại.
main.js - điều phối
form.addEventListener("submit", (e) => {
e.preventDefault(); // khong reload trang
tasks = addTask(tasks, input.value);
input.value = "";
render(tasks);
});
// Mot listener cho ca danh sach (event delegation):
ul.addEventListener("click", (e) => {
const id = Number(e.target.dataset.id);
if (e.target.matches(".xoa")) tasks = deleteTask(tasks, id);
else tasks = toggleTask(tasks, id);
render(tasks);
}); - ▸Vòng một chiều: sự kiện → cập nhật state (lõi) → render lại.
- ▸preventDefault() chặn form tải lại trang.
- ▸Event delegation: một listener ở cha xử lý mọi dòng (Bài 9).
Để app “nhớ” qua mỗi lần mở, lưu vào localStorage dưới dạng JSON (Bài 5). Muốn đồng bộ nhiều thiết bị thì cần server - gọi bằng fetch (Bài 10) + async/await (Bài 8).
lưu & nạp
function luu(tasks) {
localStorage.setItem("tasks", JSON.stringify(tasks)); // object -> chuoi
}
function nap() {
return JSON.parse(localStorage.getItem("tasks") || "[]"); // chuoi -> object
}
// goi luu(tasks) sau moi thay doi; tasks = nap() luc khoi dong.
// Dong bo voi SERVER (tuy chon) - fetch (Bai 10) + async/await (Bai 8):
// (jsonplaceholder: API gia-lap mien phi, chay that - doi sang server cua ban sau)
async function taiTuServer() {
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
return res.json();
} - ▸localStorage giữ dữ liệu qua các lần tải lại; chỉ lưu CHUỖI → JSON.stringify/parse.
- ▸Lưu sau mỗi thay đổi; nạp lúc khởi động.
- ▸Đa thiết bị/đám mây → server qua fetch + async/await (Bài 8 & 10).
Giới hạn của localStorage
Cuối cùng, tách file theo vai trò (Bài 11): store.js (lõi), ui.js (render), main.js (điều phối) - rồi npm run build để xuất bản.
ba file, đúng ranh giới lõi-UI
// store.js - loi (khong dung DOM)
export { addTask, toggleTask, deleteTask, stats };
// ui.js - render DOM
export function render(tasks) { /* ... ve danh sach ... */ }
// main.js - dieu phoi: gan su kien, goi store + ui
import { addTask, toggleTask } from "./store.js";
import { render } from "./ui.js"; - ▸Tách store (lõi) / ui (render) / main (điều phối) - đúng ranh giới lõi-UI.
- ▸import/export nối các file (Bài 11); Vite gộp & tối ưu.
- ▸npm run build → thư mục dist/ để deploy lên web.
Chúc mừng - bạn đã hoàn thành khoá! 🎉
Câu hỏi thường gặp
Vì lõi (hàm thuần trên dữ liệu) test được độc lập, không cần DOM; UI chỉ lo render. Sửa giao diện không đụng logic và ngược lại. Đây đúng là nguyên tắc mọi công cụ tương tác trong khoá này dùng - và là lý do chúng kiểm thử được.
Dữ liệu bất biến dễ suy luận và so sánh “trước/sau”, tránh bug do chia sẻ tham chiếu, và hợp với cách các framework (React/Svelte) phát hiện thay đổi để vẽ lại. map/filter/spread (Bài 5) trả về dữ liệu MỚI một cách tự nhiên.
Là kho key-value của trình duyệt, giữ qua các lần tải lại trang. Nó chỉ lưu CHUỖI → dùng JSON.stringify khi lưu và JSON.parse khi đọc. Hợp cho dữ liệu nhỏ; dữ liệu lớn hoặc đồng bộ đa thiết bị thì cần server.
localStorage là đồng bộ nên app cơ bản không cần. Khi muốn đồng bộ với SERVER (lưu đám mây, dùng nhiều thiết bị), bạn gọi API bằng fetch (Bài 10) + async/await (Bài 8) để tải/đẩy dữ liệu mà không chặn giao diện.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Vì sao app tách LÕI (logic thuần) khỏi UI (DOM)?
- 1
Dùng thử
Với app ở Bước 1: thêm vài task, đánh dấu xong, đổi bộ lọc. Quan sát số đếm thay đổi.
Hoàn thành khi: Thêm/đánh dấu/lọc hoạt động; phần thống kê (chưa xong / hoàn thành) cập nhật.
- 2
Hàm thuần
Tự viết
toggleTask(tasks, id)trả về MẢNG MỚI, không sửa mảng gốc.Hoàn thành khi: Dùng
map; chỉ task khớpidđổidone; mảng gốc giữ nguyên. - 3
Render
Viết
render(tasks)tạo một<li>cho mỗi task (map+createElement/textContent).Hoàn thành khi: Danh sách trên trang khớp đúng dữ liệu; dùng
textContent(an toàn). - 4
Sự kiện
Gắn submit form để thêm; dùng delegation trên
ulđể đánh dấu/xoá.Hoàn thành khi: Mỗi thao tác: cập nhật state bằng lõi → gọi render lại.
- 5
localStorage
Lưu
tasksmỗi khi đổi (JSON.stringify) và nạp lúc mở (JSON.parse).Hoàn thành khi: Tải lại trang vẫn còn task như trước.
- 6
Mở rộng
Thêm MỘT tính năng: sửa nội dung task, hoặc “xoá đã xong”, hoặc đếm còn lại.
Hoàn thành khi: Tính năng mới chạy; bạn giải thích được luồng sự kiện → state → render.