HTML基础与文档结构

张玥 2026年9月19日 阅读时间 25分钟
HTML 文档结构 DOM 语义化 最佳实践
前端开发技巧之HTML基础与文档结构

很多人以为 HTML 简单——不就是一堆尖括号吗?写几个 div、塞点文字、加个 img,页面就出来了。可真正把 HTML 写对的人并不多。浏览器面对一份残缺、嵌套错误、属性乱写的 HTML,会启动一整套容错机制去“猜”你的意图:table 里少了 tr 它会自动补、p 里嵌套 div 它会强行闭合、忘记声明编码就会乱码。这些“猜”出来的结构,和你脑子里想的结构往往不是一回事。HTML 不是“随便写写浏览器都能显示”的松散标记,而是一份有严格规则、会被解析成一棵文档树的正式文档。本篇 25 分钟长文,从 HTML 的本质讲起,依次拆解文档骨架、head 里的元信息、元素分类与文档流、文本与列表、链接与路径、图片与媒体、表格与表单、全局属性、浏览器解析流程,最后给出一份可落地的重构对比与检查清单。

一、HTML 是什么:一门描述结构的语言

HTML 的全称是 HyperText Markup Language,超文本标记语言。拆开看这三个词,其实已经把它的职责说清楚了:

  • HyperText(超文本):文档之间可以通过链接互相跳转,这是 Web 之所以是“网”的根本。
  • Markup(标记):用标签给内容“打标记”,说明这一段是什么、那一块是什么。
  • Language(语言):它有语法、有规则、有解析器,写错了会有明确的处理方式。

需要注意的是,HTML 不是编程语言。它没有变量、没有循环、没有条件判断,不会“执行”,只会被“解析”。你写的每一个标签,最终都会变成一个 DOM 节点,组成一棵树,交给 CSS 去排版、交给 JavaScript 去操作。

从 HTML4 到 Living Standard

HTML 的版本演进史,本身就是一部 Web 的发展史:

HTML 4.01 1999 年 W3C 推荐标准,用 id / class 表达结构,div 汤时代的开端
XHTML 1.0 2000 年,要求 HTML 必须符合 XML 语法,标签必须闭合、属性必须加引号
HTML5 2014 年成为 W3C 正式推荐标准,带来语义标签、原生表单类型、媒体元素
Living Standard 如今由 WHATWG 维护,不再有版本号,持续滚动更新

“Living Standard”(活标准)意味着 HTML 不再有“HTML6”这种大版本发布,规范是持续演进的。dialog 的 closedby 属性、details 的 name 互斥、popover 属性,都是在近两年陆续进入规范的。所以你不需要等“下一版 HTML”,新特性会不断出现在你每天写的代码里。

一份 HTML 文档,本质是一棵树

这是理解 HTML 最重要的一句话。浏览器读到你的源码后,会把它解析成一棵节点树(DOM 树)。嵌套关系就是父子关系,兄弟关系就是同一层级的节点。看下面这段结构:

<body>
  <header>
    <h1>标题</h1>
  </header>
  <main>
    <p>正文</p>
  </main>
</body>

解析后,body 是 header 和 main 的父节点,header 是 h1 的父节点,h1 是 header 的子节点。你在 DevTools 里看到的那个可以逐层展开的面板,就是这棵树的直观呈现。

理解了树,很多事情就顺了:CSS 选择器沿树查找、JavaScript 用 parentNode / children 遍历、屏幕阅读器按树朗读、搜索引擎按树判断层级。你写下的嵌套关系,就是这棵树长什么样。

常见误解
“HTML 就是排版用的,标签随便嵌套,浏览器会自动修正。”——浏览器确实会容错,但修正结果往往和你想的不一样,而且不同浏览器可能修正得不同。
正确姿势
把 HTML 当作一份正式的数据文档来写:结构清晰、嵌套合法、闭合完整。解析出的 DOM 树才和你的设计一致。

二、文档骨架:五秒钟写出一个合格的 HTML 页面

不管是手写还是编辑器一键生成,一个合格的 HTML 文档至少要包含下面这些部分。它们每一个都有明确的作用,删掉任何一个都会带来实际问题。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>页面标题</title>
</head>
<body>
  <h1>你好,世界</h1>
</body>
</html>

DOCTYPE:告诉浏览器用哪套规则

<!DOCTYPE html> 必须写在文档的第一行,且不能有任何内容在它前面(包括注释和 BOM 之外的空白)。它的唯一作用是触发标准模式(Standards Mode)。

如果漏写 DOCTYPE,浏览器会进入怪异模式(Quirks Mode),用一套模拟上世纪 90 年代浏览器的规则来渲染你的页面:box-sizing 的默认值变了、行内元素的高度计算方式变了、百分比宽度的解析方式变了。你的 CSS 会莫名其妙地“不对劲”,而排查起来极其痛苦。

HTML5 的 DOCTYPE 被简化到了极致,只有 html 四个字符,不再需要写 DTD 声明。这是对旧写法的一点点减负,但它的重要性丝毫没有降低。

html 与 lang:别小看这个属性

lang="zh-CN" 看起来只是个装饰,实际上它影响着好几件事:

  • 屏幕阅读器:会依据 lang 切换发音引擎。如果标成 en 却写着中文,读出来的是一串奇怪的拼音式发音。
  • 浏览器翻译:Chrome 判断是否需要弹出“是否翻译此页面”的提示,靠的就是它。
  • 断词与连字符:不同语言的断词规则不同,hyphens 依赖语言标记。
  • 字体选择:部分系统会依据语言选择更合适的中文字形(简体 vs 繁体)。
  • 语音合成与检索:搜索引擎和 TTS 引擎都会读取这个信号。

页面中如果有局部内容使用了其他语言,可以单独在元素上再标一次:

