今天开始学习 Web 全栈时,我很快发现原来的节奏并不适合我:从空文件开始,一个标签一个标签地背,再反复回答“这是标签名还是属性名”。这种方式当然能建立语法基础,但我的目标不是参加 HTML 默写考试,而是借助 AI 完成一个真正的企业知识库产品。
我平时会使用 AI 生成代码,因此更需要的能力是:看得懂生成结果,知道浏览器为什么这样运行,能发现明显错误,能做局部修改,并且用测试和 diff 验证修改没有伤到其他地方。
于是今天的练习从“背标签”改成了一次真实的代码审查:AI 先生成一份企业知识库静态页面,我负责理解、找错、修改和验证。这篇文章记录其中最值得保留的 HTML 基础知识,也记录几个看起来很小、实际很有代表性的错误。
HTML 最小骨架:先分清浏览器配置和页面内容
一个 HTML 文档最常见的骨架如下:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Tracebase 企业知识库</title>
</head>
<body>
<p>页面内容</p>
</body>
</html>
<!doctype html> 告诉浏览器按现代 HTML 标准解析页面。它不是普通内容标签,但应该放在文档最前面。
<html lang="zh-CN"> 是整个文档的根元素。其中:
html是标签名;lang是属性名;zh-CN是属性值。
lang 能帮助浏览器、搜索引擎和屏幕阅读器判断页面主要语言。
<head> 保存页面元信息。字符编码、移动端视口、标签页标题,以及未来要引入的样式或脚本,都通常在这里声明。它的内容主要供浏览器使用,不是页面的主要可见内容。
<body> 保存用户在网页中看到和操作的内容,例如标题、导航、文档列表、按钮、表单和回答。
这里最容易混淆的一组标签是 head 与 header:
head是文档级配置区域,一个页面通常只有一个,位于body之前;header是页面或某个分区的介绍性区域,位于body中,可以包含产品名、标题、导航和操作。
它们不是长写法和短写法的关系,而是职责完全不同的两个元素。
标签、元素、属性和文本内容不是同一个概念
以一个链接为例:
<a href="/documents">查看文档</a>
可以拆成:
- 标签名:
a; - 属性名:
href; - 属性值:
/documents; - 文本内容:
查看文档; - 完整元素:从
<a ...>开始,到</a>结束的整体。
“标签”和“元素”在日常交流中经常被混用,但理解它们的区别有助于读代码:开始标签描述节点如何开始,结束标签描述节点在哪里结束,属性补充节点信息,文本则是节点包含的数据。
父子关系也很重要:
<nav aria-label="主导航">
<ul>
<li><a href="/documents">知识文档</a></li>
</ul>
</nav>
在这段结构中,nav 包含 ul,ul 包含 li,li 又包含 a。HTML 不是一堆彼此独立的标签,而是一棵有层级关系的树。
从通用容器到语义地标
最初的知识库页面使用了两个通用容器:
<div class="product-header">...</div>
<div class="workspace">...</div>
div 只是通用容器,本身不表达区域职责。它并不是错误标签,但当一个区域已经有明确含义时,使用语义标签能让结构更清楚:
<header class="product-header">
<a href="/">Tracebase</a>
<nav aria-label="主导航">...</nav>
</header>
<main class="workspace">
...
</main>
这里的 header 表示产品页头,nav 表示导航区域,main 表示页面的核心工作内容。
aria-label="主导航" 给导航提供了可访问名称。页面可能同时存在主导航、文档目录和页脚导航,只有一个没有名称的 nav 时,人眼也许能根据位置猜出用途,但屏幕阅读器需要更明确的信息。
页面内部还使用了:
section:围绕一个主题组织内容,例如“知识文档”和“AI 问答”;article:表示可以独立理解的回答内容;ul:无顺序要求的文档列表;ol:有顺序的引用来源列表;form:组织问题输入和提交动作。
语义标签不直接决定视觉位置。一个 header 并不因为名字叫页头就自动固定在屏幕顶部,具体布局由 CSS 决定。语义描述的是“它是什么、承担什么职责”,而不是“它画在哪里”。
链接与按钮:看起来都能点,职责并不相同
候选页面最初把“上传文档”写成链接:
<a href="#upload">上传文档</a>
但上传不是跳转到另一个资源,而是启动文件选择、校验、上传和处理流程。因此更准确的控件是按钮:
<button id="upload" type="button">上传文档</button>
判断原则很简单:
- 链接负责导航到页面、资源或页面锚点;
- 按钮负责触发操作或状态变化。
button 的 type 也值得显式声明:
type="button":普通操作按钮,由 JavaScript 接管行为;type="submit":提交所属表单;type="reset":重置表单字段。
没有写 type 的按钮,在关联表单时默认表现为提交按钮。上传和重新处理暂时位于表单之外,看起来不会有区别,但如果以后调整结构把它们放进 form,缺省类型可能意外提交表单。因此“上传文档”和“重新处理”使用 type="button",而“发送问题”明确使用 type="submit"。
浏览器操作的不是源码字符串,而是 DOM
浏览器读取 HTML 后,会把它解析为 DOM,也就是 Document Object Model,文档对象模型。可以把当前页面简化成下面这棵树:
html
├── head
└── body
├── header
│ ├── product link
│ └── nav
│ └── ul
└── main
├── documents section
│ ├── upload button
│ └── document list
└── assistant section
├── form
└── answer article
CSS 根据这棵树选择和布局节点,JavaScript 根据这棵树查找和修改节点,辅助技术也根据节点语义理解页面。
这解释了今天最典型的一次失败。我把开始标签改成了:
<header class="product-header">
却一度保留了错误的结束标签:
</div>
后来又误写成:
</head>
源码中明明出现了 <header>,测试却仍然报告 missing <header>。原因是解析器不会只搜索这几个字符,而是尝试构造一棵合法的节点树。开始和结束标签不匹配时,解析器会纠正或丢弃异常结构,最终 DOM 中未必存在预期的 header 节点。
正确写法必须配对:
<header class="product-header">
...
</header>
另一次修改中,我意外删除了文档列表的开始标签 <ul>,却保留了后面的 </ul>。现有测试仍然可以通过,因为它只检查页面地标,并不检查列表。这说明 HTML 容错能力很强,但“浏览器能显示”和“结构正确”不是同一件事。
Node.js 为什么能测试 HTML
我运行的命令是:
node tests/html-structure.test.mjs
node 指 Node.js,它是让 JavaScript 在浏览器之外运行的 JavaScript 运行时。浏览器里的 JavaScript 通常操作页面和 Web API;Node.js 中的 JavaScript 可以读写文件、运行测试、执行构建工具和启动服务。
这条命令的实际过程是:
Shell 启动 node
→ Node.js 执行 .mjs 测试文件
→ 测试读取 index.html
→ node-html-parser 将 HTML 解析成树
→ querySelector 查询语义节点
→ assert 检查结果
→ Node Test Runner 输出 TAP 测试结果
测试的核心断言很短:
assert.ok(document.querySelector('header'), 'missing <header>');
assert.ok(
document.querySelector('nav[aria-label]'),
'missing labeled <nav>',
);
assert.ok(document.querySelector('main'), 'missing <main>');
document.querySelector('header') 查询的是解析后的 DOM 节点。nav[aria-label] 表示“具有 aria-label 属性的 nav 节点”。如果导航存在但没有这个属性,选择器仍然不能匹配。
RED、GREEN 和 diff 各自证明什么
今天的验证过程经历了三个阶段:
- 页面文件不存在,测试得到
ENOENT。这证明测试确实读取目标路径,而不是错误地通过。 - 页面创建后,测试报告
missing <header>。失败原因从环境问题变成了明确的结构契约。 - 修复
header、带名称的nav和main后,测试变成pass 1, fail 0。
这就是最小的 RED/GREEN 过程:先看到测试因为目标行为缺失而失败,再通过局部实现让它通过。
但绿色测试只能证明断言覆盖的内容。它没有检查:
- 上传控件究竟是链接还是按钮;
- 重试按钮有没有明确的
type; - 文档列表是否保留了
ul; - 错误提示是否包含可行动原因。
因此还需要比较修改前后的差异:
diff -u practice/index-review-baseline.html index.html
diff 中以 - 开头的是删除行,以 + 开头的是新增行。正是 diff 暴露了 <ul> 被意外删除。需要注意,diff 在发现文件有差异时通常返回状态码 1,这不代表命令出错,而是表示“两份文件不同”。
这次练习让我形成了一个更可靠的验证闭环:
阅读需求
→ 阅读 AI 生成代码
→ 预测问题
→ 运行测试获得证据
→ 做最小修改
→ 重新运行测试
→ 审查 diff
→ 核对测试未覆盖的产品要求
我的 HTML 与 AI 代码审查清单
以后审查 AI 生成的静态页面,我会至少检查下面七类问题。
1. 文档骨架
- 是否声明现代 doctype;
html是否声明正确语言;- 字符编码、移动端视口和标题是否存在;
head与body的职责是否混淆。
2. 标签配对
- 开始标签与结束标签是否同名;
- 缩进是否真实反映父子关系;
- 列表、表单和分区是否存在孤立或多余的结束标签。
3. 语义地标
- 产品页头是否适合使用
header; - 导航是否使用
nav并有可访问名称; - 页面是否只有清晰的主要内容区域;
section和article是否承担明确主题。
4. 交互控件
- 导航使用链接,操作使用按钮;
- 按钮是否显式声明
type; - 表单输入是否有关联的
label; - 文案是否明确描述操作结果。
5. DOM 结构
- 浏览器最终会构造怎样的节点树;
- CSS、JavaScript 和辅助技术能否稳定选择目标节点;
- 浏览器的容错是否掩盖了源码错误。
6. 自动化测试
- 测试是否真的在失败状态下运行过;
- 每条断言究竟证明了什么;
- 哪些需求完全没有被测试覆盖。
7. 修改范围
- diff 是否只有预期的局部变化;
- 是否误删父元素、属性或空状态;
- 格式变化是否掩盖了实际行为变化。
总结
今天真正掌握的并不是一张标签大全,而是一套处理 HTML 的方法:源码会被解析成 DOM;语义标签表达区域职责;链接与按钮表达不同交互意图;开始和结束标签必须配对;测试验证明确契约;diff 发现测试之外的意外修改。
对 AI 辅助开发来说,这比从空白文件背写完整页面更重要。常规结构可以让 AI 生成,但我必须能解释关键节点、判断行为是否正确,并用运行证据证明修改结果。
下一篇文章会从这个静态页面继续向外推导:为什么文档状态不能由 DOM 负责,上传为什么需要 Worker,RAG 的入库和查询为何是两条链路,以及一个企业知识库 MVP 应该如何处理权限、失败、流式输出和可观测指标。

没有回应