知

模块一 · 语言与 DOM

第 1 / 14 节19 分钟阅读型

起点:HTML、DOM 与第一个可交互页面

框架之前先有页面。这一节讲清 HTML 的语义结构、表单与可访问性、DOM 树与事件模型,并用原生 JS 写出一个可用的待办列表。

读完这一节,你能做到

  1. 写出一份结构正确、语义清晰的 HTML 文档。
  2. 说出 DOM 树、节点类型与"查询—修改—监听"三件事。
  3. 用事件冒泡实现事件委托,而不是给每个子元素绑监听。
  4. 不依赖框架写出一个可增删改的待办列表。

一、HTML 文档的最小结构

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>待办</title>
  </head>
  <body>
    <main>
      <h1>待办</h1>
      <form id="todo-form">
        <label for="todo-input">新任务</label>
        <input id="todo-input" name="title" required />
        <button type="submit">添加</button>
      </form>
      <ul id="todo-list"></ul>
    </main>
  </body>
</html>

每一行的作用都不该省略:

标签作用漏掉的后果
<!doctype html>触发标准模式进入怪异模式,盒模型与布局异常
lang="zh-CN"声明语言屏幕阅读器发音错误、翻译提示错误
charset编码中文乱码
viewport移动端视口手机上页面被缩放成很小
label for关联输入框点文字无法聚焦,无障碍失效

二、语义化:不是"更规范",是有实际收益

标签代替收益
<header> / <footer>div屏幕阅读器可跳转
<nav>div.menu读屏软件识别导航区
<main>div提供主内容锚点
<button>div onclick自带键盘、焦点、语义
<ul>/<li>div 列表读出"列表,共 3 项"

最常见的错误是"用 div 做按钮":

<!-- 错误:键盘不可用,读屏不识别 -->
<div class="btn" onclick="save()">保存</div>

<!-- 正确 -->
<button type="button" onclick="save()">保存</button>

<button> 默认 type 是 submit,在表单里不写 type="button" 会意外提交表单。这是新手最常见的 bug 之一。

三、表单:原生能力比想象的多

属性作用例子
required必填校验<input required>
type="email"邮箱格式校验 + 键盘优化手机上出现 @ 键
pattern正则校验pattern="\d{6}"
minlength / maxlength长度限制密码位数
autocomplete自动填充autocomplete="one-time-code"

原生校验只做"格式与必填",业务规则仍要 JS 与服务端校验。三层校验的分工在后面讲表单时会反复出现:原生管体验,JS 管业务,服务端管安全。

四、DOM:一棵可以被修改的树

const list = document.querySelector("#todo-list");
const item = document.createElement("li");
item.textContent = "买牛奶";
list.appendChild(item);

三件事概括 DOM 操作:

  1. 查询:querySelector(返回第一个)、querySelectorAll(返回静态 NodeList)、getElementById(最快)。
  2. 修改:textContent(安全)、innerHTML(会解析 HTML,有 XSS 风险)、classList、dataset、style。
  3. 监听:addEventListener(type, handler, options)。

节点类型与关系:

概念例子注意
元素节点<div>children 只含元素
文本节点"hello"childNodes 含文本与注释
父/子/兄弟parentElement、children、nextElementSibling带 Element 的版本跳过文本节点

innerHTML 与 textContent 的选择只有一条:内容来自用户输入,就必须用 textContent。用 innerHTML 拼接用户输入是最经典的 XSS 入口。

五、事件模型:捕获、冒泡与委托

捕获阶段(window → target)→ 目标阶段 → 冒泡阶段(target → window)

addEventListener 默认在冒泡阶段触发;传 true 或 { capture: true } 则在捕获阶段。

事件委托利用冒泡:只在父级绑一个监听,通过 event.target 判断点到了谁。

list.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-action]");
  if (!btn) return;
  const id = btn.closest("li").dataset.id;
  if (btn.dataset.action === "remove") removeTodo(id);
});

三个收益:

  • 新增列表项不需要重新绑定事件。
  • 只占一个监听器,内存更省。
  • 逻辑集中,便于统一处理权限与埋点。

两个注意点:

  • 用 closest 而不是 e.target 直接判断,因为点击可能落在按钮内的图标上。
  • 不是所有事件都冒泡(focus、blur 不冒泡,要用 focusin/focusout)。

六、一个完整的待办列表

const form = document.querySelector("#todo-form");
const input = document.querySelector("#todo-input");
const list = document.querySelector("#todo-list");

let todos = JSON.parse(localStorage.getItem("todos") ?? "[]");

