从 missing
到读懂 DOM:我的 HTML 基础补课与 AI 代码审查实践

今天开始学习 Web 全栈时,我很快发现原来的节奏并不适合我:从空文件开始,一个标签一个标签地背,再反复回答“这是标签名还是属性名”。这种方式当然能建立语法基础,但我的目标不是参加 HTML 默写考试,而是借助 AI 完成一个真正的企业知识库产品。

坐在电脑旁阅读资料的人物,象征 HTML 与 DOM 学习和代码审查

今天开始学习 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> 保存用户在网页中看到和操作的内容,例如标题、导航、文档列表、按钮、表单和回答。

这里最容易混淆的一组标签是 headheader

  • 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 包含 ulul 包含 lili 又包含 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>

判断原则很简单:

  • 链接负责导航到页面、资源或页面锚点;
  • 按钮负责触发操作或状态变化。

buttontype 也值得显式声明:

  • 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 各自证明什么

今天的验证过程经历了三个阶段:

  1. 页面文件不存在,测试得到 ENOENT。这证明测试确实读取目标路径,而不是错误地通过。
  2. 页面创建后,测试报告 missing <header>。失败原因从环境问题变成了明确的结构契约。
  3. 修复 header、带名称的 navmain 后,测试变成 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 是否声明正确语言;
  • 字符编码、移动端视口和标题是否存在;
  • headbody 的职责是否混淆。

2. 标签配对

  • 开始标签与结束标签是否同名;
  • 缩进是否真实反映父子关系;
  • 列表、表单和分区是否存在孤立或多余的结束标签。

3. 语义地标

  • 产品页头是否适合使用 header
  • 导航是否使用 nav 并有可访问名称;
  • 页面是否只有清晰的主要内容区域;
  • sectionarticle 是否承担明确主题。

4. 交互控件

  • 导航使用链接,操作使用按钮;
  • 按钮是否显式声明 type
  • 表单输入是否有关联的 label
  • 文案是否明确描述操作结果。

5. DOM 结构

  • 浏览器最终会构造怎样的节点树;
  • CSS、JavaScript 和辅助技术能否稳定选择目标节点;
  • 浏览器的容错是否掩盖了源码错误。

6. 自动化测试

  • 测试是否真的在失败状态下运行过;
  • 每条断言究竟证明了什么;
  • 哪些需求完全没有被测试覆盖。

7. 修改范围

  • diff 是否只有预期的局部变化;
  • 是否误删父元素、属性或空状态;
  • 格式变化是否掩盖了实际行为变化。

总结

今天真正掌握的并不是一张标签大全,而是一套处理 HTML 的方法:源码会被解析成 DOM;语义标签表达区域职责;链接与按钮表达不同交互意图;开始和结束标签必须配对;测试验证明确契约;diff 发现测试之外的意外修改。

对 AI 辅助开发来说,这比从空白文件背写完整页面更重要。常规结构可以让 AI 生成,但我必须能解释关键节点、判断行为是否正确,并用运行证据证明修改结果。

下一篇文章会从这个静态页面继续向外推导:为什么文档状态不能由 DOM 负责,上传为什么需要 Worker,RAG 的入库和查询为何是两条链路,以及一个企业知识库 MVP 应该如何处理权限、失败、流式输出和可观测指标。

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注