<p>这个单词 <span lang="en">accessibility</span> 的意思是“无障碍”。</p>

head 与 body 的分工

head 里放的是给机器看的信息:编码、标题、描述、样式表、脚本、图标。这些内容不会直接显示在页面上(title 显示在标签页,meta description 显示在搜索结果里)。

body 里放的是给用户看的内容:文字、图片、链接、表单、视频。所有可见内容都必须写在 body 里。

一个常见的低级错误是:把内容写在 body 之外。</body> 之后写的文字,浏览器解析时会“贴心地”把它们挪到 body 末尾,但此时文档结构已经乱了,某些情况下这些内容会被直接丢弃。

反例
漏写 DOCTYPE、漏写 lang、漏写 viewport。
前两个影响解析与朗读,第三个会让页面在手机上被缩放到一个虚拟的 980px 宽视口里,字小得看不清。
正解
DOCTYPE + lang + charset + viewport + title,这五项是所有页面的最低配置,建议做成编辑器代码片段。

三、head 里的元信息:页面的“后台配置”

head 里的内容虽然用户看不到,却决定了页面如何被加载、被索引、被分享。一个完整的 head 通常包含以下几类内容。

字符编码:charset 必须靠前

<meta charset="UTF-8"> 应该出现在 head 的最前面,规范要求它必须位于文档的前 1024 字节内。原因是:浏览器需要先知道编码,才能正确解码后面的字节。如果编码声明太靠后,浏览器已经用默认编码(通常是 Latin-1 或系统编码)解析了一段内容,中文就会变成乱码。

编码声明有三种方式,优先级从高到低:HTTP 响应头 Content-Type、BOM 字节顺序标记、HTML 里的 meta charset。服务端配置优先于页面内声明,所以如果两者不一致,以响应头为准——这也是“本地正常、线上乱码”的常见原因。

视口与移动端

<meta name="viewport" content="width=device-width, initial-scale=1.0">

没有这一行,移动端浏览器会假设你的页面是为桌面设计的,用一个约 980px 宽的虚拟视口渲染,然后整体缩小,用户看到的就是一屏密密麻麻的小字。有了它,视口宽度等于设备宽度,CSS 媒体查询才能真正按预期工作。

另外两个常见取值:maximum-scale 和 user-scalable=no。强烈建议不要使用它们——禁止用户缩放会直接伤害低视力用户,这属于无障碍的严重问题,而且现代浏览器(如 iOS Safari)已经开始忽略这两个设置。

标题与描述

<title>前端开发技巧之HTML基础与文档结构 | 前端开发技巧</title>
<meta name="description" content="从文档骨架、head 元信息、元素分类到浏览器解析流程,系统讲解 HTML 基础与文档结构。">

title 是页面唯一的“名字”,会显示在浏览器标签页、书签、历史记录和搜索结果标题里。它应该先写具体内容,再写站点名,并且每个页面都要不同。

description 不参与排名计算,但它常常被搜索引擎用作搜索结果里的摘要文案。写成一段 70~150 字、能概括页面核心价值的自然语句,比堆砌关键词有效得多。注意:搜索引擎不保证使用你写的 description,如果它判断页面正文里有更合适的内容,会自行截取。

样式表与脚本

<link rel="stylesheet" href="css/main.css">
<link rel="icon" href="favicon.ico">
<link rel="canonical" href="https://example.com/article">

<script src="js/app.js" defer></script>

defer 和 async 是 script 上最值得理解的两个属性:

无属性 立即下载并执行,阻塞 HTML 解析。放在 head 里会拖慢首屏,除非必要,不要这么做
async 异步下载,下载完成后立即执行(此时会中断解析)。执行顺序不确定,适合独立统计脚本
defer 异步下载,等 HTML 解析完成后再按顺序执行。适合依赖 DOM 的业务脚本

经验法则:业务代码用 defer,第三方独立脚本(埋点、广告)用 async,内联脚本尽量别用。

社交分享与 theme-color

当页面被分享到社交平台时,平台会抓取 Open Graph 或 Twitter Card 元信息来生成卡片。这些不是“可选项”,而是内容型站点的标配:

<meta property="og:title" content="前端开发技巧之HTML基础与文档结构">
<meta property="og:description" content="系统讲解 HTML 基础与文档结构。">
<meta property="og:image" content="img/7.avif">
<meta property="og:type" content="article">
<meta name="theme-color" content="#2563eb">

theme-color 会让移动端浏览器的地址栏跟随你的品牌色变化,是一个几乎零成本、效果却很明显的细节。

四、元素分类与文档流:理解布局的第一课

HTML 元素按默认的显示方式,大致可以分为三类。理解它们的差异,是理解 CSS 布局的前提。

块级元素 Block
div · p · h1
行内元素 Inline
span · a · em
行内块 Inline-Block
img · input · button
默认宽度
块级占满父元素

块级元素:独占一行,可设宽高

块级元素默认从新的一行开始,并且尽可能撑满父容器的宽度。width、height、margin、padding 都完全生效。常见的块级元素有 div、p、h1~h6、ul、ol、li、section、article、header、footer、blockquote、table、form。

行内元素:跟随文字流,宽高无效

行内元素不会打断文字流,它会和前后文字排在同一行。给它设置 width 和 height 是无效的,垂直方向的 margin 也不生效(水平方向的 margin 和 padding 生效,但 padding 会溢出到行框外而不撑开行高)。常见行内元素有 span、a、strong、em、code、label、abbr、cite。

行内块元素:混合特性

img、input、button、select、textarea 属于行内块:它们像行内元素一样排在同一行,但又能像块级元素一样设置宽高和内边距。

不过,img 有一个非常经典的“坑”:它默认是 vertical-align: baseline,也就是图片底边与文字基线对齐。由于基线下方还留有字母降部(如 g、p 的下半部分)的空间,图片下方会出现一条几像素的缝隙。解决方法有三种:

