JavaScript 实战:不用框架写一个前端小应用
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
框架不是必需品。状态注入、事件委托、局部渲染这三件事想清楚,一个几百行的原生应用也能好维护。下面做一个完整的读书清单:添加、标记读完、删除、筛选、本地保存。
目标与结构
books/
index.html
app.js
styles.css
功能清单:
- 输入书名添加条目,空值与非空校验;
- 勾选「已读」,可按全部/未读筛选;
- 删除条目,带确认;
- 数据存
localStorage,刷新不丢; - 键盘可操作,列表区有
aria-live提示。
HTML 骨架
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>读书清单</title>
<link rel="stylesheet" href="./styles.css" />
<script type="module" src="./app.js"></script>
</head>
<body>
<main class="app">
<h1>读书清单</h1>
<form id="add-form" novalidate>
<label for="title">书名</label>
<input id="title" name="title" required maxlength="80" autocomplete="off" />
<button type="submit">添加</button>
<p id="form-error" class="error" role="alert" hidden></p>
</form>
<div class="filters" role="group" aria-label="筛选">
<button type="button" data-filter="all" aria-pressed="true">全部</button>
<button type="button" data-filter="open" aria-pressed="false">未读</button>
<button type="button" data-filter="done" aria-pressed="false">已读</button>
</div>
<p id="summary" role="status" aria-live="polite"></p>
<ul id="list" class="list"></ul>
<p id="empty" class="empty" hidden>还没有条目,先添加一本。</p>
</main>
</body>
</html>
type="module" 让脚本延迟执行,DOM 已就绪,不用等 DOMContentLoaded。
单一状态 + 渲染
核心约定:状态是唯一事实来源,改状态后调用 render(),不直接改 DOM。
// app.js
const STORAGE_KEY = "books.v1";
const state = {
books: load(),
filter: "all",
};
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter(isValidBook) : [];
} catch {
return [];
}
}
function isValidBook(item) {
return item && typeof item.id === "string" && typeof item.title === "string";
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.books));
}
读取时做一次结构校验,避免旧版本数据或手改的存储值让页面直接崩掉。
const listEl = document.querySelector("#list");
const emptyEl = document.querySelector("#empty");
const summaryEl = document.querySelector("#summary");
function visibleBooks() {
if (state.filter === "open") return state.books.filter((b) => !b.done);
if (state.filter === "done") return state.books.filter((b) => b.done);
return state.books;
}
function render() {
const books = visibleBooks();
const frag = document.createDocumentFragment();
for (const book of books) {
const li = document.createElement("li");
li.className = "item";
li.dataset.id = book.id;
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = book.done;
checkbox.dataset.action = "toggle";
const text = document.createElement("span");
text.textContent = book.title; // 永远用 textContent
text.classList.toggle("done", book.done);
label.append(checkbox, text);
const remove = document.createElement("button");
remove.type = "button";
remove.dataset.action = "delete";
remove.textContent = "删除";
remove.setAttribute("aria-label", `删除《${book.title}》`);
li.append(label, remove);
frag.append(li);
}
listEl.replaceChildren(frag); // 一次性替换,避免残留节点
emptyEl.hidden = books.length > 0;
const done = state.books.filter((b) => b.done).length;
summaryEl.textContent = `共 ${state.books.length} 本,已读 ${done} 本`;
}
全量重绘在几百条数据内完全够用;条目上万条再考虑增量更新或虚拟列表。replaceChildren 比 innerHTML = "" + 逐条 append 更干净。
事件委托
列表是动态的,监听器挂在容器上:
listEl.addEventListener("click", (event) => {
const target = event.target.closest("[data-action]");
if (!target || !listEl.contains(target)) return;
const id = target.closest(".item")?.dataset.id;
if (!id) return;
if (target.dataset.action === "delete") {
const book = state.books.find((b) => b.id === id);
if (!book || !confirm(`删除《${book.title}》?`)) return;
state.books = state.books.filter((b) => b.id !== id);
} else if (target.dataset.action === "toggle") {
const book = state.books.find((b) => b.id === id);
if (!book) return;
book.done = target.checked;
}
save();
render();
});
复选框用 click 能拿到最新的 checked;用 change 也一样,但注意键盘空格触发时的事件顺序。
表单与校验
const form = document.querySelector("#add-form");
const titleInput = document.querySelector("#title");
const errorEl = document.querySelector("#form-error");
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = titleInput.value.trim();
if (!title) {
showError("书名不能为空");
titleInput.focus();
return;
}
if (state.books.some((b) => b.title === title)) {
showError("这本书已经在清单里了");
titleInput.select();
return;
}
state.books.unshift({
id: crypto.randomUUID(),
title,
done: false,
createdAt: new Date().toISOString(),
});
save();
render();
hideError();
form.reset();
titleInput.focus(); // 键盘用户停留在输入位置
});
function showError(message) {
errorEl.textContent = message;
errorEl.hidden = false;
titleInput.setAttribute("aria-invalid", "true");
}
function hideError() {
errorEl.hidden = true;
titleInput.removeAttribute("aria-invalid");
}
titleInput.addEventListener("input", hideError);
novalidate 关掉浏览器气泡,改用自己可控的提示;如果不想接管,去掉 novalidate 直接用内置校验。
筛选按钮
const filterGroup = document.querySelector(".filters");
filterGroup.addEventListener("click", (event) => {
const btn = event.target.closest("[data-filter]");
if (!btn) return;
state.filter = btn.dataset.filter;
for (const button of filterGroup.querySelectorAll("[data-filter]")) {
button.setAttribute("aria-pressed", String(button === btn));
}
render();
});
aria-pressed 既表达选中态,也方便 CSS 用 [aria-pressed="true"] 写样式,不必再加 class。
样式要点
.done { text-decoration: line-through; opacity: 0.6; }
.item { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
:focus-visible { outline: 2px solid #10b981; outline-offset: 2px; }
三件事别省:可点击区域足够大、focus-visible 有可见轮廓、done 不只靠颜色区分(加删除线)。
数据迁移
应用会迭代,存储结构会变。留一个版本号,迁移逻辑集中在 load():
const VERSION = 2;
function migrate(data) {
if (!data || typeof data !== "object") return [];
// v1 是字符串数组
if (Array.isArray(data) && typeof data[0] === "string") {
return data.map((title) => ({ id: crypto.randomUUID(), title, done: false }));
}
return data.books ?? [];
}
否则用户升级后看到空白列表,还以为数据丢了。
常见错误与注意事项
1. 直接改 DOM 又改状态
两套事实来源必然打架。所有交互都走「改 state → save() → render()」。
2. 用 innerHTML 拼书名
书名来自用户输入,<img src=x onerror=…> 就会执行。渲染一律 textContent。
3. localStorage 写入失败没兜底
隐私模式、配额满都会抛 QuotaExceededError。save() 外面包 try/catch,失败时提示「本次修改未保存」。
4. 每次输入都写存储
输入框的 input 事件别直接落盘,等提交或加防抖,否则频繁写会卡。
5. 删除没有二次确认
confirm 不算优雅但有效;要做自定义弹窗,记得把焦点移进对话框并支持 Esc 关闭。
6. 只用鼠标能操作
div 加 onclick 键盘按不到。用原生 button、input,或补 tabindex 与 keydown。
7. 忘记处理空状态
删到零条后页面只剩空白,用户以为出错。用 #empty 提示。
小结
- 一个状态对象 + 一个
render(),是原生应用最省心的结构。 - 动态列表用事件委托,
closest()拿到data-action与条目 id。 - 用户输入只用
textContent渲染;存储读取要校验结构、留迁移入口。 - 表单校验自己接管时,同步
aria-invalid与错误提示。 - 可访问性不是装饰:原生控件、焦点样式、
aria-live提示。