JavaScript 实战:不用框架写一个前端小应用

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Chrome 130

框架不是必需品。状态注入、事件委托、局部渲染这三件事想清楚,一个几百行的原生应用也能好维护。下面做一个完整的读书清单:添加、标记读完、删除、筛选、本地保存。

目标与结构

books/
  index.html
  app.js
  styles.css

功能清单:

  1. 输入书名添加条目,空值与非空校验;
  2. 勾选「已读」,可按全部/未读筛选;
  3. 删除条目,带确认;
  4. 数据存 localStorage,刷新不丢;
  5. 键盘可操作,列表区有 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 提示。

下一步