/* 方案一:改为块级 */
img { display: block; }

/* 方案二:对齐到顶部 */
img { vertical-align: top; }

/* 方案三:让父容器字号为 0(不推荐,会影响子元素) */
.box { font-size: 0; }

display 可以改变一切

需要强调的是:上面说的“块级”“行内”只是默认样式,不是元素的固有属性。你完全可以让一个 span 变成块级:

span { display: block; }
div { display: inline; }
a { display: flex; }

所以,选择标签时应该看语义,而不是看它默认的显示方式。需要换行不代表要用 div,用 CSS 把 span 改成 block 同样可以。

HTML 的嵌套规则

元素之间能不能嵌套,规范里写得很清楚。违反规则时浏览器会自动“纠正”,但纠正结果不一定符合预期:

规则 说明 典型错误
p 只能包含行内元素 段落里不能放块级元素 <p><div>…</div></p>
a 不能嵌套 a 交互式内容不能互相嵌套 整张卡片是链接,里面又有链接
button 里不能放交互元素 按钮内不能再有链接或按钮 <button><a>…</a></button>
ul / ol 只能包含 li 列表项必须直接是 li <ul><div>…</div></ul>
dl 只能包含 dt / dd 描述列表有固定结构 往 dl 里塞 p 或 div
table 有严格子元素 只能是 caption / colgroup / thead / tbody / tfoot / tr 直接往 table 里写 td

最典型的例子是 <p><div>内容</div></p>。浏览器解析到 div 时,会自动闭合前面的 p,最终变成两个兄弟节点:一个空的 p 和一个 div。你写的嵌套关系被悄悄改写了,而 CSS 选择器 p > div 自然也就匹配不到任何东西。

不要用 br 制造间距

<br> 的语义是“此处有一个换行”,用于诗歌、地址、歌词这类确实需要强制换行的地方。用它来制造段落之间的空隙是错的:

反例
第一段<br><br>第二段
屏幕阅读器读不出段落停顿,搜索引擎看不到段落划分,样式也无法单独控制段间距。
正解
<p>第一段</p><p>第二段</p>
间距交给 CSS 的 margin,结构与表现彻底分离。

五、文本组织:标题、段落、列表与引用

页面里占比最大的永远是文字。如何组织文字,决定了页面的可读性与机器可理解性。

标题:h1 到 h6

标题标签表达的是内容的层级,不是字号大小。一个页面通常只有一个 h1(页面主题),下面按 h2 → h3 → h4 逐级展开,不要跳级。

h1 页面主标题,通常一个页面只出现一次
h2 一级章节,文章里的“大节”
h3 二级小节,隶属于某个 h2 之下
h4 ~ h6 更深层级;用到 h4 以下时,通常该考虑拆分页面了
禁止 从 h1 直接跳到 h3;用 h4 只是因为“字号刚好合适”

屏幕阅读器和部分浏览器插件都提供“按标题跳转”的功能。用户打开长页面,第一件事往往是拉出标题列表,快速判断有没有自己要找的内容。层级一乱,这份列表就失去了价值。

段落与换行

p 表示一个段落。HTML 源码里的换行和连续空格在渲染时会被合并成一个空格,这叫“空白折叠”。想要控制间距,只能靠 CSS,不能靠回车和空格。

<!-- 源码里的换行不会变成页面上的换行 -->
<p>
  这一行和下一行
  会被合并成一行显示。
</p>

<!-- 需要强制换行时用 br,但要克制 -->
<p>北京市海淀区<br>中关村大街 1 号</p>

hr 表示“主题转换”,是一条语义上的分隔线,不是画线工具。如果你只是想要一条装饰线,用 CSS 的 border 更合适。wbr 则表示“可选的换行机会”,常用于长 URL 或长单词,告诉浏览器实在放不下时可以在这里断开。

列表:ul、ol、dl

列表是导航菜单、目录、步骤说明、术语表的标准结构。三种列表各有分工:

<!-- 无序列表:顺序不重要 -->
<ul>
  <li>HTML</li>
  <li>CSS</li>
</ul>

<!-- 有序列表:顺序有意义,支持 start / reversed -->
<ol start="3">
  <li>第三步</li>
  <li>第四步</li>
</ol>

<!-- 描述列表:术语与解释成对出现 -->
<dl>
  <dt>DOM</dt>
  <dd>文档对象模型,HTML 解析后形成的节点树。</dd>
  <dt>Doctype</dt>
  <dd>位于文档首行,用于触发浏览器的标准模式。</dd>
</dl>

dl 是被严重低估的标签。FAQ 页面、参数表、术语表、键值对展示,用 dl 都比 div + span 更贴切。屏幕阅读器会朗读出“定义列表,包含 N 个项目”,帮助用户建立预期。

另外,ol 支持 reversed 属性,可以倒序编号。倒计时、排行榜(从高到低)、历史事件回顾等场景用得上。

引用:blockquote、q、cite

<blockquote cite="https://example.com/src">
  <p>结构先于表现,语义先于样式。</p>
  <footer>—— <cite>某位前端工程师</cite></footer>
</blockquote>

<!-- 短引用用 q,浏览器自动加引号 -->
<p>他常说 <q>先写结构</q>。</p>
blockquote 块级引用,可嵌套 footer 标注出处
q 行内引用,自动补引号
cite 作品名(书名、标准名、歌名)
注意 cite 属性 ≠ cite 标签;人名一般不用 cite

强调、代码与其它文本标签

文本级标签数量不少,但常用的就那么十几个。下面这张表可以当作速查:

