📚 第八章 应用协议
📄 一句话概括

HTML 只做一件事:给纯文本加上「这段是什么」的标注。 实体(&lt; 这类)的存在是因为 < 本身是语法字符。

📝 本节和前面几节的关系

这一节真解析一段 HTML,看标签怎么变成一棵树,再看实体转义为什么是 XSS 的源头。 最后说清 HTML 那个「容错解析」的历史包袱。

一、标签变成一棵树

HTML 的解析结果是一棵树,每个标签的深度等于它父节点的深度加一。 浏览器画页面时按这棵树递归下去,所以嵌套错了,样子就全乱。 「深度」这个概念值得说清:它不是「第几个标签」,而是「它上面有几层父节点」。 所以兄弟节点深度相同,<p> 和 <ul> 挂在同一个 <div> 下,两者的深度就一样。 这棵树还决定了「样式怎么继承」:子节点默认继承父节点的字体、颜色、行高。 所以「整个页面统一字体」只要在最外层写一次,这就是「层叠样式」里那个「层叠」的意思。

二、实体:把语法字符变成内容

< 在 HTML 里是「标签开始」的信号。 所以正文里要显示一个真的 <,必须写成 &lt;。 同理 & 写成 &amp;,双引号写成 &quot;。 & 这一条最容易漏:因为它不是语法字符,但它会「吞掉后面的实体名」。 比如你想显示 &lt; 这三个字符本身,写成 &amp;lt;,否则浏览器会把它渲染成 <。 这套转义规则后来变成了安全漏洞的源头: 用户输入的内容如果不转义就拼进页面,就是 XSS(跨站脚本)。 因为攻击者可以输入 <script>,浏览器会把它当代码执行。 所以「输出到 HTML 之前必须转义」是所有 Web 框架的第一条铁律。 这条铁律的本质是「数据和代码不能混在同一个通道里」,和 SQL 注入是同一个道理。

三、容错解析:历史包袱

HTML 的解析是「容错」的。 少一个 </div>,浏览器不会报错,它会自己猜一个位置补上。 这个「猜」的规则写满了规范,各家浏览器当年就是在这上面打得不可开交。 为什么会有这么奇怪的规则:因为 HTML 从一开始就要求「写错了也得能显示」。 1990 年代的网页作者大多不是程序员,如果浏览器对每个语法错误都报错,Web 根本长不起来。 所以「宽容」是一个有意的产品决策,不是技术疏忽。 代价是「解析器极难写」:一个符合规范的 HTML 解析器,代码量远超一个 JSON 解析器。 而且「容错」意味着「同一段错误代码在不同浏览器上可能长得不一样」—— 这个不确定性困扰了 Web 开发者十几年,直到 HTML5 把「怎么猜」也写成了标准。

四、动手 Demo:HTML 是「标记」,不是「程序」

用标准库的解析器遍历一段 HTML,把每个标签的深度打出来。 再摆一遍五个必须转义的实体,各说明「什么时候非写不可」。

import html.parser

BODY = ('<html><body><div><p>你好</p><ul><li>a</li><li>b</li></ul></div></body></html>')

class Tree(html.parser.HTMLParser):
    def __init__(self):
        html.parser.HTMLParser.__init__(self)
        self.stack, self.rows, self.n = [], [], 0
    def handle_starttag(self, tag, attrs):
        self.n += 1
        self.rows.append((self.n, tag, len(self.stack), attrs))
        self.stack.append(tag)
    def handle_endtag(self, tag):
        if self.stack and self.stack[-1] == tag:
            self.stack.pop()

t = Tree()
t.feed(BODY)
for no, tag, depth, attrs in t.rows:
    print(no, '<%s>' % tag, '深度', depth)

实跑输出:

--------------------------------------------------------------------
HTML 是「标记」,不是「程序」
--------------------------------------------------------------------
**HTML 的作用只有一个:给一段纯文本加上「这段是什么」的标注。**
**它自己不会算、不会判断、不会循环。**
**所以浏览器要「解析」它,把它变成一棵树。**

  序     标签         进栈前深度     属性
  ------------------------------------------
  1      `<html>`     0              (无)
  2      `<body>`     1              (无)
  3      `<div>`      2              (无)
  4      `<p>`        3              (无)
  5      `<ul>`       3              (无)
  6      `<li>`       4              (无)
  7      `<li>`       4              (无)

**注意第三列**:**`<html>` 进栈前深度是 0,`<body>` 是 1,`<div>` 是 2。**
**这就是「树」——每个标签的深度等于它父节点的深度加一。**
**浏览器画页面时按这棵树递归下去,所以嵌套错了,样子就全乱。**

**第二件事:HTML 里 `&lt;` 这类「实体」是干什么的**

  原字符     写成         什么时候必须写
  -------------------------------------------------------------
  `<`        `&lt;`       **正文里要显示小于号时**
  `>`        `&gt;`       **正文里要显示大于号时**
  `&`        `&amp;`      **任何地方,否则会吞掉后面的实体名**
  `"`        `&quot;`     **写在双引号包起来的属性值里时**
  `'`        `&#x27;`     **写在单引号包起来的属性值里时**

**第二列就是 `html.escape` 的输出**,**第三列说明「为什么非写不可」。**
**第一行是最要紧的**:**因为 `<` 是「标签开始」的信号。**
**如果正文里要显示一个真的 `<`,不转义的话浏览器会以为标签来了。**
**这个规则后来变成了安全漏洞的源头**——**用户输入的内容如果不转义就拼进页面,就是 XSS。**

**最后一件容易被忽略的事**:**HTML 的解析是「容错」的。**
**少一个 `</div>`,浏览器不会报错,它会自己猜一个位置补上。**
**这个「猜」的规则写满了规范,各家浏览器当年就是在这上面打得不可开交。**
**所以「HTML 写错了也能显示」不是优点,是历史包袱。**

七个标签,最大深度 5。 <p> 和 <ul> 都是第 4 层(进栈前深度 3),<li> 是第 5 层。 这个「进栈前深度」就是「它上面有几层父节点」,也就是树里的层级减一。 注意这个解析器是「事件驱动」的:它不建树,只在遇到开始标签、结束标签、文本时回调你。 所以要建树得自己维护一个栈。 这也解释了为什么「解析 HTML」在浏览器里那么复杂—— 真正的实现要把「容错规则」也编进这个状态机。 实体表里最该记的是第二行 &:它「任何地方都要转义」,因为它会吞掉后面的实体名。 而双引号和单引号只在「属性值里」需要—— 这是「同一字符在不同上下文里安全性不同」的又一个例子。

30 秒实验

  1. 想一下:& 为什么在任何地方都要转义?
  2. 想一下:为什么 HTML 的解析要设计成容错的?
  3. 想一下:XSS 和 SQL 注入的共同点是什么?

常见误区

  1. 以为「HTML 是编程语言」。 它是标记语言,没有变量、没有循环。
  2. 以为「少个闭合标签会报错」。 浏览器会自己猜,这是有意的设计。
  3. 以为「转义是可选的好习惯」。 不转义就是 XSS 漏洞,这是必须做的事。
自测三问(点开对答案) 1. HTML 解析出来是什么? —— **一棵树**,深度等于父节点深度加一。 2. `<` 为什么必须写成 `<`? —— **因为 `<` 是「标签开始」的语法字符。** 3. 为什么 HTML 要容错? —— **早期作者大多不是程序员**,报错会让 Web 长不起来。

下一节看 HTTP 的请求和响应到底长什么样。

相关笔记