16.6.2 解码命名字符引用
Vue 模板的文本节点最终是通过
el.textContent之类的文本方法写入页面的,浏览器不会再解码 HTML 实体,所以解析器必须自己按 WHATWG 规范解码。
一、HTML 实体与字符引用
- HTML 实体是一段以字符
&开始的文本内容,用来描述 HTML 的保留字符、难以用键盘输入的字符以及不可见的字符; - 例如
<用来表示字符<:直接写<会产生非法的 HTML 内容; - 实体以
&开头、以;结尾;WHATWG 规范规定不加分号属于解析错误,但考虑到历史原因(互联网上大量省略分号),现代浏览器仍能解析早期规范中「可以省略分号」的那些实体。
字符引用分两类:
| 类型 | 形式 | 示例 |
|---|---|---|
| 命名字符引用(named character reference / 命名实体) | & + 名称 + ; | < |
| 数字字符引用(numeric character reference) | &#(十进制)或 &#x(十六进制) | <、< |
<与<等价,都表示字符<;- 数字字符引用的解码见 16.6.3 解码数字字符引用。
二、为什么解析阶段就要解码
- 大前提:在 Vue.js 模板中,文本节点包含的 HTML 实体不会被浏览器解析;
- 因为模板中的文本节点最终通过
el.textContent等文本操作方法设置到页面,而这些方法不会做 HTML 实体解码; - 结果就是:用户写
<,如果不解码,页面会原封不动地显示字符串'<',而不是字符<; - 所以应该在解析阶段对文本节点中的 HTML 实体解码,且行为要与浏览器一致,即按 WHATWG 规范实现。
三、分号策略:完整匹配与最短匹配
| 情况 | 匹配策略 |
|---|---|
| 存在分号 | 完整匹配(拿整个带分号的名字去查表) |
| 省略分号 | 最短匹配(从短到长里能命中的最短名称) |
因此引用表里同时存在 "gt" 与 "gt;" 这样的条目,这就是「最长匹配优先、逐级缩短」能够实现最短匹配的基础:
const namedCharacterReferences = {
"gt": ">",
"gt;": ">",
"lt": "<",
"lt;": "<",
"ltcc;": "⪦"
}
四、decodeHtml 的实现
// 第一个参数为要被解码的文本内容
// 第二个参数 asAttr 代表文本内容是否作为属性值
function decodeHtml(rawText, asAttr = false) {
let offset = 0
const end = rawText.length
let decodedText = ''
// 引用表中实体名称的最大长度(惰性计算)
let maxCRNameLength = 0
function advance(length) {
offset += length
rawText = rawText.slice(length)
}
while (offset < end) {
// 匹配字符引用的开始部分,head[0] 有三种可能:
// '&' → 命名字符引用;'&#' → 十进制数字字符引用;'&#x' → 十六进制数字字符引用
const head = /&(?:#x?)?/i.exec(rawText)
if (!head) {
// 没有需要解码的内容了:把剩余内容原样拼上并结束
const remaining = end - offset
decodedText += rawText.slice(0, remaining)
advance(remaining)
break
}
// 字符 & 之前的内容原样保留
decodedText += rawText.slice(0, head.index)
advance(head.index)
if (head[0] === '&') {
// ── 命名字符引用 ──
let name = ''
let value
// & 的下一个字符必须是 ASCII 字母或数字,才是合法的命名字符引用
if (/[0-9a-z]/i.test(rawText[1])) {
if (!maxCRNameLength) {
maxCRNameLength = Object.keys(namedCharacterReferences).reduce(
(max, name) => Math.max(max, name.length), 0
)
}
// 从最大长度开始截取并查表:先完整匹配,再逐级缩短
for (let length = maxCRNameLength; !value && length > 0; --length) {
name = rawText.substr(1, length)
value = namedCharacterReferences[name]
}
if (value) {
const semi = name.endsWith(';')
// 作为属性值、且最后一个匹配字符不是分号、
// 且它的下一个字符是 '=' / ASCII 字母或数字时,
// 由于历史原因不解码,把 &name 当普通文本
if (asAttr && !semi && /[=a-z0-9]/i.test(rawText[name.length + 1] || '')) {
decodedText += '&' + name
advance(1 + name.length)
} else {
decodedText += value
advance(1 + name.length)
}
} else {
// 解码失败:原样保留
decodedText += '&' + name
advance(1 + name.length)
}
} else {
// & 后面不是 ASCII 字母或数字:& 作为普通文本
decodedText += '&'
advance(1)
}
} else {
// 数字字符引用,见 16.6.3
}
}
return decodedText
}
接入 parseText:
return {
type: 'Text',
content: decodeHtml(content) // 文本节点内容先解码
}
易错点:一定要从最长名称开始截取查表。若先匹配短的
<,⪦就会被错误地解码成<+cc;。
相关
- 16.6.1 解析文本:
decodeHtml的调用位置 - 16.6.3 解码数字字符引用:
&#/&#x形式的解码与码点合法性检查 - 16.5 解析属性:属性值解码时
asAttr为true的特殊处理