标签 含义 使用场景
strong 重要性、严重性 警告、必须注意的信息
b 视觉加粗,无语义 产品名、关键词,仅需视觉突出
em 重音强调,改变句意 “是我说的”这类需要重读的词
i 外语词、术语、心语 拉丁学名、外来词
mark 与当前上下文相关的高亮 搜索命中词
code 代码片段 行内变量名、函数名
pre 保留空白格式 代码块、ASCII 图,常与 code 嵌套
kbd 用户键盘输入 快捷键说明
samp 程序输出 命令行结果
var 变量或占位符 数学变量、模板占位符
abbr 缩写,title 提供全称 CSS、HTML、API
time 机器可读的时间 发布时间、事件日期
del / ins 修订中的删除与插入 价格调整、条款修订
s 内容已不再准确 原价划掉
small 附属细则 版权、法律声明
sub / sup 下标 / 上标 H2O、x2
address 联系信息 作者邮箱、电话,不是任意地址
wbr 可选换行点 长 URL、长单词

其中 time 的性价比最高。它的 datetime 属性让机器可以精确解析时间,而页面显示可以是任意人读格式:

<time datetime="2026-09-19">2026年9月19日</time>
<time datetime="2026-09-19T14:30">今天下午两点半</time>
<time datetime="PT25M">二十五分钟</time>

搜索引擎会利用它判断内容时效性,社交平台抓取摘要时也会优先读取它。这是少数“写一行代码就能带来实际收益”的标签。

六、超链接与路径:Web 的血管

没有链接就没有 Web。a 元素看似简单,但 href 的写法、路径的解析规则、target 与 rel 的配合,处处都是细节。

href 的五种常见形式

形式 示例 说明
绝对 URL https://example.com/a 包含协议和域名,跳转到外部站点
根相对路径 /css/main.css 从域名根目录开始,与当前页面位置无关
文档相对路径 ./about.html 或 ../img/a.png 相对于当前文档所在目录,.. 表示上一级
片段标识符 #section-2 跳转到本文档中 id 为 section-2 的元素
协议处理器 mailto: tel: sms: 唤起邮件、拨号、短信等系统应用

相对路径的解析规则值得单独记一下:不带斜杠开头、也不带协议的路径,一律相对于当前文档的 URL。假设当前页面是 https://example.com/blog/post/index.html:

<!-- 解析为 https://example.com/blog/post/a.png -->
<img src="a.png">

<!-- 解析为 https://example.com/blog/img/a.png -->
<img src="../img/a.png">

<!-- 解析为 https://example.com/img/a.png -->
<img src="/img/a.png">

注意最后一种:以 / 开头的路径与当前页面位置无关,永远从域名根开始。这也是为什么很多项目推荐使用根相对路径——页面无论放在哪一层目录,引用都不会出错。当然,前提是站点部署在域名根目录下;如果部署在子目录(如 /blog/),根相对路径就会指向错误位置,此时可以用 <base> 标签调整基准 URL。

target 与 rel

target="_blank" 会在新标签页打开链接。看起来很友好,但有一个必须注意的安全问题:新页面可以通过 window.opener 访问原页面的 window 对象,甚至把原页面重定向到钓鱼站点。解决办法是同时加上 rel:

<a href="https://example.com" target="_blank" rel="noopener noreferrer">外部链接</a>

现代浏览器已经默认给 target="_blank" 加上了 noopener 行为,但显式写上更稳妥,也更能表达意图。rel 的其它常用取值:

  • nofollow:告诉搜索引擎不要追踪这个链接,也不要把权重传递过去。
  • sponsored:标明这是付费或赞助链接。
  • ugc:标明这是用户生成内容里的链接(评论区、论坛)。
  • noopener:切断新页面与原页面的 window.opener 引用。
  • noreferrer:不发送 Referer 请求头,同时也包含 noopener 的效果。

下载与锚点

download 属性可以让浏览器下载而不是导航到目标资源,属性值可以指定保存的文件名:

<a href="report.pdf" download="2026年度报告.pdf">下载报告</a>

注意 download 只对同源资源生效。跨域链接加上它会被浏览器忽略,因为跨域下载存在安全风险。

锚点跳转是另一个高频场景。点击 <a href="#main"> 时,浏览器会找到 id="main" 的元素并滚动过去。这也是“跳到主内容”链接的实现方式。

链接文本要能独立表意

屏幕阅读器用户可以拉出页面上的所有链接,形成一个列表快速浏览。如果列表里全是“点击这里”“了解更多”“阅读全文”,这个功能就完全失效了。正确做法是让链接文本本身携带信息:

反例
<a href="/a">点击这里</a>
脱离上下文后完全不知道会跳到哪里。
正解
<a href="/a">查看 HTML 基础教程</a>
单独读出来依然表意清晰。

如果设计上确实只能显示“了解更多”,可以在链接里加一个视觉隐藏的补充文本:

<a href="/a">
  了解更多
  <span class="visually-hidden">关于 HTML 文档结构</span>
</a>

七、图片与媒体:img 的属性详解

img 是一个很特殊的元素:它是行内元素,却可以设置宽高;它是自闭合标签,却有一个必需的“替代内容”。

src 与 alt

src 指定图片地址,alt 提供替代文本。关于 alt,有三条明确的规则:

  • 有含义的图片:写出图片传达的信息,而不是描述图片长什么样。写“2026年第三季度访问量增长 40%”比“一张折线图”有用得多。
  • 纯装饰的图片:写 alt=""(空字符串),屏幕阅读器会直接跳过。
  • 千万不要省略 alt 属性:省略时,部分屏幕阅读器会朗读文件名,比如“IMG underscore 2026 underscore zero seven dot jpg”,体验极差。

还有一个常见场景:图片本身是链接。此时 alt 会成为链接的可访问名称,更应该写清楚:

<a href="/">
  <img src="logo.svg" alt="前端开发技巧首页">
