一、HTML 是什么:你的第一个页面
HTML 不是编程语言,而是标记语言:它用一对对尖括号包裹的「标签」告诉浏览器,某一段文字是标题、某一段是段落、某一张图可以点开。理解这一点,后面所有标签的学习都会变成「这个标签表达什么含义」的问题,而不是「这个标签怎么写样式」的问题——样式是 CSS 的事。
浏览器把一个文件变成画面的过程
你在地址栏输入一个 .html 文件,浏览器大致做四件事:
- 拿到字节流:按
<meta charset>声明的编码(通常是 UTF-8)把字节解码成文字。声明写错,中文就会变成「æ¬å」这类乱码。 - 解析成节点树:逐字符读标签,遇到
<p>就开一个节点,遇到</p>就闭合,层层嵌套形成「DOM 树」。树根是html节点。 - 构建渲染树:结合 CSS 决定哪些节点可见、以什么顺序和位置排布。
- 绘制到屏幕:按层叠顺序一笔画一画涂出来。
这条链路解释了后面会反复遇到的两个现象:标签嵌套顺序决定结构,而书写顺序不决定视觉层级(那是 CSS 定位的事)。
一个页面由三部分拼起来
任何 HTML 文件都可以拆成三层,先看最小可用版本:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>我的第一个网页</title>
</head>
<body>
<h1>我的第一个网页</h1>
<p>这一行是段落。</p>
</body>
</html>
<!DOCTYPE html>:告诉浏览器「按现代标准模式渲染」,第二章细说。<head>:给浏览器和搜索引擎看的元信息,标题、编码、样式表引用都放这里,不会显示在页面正文里。<body>:给人看的全部内容,页面上你看到的每一个字都在这个标签内部。
新手最容易混淆的是 <title> 与 <h1>:前者出现在浏览器标签页和搜索结果里,后者出现在页面正文顶部。两者都要写,作用完全不同。
写下你的第一个文件
准备一个空文件夹,用任意文本编辑器(记事本、VS Code 都行)新建 index.html,把上面那段代码粘进去,保存后双击文件,它就会在自己的浏览器里打开。
改一行 <p> 里的文字,保存,回到浏览器按刷新——这就是 HTML 的开发循环,不需要任何编译工具。把文件拖进 VS Code 再按 F1 搜「Open with Live Server」(需装插件)可以省掉手动刷新。
下面这个框里就是上面那段代码的真实渲染结果,右边目录的小节、左侧的章节导航,以及正文里所有类似的可运行示例,都是独立文件,点「在新标签页打开」可以单独查看:
接下来往哪走
从第二章开始按顺序推进:先解决「文件为什么这么开头」(文档结构),再补齐文字、列表、链接、图片、表格、表单这些内容型标签,然后进入结构型标签与语义化,最后是可访问性、SEO 与综合实战。每一章都配可运行示例,看懂了就用,忘了就回来查——正文左侧是章节目录,也可以在搜索框里直接输标签名(例如 table)定位到讲它的那一节。
二、文档结构与 DOCTYPE
第一章的最小示例里有四个容易被跳过但不该跳过的部分:<!DOCTYPE html>、<html lang>、<head>、<meta charset>。它们不产出任何可见内容,却决定了页面「被怎么解读」。
DOCTYPE 决定浏览器用哪套规则排版
<!DOCTYPE html> 不是标签,而是一条处理指令,作用只有一个:让浏览器进入「标准模式(standards mode)」。
在 2000 年前后,浏览器为了兼容当年写得很不规范的网页,保留了两套排版引擎行为。没有 DOCTYPE(或写法是老式的一长串)时,浏览器会退回「怪异模式(quirks mode)」,其中的差异至今还能测出来:
- 怪异模式下元素的
box-sizing行为、表格里的字体继承、<img>的基线对齐都与标准模式不同; - 最直观的一条:标准模式下
html { height: 100% }才让百分比高度逐级生效,怪异模式里同样的写法可能得到 0 高度。
现代写法只有一种,且必须放在文件最前面(前面除了空白和注释不能有任何内容,否则浏览器会当成文本节点渲染到页面上):
<!DOCTYPE html>
看到搜索结果里有 <meta name="generator" content="FrontPage.Editor.1"> 这类古董 DOCTYPE 的教程,可以直接关掉那个页面——它们教的正是怪异模式。
html、head、body 各自负责什么
<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="style.css">
</head>
<body>
<h1>正文从这开始</h1>
</body>
</html>
三条边界值得记住:
<head>里放的是「关于页面的信息」:编码、标题、样式表、图标、SEO 元信息。<head>里出现<p>、<h1>这类可见元素是不合法的,浏览器会把它「揪」到 body 开头,导致难以排查的错位。<body>里放全部可见内容,且一个文档只能有一个<body>。<html>是根节点,所有 CSS/JS 操作 DOM 时面对的树根就是它;lang写在它上面才能被辅助技术和浏览器识别(第十七、十八章会再用到这一点)。
浏览器解析时会自动补全缺失标签:即使你只写 <body> 内容而不写 <html>,DevTools 里的 Elements 面板仍然显示完整的树。但「浏览器能补救」不等于「写错了没代价」——补救规则让结构推断变得不可预期,也让别人接手时读不出意图。
charset 与 lang:两个必须显式声明的属性
<meta charset="UTF-8"> 声明字节到文字的解码方式。它有两个实操要点:
- 位置要尽量靠前,且在
<title>之前。浏览器为了省时间,只会读取文档开头的有限字节来找编码声明;放得太晚,标题就可能乱码。 - 声明只管「浏览器怎么读」,文件本身也得真的存成 UTF-8。VS Code 右下角能看到并修改当前编码;用记事本保存时选「UTF-8」。声明与实际编码不一致,改声明没用,改存储编码才有用。
lang 的取值是 BCP 47 语言标签:简体中文 zh-CN、繁体中文 zh-TW、英文 en、日文 ja。它影响屏幕阅读器发音、浏览器翻译提示、连字与换行规则(CSS 的 :lang() 选择器也读它,CSS 教程的选择器一章里会用到)。
下面这个示例把本节要点都放进去了,右上角可以直接看它自己的 <head> 写了什么:
常见报错与自查
| 现象 | 大概率原因 | 怎么确认 |
|---|---|---|
| 中文变乱码 | 文件实际编码不是 UTF-8 | 编辑器右下角看编码,另存为 UTF-8 |
| 页面顶部多出一行文字 | DOCTYPE 前后有内容,或首行漏写 | 查看源码第一行是否为 <!DOCTYPE html> |
| 百分比高度不生效、间距莫名 | 浏览器进了怪异模式 | DevTools Console 输入 document.compatMode,标准模式返回 CSS1Compat,怪异模式返回 BackCompat |
| 屏幕阅读器读中文很怪 | <html> 上没有 lang | 查看源码 <html> 标签 |
三、文本与段落标签
从这一章起进入「内容型标签」。判断一个标签该不该用,先问:它表达的是结构含义还是视觉效果?前者归 HTML,后者归 CSS。
p、br、hr 的分工
<p>是「一个语意段落」,浏览器默认给它上下外边距。段落之间空一行是排版结果,不是你把回车敲出来的。<br>只表示「同一句话内部必须换行」,典型场景是地址、诗歌、签名。它不产生新段落,所以段间距不会变大。<hr>表示「主题转换的分隔」,比如一章结束、话题跳到另一个。它语义上有意义,不是装饰线——想画一条线请交给 CSS 的border。
HTML 会把源码里连续的空白字符(空格、制表符、回车)折叠成一个空格,这叫空白折叠。所以你在 <p> 里敲十个回车也不会出现空行。想保留原始空白要用 <pre>(它还保留缩进,常用于代码块),想要真正的空格请用实体 。
下面这个示例把这三条都做实了,包括「空白折叠验证」和 <pre> 的对比:
h1–h6:层级不是字号
六个标题标签构成文档的大纲,屏幕阅读器和浏览器的「页面概览」都读它。规则只有两条:
- 一页可以有多个
<h1>,但通常只放一个(页面主标题),从<h2>开始往下分章节。 - 不要跳层级:
h2之后直接写h4会在大纲里留下空洞。
新手最常见的错误是「觉得这行字要小一点,所以用 h5」。层级和字号是两件事,字号交给 CSS 改;用错层级只是把可访问性弄坏了。
strong、em 与 b、i 的语义差别
| 标签 | 含义 | 屏幕阅读器 | 默认视觉 |
|---|---|---|---|
<strong> | 内容重要 | 加重语气播报 | 加粗 |
<em> | 语气重音 | 换语调播报 | 斜体 |
<b> | 仅视觉偏移(产品名、关键词) | 不特别处理 | 加粗 |
<i> | 仅视觉偏移(术语、外文、物种拉丁名) | 不特别处理 | 斜体 |
判断办法:如果把这段文字的强调效果去掉,意思有没有变?变了就是 strong/em,没变就是 b/i,而纯粹「想让它显眼」应该用 CSS。
代码、引用与其余文本标签
正文里还有几个高频标签值得一次记牢:
<code>行内代码,通常包在<pre>里做成代码块:<pre><code>…</code></pre>。<abbr title="Document Object Model">DOM</abbr>首次出现的缩写给它全称,鼠标悬停和读屏都能拿到。<blockquote>引用整段,<q>引用句内;<cite>标出处。<del>/<ins>标删除与插入(价格改动、修订记录),带语义而非单纯划线。<s>表示「已不准确」,跟<del>(内容被删除)不同。<sub>/<sup>下标上标,H<sub>2</sub>O。<time datetime="2026-10-03">10 月 3 日</time>给人看中文日期,给机器读 ISO 格式,搜索引擎会用它。
四、列表
ul 与 ol:顺序有没有意义
判断只需一个问题:换一个顺序,内容还成立吗?
- 不成立(安装步骤、排行榜、法律条款编号)→
<ol>,序号本身携带信息。 - 成立(特性清单、导航项、标签云)→
<ul>。
<ol> 的三个属性很实用:type="A|a|I|i|1" 换编号样式,start="5" 指定起始序号,reversed 倒序。给单个 <li> 写 value="10" 可以插入自定义序号。
圆点和数字都由 CSS 的 list-style 控制,「不想要圆点」不是放弃 <ul> 的理由——写 list-style: none 即可,语义保留。
嵌套列表的正确位置
嵌套的唯一规则:新的 <ul> 或 <ol> 必须放在父 <li> 内部、文本之后、</li> 之前。
<ul>
<li>支持 UTF-8
<ul>
<li>中文标题不乱码</li>
</ul>
</li>
<li>支持表格</li>
</ul>
如果误把子列表写在 </li> 之后,它会变成和父列表同级,缩进全乱——而且浏览器不会报错,这是最难自查的一类问题。
dl、dt、dd:术语表专用
<dl> 是「描述列表」,<dt> 名词、<dd> 解释,适合 API 参数表、FAQ、词典条目。一个 <dt> 可以跟多个 <dd>(一词多义),也可以多个 <dt> 共用一个 <dd>(多词一义)。用 <p> 假装术语表是常见错误,因为它丢掉了「这是配对关系」的结构信息。
列表的三处常见误用
- 用
<br>或连续<p>堆清单——读屏会播报「列表,共 3 项」,帮你数位置,堆出来的没有。 - 站内导航写成
<div><a>。导航的正确形状是<nav>+<ul>+<li>+<a>,第九章展开。 - 表格数据用列表写、列表用
<table>写。判断标准:横竖都有对应关系、需要表头才能读懂 → 表格;否则列表。第七章细说。
五、超链接 a
<a> 是 HTML 里唯一让「文档」变成「网络」的标签——href 指向另一个位置,浏览器就把两张图连成一条边。
href 的四种路径写法
假设当前页面在 /blog/2026/intro.html:
| 写法 | 实际指向 | 什么时候用 |
|---|---|---|
other.html | /blog/2026/other.html | 同目录跳转 |
../img/a.png | /blog/img/a.png | 往上退一级 |
/img/a.png | 站点根下的 /img/a.png | 最推荐,与当前页层级无关 |
https://site.com/x | 站外 | 真的去站外时 |
./ 开头等于当前目录,可省略。以 / 开头的路径最稳,因为「跟着目录跑」的相对路径在部署到子目录后极易断链。本站部署在 /nuxt/ 子路径下,教程里的示例统一用带前缀的站内绝对路径。
页内锚点:id 是跳转目标
任何元素只要有 id,就能被 #那个id 定位:
<h2 id="pricing">价格</h2>
<a href="#pricing">跳到价格</a>
跨页锚点把两者拼起来:/tutorials/html#表格。旧教程里的 <a name="x"> 已过时,一律改用 id。
注意:锚点跳转是浏览器行为,CSS 的 scroll-behavior: smooth 可以让它平滑滚动,被跳转的标题需要 scroll-margin-top 才不会被吸顶导航遮住——这两条都属于 CSS,写在样式里而不是 JS。
target 与 rel:新标签页必须带 noopener
<a href="https://example.com" target="_blank" rel="noopener">外部站点</a>
target="_blank" 会让被打开的页面在旧浏览器里拿到 window.opener,从而能反向修改你本页地址(tabnabbing)。现代浏览器已对 _blank 隐含 noopener,但显式写出更保险。加上 noreferrer 还会禁止发送来源信息,代价是对方统计里看不到你。
两条实践约定:
- 站内链接不要加
target="_blank":它切断返回路径,也多占一个标签页。 - 下载链接、PDF 之类需要保留当前页时才用新标签页。
链接文字要能被脱离上下文读懂
<a>点击这里</a> 是反例:读屏用户逐个链接浏览时只听到一串「点击这里」。把链接嵌在能独立表意的短语上:
<p>具体配置项见<a href="/docs/config">配置文件说明</a>。</p>
同理,href="#" 的空链接会让页面滚回顶部且没有任何语义,纯按钮行为应该用 <button>(第十章),不要拿 <a> 冒充。
六、图片 img
图片是页面里最容易「看起来正常、实际上很糟」的标签:破图、跳动、体积超标、读屏念不出来,四类问题各对应一个属性没写。
src、alt、width、height:四个都要写
<img src="/img/hero.webp" alt="首页主图:三人协作场景" width="1200" height="600" loading="lazy">
src必填,路径规则同第五章。alt必填(装饰图写空字符串alt="",但不能省略属性)。它同时是三样东西:屏幕阅读器的播报文本、图片加载失败时显示的兜底文字、搜索引擎理解图片的唯一线索。别写「图片」「photo of」这类前缀,播报本身已带「图像」提示。width/height写图片固有尺寸(不是最终显示尺寸)。浏览器据此预先算出宽高比并占好位置,图下载完页面不会「跳一下」——这个跳动会打断用户阅读,也是 Google 衡量的 CLS 指标的主要来源。显示尺寸想用 CSS 改,直接写img { width: 100%; height: auto },比例仍由那两个属性提供。
格式怎么选
| 格式 | 用在哪 | 注意 |
|---|---|---|
| SVG | 图标、logo、图表、需要无损缩放的图形 | 文本型内容清晰;不适合照片;来自第三方上传的 SVG 有 XSS 风险,别直接内联 |
| WebP / AVIF | 照片、大图首选 | 同画质体积比 JPEG 小约三成;AVIF 更小但编码慢 |
| JPEG | 需要兼容兜底的照片 | 无透明通道 |
| PNG | 需要透明通道、或纯色大面积截图 | 照片用 PNG 体积通常是 JPEG 的 3–5 倍 |
| GIF | 基本别用 | 动图改用 <video muted loop playsinline>,体积能小一个数量级 |
需要多格式降级时套 <picture>(和第十二章 <video> 的多 <source> 降级是同一思路):
<picture>
<source srcset="/img/hero.avif" type="image/avif">
<source srcset="/img/hero.webp" type="image/webp">
<img src="/img/hero.jpg" alt="首页主图" width="1200" height="600">
</picture>
浏览器从上往下挑第一个支持的 <source>,都不支持时落到 <img>——<img> 及其 alt 必须留在最后,否则降级路径和替代文本一起丢。
懒加载与破图排查
loading="lazy":首屏外的图片等到接近视口才下载,长列表页收益明显。首屏主图不要加,反而应写fetchpriority="high",否则会被自己的懒加载推迟。decoding="async":让图片解码不阻塞其它内容渲染。- 破图排查按顺序四步:① DevTools Network 看请求状态码;② 路径是否漏了部署前缀(本站在
/nuxt/子路径下,写/img/a.png会打到域名根);③ 文件真实扩展名与大小写是否一致(Linux 服务器区分大小写);④ 本地双击能显示、部署后不显示 → 几乎一定是相对路径层级问题。
七、表格
<table> 用来呈现需要横竖对照才能读懂的数据。用它做页面布局是九十年代的遗产,今天由 Flex/Grid 负责(见 CSS 教程第十、十一章)。
六个结构标签
<table>
<caption>季度开销</caption>
<thead><tr><th scope="col">项目</th><th scope="col">金额</th></tr></thead>
<tbody><tr><th scope="row">计算</th><td>1280</td></tr></tbody>
<tfoot><tr><td>合计</td><td>1950</td></tr></tfoot>
</table>
<table>容器;<tr>一行;<td>数据格;<th>表头格。<thead>/<tbody>/<tfoot>把行分组。<tfoot>建议写在<tbody>之后,但浏览器会把它绘制在表格末尾——历史上为了让多页打印时表头每页重复才这么规定。<caption>是这张表的标题,必须紧跟<table>之后、且是第一个子元素。它对读屏用户的价值相当于图片的alt。
caption 与 th scope:让读屏知道这一格是什么
屏幕阅读器逐格朗读时,靠 scope 建立「这一格属于哪一行、哪一列」的对应关系:
- 列表头写
<th scope="col">,行表头写<th scope="row">。 - 没有
scope,用户念到「512」时只听得到一个孤立数字;有了它,播报是「存储,8 月,512」。 - 复杂表头(一格横跨多列)用
headers="格 id 列表"显式关联,配合<th id="...">。
<td> 也能当行标题用吗?可以(第一列写成 <td>计算</td>),但它就失去了表头语义,读屏不会把它作为对应关系播报——这是最常见的表格可访问性缺失。
colspan 与 rowspan
合并单元格只有一条铁律:colspan / rowspan 写在被合并的第一个格子上,被它盖住的格子要从源码里删掉,不能留空 <td></td>,否则行列整体错位。
<tr>
<th scope="row" rowspan="2">Nuxt</th>
<td>3.12</td><td>2024-07</td>
</tr>
<tr>
<!-- 这里没有第一个格子,被上面的 rowspan 占了 -->
<td>4.0</td><td>2025-07</td>
</tr>
宽表格与滚动
窄屏上表格溢出会撑破整页布局。处理办法是外套一层横向滚动容器,而不是把字号压到 10px:
<div style="overflow-x:auto"><table>…</table></div>
数据量很大时考虑改成「小屏幕用卡片列表、大屏幕用表格」两套结构按媒体查询切换——这属于 CSS 教程第十三章的响应式范畴。
八、div、span 与 class、id
<div> 和 <span> 是「无内容的容器」:它们不表达任何含义,唯一作用是把一组元素圈起来,好让 CSS 或 JS 找到这一块。所以判断标准始终是:有语义更准的标签吗?有就别用 div。
块级与行内:默认排版行为
| 块级(block) | 行内(inline) | |
|---|---|---|
| 代表 | div、p、h1–h6、ul、li、section、table、form | span、a、strong、em、code、img、small |
| 占宽 | 独占一行,撑满可用宽度 | 只包住内容宽度 |
| 宽高/margin | 可设宽高,上下 margin 生效 | 宽高无效,上下 margin 不生效(padding 生效但会溢出) |
| 能包含 | 可包含块级和行内 | 默认只能放行内(<a>、<button> 例外) |
「块级和行内」是默认显示行为,不是标签的本质属性——CSS 的 display 可以互相转换(display: inline-block、display: flex、display: grid),这在 CSS 教程第九章专门讲。
class 与 id 的边界
id在整篇文档内必须唯一,合法用途只有三类:锚点跳转目标(#id)、被for/aria-labelledby指向、被脚本getElementById抓取。- 不要用
id写 CSS 选择器。它的优先级是 100,远高于 class(10),后面临时覆盖只能靠!important,等于把样式的可维护性提前透支。 class可以重复使用,也可以一个元素挂多个:class="btn btn--primary is-disabled"——基础样式 + 变体 + 状态分开,本站所有组件都用这个命名法(BEM 风格)。- 命名带用途而不是带外观:
is-error好于red-text。 - 重复
id浏览器不报错,但锚点和label绑定只命中第一个,属于最难排查的一类 bug。用 DevTools Console 执行document.querySelectorAll('[id]').length与[...document.querySelectorAll('[id]')].map(e => e.id)排序对比,可以一眼看到重复。
嵌套规则与缩进
后开的标签必须先关(<b><i>文字</i></b>),交叉嵌套 <b><i>文字</b></i> 是非法的,浏览器会强行「就近闭合」,结果与你的预期完全不同。
三条实操约定:
- 缩进体现层级,两个空格一档;VS Code 里
Shift+Alt+F自动格式化整份 HTML。 - 块级里放块级、行内里放行内,是唯一安全的默认策略。
<p>里不能再放<div>、<p>、<ul>等块级元素——浏览器会自动把<p>提前闭合,导致结构意外分裂(第十八章排错清单里它是常客)。
九、语义化结构与布局标签
七个地标标签
| 标签 | 含义 | 数量约定 |
|---|---|---|
<header> | 区块的介绍性内容(站点头、文章头) | 每个区块各一个 |
<nav> | 主导航区块 | 站内导航各一个 |
<main> | 页面主内容 | 整页一个 |
<section> | 需要标题的一个分区 | 任意,但每个都该有标题 |
<article> | 可独立分发的一段内容(博文、评论、卡片) | 任意 |
<aside> | 与主内容相关但可剥离(侧栏、相关推荐) | 任意 |
<footer> | 区块的收尾内容 | 每个区块各一个 |
唯一性说的是嵌套层级:<main> 整页只有一个;但 <article> 内部的 <header> 和 <footer> 与页面的 <header> 不冲突,它们属于各自父区块。
整页骨架
一个可直接复制的起点,不含任何样式:
<body>
<header>
<h1>站点名</h1>
<nav><ul><li><a href="/">首页</a></li></ul></nav>
</header>
<main>
<article>
<header><h2>文章标题</h2><p><time datetime="2026-10-03">10 月 3 日</time></p></header>
<section><h3>小节</h3><p>正文……</p></section>
<footer><p>标签、作者</p></footer>
</article>
</main>
<aside><h2>相关推荐</h2><ul><li><a href="#">另一篇</a></li></ul></aside>
<footer><p>版权信息</p></footer>
</body>
<aside> 放在 <main> 外面还是里面,取决于它是不是「主内容的一部分」:相关推荐属于某篇文章就放进去,全站侧栏就放外面。
为什么不能 div 全天下
同样一个页面,全用 <div> 视觉上毫无区别,但你会丢掉三样东西:
- 辅助技术的地标导航。读屏软件(NVDA/JAWS/VoiceOver)有「地标列表」,用户靠它在导航、主内容、侧栏之间一键跳转。全是 div 时这个列表是空的,用户只能从头听到尾。
- 机器可读的结构。搜索引擎、微信/微博的抓取器、浏览器的「阅读模式」都靠标签推断正文。
<article>+<main>的页面被正确抽取的概率明显更高。 - 可读的源码。半年后回来看
<article class="post-card">和<div class="d3">,维护成本不是一个量级。
反过来说,语义标签不是越多越好:给一个既无标题也不能独立分发的包裹层硬套 <section>,比 <div> 更糟——你对读屏用户许下了一个兑现不了的承诺。
什么时候仍然该用 div
三种情况:纯样式包裹层(为了 flex 布局多套一层)、组件的根容器、以及找不到对应语义标签的抽象分组。判断顺序永远是:先找语义标签 → 再找列表/表格/表单结构标签 → 最后才是 div/span。
十、表单 form 与常见 input
表单是页面把数据交给服务器的唯一标准通道。它的问题通常不在标签语法,而在三个容易被忽略的细节:控件没写 name、label 没绑定、type 用了默认的 text。
form、action、method:数据发去哪、怎么发
<form action="/api/login" method="post" enctype="multipart/form-data">
<!-- 控件 -->
<button type="submit">登录</button>
</form>
action:接收数据的地址,省略时提交到当前页面 URL。method:get把数据拼在 URL 问号后(进浏览器历史、进服务器日志、长度受限),只用于搜索/筛选这类可分享、无副作用的查询;post把数据放请求体里,登录、写入、上传一律用它。enctype:默认application/x-www-form-urlencoded;上传文件必须改成multipart/form-data。- 只有带
name的控件才会被发送。id是给label和 JS 用的,与提交无关——这是「后端说没收到数据」的第一大原因。 <button>在表单内默认就是type="submit"。不想提交的按钮(清空、切换显示)必须显式写type="button",否则一点就触发提交。
input 的 type 决定一切
type 同时决定三件事:用什么控件呈现、手机弹出什么键盘、浏览器按哪套规则校验。
| type | 用途 | 附带校验 |
|---|---|---|
text | 默认值,单行文本 | 无 |
email | 邮箱 | 基本格式 |
password | 密码 | 无(配 autocomplete="new-password" / "current-password") |
number | 数字 | 数字 + min/max/step |
tel | 电话 | 不校验,配 inputmode="numeric" 换数字键盘 |
url | 网址 | 需含协议名 |
search | 搜索框 | 无 |
date / time / datetime-local | 日期时间 | 格式 |
range | 滑块 | min/max/step |
color | 取色器 | 十六进制色值 |
checkbox / radio | 多选 / 单选 | 同组 radio 必须同名 name |
file | 文件 | 配 accept 筛选类型 |
hidden | 随表单提交的不可见数据(如 CSRF token) | 无 |
两个高频纠正:
placeholder不能替代label:它一输入就消失、对比度常常不达标、翻译后易截断。- 读写控件的值要用属性而不是特征:
el.value(复选框el.checked、文件el.files)。改 HTML 的value特征只改「初始值」,用户已输入的内容不受影响。
label 的正确绑法
<!-- 显式绑定:for 与 id 成对,推荐 -->
<label for="email">邮箱</label>
<input id="email" name="email" type="email">
<!-- 隐式包裹:合法,但标题和控件被拆散时不好维护 -->
<label>邮箱 <input name="email" type="email"></label>
显式绑定换来三个实际收益:点标题文字即聚焦(复选框是点标题即勾选,命中区域大得多,移动端体验差距明显)、读屏软件把标题播报成这个控件的名字、浏览器推断 autocomplete 也更准。
for 指向的 id 必须存在且不重复。一个控件允许被多个 <label> 指向,但读屏会把两段都念出来。
select、textarea、optgroup 与 datalist
<select id="city" name="city">
<option value="">请选择城市</option>
<optgroup label="华东">
<option value="sh">上海</option>
<option value="hz">杭州</option>
</optgroup>
</select>
<textarea id="note" name="note" rows="4"></textarea>
<input list="langs" id="lang" name="lang">
<datalist id="langs">
<option value="HTML"></option>
<option value="CSS"></option>
</datalist>
五条约定:
<textarea>没有value属性,初始内容写在开闭标签之间,第一个字符前不要留缩进(空白会真的显示出来),也不可自闭合。<select multiple>在移动端体验很差,多数场景应改用一组复选框。<option>不带value时提交的是它的文本,带value时提交value——这个默认差异常造成前后端读到的值不一致。datalist是「自由输入 + 建议」,不强制只能选;select是「只能选」。<optgroup>给下拉里的选项分组,<fieldset>+<legend>给页面上的控件分组,两者作用域完全不同。
十一、表单校验属性
HTML 原生校验(Constraint Validation API)的价值是在服务端返回之前就先给用户反馈,成本为零。
required 与 type:最常用的两个
<input name="nick" required>
<input name="email" type="email" required>
required 拦空值,type="email" / "url" 附带格式检查,两者叠加才能既「不能不填」又「不能乱填」。type="number" 已隐含「必须是数字」,一般不需要额外 pattern。
复选框的必填有个坑:「必须同意条款」要给那一个复选框自己加 required;一组同名复选框里只要任意一个带 required,就只要求其中一个被勾选。
pattern、min、max、step、minlength
| 属性 | 规则 | 备注 |
|---|---|---|
pattern | 正则 | 隐含整串匹配,别自己加 ^…$;必须同时给 title 说明规则,否则用户只看到「与此格式不符」 |
minlength / maxlength | 文本长度 | maxlength 直接禁止继续输入(硬限制),minlength 在提交时校验(软限制) |
min / max | 数值或日期边界 | 只对数字/日期类 type 生效 |
step | 步长倍数 | step="2" 时输入 3 不合法 |
multiple | 允许多值 | 配合 type="email"(逗号分隔)或 "file" |
accept | 文件类型 | accept="image/*" 或 .pdf,.doc;只是筛选,不是安全限制 |
pattern="[A-Z]{2}\d{4}" title="两位大写字母加四位数字" 会让浏览器气泡直接显示这段 title,比默认提示有用得多。
校验提示、novalidate 与接管
原生气泡的文案语言跟随浏览器界面语言,中文站会出现「Please fill out this field」。接管的标准写法:
<form novalidate>…</form>
const form = document.querySelector('form')
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault()
form.reportValidity() // 仍然想用浏览器气泡时就调这个
return
}
// 校验通过,再发请求
})
单个控件的三个 API:el.checkValidity() 返回布尔,el.validationMessage 是浏览器准备说的话,el.setCustomValidity('文案') 替换那句话——不成立时记得设回空字符串,否则该字段永久无效。CSS 侧还有 :valid / :invalid / :required / :optional 四个伪类可以做即时描边,注意它们在用户还没输入完时就已变红,通常要配合 :not(:placeholder-shown) 收窄条件。
原生校验不能替代服务端校验
原生校验属于用户体验层,不是安全层。绕过它只要一行:
curl -X POST https://你的域名/api/register -d 'email=不是邮箱&nick='
三条边界:前端校验负责即时反馈、可以宽松;服务端校验是唯一可信的一层,所有字段必查;数据库约束是最后一道防线。
顺带厘清 disabled 与 readonly:readonly 的值会提交、可聚焦可复制;disabled 的值不会提交、不可聚焦、通常显示为灰。只想「不让改但仍要有值」用 readonly,纯展示结果更应改用 <output>。
十二、audio 与 video
音视频标签的难点不在属性多,而在浏览器的自动播放策略和多格式降级。
controls、autoplay 与浏览器的自动播放限制
<video controls preload="metadata" poster="/img/poster.jpg" width="1280" height="720">
你的浏览器不支持 video,<a href="/media/demo.mp4">下载视频</a>。
</video>
controls不写就没有播放条,用户什么都点不了——自绘播放器要几十行 JS,默认用它。autoplay必须同时写muted才可能生效。带声音的自动播放会被浏览器拦截(这是 Chrome/Safari/Firefox 共同策略,为了不把用户突然吓一跳,也为了省流量)。用户与页面有过交互(点击、按键)之后,脚本调用play()才不受限制。playsinline在 iPhone 上必写,漏了它 Safari 会强制切换到全屏播放器,你在视频上方叠的字幕、水印、按钮全部失效。loop+muted+playsinline+preload="none"是背景装饰视频的标准组合。preload取none/metadata/auto:列表页里的多个视频绝不能一律auto,否则首屏带宽被视频吃光。poster给首帧占位图,既省流量也防止布局塌陷。
source 多格式降级
<video controls poster="/img/poster.jpg">
<source src="/media/demo.av1.mp4" type="video/mp4;codecs=av01.0.09M.08">
<source src="/media/demo.webm" type="video/webm">
<source src="/media/demo.mp4" type="video/mp4">
降级文字与下载链接
</video>
浏览器从上往下逐个试,用第一个它能解码的 <source>,所以顺序应当是「体积最优 → 兼容兜底」。全部失败时渲染 <video> 内部的降级内容——这一句是必须保留的,否则旧浏览器只剩一个空白框。
编码格式的现实选择:H.264(video/mp4)兼容最好、效率一般;VP9/AV1(video/webm)同画质明显更小,Safari 对 AV1 的支持较晚。因此常见做法是同时给 mp4 和 webm,或用 <source> 列出三种让新浏览器优先取小体积的那份。音频同理,<audio> 可以只给一个 MP3,兼容性风险最小。
track 字幕
<video controls>
<source src="/media/demo.mp4" type="video/mp4">
<track kind="subtitles" src="/media/demo.zh.vtt" srclang="zh" label="中文" default>
<track kind="captions" src="/media/demo.en.vtt" srclang="en" label="English">
</video>
字幕文件是 WebVTT 格式:首行必须 WEBVTT,之后是「起止时间 → 文本」的块,可含 <i>、<c.class> 之类的轻量标记。
kind="subtitles"是给听不见声音的用户以外的人用的翻译;kind="captions"是给听障用户的,包含「掌声」这类音效说明;kind="descriptions"是给视障用户的画面描述音轨。default让该轨默认开启,label是播放器字幕菜单里的名字。- 字幕文件与页面不同源时必须返回 CORS 头,否则浏览器静默不加载——表现为「字幕点了没反应」,Network 面板里那条请求是
(failed) net::ERR_…或被 CORS 策略拦下。 - 用 JS 操作轨道:
video.textTracks[0].mode = 'showing'(其它值hidden/disabled)。
<audio> 也支持 <track>,但没有可视区域来显示,实际用途很小——给音频配文字稿才是对用户真正有帮助的做法。
十三、iframe 与内嵌内容
iframe 是「页面里另一个完整文档」:独立的 CSS、独立的 JS 运行环境、独立的滚动条。本站教程里每一个示例框都是它。
src 与 sandbox 的边界
<iframe
src="/embed/report"
title="季度报表"
loading="lazy"
sandbox="allow-scripts allow-forms"
referrerpolicy="no-referrer-when-downgrade"
></iframe>
sandbox 是白名单:不写这个属性表示什么都不限制;写了但值为空串(sandbox="")表示最严格的封锁——脚本不能跑、表单不能提交、不能弹窗、不能把外层页面跳走。需要哪项就显式列哪项:
| 值 | 放开什么 | 备注 |
|---|---|---|
allow-scripts | 内部 JS | 演示代码必须 |
allow-forms | 内部表单提交 | 表单示例 |
allow-modals | alert/confirm | 讲解弹窗时 |
allow-popups | window.open | 少用 |
allow-same-origin | 保留同源身份,可读自己的 localStorage/cookie | 需要存储的示例 |
allow-top-navigation | 允许内部把外层跳走 | 基本别开,是钓鱼入口 |
有个必须知道的陷阱:allow-scripts 与 allow-same-origin 同时给、且嵌的是同源文档时,沙箱等于形同虚设(内部脚本能拿到自己的 window 再爬 parent),浏览器控制台会直接警告这一组合。本站的示例框故意不给 allow-same-origin,代价是涉及 localStorage 的例子必须在「新标签页」里看。
title 不是可选项
读屏用户通过「框架列表」在 iframe 间跳转,title 就是列表里的名称。缺 title 的 iframe 在 Lighthouse / axe 的无障碍审计里是确定的错误项。同理,width/height 或 CSS 尺寸要写清楚,否则 iframe 会塌陷成 150px 高的默认值。
响应式内嵌容器
第三方给的嵌入代码通常写死 width="560" height="315",手机上直接横向溢出。现代写法用 aspect-ratio:
<div style="aspect-ratio:16/9;position:relative">
<iframe style="position:absolute;inset:0;width:100%;height:100%;border:0" src="…" title="…" loading="lazy"></iframe>
</div>
老浏览器兼容写法是 padding-top: 56.25% 撑出比例(原理相同:百分比 padding 相对宽度计算),CSS 教程第六章会解释这条规则。
性能代价与 facade 模式
- iframe 内容与主页面不共享渲染资源,一份 iframe 就是一次独立的网络与解析开销,是 Lighthouse「减少第三方代码」告警的主要来源。
- iframe 内的内容不会被搜索引擎当作本页内容收录——把正文放 iframe 里等于放弃 SEO。
- 视频、地图、社交组件建议用 facade 模式:先渲染一张海报图 + 播放按钮(成本几乎为零),用户点击后才写入
src真正加载。YouTube 的youtube-nocookie.com组件、多数静态站的视频方案都是这个思路。 - 跨 iframe 通信只能用
postMessage,两侧都必须校验event.origin,否则任意页面都能给你发消息。
十四、实体、注释与书写约定
常用实体
只要内容里要显示标签字符本身,就必须转义:
| 写法 | 渲染 | 用途 |
|---|---|---|
< > | < > | 写代码示例,教程页最高频 |
& | & | 与符号;URL 里的 & 也要写成它 |
" | " | 属性值里嵌套双引号 |
' 或 ' | ' | 单引号(后者兼容性更稳) |
| 空格 | 不换行空格,强制不在此处折行 |
© ® ™ | © ® ™ | 版权、注册、商标 |
— – | — – | 长破折号、短连接号 |
· | · | 面包屑分隔符 |
代 / 代 | 代 | 任意 Unicode 的十进制 / 十六进制写法 |
中文还需要实体吗
不需要。声明了 <meta charset="UTF-8"> 且文件确实存成 UTF-8,中文、emoji(😀)、数学符号(∑)都能直接写。实体的现代用途只剩两类:展示标签语法的教程页,以及编码不确定的旧环境。
反过来要警惕的是该转义时没转义:把用户输入直接拼进 HTML 是 XSS 的标准成因。转义发生在「输出到 HTML」这一步,而不是存储时;主流框架的插值语法(Vue 的 {{ }}、React 的 JSX)已默认转义,真正要小心的是主动使用 v-html / innerHTML / dangerouslySetInnerHTML。
注释的现状与风险
<!-- 普通注释 -->
<!-- 注释里不能出现连续两个减号,会被提前当成结束 -->
三条现状:① 注释会随 HTML 一起发给用户,内部接口地址、密钥、客户名字都不能写;② 浏览器不执行注释里的脚本,但「把密码注释掉」这种写法每年仍会被扫描器捡出来;③ 条件注释(<!--[if IE]>)在 IE 10+ 已彻底失效,别再用来做浏览器分支。
大小写、引号与布尔属性
| 项目 | 现代约定 | 原因 |
|---|---|---|
| 标签大小写 | 全部小写 | HTML 不区分,但 XHTML/Vue 模板区分,混写最容易出问题 |
| 属性值引号 | 始终加 | 值里含空格或 & 时不加就错 |
| 布尔属性 | required 而非 required="required" | JS 里 getAttribute('disabled') 拿到的是空串而不是 "true",判断要用 el.disabled |
| 自闭合 | <meta charset="UTF-8">,不写 /> | void 元素(br/img/input/hr/meta/link/source)本来就没有结束标签 |
| 缩进 | 两个空格,项目内统一 | 交给编辑器/Prettier 格式化,别手对齐 |
十五、data-* 自定义属性
data-* 是「给元素挂自定义数据」的合法位置,值永远是字符串。命名规则:必须以 data- 开头,后面只允许小写字母、数字和连字符——不能有大写、不能有点。
语法与命名
<article id="a12"
data-level="hot"
data-user-id="1024"
data-max-width="480"
data-config='{"sortable":true,"page":1}'>
…
</article>
- 名字用 kebab-case(
data-max-width),驼峰只出现在 JS 的dataset访问端。 - 名字要有语义:
data-role="price"好于data-x="3"。 - JSON 值用单引号包裹,内部保持标准双引号 JSON。
配合 CSS 属性选择器
CSS 能直接按属性匹配,这带来一种很实用的写法:把「内容属性」交给 data,样式响应它,JS 只改属性不碰类名。
.card[data-level="hot"] { border-color: #cf222e; } /* 精确匹配 */
.card[data-level] { cursor: pointer; } /* 只要属性存在 */
[data-level^="h"] { … } /* 前缀,还有 $= *= ~= |= */
.card[data-level]::after { content: attr(data-level); } /* 把值当文本显示出来 */
.grid-item[data-span="2"] { grid-column: span 2; }
attr() 在 content 里取值非常省事(做角标、单位后缀),但它目前只能可靠地用在 content 上,不能拿去当宽度、颜色等任意属性值使用——那条能力(attr() 的通用用法)还在逐步落地中,写生产样式前请查一次基线支持。
dataset 读取
const el = document.querySelector('[data-user-id]')
el.dataset.userId // '1024' —— 连字符名自动映射成驼峰
el.dataset.maxWidth // '480',注意仍是字符串,要自己 parseInt
el.dataset.state = 'ok'// 写入会同步反映到 HTML 属性,CSS 选择器立刻生效
Object.entries(el.dataset) // 遍历该元素上全部 data-*
delete el.dataset.clicks // 删除属性
el.getAttribute('data-user-id') // 等价读法;setAttribute 是等价写法
dataset 与 getAttribute 的区别只在可读性,操作的是同一个属性。要监听变化用 MutationObserver。
什么时候不该用 data-*
- 外观开关用 class,不用 data-*。
data-color="red"通常说明你真正想要的是一个语义类名。 - 敏感信息不要放:和所有属性一样,它会出现在源码里,任何人可见。
- 可访问性状态必须用 ARIA:
aria-expanded、aria-current、aria-hidden会被读屏播报,data-*完全不会。把data-open当成aria-expanded用是常见错误。 - 需要传结构化配置时,集中成一个
data-configJSON 比散成十几个属性更好维护。
十六、响应式视口、head 元信息与 SEO
<head> 里的内容决定三件事:手机上能不能正常排版、搜索结果长什么样、分享到社交平台时有没有卡片图。
viewport 那一行到底在说什么
<meta name="viewport" content="width=device-width, initial-scale=1">
width=device-width:按设备的逻辑宽度排版;initial-scale=1:初始不缩放。- 漏写这一行,手机会按 980px 宽排版再整体缩小,文字小到看不清,并且所有媒体查询失效——它看到的宽度永远是 980 而不是 375。
- 不要再写
user-scalable=no或maximum-scale=1:禁止双指放大是可访问性错误,主流浏览器也已不再遵守。 - 与它配套的 CSS 是「流式布局 + 少量断点」,见 CSS 教程第十三章。
title、description、canonical:三件必写
| 项 | 建议 | 常见失误 |
|---|---|---|
<title> | 每页唯一,30 字以内,核心词靠前,「文章名 - 站点名」格式 | 全站同一个标题;被截断;标题堆砌关键词 |
<meta name="description"> | 80–120 字,写给用户看的一句话摘要 | 缺失(引擎自己拼,通常很难看);把整段正文粘进去 |
<link rel="canonical"> | 同一内容的多个 URL 指向唯一规范地址 | 分页/排序参数各自一条规范地址,导致权重分散 |
description 不直接提升排名,但它决定用户在你的摘要里看到什么,影响点击率。robots 相关:内部搜索结果页、预览页写 noindex;hreflang 用于多语言版本互相声明,必须双向;sitemap.xml 提交到搜索引擎站长平台。
静态站框架通常能自动产出这几项。本站在 Nuxt 里对应 useSeoMeta()(页面级)与 nuxt.config.ts 的 site.url——注意 site.url 上线前必须换成真实域名,否则 canonical 和 sitemap 都会指向占位地址。
Open Graph 与分享卡片
<meta property="og:type" content="article">
<meta property="og:title" content="HTML 教程:从第一个标签到语义化页面">
<meta property="og:description" content="19 章循序渐进,每章配可直接运行的示例。">
<meta property="og:image" content="https://你的域名/img/html-cover.svg">
<meta property="og:url" content="https://你的域名/tutorials/html">
<meta name="twitter:card" content="summary_large_image">
要点:og:image 必须是绝对 URL,比例约 1.91:1(1200×630);相对路径不会报错,只会让卡片没有图。部分平台不支持 SVG 作为 og:image,稳妥做法是额外准备一份 JPG/PNG。各平台都有官方校验工具(微信/微博、Facebook Sharing Debugger、Twitter Card Validator),发布新模板时逐个跑一次。
其它常用 head 元信息
theme-color:手机浏览器地址栏配色;给深色模式再补一条带media="(prefers-color-scheme: dark)"的。link rel="icon":标签页图标,同时保留/favicon.ico兜底最省事。link rel="alternate" type="application/rss+xml":RSS 订阅地址。link rel="preconnect":只对确定要请求的第三方域名握手,加多了适得其反。format-detectiontelephone=no:禁止 iOS 把一串数字变成电话链接。<meta http-equiv="refresh">:除非做跳转兜底,否则别用——它打断用户且读屏不友好。
十七、可访问性
可访问性(a11y)不是「给残障人士的额外功能」,而是键盘用户、色觉差异用户、强光下阅读、临时受伤(手腕扭伤只能用键盘)都能正常使用的底线要求。它也是 Lighthouse 审计里最容易批量失分的一项。
键盘可达与焦点顺序
原生可交互元素——<button>、<a href>、<input>、<select>、<textarea>——天然能 Tab 到、天然响应 Enter/空格。用 <div> 冒充按钮时你需要自己补齐三件事:tabindex="0"、role="button"、以及监听 keydown 处理 Enter 与空格。只要能用原生标签就用原生标签,这是最省事也最不容易出错的做法。
tabindex 的三个取值要知道:0 按 DOM 顺序入列;-1 不能被 Tab 到但可被脚本聚焦(弹窗背景常用);正数会打乱全局顺序,绝大多数场景都不该出现。
另外两条:模态弹窗打开时应把焦点移入并在内部循环(focus trap),关闭后归还到触发元素;「跳到主内容」的隐藏链接(首个焦点时露出)能替键盘用户省掉每次遍历整条导航。
焦点必须看得见
最严重也最常见的问题是抹掉焦点环:
/* 错误:键盘用户直接失去定位能力 */
button:focus { outline: none; }
/* 正确:保留焦点,但换成符合设计的外观 */
:focus-visible { outline: 3px solid #0969da; outline-offset: 2px; }
:focus-visible 的好处是只在键盘聚焦时显示,鼠标点击不会留环,设计和可访问性都不用让步。本站的目录链接、搜索框用的就是这个伪类。
aria 的第一定律与常用属性
第一定律:能用原生语义标签和属性做到的,就不要用 ARIA。 <button> 已经是按钮,写 role="button" 只会增加出错面积。
确实需要 ARIA 的典型场景:
| 属性 | 用途 | 注意 |
|---|---|---|
aria-label / aria-labelledby | 给无名控件命名 | 图标按钮必用;labelledby 指向已有可见文本更稳 |
aria-expanded | 展开/折叠状态 | 自绘折叠组件必须自己维护,原生 <details> 自动带 |
aria-controls | 按钮与被控区块的关联 | 值为目标 id |
aria-current="page" | 标出导航里的当前项 | 比只靠变色更明确 |
aria-live="polite" | 局部异步更新播报 | 表单校验结果、加载中提示;用 role="status" 更简洁 |
aria-invalid + aria-describedby | 把错误信息与控件关联 | 不要只用红色描边表达错误 |
aria-hidden="true" | 从无障碍树里移除纯装饰元素 | 装饰性图标字体、内联 SVG 用;绝不能用在可聚焦元素上 |
role="region" / 地标 | 给长页面分区 | <main>/<nav>/<aside> 已自带,重复声明是冗余 |
对比度与其它底线
- 正文与背景对比度至少 4.5:1(大号字 3:1)。把提示文字统一设成
#aaa是很多站点批量失分的原因;图片上的文字要么加遮罩要么换到纯色区域。 - 尊重
prefers-reduced-motion,为前庭功能敏感的用户关掉大幅位移动画(写法见 CSS 教程第十三章)。 - 移动端可点区域不小于 44×44 像素。
<html lang="zh-CN">决定读屏的发音引擎(第二章)。- 交付前自己走两遍:只用 Tab 完成一次核心操作;把宽度压到 375px 读一遍。这两步能发现九成问题,成本远低于任何自动化工具。
十八、常见坑与排错清单
HTML 的错误绝大多数不会报错——它的设计目标就是「尽力理解你写的东西」。所以排查靠的是「对比实际 DOM 与预期 DOM」,而不是等控制台提示。
块级与行内的混用
最高频的一类:在 <p> 里放 <div>、<p>、<ul> 等块级元素。浏览器会自动闭合前面的 <p>,于是你以为的一段变成三段,缩进和间距全乱。同类问题还有 <span> 里放 <div>(不合法但会被纠正)、<a> 里套 <a>(内层被踢出)。
未闭合标签与浏览器的就近纠正
<li> 省略 </li> 在 HTML 里是合法的(下一个 <li> 或 </ul> 会隐式闭合它),但 <div>、<section> 这类没有隐式闭合规则的标签漏掉闭合,就会吞掉后续一大块内容——表现为「下半页莫名其妙变成了一列」。交叉嵌套 <b><i>文字</i></b> 会被就近纠正成两对,结果与预期完全不同。
发现办法:Elements 面板看实际层级(与源码对不上就是被纠正过),或者把文件交给 W3C Nu 校验器,它会直接指出「element not allowed as child of …」。
表格与表单的典型错误
| 现象 | 真实原因 | 修法 |
|---|---|---|
| 表格列整体错位 | 合并单元格后没删被盖住的 <td> | 数每行列数,colspan/rowspan 计入被占位的那几列 |
| 读屏只念数字 | <th> 缺 scope | 列表头 scope="col",行表头 scope="row" |
| 后端收不到某字段 | 控件只有 id 没有 name | 补 name,id 与提交无关 |
| 点标题不聚焦 | label for 与 id 不一致,或 id 重复 | 成对检查;重复 id 只命中第一个 |
| 按钮一点就整页刷新 | 表单里 <button> 默认 type="submit" | 非提交按钮写 type="button",JS 里 preventDefault() |
| 单选组能同时选两个 | 同组 radio 的 name 不同 | 一组同名,value 区分选项 |
| 图片不显示 | 路径 404 / 漏了部署子路径前缀 | 先看 Network,再看是不是写了 ../../ |
| 百分比高度为 0 | 父级没有确定高度,或进了怪异模式 | 用 min-height/aspect-ratio;document.compatMode 检查 |
排错的固定路径
- 关掉浏览器扩展再刷新(广告拦截、隐私插件是「本地正常别人打不开」的第一大来源)。
- Console 看红字,Network 看红条:路径、接口、CORS 问题一次现形。
- Elements 看实际 DOM,Computed 看最终样式:你在源码里写的顺序不是浏览器的理解结果。
- 最小复现:把可疑片段复制到独立的空白 HTML 里,只留最小结构,逐步加回。
- 跑一遍校验与审计:Nu 校验器查结构,Lighthouse 查对比度、缺
label、缺title。 - 真机验证:375px 宽度 + 只用键盘走一遍核心流程。
十九、综合实战:用语义标签重写一个页面
前面十八章的知识在这一章一次性用掉。下面两个示例是同一个页面的两个版本,视觉效果一致,差别全在结构上。先用浏览器打开看两边是否真的长得差不多,再对照源码。
先看清旧结构
这份代码的可见问题是:整页只有 div/span;导航和「阅读全文」是上了色的文字,没有任何可聚焦元素;日期是纯文本;样式全部内联。它在视觉上完全正常,但读屏用户拿不到地标列表,键盘用户一步都走不了,搜索引擎抽正文时会把友情链接一起抽走。
重写:从地标开始
重写的顺序值得记住,它和写 CSS 的顺序相反——先结构后外观:
- 先放五个地标:
header(含h1)、nav aria-label="主导航"、main、aside、footer。 nav里放真正的ul > li > a,当前页给aria-current="page";链接必须是a[href],不要用 div 上色冒充。- 每一篇可独立分发的内容给一个
article,标题用h3并把整条标题做成链接(a可以包块级内容,卡片整体可点就是这么实现的)。 - 日期用
<time datetime="2026-09-20">,机器读得到、人看到的是中文。 - 并列的目录项用
ul/ol,并给承载它的分区加h2+aria-labelledby。 - 样式全部收进类名(BEM 风格),去掉内联
style——这一步属于 CSS 教程,但改造时顺手做完最省事。
注意改造后没有新增一个 role:<main> 已隐含 role="main",<nav> 已隐含 role="navigation",重复声明是冗余。ARIA 只用来补原生标签补不了的信息(aria-current、aria-label、aria-labelledby)。
对照检查清单
改完一个页面,按这七条逐项打勾:
- 只用 Tab 能从页头走到页尾,每一步都看得见焦点环。
- 读屏的地标列表里能看到 导航 / 主内容 / 侧栏 / 内容信息 四项。
- 页面只有一个
<h1>,标题层级不跳跃(h2之后不直接出现h4)。 - 所有图片都有
alt,装饰图的alt=""而不是省略。 - 所有图标按钮都有
aria-label,所有表单控件都有绑定的<label>。 - 每个
<iframe>都有title。 - 正文文字与背景对比度 ≥ 4.5:1。
到这里 HTML 教程完结。接下来建议直接进入 CSS 教程:本教程里所有「交给 CSS」的部分(布局、单位、颜色、响应式、主题变量)都在那边展开。