function render() {
  list.replaceChildren(
    ...todos.map((todo) => {
      const li = document.createElement("li");
      li.dataset.id = todo.id;
      li.className = todo.done ? "done" : "";

      const label = document.createElement("span");
      label.textContent = todo.title; // 必须用 textContent

      const toggle = document.createElement("button");
      toggle.type = "button";
      toggle.dataset.action = "toggle";
      toggle.textContent = todo.done ? "撤销" : "完成";

      const remove = document.createElement("button");
      remove.type = "button";
      remove.dataset.action = "remove";
      remove.textContent = "删除";

      li.append(label, toggle, remove);
      return li;
    }),
  );
  localStorage.setItem("todos", JSON.stringify(todos));
}

form.addEventListener("submit", (e) => {
  e.preventDefault();
  const title = input.value.trim();
  if (!title) return;
  todos.push({ id: crypto.randomUUID(), title, done: false });
  input.value = "";
  render();
});

list.addEventListener("click", (e) => {
  const btn = e.target.closest("[data-action]");
  if (!btn) return;
  const id = btn.closest("li").dataset.id;
  const todo = todos.find((t) => t.id === id);
  if (btn.dataset.action === "toggle") todo.done = !todo.done;
  if (btn.dataset.action === "remove") todos = todos.filter((t) => t.id !== id);
  render();
});

render();

这段代码已经包含了后续框架要解决的所有问题:状态、渲染、事件、持久化。理解它,才能理解 Vue/React 到底替你做了什么——它们把"每次状态变化后手动调用 render"变成了"状态变化自动渲染"。

七、常见坑

  1. 表单里的 <button> 不写 type,点了就提交。
  2. 用 innerHTML 渲染用户输入,造成 XSS。
  3. querySelectorAll 返回的 NodeList 是静态的,新增节点后不会自动包含。
  4. 在循环里给每个元素绑事件,列表一长就卡。
  5. 用 focus/blur 做委托,发现不触发(它们不冒泡)。
  6. label 没写 for,点文字不聚焦输入框。
  7. 图片不写 alt,读屏软件与加载失败时都没有信息。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 把上面的待办列表补上"编辑"功能:双击文字变输入框,回车保存、Esc 取消。
  2. 用事件委托实现"点击列表项切换完成状态",要求点击按钮时不触发切换。
  3. 给表单加原生校验与自定义错误提示,要求错误信息与输入框通过 aria-describedby 关联。
  4. 用 localStorage 持久化,并在刷新后恢复。

② 让 AI 写同一题

把第 2 题(列表交互)交给 AI,补一句 原生 JS,不用框架,不引入任何库。

③ 对比要盯的点

  • querySelectorAll 它当成数组用了吗?NodeList 能 forEach 但没有 map、filter。
  • 事件代理它绑在容器还是 document 上?绑 document 会把无关点击也吃进来。
  • 它用 innerHTML 拼用户输入了吗?那是 XSS 的经典入口。

④ 一句话取舍

哪一处会让一段用户输入变成可执行脚本?

九、自测

  1. <!doctype html> 漏掉会发生什么?
  2. textContent 与 innerHTML 的选择标准是什么?
  3. 事件捕获与冒泡的顺序是什么?
  4. 事件委托依赖什么机制,有什么前提?
  5. 为什么不该用 div 做按钮?

参考答案要点:怪异模式;内容是否来自用户;捕获→目标→冒泡;冒泡与 closest 判断;缺少键盘与焦点语义。

附:可访问性与 DOM 性能清单

页面能跑只是底线,能用才是目标。下面两组清单可以直接拿去评审。

可访问性八条:

检查项做法常见错误
图片有替代文本alt 描述内容,装饰图写 alt=""所有图片都写 alt="图片"
表单有标签<label for> 或 aria-label只用 placeholder 当标签
标题层级连续h1 → h2 → h3为了字号跳级使用 h4
可交互元素可聚焦用 button/a,或用 tabindex + 键盘事件div 绑 click 却没键盘支持
焦点可见保留 outline 或自定义 focus-visible全局 outline: none
状态可感知aria-expanded、aria-selected只靠颜色区分
语言声明<html lang>漏写导致发音错误
错误可朗读role="alert" + 关联描述错误只显示在视觉上

DOM 性能五条:

  1. 批量修改:先把节点在 DocumentFragment 里拼好,再一次性插入,避免每次都触发布局。
  2. 先读后写:把 offsetHeight 之类的读取集中在一起,不要“读—写—读—写”,否则强制同步布局。
  3. 减少查询:循环里反复 querySelector 是常见浪费,把结果提到循环外。
  4. 事件委托:列表用父级一个监听,而不是每项一个。
  5. 离屏操作:大量更新前先 display: none,或先移出文档,改完再放回。

一个具体的对比:往列表里插 1000 个节点,逐个 appendChild 会触发多次布局与重绘,用 DocumentFragment 只触发一次。这个差异在低端机上能从 300ms 降到 30ms,比任何“框架优化”都直接。

语义化还影响搜索与分享:标题层级、链接文本与图片描述会被搜索引擎当作内容信号,错误的嵌套会让爬虫提前截断正文。可访问性与 SEO 在多数场景下是同一件事,按前者修,后者自然受益。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解