</a>

width 与 height:不只是尺寸

给 img 写上 width 和 height 属性,除了指定尺寸,还有一个更重要的作用:让浏览器提前算出图片的宽高比,预留出空间。

没有这两个属性时,图片加载完成前占位高度是 0,加载完成后突然撑开,把下方内容猛地推下去——这就是累积布局偏移(CLS),Core Web Vitals 的三大指标之一。写了宽高,浏览器就能按比例预留空间,加载完成只是填充,不会引起跳动。

需要注意的是,如果同时用 CSS 设置了 width: 100%,要让高度自适应,应该配合:

img {
  width: 100%;
  height: auto;
}

loading 与 decoding

loading="lazy" 让浏览器延迟加载视口外的图片,能显著减少首屏的网络请求和内存占用。但要注意:

  • 首屏图片不要用 lazy:会延迟首屏渲染,反而拖慢 LCP。
  • 不要给所有图片都加 lazy:少量图片时,懒加载的收益可能还抵不上它自身的开销。
  • decoding="async" 告诉浏览器可以异步解码图片,避免解码阻塞主线程;decoding="sync" 则用于必须立刻显示的关键图片。

srcset 与 sizes

同一张图在不同屏幕上需要的分辨率不同。手机不需要下载 2000px 宽的图,桌面 4K 屏则需要更清晰的版本。srcset 让浏览器自己挑:

<img
  src="hero.jpg"
  srcset="hero-400.jpg 400w,
          hero-800.jpg 800w,
          hero-1600.jpg 1600w"

  sizes="(max-width: 600px) 100vw, 800px"
  alt="团队成员在会议室讨论方案"
  width="1600" height="900">

w 描述符表示图片的固有宽度,sizes 告诉浏览器“在不同视口下,这张图在页面上实际会占多宽”。浏览器结合设备像素比,从候选列表里挑一个最合适的下载。

picture:艺术指导

srcset 解决的是“同一构图不同分辨率”,picture 解决的是“不同条件用不同构图或格式”:

<picture>
  <source srcset="hero.avif" type="image/avif">
  <source srcset="hero.webp" type="image/webp">
  <source media="(max-width: 600px)" srcset="hero-mobile.jpg">
  <img src="hero.jpg" alt="描述" width="1600" height="900">
</picture>

浏览器会从上到下匹配第一个满足条件的 source。注意最后那个 img 是必需的,它承担实际的渲染、alt 文本与尺寸占位,也是所有 source 都不匹配时的兜底。

figure 与 figcaption

正文中的插图、图表、代码清单常常需要一段说明文字。figure 与 figcaption 正是为此而生:

<figure>
  <img src="chart.png" alt="访问量趋势图" width="800" height="400">
  <figcaption>图 1:访问量在第三季度出现明显增长</figcaption>
</figure>

figure 里不一定是图片,代码块、引用、公式都可以。figcaption 必须是 figure 的第一个或最后一个子元素。

视频与音频

<video controls poster="cover.jpg" width="960" height="540">
  <source src="intro.webm" type="video/webm">
  <source src="intro.mp4" type="video/mp4">
  <track kind="captions" src="zh.vtt" srclang="zh" label="中文" default>
  您的浏览器不支持视频播放,<a href="intro.mp4">点击下载</a>。
</video>

三个要点:controls 提供原生的无障碍播放控件,不要自己用 div 造;poster 指定封面图,避免出现黑屏;track 提供字幕,只要视频里有人说话就应该加上。

iframe 必须写 title 属性,否则屏幕阅读器只能读成“框架”,用户不知道里面是什么。嵌入第三方内容时,配合 loading="lazy" 与 sandbox 使用更安全。

八、表格:数据结构的正确表达

“不要用 table 布局”这句口号流行了十几年,结果让不少开发者走向另一个极端:连数据表格也用 div 拼。这是错的——数据表格用 div 搭建后,屏幕阅读器无法朗读“第 3 行第 2 列”,用户彻底失去行列关系。

正确的做法是:布局用 CSS,数据用 table。

一个完整的表格结构

<table>
  <caption>2026 年第二季度各浏览器市场份额</caption>

  <thead>
    <tr>
      <th scope="col">浏览器</th>
      <th scope="col">份额</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <th scope="row">Chrome</th>
      <td>65.4%</td>
    </tr>
    <tr>
      <th scope="row">Safari</th>
      <td>18.2%</td>
    </tr>
  </tbody>

  <tfoot>
    <tr>
      <td colspan="2">数据来源:第三方统计机构</td>
    </tr>
  </tfoot>
</table>

逐项说明:

  • caption:表格标题。屏幕阅读器进入表格时会优先朗读它,用户立刻知道这张表在讲什么。它必须紧跟 table 开始标签。
  • thead / tbody / tfoot:区分表头、主体、表尾。长表格滚动或分页打印时,表头可以重复出现。
  • th + scope:明确表头的作用范围。scope="col" 表示这一列的表头,scope="row" 表示这一行的表头。屏幕阅读器读到某个单元格时,会自动播报它的行表头和列表头。
  • colspan / rowspan:跨列与跨行合并。合并会打乱行列对应关系,复杂表格应改用 id + headers 建立显式关联。

复杂表格的 headers 关联

当表头有多层合并时,scope 已经不够用了。此时给每个表头 th 一个 id,再在 td 上用 headers 列出所有相关的表头 id:

<th id="q3">第三季度</th>
<th id="revenue">营收</th>
...
<td headers="q3 revenue">1,280 万</td>

这样屏幕阅读器读到这个单元格时,会播报“第三季度,营收,1280 万”,而不是孤零零的一个数字。

什么时候不该用表格

