HTML 教程:从第一个标签到语义化页面

一、HTML 是什么:你的第一个页面

HTML 不是编程语言,而是标记语言:它用一对对尖括号包裹的「标签」告诉浏览器,某一段文字是标题、某一段是段落、某一张图可以点开。理解这一点,后面所有标签的学习都会变成「这个标签表达什么含义」的问题,而不是「这个标签怎么写样式」的问题——样式是 CSS 的事。

浏览器把一个文件变成画面的过程

你在地址栏输入一个 .html 文件,浏览器大致做四件事:

  1. 拿到字节流:按 <meta charset> 声明的编码(通常是 UTF-8)把字节解码成文字。声明写错,中文就会变成「æ¬å」这类乱码。
  2. 解析成节点树:逐字符读标签,遇到 <p> 就开一个节点,遇到 </p> 就闭合,层层嵌套形成「DOM 树」。树根是 html 节点。
  3. 构建渲染树:结合 CSS 决定哪些节点可见、以什么顺序和位置排布。
  4. 绘制到屏幕:按层叠顺序一笔画一画涂出来。

这条链路解释了后面会反复遇到的两个现象:标签嵌套顺序决定结构,而书写顺序不决定视觉层级(那是 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」(需装插件)可以省掉手动刷新。

下面这个框里就是上面那段代码的真实渲染结果,右边目录的小节、左侧的章节导航,以及正文里所有类似的可运行示例,都是独立文件,点「在新标签页打开」可以单独查看:

示例 01:三个标签搭出的页面在新标签页打开

接下来往哪走

从第二章开始按顺序推进:先解决「文件为什么这么开头」(文档结构),再补齐文字、列表、链接、图片、表格、表单这些内容型标签,然后进入结构型标签与语义化,最后是可访问性、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>

三条边界值得记住:

  1. <head> 里放的是「关于页面的信息」:编码、标题、样式表、图标、SEO 元信息。<head> 里出现 <p>、<h1> 这类可见元素是不合法的,浏览器会把它「揪」到 body 开头,导致难以排查的错位。
  2. <body> 里放全部可见内容,且一个文档只能有一个 <body>。
  3. <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> 写了什么:

示例 02:完整文档骨架在新标签页打开

常见报错与自查

现象大概率原因怎么确认
中文变乱码文件实际编码不是 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>(它还保留缩进,常用于代码块),想要真正的空格请用实体 &nbsp;。

下面这个示例把这三条都做实了,包括「空白折叠验证」和 <pre> 的对比:

示例 03:段落、换行与分隔线在新标签页打开

h1–h6:层级不是字号

六个标题标签构成文档的大纲,屏幕阅读器和浏览器的「页面概览」都读它。规则只有两条:

  1. 一页可以有多个 <h1>,但通常只放一个(页面主标题),从 <h2> 开始往下分章节。
  2. 不要跳层级:h2 之后直接写 h4 会在大纲里留下空洞。

新手最常见的错误是「觉得这行字要小一点,所以用 h5」。层级和字号是两件事,字号交给 CSS 改;用错层级只是把可访问性弄坏了。

示例 03b:标题层级与语义强调在新标签页打开

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> 假装术语表是常见错误,因为它丢掉了「这是配对关系」的结构信息。

示例 04:ul / ol / dl 三种列表在新标签页打开

列表的三处常见误用

  1. 用 <br> 或连续 <p> 堆清单——读屏会播报「列表,共 3 项」,帮你数位置,堆出来的没有。
  2. 站内导航写成 <div><a>。导航的正确形状是 <nav> + <ul> + <li> + <a>,第九章展开。
  3. 表格数据用列表写、列表用 <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。

示例 05:路径写法与页内锚点在新标签页打开

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 },比例仍由那两个属性提供。
示例 06:img 的四个必填属性在新标签页打开

格式怎么选

格式用在哪注意
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。
示例 07:表格的完整结构在新标签页打开

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、formspan、a、strong、em、code、img、small
占宽独占一行,撑满可用宽度只包住内容宽度
宽高/margin可设宽高,上下 margin 生效宽高无效,上下 margin 不生效(padding 生效但会溢出)
能包含可包含块级和行内默认只能放行内(<a>、<button> 例外)

「块级和行内」是默认显示行为,不是标签的本质属性——CSS 的 display 可以互相转换(display: inline-block、display: flex、display: grid),这在 CSS 教程第九章专门讲。

示例 08:块级与行内、class 与 id在新标签页打开

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> 是非法的,浏览器会强行「就近闭合」,结果与你的预期完全不同。

三条实操约定:

  1. 缩进体现层级,两个空格一档;VS Code 里 Shift+Alt+F 自动格式化整份 HTML。
  2. 块级里放块级、行内里放行内,是唯一安全的默认策略。
  3. <p> 里不能再放 <div>、<p>、<ul> 等块级元素——浏览器会自动把 <p> 提前闭合,导致结构意外分裂(第十八章排错清单里它是常客)。

九、语义化结构与布局标签

七个地标标签

标签含义数量约定
<header>区块的介绍性内容(站点头、文章头)每个区块各一个
<nav>主导航区块站内导航各一个
<main>页面主内容整页一个
<section>需要标题的一个分区任意,但每个都该有标题
<article>可独立分发的一段内容(博文、评论、卡片)任意
<aside>与主内容相关但可剥离(侧栏、相关推荐)任意
<footer>区块的收尾内容每个区块各一个

