Bài 16. Dự án: ứng dụng ghi chú hoàn chỉnh

Dự án tổng hợp: ứng dụng ghi chú chạy trên trình duyệt với thêm/sửa/xóa, tìm kiếm có debounce, lưu localStorage, xuất/nhập tệp JSON, chia mô-đun và có kiểm thử.

120 phútTrung cấp
Bạn sẽ học được
  • Tổ chức dự án theo mô-đun ES
  • Áp dụng DOM, sự kiện, closure, Web Storage
  • Tách logic thuần để kiểm thử
  • Triển khai lên GitHub Pages
Thuật ngữ cần biết
Single source of truth
Một nơi duy nhất lưu trạng thái, giao diện vẽ lại từ đó.
Render
Hàm vẽ giao diện từ dữ liệu.
Event delegation
Gắn một bộ nghe sự kiện ở phần tử cha cho mọi phần tử con.

Khái niệm và lý thuyết

Kiến trúc: notes.js chứa hàm thuần (thêm, sửa, xóa, lọc) nhận mảng cũ trả mảng mới; storage.js đọc/ghi localStorage; main.js giữ trạng thái và hàm render(). Mọi thao tác đều theo vòng: sự kiện → cập nhật trạng thái → lưu → render. Đây chính là tư tưởng của React, Vue mà bạn sẽ học tiếp.

Cấu trúc

ghi-chu/
├── index.html
├── style.css
└── src/
    ├── notes.js      # logic thuần
    ├── storage.js
    ├── main.js
    └── notes.test.js

notes.js

export const them = (ds, noiDung) =>
  [{ id: crypto.randomUUID(), noiDung, ghim: false, capNhat: Date.now() }, ...ds];
export const sua = (ds, id, noiDung) =>
  ds.map(n => n.id === id ? { ...n, noiDung, capNhat: Date.now() } : n);
export const xoa = (ds, id) => ds.filter(n => n.id !== id);
export const ghim = (ds, id) => ds.map(n => n.id === id ? { ...n, ghim: !n.ghim } : n);
export const loc = (ds, tu) =>
  ds.filter(n => n.noiDung.toLowerCase().includes(tu.toLowerCase()))
    .toSorted((a, b) => b.ghim - a.ghim || b.capNhat - a.capNhat);

main.js

import * as N from "./notes.js";
import { doc, ghi } from "./storage.js";
let ds = doc(), tuKhoa = "";
const $ = s => document.querySelector(s);

function capNhat(moi) { ds = moi; ghi(ds); render(); }
function render() {
  $("#ds").innerHTML = N.loc(ds, tuKhoa).map(n => `
    <li data-id="${n.id}" class="${n.ghim ? "ghim" : ""}">
      <p></p>
      <button data-act="ghim">📌</button><button data-act="xoa">🗑</button>
    </li>`).join("");
  $("#ds").querySelectorAll("li").forEach((li, i) =>
    li.querySelector("p").textContent = N.loc(ds, tuKhoa)[i].noiDung); // tránh XSS
}
$("#form").addEventListener("submit", e => {
  e.preventDefault();
  const v = $("#o-nhap").value.trim();
  if (v) { capNhat(N.them(ds, v)); e.target.reset(); }
});
$("#ds").addEventListener("click", e => {            // event delegation
  const act = e.target.dataset.act, id = e.target.closest("li")?.dataset.id;
  if (act === "xoa" && confirm("Xóa ghi chú?")) capNhat(N.xoa(ds, id));
  if (act === "ghim") capNhat(N.ghim(ds, id));
});
let hen;
$("#tim").addEventListener("input", e => {
  clearTimeout(hen); hen = setTimeout(() => { tuKhoa = e.target.value; render(); }, 250);
});
render();

Nội dung người dùng gán bằng textContent, không chèn thẳng vào innerHTML để tránh lỗ hổng XSS.

Mẹo hay

Đẩy thư mục lên GitHub, vào Settings → Pages chọn nhánh main: ứng dụng có địa chỉ công khai sau khoảng một phút.

Lưu ý

Mô-đun ES không chạy khi mở tệp trực tiếp (file://). Dùng tiện ích Live Server của VS Code hoặc npx serve.

Thực hành
  1. Hoàn thiện ứng dụng theo khung trên, thêm chức năng sửa khi nhấp đúp.
  2. Thêm nút xuất toàn bộ ghi chú ra tệp JSON và nhập lại từ tệp.
  3. Viết test cho notes.js bằng Vitest (Bài 15).
  4. Viết lại bằng TypeScript theo loạt bài TypeScript.
Tóm tắt bài học
  • Tách logic thuần, lưu trữ, giao diện.
  • Vòng sự kiện → trạng thái → lưu → render.
  • Event delegation và debounce giúp hiệu quả.
  • Dùng textContent để an toàn trước XSS.