如果一段内容只是“看起来像表格”,而本质上是布局(比如左侧头像右侧文字的卡片),那就不要用 table。判断标准很简单:这份内容有没有“行”和“列”的对应关系?有就用表格,没有就用 CSS 布局。

九、表单基础:交互最密集的地方

表单是用户与页面交互最密集的地方,也是 HTML 基础不牢最容易暴露的地方。一个语义正确的表单,能自动获得:点击标签聚焦输入框、屏幕阅读器正确朗读、浏览器自动填充、移动端弹出合适的键盘类型。

<form action="/subscribe" method="post">
  <fieldset>
    <legend>订阅资讯</legend>

    <label for="email">邮箱地址</label>
    <input
      type="email"
      id="email"
      name="email"
      autocomplete="email"
      required>

    <button type="submit">订阅</button>
  </fieldset>
</form>

label 是表单的地基

label 有两种关联方式:

<!-- 方式一:for 与 id 显式关联(推荐) -->
<label for="name">姓名</label>
<input id="name" type="text">

<!-- 方式二:包裹式,隐式关联 -->
<label>
  姓名 <input type="text">
</label>

显式关联更灵活,样式更好控制,也便于把标签和控件分开布局,是推荐做法。有了 label,点击文字就能聚焦输入框,这在移动端和触摸屏上尤其重要——小小的输入框很难点准。

placeholder 不能替代 label。它在用户开始输入后就消失了,用户无法回头确认这个字段要填什么;屏幕阅读器对它的支持也参差不齐。正确做法是始终提供可见的 label,placeholder 只作为格式示例。

input 的 type 决定一切

HTML5 新增了十几种 type,每一种都有自己的校验规则和移动端键盘:

type 移动端键盘 内置校验
email 带 @ 的邮箱键盘 格式校验
tel 数字拨号键盘 无(格式因地区而异)
url 带 .com 的键盘 URL 格式校验
number 数字键盘 配合 min / max / step
date / time 系统日期时间选择器 范围校验
search 带搜索键 无
password 普通键盘 无
checkbox / radio — 配合 required 分组
file 系统文件选择器 配合 accept 限制类型

单选按钮有一个关键细节:同一组的 radio 必须共享同一个 name,否则它们不会互斥,用户能同时选中多个。

button 的 type 陷阱

这是最容易被忽略、也最容易出问题的地方:在 form 内部,不写 type 的 button 默认是 submit。也就是说,一个看起来只是“打开弹窗”的按钮,点击后会把整个表单提交掉,页面刷新,用户填的内容全没了。

反例
<button>查看详情</button>
在表单里等同于提交按钮,点击即触发提交。
正解
<button type="button">查看详情</button>
显式声明类型,避免意外提交。

原生校验属性

不写一行 JavaScript,就能获得基础的表单校验:

  • required:必填。为空时浏览器会阻止提交并提示。
  • minlength / maxlength:字符数限制。
  • min / max / step:数值或日期范围。
  • pattern:正则表达式校验(注意是隐式全匹配)。
  • autocomplete:告诉浏览器和密码管理器这个字段是什么,取值有标准枚举,如 email、tel、name、street-address、one-time-code。
  • inputmode:只影响移动端键盘类型,不做校验。比如 inputmode="numeric" 用于身份证号、验证码。

原生校验的提示文案由浏览器提供,无法完全自定义样式。如果需要定制化提示,可以用 JavaScript 的 Constraint Validation API,或者用 novalidate 关闭原生校验后自行实现。但无论如何,服务端校验永远不能省——前端校验只是体验优化,不是安全措施。

十、全局属性:每个标签都能用的那些

有一批属性可以出现在任何元素上,它们构成了 HTML 的“通用能力层”。

属性 作用 使用建议
id 文档内唯一标识 用于锚点、label 关联、JS 获取。全页不可重复
class 可复用的分类名 用于样式与批量选择,可同时写多个
style 行内样式 仅用于动态计算值或临时调试,不要用来组织样式
title 补充说明(悬停提示) 触屏与键盘不可靠,不要用它传关键信息
hidden 隐藏元素 等价于 display:none,辅助技术也会忽略
lang 语言标记 局部切换语言时使用
dir 文字方向 ltr / rtl / auto,多语言站点必需
tabindex 键盘焦点顺序 用 -1 让元素可编程聚焦;正数请慎用
contenteditable 内容可编辑 富文本编辑器的基础能力
data-* 自定义数据 JS 与 HTML 之间传值,通过 dataset 读取
aria-* 无障碍语义补充 仅在原生语义不足时使用

id 与 class 的分工

初学者最容易混淆这两个属性。一句话区分:

  • id 是身份证:全文档唯一,用于精确定位某一个元素。
  • class 是标签分类:可以重复,一个元素可以有多个,用于批量应用样式。

样式优先用 class,因为 id 选择器的权重极高,一旦用它写样式,后续想覆盖就得用更长的选择器或 !important,维护成本很高。id 更适合承担“锚点”“label 关联”“JS 精确获取”这些职责。

data-* 的用法

data-* 属性让你可以在 HTML 上挂载任意自定义数据,而不用污染 class 名:

<button data-action="delete" data-id="1024">删除</button>

// JS 中通过 dataset 读取,连字符会转成驼峰
const btn = document.querySelector('[data-action="delete"]');
console.log(btn.dataset.id); // "1024"

// data-user-name 会变成 dataset.userName

命名规则:data- 后面不能有大写字母,多个单词用连字符分隔。这是规范要求,也是浏览器行为决定的(HTML 属性名不区分大小写,所以必须用连字符)。

tabindex 的正确用法

tabindex 有三个取值区间,含义完全不同:

  • tabindex="0":元素加入自然 Tab 顺序,按文档位置排序。用于让本来不可聚焦的元素(如 div)可以获得焦点。
  • tabindex="-1":元素不能通过 Tab 到达,但可以通过 element.focus() 编程聚焦。用于模态框、跳转目标容器。
  • tabindex="1" 及更大的正数:强烈不建议使用。它会打乱自然顺序,让键盘导航变得极其混乱,维护者很难追踪。

