HTML 只做一件事:给纯文本加上「这段是什么」的标注。
实体(< 这类)的存在是因为 < 本身是语法字符。
这一节真解析一段 HTML,看标签怎么变成一棵树,再看实体转义为什么是 XSS 的源头。 最后说清 HTML 那个「容错解析」的历史包袱。
一、标签变成一棵树
HTML 的解析结果是一棵树,每个标签的深度等于它父节点的深度加一。
浏览器画页面时按这棵树递归下去,所以嵌套错了,样子就全乱。
「深度」这个概念值得说清:它不是「第几个标签」,而是「它上面有几层父节点」。
所以兄弟节点深度相同,<p> 和 <ul> 挂在同一个 <div> 下,两者的深度就一样。
这棵树还决定了「样式怎么继承」:子节点默认继承父节点的字体、颜色、行高。
所以「整个页面统一字体」只要在最外层写一次,这就是「层叠样式」里那个「层叠」的意思。
二、实体:把语法字符变成内容
< 在 HTML 里是「标签开始」的信号。
所以正文里要显示一个真的 <,必须写成 <。
同理 & 写成 &,双引号写成 "。
& 这一条最容易漏:因为它不是语法字符,但它会「吞掉后面的实体名」。
比如你想显示 < 这三个字符本身,写成 &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 里 `<` 这类「实体」是干什么的**
原字符 写成 什么时候必须写
-------------------------------------------------------------
`<` `<` **正文里要显示小于号时**
`>` `>` **正文里要显示大于号时**
`&` `&` **任何地方,否则会吞掉后面的实体名**
`"` `"` **写在双引号包起来的属性值里时**
`'` `'` **写在单引号包起来的属性值里时**
**第二列就是 `html.escape` 的输出**,**第三列说明「为什么非写不可」。**
**第一行是最要紧的**:**因为 `<` 是「标签开始」的信号。**
**如果正文里要显示一个真的 `<`,不转义的话浏览器会以为标签来了。**
**这个规则后来变成了安全漏洞的源头**——**用户输入的内容如果不转义就拼进页面,就是 XSS。**
**最后一件容易被忽略的事**:**HTML 的解析是「容错」的。**
**少一个 `</div>`,浏览器不会报错,它会自己猜一个位置补上。**
**这个「猜」的规则写满了规范,各家浏览器当年就是在这上面打得不可开交。**
**所以「HTML 写错了也能显示」不是优点,是历史包袱。**
七个标签,最大深度 5。
<p> 和 <ul> 都是第 4 层(进栈前深度 3),<li> 是第 5 层。
这个「进栈前深度」就是「它上面有几层父节点」,也就是树里的层级减一。
注意这个解析器是「事件驱动」的:它不建树,只在遇到开始标签、结束标签、文本时回调你。
所以要建树得自己维护一个栈。
这也解释了为什么「解析 HTML」在浏览器里那么复杂——
真正的实现要把「容错规则」也编进这个状态机。
实体表里最该记的是第二行 &:它「任何地方都要转义」,因为它会吞掉后面的实体名。
而双引号和单引号只在「属性值里」需要——
这是「同一字符在不同上下文里安全性不同」的又一个例子。
30 秒实验
- 想一下:
&为什么在任何地方都要转义? - 想一下:为什么 HTML 的解析要设计成容错的?
- 想一下:XSS 和 SQL 注入的共同点是什么?
常见误区
- 以为「HTML 是编程语言」。 它是标记语言,没有变量、没有循环。
- 以为「少个闭合标签会报错」。 浏览器会自己猜,这是有意的设计。
- 以为「转义是可选的好习惯」。 不转义就是 XSS 漏洞,这是必须做的事。
自测三问(点开对答案)
1. HTML 解析出来是什么? —— **一棵树**,深度等于父节点深度加一。 2. `<` 为什么必须写成 `<`? —— **因为 `<` 是「标签开始」的语法字符。** 3. 为什么 HTML 要容错? —— **早期作者大多不是程序员**,报错会让 Web 长不起来。下一节看 HTTP 的请求和响应到底长什么样。
- ☐ (本节完,下一节 8.5.5 HTTP)