HTML 文档结构与渲染基础
一、从一个历史问题说起
1.1 DOCTYPE 的前世今生
当你打开一个现代网页,控制台偶尔会弹出这样的警告:"Document type is not valid",或者你可能见过页面的渲染方式莫名其妙地与预期不符。这些问题的根源往往要追溯到 DOCTYPE。
DOCTYPE 的故事要从 1990 年代讲起。那时候网景 Navigator 和微软 Internet Explorer 两大浏览器竞争激烈,它们各自实现了不兼容的 HTML 扩展。为了保持向后兼容,浏览器厂商引入了一种特殊逻辑:如果页面没有 DOCTYPE 声明,就按照" quirks mode"(怪异模式)渲染——模仿当年那些充满 hack 的旧页面。如果页面包含了 DOCTYPE 声明,就按照标准方式渲染。
这个设计在当年是合理的,但它留下了一个延续至今的复杂性:现代开发者必须理解 DOCTYPE 的存在意义,否则就可能在页面顶部埋下一个影响整个渲染行为的隐藏开关。
1.2 实际观察 DOCTYPE 行为
<!-- 标准模式:现代 HTML5 文档 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>标准模式页面</title>
</head>
<body>
<p>浏览器会以标准模式渲染这个页面。</p>
</body>
</html>
<!-- 怪异模式:缺少 DOCTYPE -->
<html>
<head>
<title>怪异模式页面</title>
</head>
<body>
<p>这个页面会以 quirks mode 渲染,可能出现布局异常。</p>
</body>
</html>
在怪异模式下,浏览器的 CSS 盒模型计算会发生改变:IE 浏览器的"box-sizing"行为会被采用,表格的字体继承逻辑也会不同。这在现代开发中几乎是不可接受的——没人想在 2026 年还去调试 IE5 时代的兼容性问题。
二、HTML 文档的标准结构
2.1 三层骨架:html、head、body
每一个符合规范的 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>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- 可视内容区域 -->
<header>
<nav>导航内容</nav>
</header>
<main>
<article>主要内容</article>
</main>
<footer>页脚</footer>
<script src="app.js"></script>
</body>
</html>
<html> 元素是文档的根元素,lang 属性告诉浏览器和辅助技术页面的主语言。这个属性对屏幕阅读器选择语音库至关重要——一个带有lang="zh-CN"属性的页面会被中文屏幕阅读器用中文语音引擎来朗读。
<head> 元素包含元数据——关于文档本身的信息。浏览器不会把这些内容渲染为可见内容,但它们会影响文档如何被解析、如何被搜索、如何被分享到社交平台。
<body> 元素包含所有可见内容。这是用户实际看到的部分,也是 JavaScript 操作的主要区域。
2.2 head 内常见元素详解
<head>
<!-- 字符编码:必须在文档前 1024 字节内被解析 -->
<meta charset="UTF-8">
<!-- 视口设置:移动端开发的核心 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 文档标题:影响标签页显示和 SEO -->
<title>页面标题</title>
<!-- 外部资源链接 -->
<link rel="stylesheet" href="main.css">
<link rel="icon" href="favicon.ico">
<!-- 其他元数据 -->
<meta name="description" content="页面描述">
<meta name="keywords" content="HTML, CSS, JavaScript">
<meta name="author" content="作者名">
</head>
charset 声明的位置是一个历史遗留问题。早年有些浏览器在看到非 ASCII 字符时会误判编码,所以规范要求 charset 必须在文档开头附近,以便浏览器尽早用正确的编码重新解析已下载的部分。
三、meta 标签全集
3.1 charset 的正确声明
<!-- HTML5 标准写法:必须放在前 1024 字节内 -->
<meta charset="UTF-8">
<!-- 旧写法(仍然有效,但不推荐) -->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
UTF-8 是现代 Web 的标准字符编码。它能表示 Unicode 的全部字符集,包括中文、日文、韩文以及各种 emoji。不用 UTF-8 的页面在处理非英文字符时几乎必然出现乱码问题。
3.2 viewport 的完整参数
<!-- 基础写法 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 完整参数 -->
<meta name="viewport" content="
width=device-width, <!-- 视口宽度等于设备宽度 -->
initial-scale=1.0, <!-- 初始缩放比例 -->
maximum-scale=1.0, <!-- 最大缩放比例(阻止用户缩放有可访问性问题)-->
minimum-scale=1.0, <!-- 最小缩放比例 -->
user-scalable=no <!-- 是否允许用户缩放(不推荐,损害可访问性)-->
">
width=device-width 的作用是把视口宽度设置为设备独立像素的宽度。这听起来有点绕——为什么要用"设备独立像素"而不是实际像素?因为不同设备的屏幕像素密度不同。同样是"宽度 375 像素",在 iPhone Retina 屏幕上对应 750 个物理像素,在普通 Android 屏幕上对应 375 个物理像素。使用设备独立像素确保 CSS 像素在不同设备上有相同的物理尺寸。
3.3 Open Graph 元数据
当你把链接分享到微信、微博、Twitter 或 Facebook 时,这些平台会抓取页面的 OG(Open Graph)元数据来生成预览卡片:
<head>
<meta property="og:type" content="website">
<meta property="og:url" content="https://example.com/page">
<meta property="og:title" content="分享标题">
<meta property="og:description" content="分享描述">
<meta property="og:image" content="https://example.com/image.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Twitter 标题">
<meta name="twitter:description" content="Twitter 描述">
<meta name="twitter:image" content="https://example.com/image.jpg">
</head>
OG 标签不是 HTML 标准的一部分,但它们是事实上的行业标准。没有正确配置 OG 标签的页面在社交分享时只能展示一个毫无吸引力的纯链接。
四、link 标签与资源提示
4.1 preload、prefetch、dns-prefetch 的区别
这是 Web 性能优化中最容易被混淆的几个资源提示:
<head>
<!-- preload:提前加载当前页面必需的资源 -->
<link rel="preload" href="fonts/Knight-Bold.woff2" as="font" crossorigin="anonymous">
<link rel="preload" href="critical.css" as="style">
<link rel="preload" href="main.js" as="script">
<!-- prefetch:提前获取将来可能需要的资源(空闲时)-->
<link rel="prefetch" href="next-page.html">
<link rel="prefetch" href="images/photo.webp">
<!-- dns-prefetch:提前解析 DNS(兼容性更好)-->
<link rel="dns-prefetch" href="https://fonts.googleapis.com">
<!-- preconnect:提前建立网络连接(包含 DNS + TCP + TLS)-->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
</head>
preload 告诉浏览器:"这个资源在当前页面一定会用到,请立刻开始下载。"浏览器收到这个提示后会立即开始获取资源,不等待解析到实际使用位置。这对于关键 CSS 和首屏需要的字体特别重要。
prefetch 告诉浏览器:"这个资源将来可能需要,请在空闲时下载。"prefetch 的优先级很低,浏览器只在没有其他更高优先级任务时才获取它。
dns-prefetch 只预解析域名 DNS,不建立 TCP 连接也不 TLS 握手。它的兼容性比 preconnect 更好,但功能也更少。
preconnect 预解析 DNS 并提前完成 TCP 握手和 TLS 协商。对于跨域资源(如 CDN 上的字体文件),preconnect 可以显著减少后续请求的延迟。
4.2 as 属性的重要性
preload 必须带 as 属性,否则浏览器无法正确设置资源优先级:
<!-- ✅ 正确:指定了 as 属性 -->
<link rel="preload" href="main.js" as="script">
<link rel="preload" href="fonts/Knight-Bold.woff2" as="font" crossorigin>
<!-- ❌ 错误:缺少 as 属性 -->
<link rel="preload" href="main.js">
as 属性告诉浏览器资源的类型,浏览器据此决定:(1) 正确的优先级;(2) 是否需要跨域获取(如 as="font" 需要 crossorigin);(3) 是否应用 CSP(Content Security Policy)限制。
五、script 标签的行为差异
5.1 三种执行模式对比
这是前端面试中的经典问题,也是实际开发中必须深入理解的基础知识:
<!-- 渲染阻塞:HTML 解析暂停,等脚本下载并执行完成 -->
<script src="blocking.js"></script>
<!-- 异步执行:下载不阻塞解析,执行时阻塞解析(顺序无关)-->
<script async src="async.js"></script>
<!-- 延迟执行:下载不阻塞解析,在 DOMContentLoaded 前按顺序执行 -->
<script defer src="defer.js"></script>
时间线 →
├─ HTML 解析 ─┤
├─ 下载 blocking.js ─┤├─ 执行 ─┤├─ HTML 继续解析 ─┤
blocking.js:
├─ HTML 解析 ─┤├─ 下载 async.js ─┤├─ 执行 ─┤├─ HTML 继续解析 ─┤
async.js: (下载完成后立即执行,可能在 DOMContentLoaded 前或后)
├─ HTML 解析 ─┤├─ 下载 defer.js ─┤ ├─ 按顺序执行 ─┤
defer.js: (延迟到解析完成后,DOMContentLoaded 前执行)
普通脚本会阻塞 HTML 解析。浏览器遇到普通 script 标签时,会暂停 HTML 解析,先下载(如果是外部脚本)并执行脚本,完成后才继续解析 HTML。这是因为脚本可能用 document.write() 修改文档流,或者读取已经解析的部分并依赖它们。
async 脚本下载时不阻塞解析,但执行时会阻塞解析。多个 async 脚本的执行顺序与下载完成顺序相同,与文档中的位置无关。这适合不依赖 DOM 的独立脚本,如统计脚本、分析脚本。
defer 脚本下载时不阻塞解析,执行推迟到 HTML 解析完成后、DOMContentLoaded 事件触发前。多个 defer 脚本按文档顺序执行。这是最适合大多数场景的选择,特别是当脚本依赖 DOM 结构时。
5.2 实际场景选择
<head>
<!-- 关键脚本:影响首屏渲染的,放在 body 末尾或使用 defer -->
<!-- 不阻塞渲染,但确保在 DOMReady 前执行 -->
<!-- 第三方脚本:不依赖 DOM 且不需要按顺序执行 -->
<script async src="https://analytics.example.com/tracker.js"></script>
<!-- 必须立即执行的脚本:几乎不存在,大多数场景 defer 更好 -->
<!-- 只有当脚本必须在页面加载前执行时,才用阻塞型普通 script -->
</head>
<body>
<!-- 推荐:所有依赖 DOM 的脚本放在 body 末尾 -->
<script src="app.js" defer></script>
</body>
现代 Web 开发中,defer 几乎是所有外部脚本的最佳选择。它确保脚本在 DOM 准备就绪后执行,且不阻塞页面渲染。除非有极其特殊的理由,不要使用阻塞型普通 script。
六、HTML 语义化的实际意义
6.1 语义化如何影响浏览器行为
语义化 HTML 不只是"代码看起来更规范"。浏览器、屏幕阅读器、搜索引擎爬虫会根据 HTML 元素的语义做出不同处理:
<!-- 无障碍:屏幕阅读器识别为导航区域,用户可快速跳转 -->
<nav aria-label="主导航">
<ul>
<li><a href="/">首页</a></li>
<li><a href="/about">关于</a></li>
</ul>
</nav>
<!-- 搜索引擎:理解页面结构,影响内容索引 -->
<article>
<header>
<h1>文章标题</h1>
<time datetime="2026-04-08">2026年4月8日</time>
</header>
<section>
<h2>章节一</h2>
<p>正文内容...</p>
</section>
</article>
<!-- 浏览器:自动提供上下文菜单、拖拽行为等 -->
<article contenteditable="true">
<!-- 可以直接编辑,带有浏览器内置的复制粘贴行为 -->
</article>
6.2 可访问性树的生成
浏览器在解析 HTML 后会生成两棵树:DOM 树和可访问性树(Accessibility Tree)。可访问性树是屏幕阅读器等辅助技术消费的结构:
DOM 树 可访问性树
├── <html> [Document]
├── <body> [Content]
│ ├── <header> [Banner landmark]
│ │ └── <nav> [Navigation landmark]
│ │ └── <ul> [List]
│ │ └── <li> [Listitem]
│ │ └── <a> [Link: "首页"]
语义元素(nav、article、main、button 等)会映射为可访问性树中的 Landmark。使用正确的语义元素,用户可以用键盘快速在页面的不同区域之间跳转,而屏幕阅读器可以宣布区域的性质(如"这是导航区域")。
七、性能:关键渲染路径
7.1 渲染阻塞的完整图景
关键渲染路径(Critical Rendering Path)是指浏览器从接收 HTML 字节到显示像素的完整过程。理解这个过程才能理解什么会阻塞渲染:
HTML 下载 → HTML 解析 → DOM 树构建 → CSS 解析 → CSSOM 树构建 →
渲染树构建 → 布局计算 → 绘制 → 合成
渲染阻塞资源(Render-Blocking Resources)是指必须全部获取并解析才能继续渲染的资源:
<link rel="stylesheet">:必须获取并解析完 CSS 才能构建 CSSOM<script>(无 defer/async):必须执行完才能继续解析 HTML
非渲染阻塞资源:
<script defer>:下载时 HTML 解析继续,解析完成后执行<script async>:下载完成后立即执行<img>:下载期间不阻塞渲染
7.2 优化关键渲染路径
<head>
<!-- 1. 只加载首屏需要的 CSS -->
<link rel="stylesheet" href="critical.css" media="print" onload="this.media='all'">
<!-- 2. 非关键 CSS 延迟加载 -->
<link rel="stylesheet" href="non-critical.css" media="print" onload="this.media='all'">
<!-- 3. preload 关键资源 -->
<link rel="preload" href="fonts/Knight-Bold.woff2" as="font" crossorigin>
<!-- 4. defer 所有可能放在 head 的脚本 -->
<script defer src="analytics.js"></script>
</head>
CSS 之所以是渲染阻塞的,是因为 JavaScript 可以查询和修改 CSSOM。如果 CSSOM 不完整就开始执行 JavaScript,可能导致不可预测的结果。这是浏览器做出这个决定的根本原因。
八、面试高频问题
Q: defer 和 async 的区别是什么?
回答要点:defer 脚本在 HTML 解析完成后、DOMContentLoaded 前按文档顺序执行;async 脚本在下载完成后立即执行,顺序与文档顺序无关。实际选择:如果脚本依赖 DOM(大多数情况),用 defer;如果脚本不依赖 DOM 且不需要按顺序执行(如统计脚本),用 async。
Q: 为什么 CSS link 标签通常放在 head 里,而 script 标签通常放在 body 末尾?
回答要点:CSS 放在 head 是因为它是渲染阻塞资源——CSSOM 必须构建完成才能渲染,而 body 里的内容会被 CSS 影响。script 放在 body 末尾是因为普通 script 会阻塞 HTML 解析,放到末尾可以让 HTML 尽可能快地开始解析和渲染(首屏内容先行)。
Q: preload 和 prefetch 的区别?
回答要点:preload 是当前页面必需的资源,浏览器会立即以高优先级获取;prefetch 是将来可能需要的资源,浏览器在空闲时以低优先级获取。preload 用于当前导航性能,prefetch 用于预取下一个导航可能用到的资源。