记住一条原则:能用原生可聚焦元素(a、button、input)就不要用 tabindex 改造别的元素。

十一、从字节到 DOM:浏览器如何解析你的 HTML

理解解析过程,才能理解很多“为什么”。比如为什么 script 会阻塞渲染,为什么编码声明必须靠前,为什么浏览器能容忍残缺的 HTML。点击下面的每个阶段查看细节。

1
字节流
Bytes
网络层返回的原始二进制数据。此时浏览器还不知道这是什么内容,需要依据 HTTP 响应头里的 Content-Type 判断编码。
2
字符流
Characters
按确定的编码(UTF-8 等)把字节解码成 Unicode 字符。编码判断错了,后面全都是乱码——这就是 meta charset 必须靠前的原因。
3
标记化
Tokenization
分词器把字符流切成一个个标记:起始标签、属性、文本、结束标签、注释、DOCTYPE。这一步会处理实体转义、属性解析、标签名小写化。
4
树构建
Tree Construction
按标记的先后顺序构建 DOM 节点树,同时处理嵌套规则与容错。遇到 CSS 会阻塞渲染,遇到同步 script 会暂停解析去执行脚本。

解析器的容错能力

HTML 解析器被设计成“永不报错”。规范里用大量篇幅定义了遇到各种错误时应该如何恢复。几个典型例子:

  • 未闭合的标签:<div><p>文字 会被自动补上 </p></div>。
  • 嵌套错误:<p><div>…</div></p> 会被拆成兄弟节点。
  • 表格结构缺失:往 table 里直接写文本,解析器会自动创建 tbody 和 tr 来包裹。
  • 重复的属性:同名属性只保留第一个,后面的被忽略。
  • 未知的标签:会被当作普通元素处理,样式可以用 CSS 选择器命中(这正是 HTML5 语义标签能在旧浏览器里工作的原理)。

容错能力是 Web 生态能繁荣的重要原因——一份 1998 年写的页面,今天打开依然能看。但容错不等于你可以随便写:解析器补出来的结构,和你想的结构往往不同,而调试这种“结构错位”极其耗时。

阻塞渲染的两件事

解析过程中有两个环节会打断流程:

<!-- 1. CSS 阻塞渲染 -->
<link rel="stylesheet" href="main.css">

<!-- 2. 同步 script 阻塞解析 -->
<script src="app.js"></script>

<!-- 优化:异步 + 延迟执行 -->
<script src="app.js" defer></script>
CSS 阻塞 浏览器必须等 CSSOM 构建完成才能渲染,避免无样式闪烁
script 阻塞 同步脚本下载并执行时会暂停 HTML 解析
defer 不阻塞解析,DOM 就绪后按顺序执行
async 不阻塞解析,下载完立即执行,顺序不确定

所以最佳实践很明确:CSS 放 head 里(尽早开始下载,且不阻塞解析),脚本加 defer 放 head 里或放 body 末尾。

DOMContentLoaded 与 load

两个事件经常被混淆:

  • DOMContentLoaded:HTML 解析完成、DOM 树构建完毕时触发。此时图片、样式表、子框架可能还没加载完。绝大多数脚本应该监听它。
  • load:页面上所有资源(图片、样式、脚本、字体)全部加载完成时触发。适用于依赖图片尺寸的场景。
document.addEventListener('DOMContentLoaded', () => {
  // DOM 已就绪,可以安全地操作元素
});

window.addEventListener('load', () => {
  // 所有资源加载完毕
});

十二、校验与调试:让工具帮你发现问题

HTML 的错误往往不会立刻暴露——页面能显示,只是显示得不太对。但错误会累积,最终以“样式怎么调都不生效”“JS 获取不到元素”的形式爆发。养成校验的习惯,能省下大量排查时间。

W3C Markup Validation Service

把页面 URL 或 HTML 源码贴到 validator.w3.org,它会逐行指出不符合规范的地方:标签未闭合、属性重复、嵌套非法、id 重复、alt 缺失等。这是最权威、最便宜的检查手段。

DevTools 的 Elements 面板

浏览器开发者工具的 Elements 面板显示的是解析后的 DOM,而不是你的源码。这非常有用:如果面板里的结构和源码不一样,说明你的 HTML 触发了容错机制。

// 在 Console 里快速检查常见问题

// 1. 找出重复的 id
const ids = [...document.querySelectorAll('[id]')].map(el => el.id);
const dup = ids.filter((id, i) => ids.indexOf(id) !== i);
console.log('重复 id:', [...new Set(dup)]);

// 2. 找出没有 alt 的图片
[...document.images]
  .filter(img => !img.hasAttribute('alt'))
  .forEach(img => console.log(img.src));

// 3. 找出没有标签的表单控件
[...document.querySelectorAll('input, select, textarea')]
  .filter(el => !el.labels || el.labels.length === 0)
  .forEach(el => console.log(el));

Accessibility 面板

DevTools 的 Elements 面板右侧有一个 Accessibility 标签页,它会显示浏览器为当前元素计算出的“角色”和“可访问名称”。这是验证语义是否正确的直接方式:

  • 选中一个 button,看 Role 是否为 button,Name 是否合理。
  • 选中一个 div 加 onclick 的元素,你会发现它的 Role 依然是 generic——屏幕阅读器不会把它当作按钮。
  • 选中 nav,Role 会显示 navigation,说明它被识别为地标。

五个高频错误的快速排查