唯一性说的是嵌套层级:<main> 整页只有一个;但 <article> 内部的 <header> 和 <footer> 与页面的 <header> 不冲突,它们属于各自父区块。

示例 09:语义化页面骨架在新标签页打开

整页骨架

一个可直接复制的起点,不含任何样式:

<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> 视觉上毫无区别,但你会丢掉三样东西:

  1. 辅助技术的地标导航。读屏软件(NVDA/JAWS/VoiceOver)有「地标列表」,用户靠它在导航、主内容、侧栏之间一键跳转。全是 div 时这个列表是空的,用户只能从头听到尾。
  2. 机器可读的结构。搜索引擎、微信/微博的抓取器、浏览器的「阅读模式」都靠标签推断正文。<article> + <main> 的页面被正确抽取的概率明显更高。
  3. 可读的源码。半年后回来看 <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",否则一点就触发提交。
示例 10a:表单结构与提交内容在新标签页打开

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)无
示例 10b:input 类型速查在新标签页打开

两个高频纠正:

  1. placeholder 不能替代 label:它一输入就消失、对比度常常不达标、翻译后易截断。
  2. 读写控件的值要用属性而不是特征: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,就只要求其中一个被勾选。

示例 11:原生校验与提示接管在新标签页打开

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 给首帧占位图,既省流量也防止布局塌陷。
示例 12:audio 与 video 的完整结构在新标签页打开

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-modalsalert/confirm讲解弹窗时
allow-popupswindow.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 高的默认值。

示例 13:iframe、sandbox 与响应式内嵌在新标签页打开

响应式内嵌容器

第三方给的嵌入代码通常写死 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,否则任意页面都能给你发消息。

十四、实体、注释与书写约定

常用实体

只要内容里要显示标签字符本身,就必须转义:

写法渲染用途
&lt; &gt;< >写代码示例,教程页最高频
&amp;&与符号;URL 里的 & 也要写成它
&quot;"属性值里嵌套双引号
&apos; 或 &#39;'单引号(后者兼容性更稳)
&nbsp;空格不换行空格,强制不在此处折行
&copy; &reg; &trade;© ® ™版权、注册、商标
&mdash; &ndash;— –长破折号、短连接号
&middot;·面包屑分隔符
&#20195; / &#x4EE3;代任意 Unicode 的十进制 / 十六进制写法
示例 14:实体表与书写约定在新标签页打开

中文还需要实体吗

不需要。声明了 <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() 的通用用法)还在逐步落地中,写生产样式前请查一次基线支持。

示例 15:data-* 与 CSS/JS 的配合在新标签页打开

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-config JSON 比散成十几个属性更好维护。

十六、响应式视口、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 教程第十三章。
示例 16:head 元信息与分享卡片效果在新标签页打开

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-detection telephone=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),关闭后归还到触发元素;「跳到主内容」的隐藏链接(首个焦点时露出)能替键盘用户省掉每次遍历整条导航。

示例 17:键盘遍历、焦点样式与对比度在新标签页打开

焦点必须看得见

最严重也最常见的问题是抹掉焦点环:

/* 错误:键盘用户直接失去定位能力 */
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>(内层被踢出)。

示例 18:坑清单与排错路径在新标签页打开

未闭合标签与浏览器的就近纠正

<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 检查

排错的固定路径

  1. 关掉浏览器扩展再刷新(广告拦截、隐私插件是「本地正常别人打不开」的第一大来源)。
  2. Console 看红字,Network 看红条:路径、接口、CORS 问题一次现形。
  3. Elements 看实际 DOM,Computed 看最终样式:你在源码里写的顺序不是浏览器的理解结果。
  4. 最小复现:把可疑片段复制到独立的空白 HTML 里,只留最小结构,逐步加回。
  5. 跑一遍校验与审计:Nu 校验器查结构,Lighthouse 查对比度、缺 label、缺 title。
  6. 真机验证:375px 宽度 + 只用键盘走一遍核心流程。

十九、综合实战:用语义标签重写一个页面

前面十八章的知识在这一章一次性用掉。下面两个示例是同一个页面的两个版本,视觉效果一致,差别全在结构上。先用浏览器打开看两边是否真的长得差不多,再对照源码。

先看清旧结构

示例 19:改造前的 div 布局在新标签页打开

这份代码的可见问题是:整页只有 div/span;导航和「阅读全文」是上了色的文字,没有任何可聚焦元素;日期是纯文本;样式全部内联。它在视觉上完全正常,但读屏用户拿不到地标列表,键盘用户一步都走不了,搜索引擎抽正文时会把友情链接一起抽走。

重写:从地标开始

示例 19b:改造后的语义结构在新标签页打开

重写的顺序值得记住,它和写 CSS 的顺序相反——先结构后外观:

  1. 先放五个地标:header(含 h1)、nav aria-label="主导航"、main、aside、footer。
  2. nav 里放真正的 ul > li > a,当前页给 aria-current="page";链接必须是 a[href],不要用 div 上色冒充。
  3. 每一篇可独立分发的内容给一个 article,标题用 h3 并把整条标题做成链接(a 可以包块级内容,卡片整体可点就是这么实现的)。
  4. 日期用 <time datetime="2026-09-20">,机器读得到、人看到的是中文。
  5. 并列的目录项用 ul/ol,并给承载它的分区加 h2 + aria-labelledby。
  6. 样式全部收进类名(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」的部分(布局、单位、颜色、响应式、主题变量)都在那边展开。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~