JavaScript cơ bản – Bài 18: Dự án thực hành: ứng dụng quản lý công việc học tập
- 17-10-2026
- Toanngo92
- 0 Comments
Loạt bài JavaScript cơ bản · Bài 18/18 — Mục lục học tập
Đây là bài tổng hợp của loạt JavaScript cơ bản. Bạn sẽ xây dựng danh sách công việc học tập: thêm việc mới, đánh dấu hoàn thành, xóa việc và giữ dữ liệu sau khi tải lại trang. Ứng dụng chạy trong trình duyệt, không cần framework và không có máy chủ lưu dữ liệu.
Mục lục
Yêu cầu và mô hình dữ liệu
- Công việc có text là chuỗi và done là boolean.
- Nội dung sau khi bỏ khoảng trắng không được rỗng; tối đa 120 đơn vị độ dài chuỗi.
- Mỗi lần thay đổi đều cập nhật giao diện và thử lưu vào localStorage.
- Nếu lưu thất bại, người dùng vẫn thao tác được và có thông báo.
Mảng tasks là nguồn dữ liệu chính. Hàm render tạo giao diện từ mảng; loadTasks và saveTasks phụ trách lưu trữ. Bản nhỏ này dùng vị trí trong mảng bên trong các callback và dựng lại listener sau mỗi render. Khi thêm tính năng lọc hoặc sắp xếp độc lập, nên bổ sung định danh ổn định cho từng công việc.
Mã hoàn chỉnh
Lưu toàn bộ mã dưới đây thành index.html trong một thư mục riêng. Mở bằng máy chủ tĩnh cục bộ của trình soạn thảo và giữ cùng địa chỉ để kiểm tra lưu trữ. Đây là mã để tự chạy trên máy, không dán thẻ script trực tiếp vào nội dung bài WordPress.
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Công việc học tập</title>
<style>
body { font: 16px/1.6 system-ui, sans-serif; max-width: 680px; margin: 40px auto; padding: 0 16px; color: #182638; }
form { display: flex; gap: 8px; flex-wrap: wrap; }
label[for="task"] { width: 100%; }
input[type="text"] { flex: 1; min-width: 160px; padding: 10px; }
button { padding: 8px 12px; cursor: pointer; }
ul { padding: 0; list-style: none; }
li { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid #ddd; }
li label { flex: 1; overflow-wrap: anywhere; }
.done { text-decoration: line-through; color: #596575; }
#storage-message { color: #9c3b12; }
</style>
</head>
<body>
<h1>Công việc học tập</h1>
<form id="task-form">
<label for="task">Công việc mới</label>
<input id="task" type="text" maxlength="120" required placeholder="Ví dụ: luyện 3 bài vòng lặp">
<button type="submit">Thêm</button>
</form>
<p id="message" role="status"></p>
<p id="storage-message" role="status"></p>
<p id="summary" aria-live="polite"></p>
<ul id="task-list"></ul>
<script>
"use strict";
const KEY = "hvc-basic-tasks-v1";
const form = document.querySelector("#task-form");
const input = document.querySelector("#task");
const message = document.querySelector("#message");
const storageMessage = document.querySelector("#storage-message");
const summary = document.querySelector("#summary");
const list = document.querySelector("#task-list");
let tasks = loadTasks();
function loadTasks() {
try {
const raw = localStorage.getItem(KEY);
if (raw === null) return [];
const data = JSON.parse(raw);
const valid = Array.isArray(data) && data.length <= 100 && data.every((item) =>
item !== null && typeof item === "object" &&
typeof item.text === "string" && item.text.trim().length > 0 &&
item.text.length <= 120 && typeof item.done === "boolean"
);
if (!valid) throw new Error("Dữ liệu không đúng cấu trúc");
return data.map((item) => ({ text: item.text, done: item.done }));
} catch (error) {
storageMessage.textContent = "Không đọc được dữ liệu cũ. Bạn có thể tạo danh sách mới.";
console.warn(error);
return [];
}
}
function saveTasks() {
try {
localStorage.setItem(KEY, JSON.stringify(tasks));
storageMessage.textContent = "";
} catch (error) {
storageMessage.textContent = "Chưa lưu được. Dữ liệu hiện tại chỉ được giữ đến khi tải lại trang.";
console.warn(error);
}
}
function render() {
list.replaceChildren();
tasks.forEach((task, index) => {
const item = document.createElement("li");
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = task.done;
const text = document.createElement("span");
text.textContent = " " + task.text;
text.classList.toggle("done", task.done);
label.append(checkbox, text);
checkbox.addEventListener("change", () => {
tasks[index].done = checkbox.checked;
saveTasks();
render();
const nextCheckbox = list.querySelectorAll('input[type="checkbox"]')[index];
if (nextCheckbox) nextCheckbox.focus();
});
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.textContent = "Xóa";
removeButton.setAttribute("aria-label", "Xóa công việc: " + task.text);
removeButton.addEventListener("click", () => {
tasks.splice(index, 1);
saveTasks();
render();
input.focus();
message.textContent = "Đã xóa công việc.";
});
item.append(label, removeButton);
list.append(item);
});
const doneCount = tasks.filter((task) => task.done).length;
summary.textContent = tasks.length === 0
? "Danh sách đang trống. Hãy thêm công việc đầu tiên."
: "Đã hoàn thành " + doneCount + "/" + tasks.length + " công việc.";
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = input.value.trim();
if (text.length === 0 || text.length > 120) {
message.textContent = "Nhập nội dung từ 1 đến 120 ký tự.";
input.focus();
return;
}
if (tasks.length >= 100) {
message.textContent = "Ví dụ giới hạn 100 công việc. Hãy xóa bớt trước khi thêm.";
return;
}
tasks.push({ text, done: false });
saveTasks();
render();
form.reset();
input.focus();
message.textContent = "Đã thêm công việc.";
});
render();
</script>
</body>
</html>Đọc chương trình theo luồng
- Trang đọc dữ liệu cũ, kiểm tra cấu trúc và gọi render lần đầu.
- Người dùng submit form: chương trình chặn điều hướng, kiểm tra nội dung rồi thêm object vào mảng.
- saveTasks ghi chuỗi JSON; render dựng lại danh sách bằng các phần tử DOM.
- Checkbox thay đổi done; nút Xóa bỏ phần tử rồi lưu và dựng lại giao diện.
Nội dung công việc được gán bằng textContent, nên văn bản người dùng nhập không được phân tích thành thẻ HTML. Ứng dụng không dùng localStorage.clear và chỉ thao tác khóa riêng của ví dụ.
Kịch bản tự kiểm tra
- Danh sách mới hiển thị thông báo trống.
- Thêm hai việc, đánh dấu một việc: thống kê là 1/2.
- Tải lại: hai việc và trạng thái hoàn thành vẫn còn khi lưu trữ hoạt động.
- Xóa một việc: số lượng và dữ liệu lưu thay đổi theo.
- Nhập chỉ khoảng trắng: không tạo mục rỗng.
- Nhập chữ có ký tự < và >: giao diện hiển thị văn bản.
- Thao tác được bằng bàn phím; lỗi đọc hoặc ghi dữ liệu có thông báo.
Bài tập mở rộng
Tự bổ sung bộ lọc Tất cả / Chưa xong / Đã xong. Trước khi lọc, thêm id ổn định vào mỗi công việc và dùng id để cập nhật, vì chỉ số của danh sách đã lọc có thể khác chỉ số trong mảng gốc. Sau đó thử thêm tính năng sửa nội dung và đếm số việc còn lại.
Tổng kết: bạn đã kết hợp dữ liệu, hàm, DOM và sự kiện thành một ứng dụng hoàn chỉnh ở mức nhập môn. Hãy tự dựng lại từng phần trước khi học tiếp Promise, Async/Await hoặc thư viện giao diện.