1 样式不生效 → 先检查 Elements 面板里 DOM 结构是否和源码一致
2 JS 拿不到元素 → 检查 id 是否重复、脚本是否在 DOM 就绪前执行
3 中文乱码 → 检查响应头与 meta charset 是否一致
4 移动端字太小 → 检查 viewport meta 是否缺失
5 页面莫名跳动 → 检查 img 是否写了 width / height

十三、重构对比:从 div 汤到结构化文档

下面是一段典型的“div 汤”页面骨架。它用 class 名字描述了所有语义,但在 HTML 层面什么都没说。点击按钮对比改造前后的结构。

<div class="page">
  <div class="top">
    <div class="logo">前端开发技巧</div>
    <div class="menu">
      <div class="menu-item"><a href="/">首页</a></div>
      <div class="menu-item"><a href="/html">HTML</a></div>
    </div>
  </div>

  <div class="content">
    <div class="post">
      <div class="post-title">HTML 基础与文档结构</div>
      <div class="post-date">2026-09-19</div>
      <div class="post-body">正文内容……</div>
    </div>
    <div class="side">作者简介</div>
  </div>

  <div class="bottom">© 2026</div>
</div>

改造带来的六个具体收益

  • 屏幕阅读器:可以按地标跳转,一键直达 main 内容;导航被朗读为“导航,包含 2 个项目”。
  • 阅读模式:浏览器的 Reader Mode 能准确提取正文,不再把侧边栏和页脚一起抓进去。
  • 搜索引擎:正文边界清晰,摘要提取更准确;time 提供了可靠的发布时间。
  • 可维护性:新人接手时,看标签就知道结构,不必逐个查 class 的用途。
  • 体积:大量 class="post-title" 之类的重复命名可以删掉,HTML 更短。
  • 健壮性:即使 CSS 完全加载失败,页面依然保持正确的文档层级,可读性远高于纯 div 版本。

唯一需要注意的是:重构标签名会改变 CSS 选择器。class 大量依赖标签名的项目,需要同步调整样式。推荐的做法是保留原有 class 作为样式钩子,只把标签换成语义元素——这样 CSS 完全不用改,收益却立刻到手。

十四、HTML 基础与文档结构检查清单

把全文的结论浓缩成一份可以贴在工位上的清单。每次提交页面前扫一眼,能挡掉绝大多数问题。

  • 文档首行是 DOCTYPE,且它之前没有任何内容(包括注释)。
  • html 上有 lang 属性,值为 zh-CN 或对应语言。
  • meta charset 位于 head 最前面,且与服务端响应头一致。
  • 有 viewport meta,且没有禁止用户缩放。
  • 每个页面有唯一的 title,格式为“具体内容 + 站点名”。
  • 有 description,写成自然语句而非关键词堆砌。
  • 脚本使用 defer 或 async,没有阻塞解析的同步脚本。
  • 页面只有一个 main,且没有被 article / aside / nav 包裹。
  • 地标完整:页面有明确的 header、nav、main、footer 区域。
  • 标题层级不跳级:h1 → h2 → h3 依次递进,全页只有一个 h1。
  • 没有把块级元素放进 p,没有 a 嵌套 a。
  • 列表只用 ul / ol / dl,其直接子元素符合规范。
  • 图片有 alt,装饰性图片写 alt=""。
  • 图片有 width 和 height,避免布局偏移。
  • 首屏以外的图片加 loading="lazy",首屏图片不加。
  • 时间用 time + datetime,机器可读。
  • 数据表格用 table,带 caption 与 th scope。
  • 表单控件有 label,for 与 id 一一对应。
  • 非提交按钮都写了 type="button",避免意外提交。
  • 没有重复的 id,样式尽量不用 id 选择器。
  • 不用 <br> 制造间距,段落用 p,间距交给 CSS。
  • 不用 title 传递关键信息,触屏与键盘用户拿不到。
  • 外链 target="_blank" 时带 rel="noopener"。
  • 只按 Tab 键走一遍页面,确认所有交互都能完成且焦点可见。
  • 用 W3C 校验器跑一遍,把错误清空。
<!-- 一份可直接复用的页面基础骨架 -->

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>页面标题 | 站点名</title>
  <meta name="description" content="页面描述">
  <link rel="stylesheet" href="css/main.css">
  <script src="js/app.js" defer></script>
</head>

<body>
  <a href="#main" class="skip-link">跳到主内容</a>

  <header>
    <a href="/" class="logo">站点名</a>
    <nav aria-label="主导航">
      <ul>……</ul>
    </nav>
  </header>

  <main id="main">
    <article>
      <header>
        <h1>文章标题</h1>
        <p>
          <time datetime="2026-09-19">2026年9月19日</time>
        </p>
      </header>

      <section>
        <h2>第一节</h2>
        <p>正文……</p>
        <figure>
          <img src="a.png" alt="描述" width="800" height="400">
          <figcaption>图 1:说明文字</figcaption>
        </figure>
      </section>

      <footer>
        <small>本文采用 CC BY 4.0 协议</small>
      </footer>
    </article>

    <aside aria-label="相关内容">
      <h2>相关阅读</h2>
    </aside>
  </main>

  <footer>
    <small>© 2026 前端开发技巧</small>
  </footer>
</body>
</html>

HTML 基础之所以重要,是因为它处在整条前端链路的最上游。结构一旦写歪,后面的 CSS 和 JavaScript 都要为它买单。反过来,一份结构清晰、语义准确的 HTML,会让样式更容易写、脚本更容易找元素、屏幕阅读器读得更顺、搜索引擎理解得更准、半年后的你回顾起来也更轻松。

所以,下一次当你准备敲下 <div> 的时候,不妨先停半秒问自己一句:“这块内容,到底是什么?”——答案往往就是那个正确的标签。而当你写下 <!DOCTYPE html> 的那一刻,你也已经决定了这棵树会长成什么样子。