HTML 文档结构与渲染基础

深入理解 HTML 文档结构、DOCTYPE 历史、渲染阻塞机制,以及 script 标签的 defer/async/onload 行为差异,构建高性能页面。

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 用于预取下一个导航可能用到的资源。

延展阅读