模块一 · 语言与 DOM
起点:HTML、DOM 与第一个可交互页面
框架之前先有页面。这一节讲清 HTML 的语义结构、表单与可访问性、DOM 树与事件模型,并用原生 JS 写出一个可用的待办列表。
读完这一节,你能做到
- 写出一份结构正确、语义清晰的 HTML 文档。
- 说出 DOM 树、节点类型与"查询—修改—监听"三件事。
- 用事件冒泡实现事件委托,而不是给每个子元素绑监听。
- 不依赖框架写出一个可增删改的待办列表。
一、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 操作:
- 查询:
querySelector(返回第一个)、querySelectorAll(返回静态 NodeList)、getElementById(最快)。 - 修改:
textContent(安全)、innerHTML(会解析 HTML,有 XSS 风险)、classList、dataset、style。 - 监听:
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"变成了"状态变化自动渲染"。
七、常见坑
- 表单里的
<button>不写type,点了就提交。 - 用
innerHTML渲染用户输入,造成 XSS。 querySelectorAll返回的 NodeList 是静态的,新增节点后不会自动包含。- 在循环里给每个元素绑事件,列表一长就卡。
- 用
focus/blur做委托,发现不触发(它们不冒泡)。 label没写for,点文字不聚焦输入框。- 图片不写
alt,读屏软件与加载失败时都没有信息。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 把上面的待办列表补上"编辑"功能:双击文字变输入框,回车保存、Esc 取消。
- 用事件委托实现"点击列表项切换完成状态",要求点击按钮时不触发切换。
- 给表单加原生校验与自定义错误提示,要求错误信息与输入框通过
aria-describedby关联。 - 用
localStorage持久化,并在刷新后恢复。
② 让 AI 写同一题
把第 2 题(列表交互)交给 AI,补一句 原生 JS,不用框架,不引入任何库。
③ 对比要盯的点
querySelectorAll它当成数组用了吗?NodeList 能forEach但没有map、filter。- 事件代理它绑在容器还是
document上?绑 document 会把无关点击也吃进来。 - 它用
innerHTML拼用户输入了吗?那是 XSS 的经典入口。
④ 一句话取舍
哪一处会让一段用户输入变成可执行脚本?
九、自测
<!doctype html>漏掉会发生什么?textContent与innerHTML的选择标准是什么?- 事件捕获与冒泡的顺序是什么?
- 事件委托依赖什么机制,有什么前提?
- 为什么不该用
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 性能五条:
- 批量修改:先把节点在
DocumentFragment里拼好,再一次性插入,避免每次都触发布局。 - 先读后写:把
offsetHeight之类的读取集中在一起,不要“读—写—读—写”,否则强制同步布局。 - 减少查询:循环里反复
querySelector是常见浪费,把结果提到循环外。 - 事件委托:列表用父级一个监听,而不是每项一个。
- 离屏操作:大量更新前先
display: none,或先移出文档,改完再放回。
一个具体的对比:往列表里插 1000 个节点,逐个 appendChild 会触发多次布局与重绘,用 DocumentFragment 只触发一次。这个差异在低端机上能从 300ms 降到 30ms,比任何“框架优化”都直接。
语义化还影响搜索与分享:标题层级、链接文本与图片描述会被搜索引擎当作内容信号,错误的嵌套会让爬虫提前截断正文。可访问性与 SEO 在多数场景下是同一件事,按前者修,后者自然受益。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★☆ 重排和重绘有什么区别?哪些操作会触发它们?
- ★★★★☆ 浏览器的本地存储方式有哪些?怎么选?
- ★★★★☆ 说一下 BFC,它能解决什么问题?
提示:读完可以顺手做几道对应题目,检验是否真的理解