当前位置: 首页 > news >正文

《Vuejs设计与实现》第 16 章(解析器) 下

目录

16.6 解析文本与解码 HTML 实体

16.6.1 解析文本

16.6.2 解码命名字符引用

16.6.3 解码数字字符引用

16.7 解析插值与注释

16.8 总结


16.6 解析文本与解码 HTML 实体

16.6.1 解析文本

本节我们将讨论文本节点的解析。给出如下模板:

const template = '<div>Text</div>'

解析器在解析上面这段模板时,会先经过 parseTag 函数的处理,这会消费标签的开始部分 '<div>'。处理完毕后,剩余模板内容为:

const template = 'Text</div>'

紧接着,解析器会调用 parseChildren 函数,开启一个新的状态机来处理这段模板。我们来回顾一下状态机的状态迁移过程,如图所示:

image.png


状态机始于“状态 1”。在“状态 1”下,读取模板的第一个字符 T,由于该字符既不是字符 <,也不是插值定界符 {{,因此状态机会进入“状态 7”,即调用parseText 函数处理文本内容。
此时解析器会在模板中寻找下一个 < 字符或插值定界符 {{ 的位置索引,记为索引 I。然后,解析器会从模板的头部到索引 I 的位置截取内容,这段截取出来的字符串将作为文本节点的内容。以下面的模板内容为例:

const template = 'Text</div>'

parseText 函数会尝试在这段模板内容中找到第一个出现的字符 < 的位置索引。
在这个例子中,字符 < 的索引值为 4。然后,parseText 函数会截取介于索引 [0, 4) 的内容作为文本内容。在这个例子中,文本内容就是字符串'Text'。
假设模板中存在插值,如下面的模板所示:

const template = 'Text-{{ val }}</div>'

在处理这段模板时,parseText 函数会找到第一个插值定界符 {{ 出现的位置索引。
在这个例子中,定界符的索引为 5。于是,parseText 函数会截取介于索引 [0, 5) 的内容作为文本内容。在这个例子中,文本内容就是字符串'Text-'。
下面的 parseText 函数给出了具体实现:

function parseText(context) {// endIndex 为文本内容的结尾索引,默认将整个模板剩余内容都作为文本内容let endIndex = context.source.length// 寻找字符 < 的位置索引const ltIndex = context.source.indexOf('<')// 寻找定界符 {{ 的位置索引const delimiterIndex = context.source.indexOf('{{')// 取 ltIndex 和当前 endIndex 中较小的一个作为新的结尾索引if (ltIndex > -1 && ltIndex < endIndex) {endIndex = ltIndex}// 取 delimiterIndex 和当前 endIndex 中较小的一个作为新的结尾索引if (delimiterIndex > -1 && delimiterIndex < endIndex) {endIndex = delimiterIndex}// 此时 endIndex 是最终的文本内容的结尾索引,调用 slice 函数截取文本内容const content = context.source.slice(0, endIndex)// 消耗文本内容context.advanceBy(content.length)// 返回文本节点return {// 节点类型type: 'Text',// 文本内容content,}
}
 

上述代码,由于字符 < 与定界符 {{ 的出现顺序是未知的,所以我们需要取两者中较小的一个作为文本截取的终点。
有了截取终点后,只需要调用字符串的 slice 函数对字符串进行截取即可,截取出来的内容就是文本节点的文本内容。
最后,我们创建一个类型为 Text 的文本节点,将其作为 parseText 函数的返回值。
配合上述 parseText 函数解析如下模板:

const ast = parse(`<div>Text</div>`)

得到如下 AST:

const ast = {type: 'Root',children: [{type: 'Element',tag: 'div',props: [],isSelfClosing: false,children: [// 文本节点{ type: 'Text', content: 'Text' },],},],
}

这样,我们就实现了对文本节点的解析。解析文本节点本身并不复杂
复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码工作。
为此,我们有必要先了解什么是 HTML 实体。

16.6.2 解码命名字符引用

HTML 实体是一段以字符 & 开始的文本内容。实体用来描述 HTML 中的保留字符和一些难以通过普通键盘输入的字符,以及一些不可见的字符。
例如,在HTML 中,字符 < 具有特殊含义,如果希望以普通文本的方式来显示字符<,需要通过实体来表达:

<div>A&lt;B</div>

其中字符串 < 就是一个 HTML 实体,用来表示字符 <。
如果我们不用HTML 实体,而是直接使用字符 <,那么将会产生非法的 HTML 内容:

<div>A<B</div>

现代浏览器都能够解析早期规范中定义的那些可以省略分号的 HTML 实体。
HTML 实体有两类,一类叫作命名字符引用(named characterreference),也叫命名实体(named entity),
顾名思义,这类实体具有特定的名称,例如上文中的 <。WHATWG 规范中给出了全部的命名字符引用,有 2000 多个,可以通过命名字符引用表查询。下面列出了部分内容:

// 共 2000+
{"GT": ">","gt": ">","LT": "<","lt": "<",// 省略部分代码"awint;": "⨑","bcong;": "≌","bdquo;": "„","bepsi;": "϶","blank;": "␣","blk12;": "▒","blk14;": "░","blk34;": "▓","block;": "█","boxDL;": "╗","boxDl;": "╖","boxdL;": "╕",// 省略部分代码
}
 

除了命名字符引用之外,还有一类字符引用没有特定的名称,只能用数字表示,这类实体叫作数字字符引用(numeric character reference)。
与命名字符引用不同,数字字符引用以字符串 &# 开头,比命名字符引用的开头部分多出了字符 #,例如 <。实际上,< 对应的字符也是 <,换句话说,< 与 < 是等价的。
数字字符引用既可以用十进制来表示,也可以使用十六进制来表示。例如,十进制数字 60 对应的十六进制值为 3c,因此实体 < 也可以表示为 <。可以看到,当使用十六进制数表示实体时,需要以字符串 &#x 开头。
理解了 HTML 实体后,我们再来讨论为什么 Vue.js 模板的解析器要对文本节点中的 HTML 实体进行解码。
为了理解这个问题,我们需要先明白一个大前提:在 Vue.js 模板中,文本节点所包含的 HTML 实体不会被浏览器解析。
这是因为模板中的文本节点最终将通过如 el.textContent 等文本操作方法设置到页面,而通过 el.textContent 设置的文本内容是不会经过 HTML 实体解码的,例如:

el.textContent = '&lt;'

最终 el 的文本内容将会原封不动地呈现为字符串 '<',而不会呈现字符 <。
这就意味着,如果用户在 Vue.js 模板中编写了 HTML 实体,而模板解析器不对其进行解码,那么最终渲染到页面的内容将不符合用户的预期。
因此,我们应该在解析阶段对文本节点中存在的 HTML 实体进行解码。
模板解析器的解码行为应该与浏览器的行为一致。因此,我们应该按照 WHATWG 规范实现解码逻辑。
范中明确定义了解码 HTML 实体时状态机的状态迁移流程,如下图:

image.png


假定状态机当前处于初始的 DATA 模式。由上图可知,当解析器遇到字符 & 时,会进入“字符引用状态”,并消费字符 &,接着解析下一个字符。
接着解析下一个字符。如果下一个字符是 ASCII 字母或数字(ASCII alphanumeric),则进入“命名字符引用状态”,其中 ASCII 字母或数字指的是 09 这十个数字以及字符集合 az 再加上字符集合 A~Z。当然,如果下一个字符是 #,则进入“数字字符引用状态”。
一旦状态机进入命名字符引用状态,解析器将会执行比较复杂的匹配流程。我们通过几个例子来直观地感受一下这个过程。假设文本内容为:

a&ltb

上面这段文本会被解析为:

a<b

为什么会得到这样的解析结果呢?接下来,我们分析整个解析过程。
首先,当解析器遇到字符 & 时,会进入字符引用状态。接着,解析下一个字符 l,这会使得解析器进入命名字符引用状态,并在命名字符引用表(后文简称“引用表”)中查找以字符 l 开头的项。由于引用表中存在诸多以字符 l 开头的项,例如 lt、lg、le 等,因此解析器认为此时是“匹配”的。
于是开始解析下一个字符 t,并尝试去引用表中查找以 lt 开头的项。由于引用表中也存在多个以 lt 开头的项,例如 lt、ltcc;、ltri; 等,因此解析器认为此时也是“匹配”的。
于是又开始解析下一个字符 b,并尝试去引用表中查找以 ltb 开头的项,结果发现引用表中不存在符合条件的项,至此匹配结束。
当匹配结束时,解析器会检查最后一个匹配的字符。如果该字符是分号(;),则会产生一个合法的匹配,并渲染对应字符。
但在上例中,最后一个匹配的字符是字符 t,并不是分号(;),因此会产生一个解析错误,但由于历史原因,浏览器仍然能够解析它。
在这种情况下,浏览器的解析规则是:最短原则。其中“最短”指的是命名字符引用的名称最短。举个例子,假设文本内容为:

a&ltcc;

我们知道 ⪦ 是一个合法的命名字符引用,因此上述文本会被渲染为:a⪦。但如果去掉上述文本中的分号,即

a&ltcc

解析器在处理这段文本中的实体时,最后匹配的字符将不再是分号,而是字符c。按照“最短原则”,解析器只会渲染名称更短的字符引用。
在字符串 &ltcc中,&lt 的名称要短于 &ltcc,因此最终会将 &lt 作为合法的字符引用来渲染,而字符串 cc 将作为普通字符来渲染。所以上面的文本最终会被渲染为:a<cc。
需要说明的是,上述解析过程仅限于不用作属性值的普通文本。
换句话说,用作属性值的文本会有不同的解析规则。举例来说,给出如下 HTML 文本:

<a href="foo.com?a=1&lt=2">foo.com?a=1&lt=2</a>

可以看到,a 标签的 href 属性值与它的文本子节点具有同样的内容,但它们被解析之后的结果不同。其中属性值中出现的 &lt 将原封不动地展示,而文本子节点中出现的 &lt 将会被解析为字符 <。
这也是符合期望的,很明显,&lt=2 将构成链接中的查询参数,如果将其中的 &lt 解码为字符 <,将会破坏用户的 URL。
实际上,WHATWG 规范中对此也有完整的定义,出于历史原因的考虑,对于属性值中的字符引用,如果最后一个匹配的字符不是分号,并且该匹配的字符的下一个字符是等于号、ASCII 字母或数字,那么该匹配项将作为普通文本被解析。

明白了原理,我们就着手实现。
首先如何处理分号问题:

  • 当存在分号时:执行完整匹配。
  • 当省略分号时:执行最短匹配。

为此,我们需要精心设计命名字符引用表。由于命名字符引用的数量非常多,因此这里我们只取其中一部分作为命名字符引用表的内容,如下代码所示:

const namedCharacterReferences = {'gt': '>','gt;': '>','lt': '<','lt;': '<','ltcc;': '⪦',
}

上面这张表是经过精心设计的。观察 namedCharacterReferences 对象可以发现,相同的字符对应的实体会有多个,即带分号的版本和不带分号的版本,例如 "gt" 和 "gt;"。
另外一些实体则只有带分号的版本,因为这些实体不允许省略分号,例如 "ltcc;"。我们可以根据这张表来实现实体的解码逻辑。假设我们有如下文本内容:

a&ltccbbb

在解码这段文本时,我们首先根据字符 & 将文本分为两部分。

  • 一部分是普通文本:a。
  • 另一部分则是:&ltccbbb。

对于普通文本部分,由于它不需要被解码,因此索引原封不动地保留。而对于可能是字符引用的部分,执行解码工作。

  • 第一步:计算出命名字符引用表中实体名称的最大长度。由于在 namedCharacterReferences 对象中,名称最长的实体是 ltcc;,它具有 5 个字符,因此最大长度是 5。
  • 第二步:根据最大长度截取字符串 ltccbbb,即 'ltccbbb'.slice(0, 5),最终结果是:'ltccb'
  • 第三步:用截取后的字符串 'ltccb' 作为键去命名字符引用表中查询对应的值,即解码。由于引用表 namedCharacterReferences 中不存在键值为'ltccb' 的项,因此不匹配。
  • 第四步:当发现不匹配时,我们将最大长度减 1,并重新执行第二步,直到找到匹配项为止。在上面这个例子中,最终的匹配项将会是 'lt'。因此,上述文本最终会被解码为:
a<ccbbb

这样,我们就实现了当字符引用省略分号时按照“最短原则”进行解码。
下面的 decodeHtml 函数给出了具体实现:

// 第一个参数为要被解码的文本内容
// 第二个参数是一个布尔值,代表文本内容是否作为属性值
function decodeHtml(rawText, asAttr = false) {let offset = 0const end = rawText.length// 经过解码后的文本将作为返回值被返回let decodedText = ''// 引用表中实体名称的最大长度let maxCRNameLength = 0// advance 函数用于消费指定长度的文本function advance(length) {offset += lengthrawText = rawText.slice(length)}// 消费字符串,直到处理完毕为止while (offset < end) {// 用于匹配字符引用的开始部分,如果匹配成功,那么 head[0] 的值将有三种可能:// 1. head[0] === '&',这说明该字符引用是命名字符引用// 2. head[0] === '&#',这说明该字符引用是用十进制表示的数字字符引用// 3. head[0] === '&#x',这说明该字符引用是用十六进制表示的数字字符引用const head = /&(?:#x?)?/i.exec(rawText)// 如果没有匹配,说明已经没有需要解码的内容了if (!head) {// 计算剩余内容的长度const remaining = end - offset// 将剩余内容加到 decodedText 上decodedText += rawText.slice(0, remaining)// 消费剩余内容advance(remaining)break}// head.index 为匹配的字符 & 在 rawText 中的位置索引// 截取字符 & 之前的内容加到 decodedText 上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 += '&' + nameadvance(1 + name.length)} else {// 其他情况下,正常使用解码后的内容拼接到 decodedText 上decodedText += valueadvance(1 + name.length)}} else {// 如果没有找到对应的值,说明解码失败decodedText += '&' + nameadvance(1 + name.length)}} else {// 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本decodedText += '&'advance(1)}}}return decodedText
}

有了 decodeHtml 函数之后,我们就可以在解析文本节点时通过它对文本内容进行解码:

function parseText(context) {// 省略部分代码return {type: 'Text',content: decodeHtml(content), // 调用 decodeHtml 函数解码内容}
}

16.6.3 解码数字字符引用

在上一节中,我们使用下面的正则表达式来匹配一个文本中字符引用的开始部分:

const head = /&(?:#x?)?/i.exec(rawText)

我们可以根据该正则的匹配结果,来判断字符引用的类型。

  • 如果 head[0] === '&',则说明匹配的是命名字符引用。
  • 如果 head[0] === '&#',则说明匹配的是以十进制表示的数字字符引用。
  • 如果 head[0] === '&#x',则说明匹配的是以十六进制表示的数字字符引用。

数字字符引用的格式是:前缀 + Unicode 码点。
解码数字字符引用的关键在于,如何提取字符引用中的 Unicode 码点。考虑到数字字符引用的前缀可以是以十进制表示(&#),也可以是以十六进制表示(&#x),所以我们使用下面的代码来完成码点的提取:

// 判断是以十进制表示还是以十六进制表示
const hex = head[0] === '&#x'
// 根据不同进制表示法,选用不同的正则
const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/
// 最终,body[1] 的值就是 Unicode 码点
const body = pattern.exec(rawText)

有了 Unicode 码点之后,只需要调用 String.fromCodePoint 函数即可将其解码为对应的字符:

if (body) {// 根据对应的进制,将码点字符串转换为数字const cp = parseInt(body[1], hex ? 16 : 10)// 解码const char = String.fromCodePoint(cp)
}

不过,在真正进行解码前,需要对码点的值进行合法性检查。WHATWG 规范中对此也有明确的定义:

  • 如果码点值为 0x00,即十进制的数字 0,它在 Unicode 中代表空字符(NULL),这将是一个解析错误,解析器会将码点值替换为 0xFFFD。
  • 如果码点值大于 0x10FFFF(0x10FFFF 为 Unicode 的最大值),这也是一个解析错误,解析器会将码点值替换为 0xFFFD。
  • 如果码点值处于代理对(surrogate pair)范围内,这也是一个解析错误,解析器会将码点值替换为 0xFFFD,其中 surrogate pair 是预留给 UTF-16的码位,其范围是:[0xD800, 0xDFFF]。
  • 如果码点值是 noncharacter,这也是一个解析错误,但什么都不需要做。这里的 noncharacter 代表 Unicode 永久保留的码点,用于 Unicode 内部,它的取值范围是:[0xFDD0, 0xFDEF],还包括:0xFFFE、0xFFFF、0x1FFFE、0x1FFFF、0x2FFFE、0x2FFFF、0x3FFFE、0x3FFFF、0x4FFFE、0x4FFFF、0x5FFFE、0x5FFFF、0x6FFFE、0x6FFFF、0x7FFFE、0x7FFFF、0x8FFFE、0x8FFFF、0x9FFFE、0x9FFFF、0xAFFFE、0xAFFFF、0xBFFFE、0xBFFFF、0xCFFFE、0xCFFFF、0xDFFFE、0xDFFFF、0xEFFFE、0xEFFFF、0xFFFFE、0xFFFFF、0x10FFFE、0x10FFFF。

如果码点值对应的字符是回车符(0x0D),或者码点值为控制字符集(control character)中的非 ASCII 空白符(ASCII whitespace),则是一个解析错误。这时需要将码点作为索引,在下表中查找对应的替换码点:

const CCR_REPLACEMENTS = {0x80: 0x20ac,0x82: 0x201a,0x83: 0x0192,0x84: 0x201e,0x85: 0x2026,0x86: 0x2020,0x87: 0x2021,0x88: 0x02c6,0x89: 0x2030,0x8a: 0x0160,0x8b: 0x2039,0x8c: 0x0152,0x8e: 0x017d,0x91: 0x2018,0x92: 0x2019,0x93: 0x201c,0x94: 0x201d,0x95: 0x2022,0x96: 0x2013,0x97: 0x2014,0x98: 0x02dc,0x99: 0x2122,0x9a: 0x0161,0x9b: 0x203a,0x9c: 0x0153,0x9e: 0x017e,0x9f: 0x0178,
}

如果存在对应的替换码点,则渲染该替换码点对应的字符,否则直接渲染原码点对应的字符。
上述关于码点合法性检查的具体实现如下:

if (body) {// 根据对应的进制,将码点字符串转换为数字const cp = parseInt(body[1], hex ? 16 : 10)// 检查码点的合法性if (cp === 0) {// 如果码点值为 0x00,替换为 0xfffdcp = 0xfffd} else if (cp > 0x10ffff) {// 如果码点值超过 Unicode 的最大值,替换为 0xfffdcp = 0xfffd} else if (cp >= 0xd800 && cp <= 0xdfff) {// 如果码点值处于 surrogate pair 范围内,替换为 0xfffdcp = 0xfffd} else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {// 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理// noop} else if (// 控制字符集的范围是:[0x01, 0x1f] 加上 [0x7f, 0x9f]// 去掉 ASICC 空白符:0x09(TAB)、0x0A(LF)、0x0C(FF)// 0x0D(CR) 虽然也是 ASICC 空白符,但需要包含(cp >= 0x01 && cp <= 0x08) ||cp === 0x0b ||(cp >= 0x0d && cp <= 0x1f) ||(cp >= 0x7f && cp <= 0x9f)) {// 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点cp = CCR_REPLACEMENTS[cp] || cp}// 最后进行解码const char = String.fromCodePoint(cp)
}

在上面这段代码中,我们完整地还原了码点合法性检查的逻辑,它有如下几个关键点:

  • 其中控制字符集(control character)的码点范围是:[0x01, 0x1f] 和[0x7f, 0x9f]。这个码点范围包含了 ASCII 空白符:0x09(TAB)、0x0A(LF)、0x0C(FF) 和 0x0D(CR),但 WHATWG 规范中要求包含 0x0D(CR)。
  • 码点 0xfffd 对应的符号是 �。你一定在出现“乱码”的情况下见过这个字符,它是 Unicode 中的替换字符,通常表示在解码过程中出现“错误”,例如使用了错误的解码方式等。

最后,我们将上述代码整合到 decodeHtml 函数中,这样就实现一个完善的HTML 文本解码函数:

function decodeHtml(rawText, asAttr = false) {// 省略部分代码// 消费字符串,直到处理完毕为止while (offset < end) {// 省略部分代码// 如果满足条件,则说明是命名字符引用,否则为数字字符引用if (head[0] === '&') {// 省略部分代码} else {// 判断是十进制表示还是十六进制表示const hex = head[0] === '&#x'// 根据不同进制表示法,选用不同的正则const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-9]+);?/// 最终,body[1] 的值就是 Unicode 码点const body = pattern.exec(rawText)// 如果匹配成功,则调用 String.fromCodePoint 函数进行解码if (body) {// 根据对应的进制,将码点字符串转换为数字const cp = Number.parseInt(body[1], hex ? 16 : 10)// 码点的合法性检查if (cp === 0) {// 如果码点值为 0x00,替换为 0xfffdcp = 0xfffd} else if (cp > 0x10ffff) {// 如果码点值超过 Unicode 的最大值,替换为 0xfffdcp = 0xfffd} else if (cp >= 0xd800 && cp <= 0xdfff) {// 如果码点值处于 surrogate pair 范围内,替换为 0xfffdcp = 0xfffd} else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) {// 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台处理// noop} else if (// 控制字符集的范围是:[0x01, 0x1f] 加上 [0x7f, 0x9f]// 去掉 ASICC 空白符:0x09(TAB)、0x0A(LF)、0x0C(FF)// 0x0D(CR) 虽然也是 ASICC 空白符,但需要包含(cp >= 0x01 && cp <= 0x08) ||cp === 0x0b ||(cp >= 0x0d && cp <= 0x1f) ||(cp >= 0x7f && cp <= 0x9f)) {// 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点cp = CCR_REPLACEMENTS[cp] || cp}// 解码后追加到 decodedText 上decodedText += String.fromCodePoint(cp)// 消费整个数字字符引用的内容advance(body[0].length)} else {// 如果没有匹配,则不进行解码操作,只是把 head[0] 追加到 decodedText 上并消费decodedText += head[0]advance(head[0].length)}}}return decodedText
}

16.7 解析插值与注释

文本插值是 Vue.js 模板中用来渲染动态数据的常用方法:

{{ count }}

默认情况下,插值以字符串 {{ 开头,并以字符串 }} 结尾。我们通常将这两个特殊的字符串称为定界符。
定界符中间的内容可以是任意合法的 JavaScript表达式,例如:

{ obj.foo }}
{{ obj.fn() }}

解析器在遇到文本插值的起始定界符({{)时,会进入文本“插值状态 6”,并调用 parseInterpolation 函数来解析插值内容,如图所示:
 

image.png


解析器在解析插值时,只需要将文本插值的开始定界符与结束定界符之间的内容提取出来,作为 JavaScript 表达式即可,具体实现如下:

function parseInterpolation(context) {// 消费开始定界符context.advanceBy('{{'.length)// 找到结束定界符的位置索引closeIndex = context.source.indexOf('}}')if (closeIndex < 0) {console.error('插值缺少结束定界符')}// 截取开始定界符与结束定界符之间的内容作为插值表达式const content = context.source.slice(0, closeIndex)// 消费表达式的内容context.advanceBy(content.length)// 消费结束定界符context.advanceBy('}}'.length)// 返回类型为 Interpolation 的节点,代表插值节点return {type: 'Interpolation',// 插值节点的 content 是一个类型为 Expression 的表达式节点content: {type: 'Expression',// 表达式节点的内容则是经过 HTML 解码后的插值表达式content: decodeHtml(content),},}
}

配合上面的 parseInterpolation 函数,解析如下模板内容:

const ast = parse(`<div>foo {{ bar }} baz</div>`)

最终将得到如下 AST:

const ast = {type: 'Root',children: [{type: 'Element',tag: 'div',isSelfClosing: false,props: [],children: [{ type: 'Text', content: 'foo ' },// 插值节点{type: 'Interpolation',content: {type: 'Expression',content: ' bar ',},},{ type: 'Text', content: ' baz' },],},],
}

解析注释的思路与解析插值非常相似,如下面的 parseComment 函数所示:

function parseComment(context) {// 消费注释的开始部分context.advanceBy('<!--'.length)// 找到注释结束部分的位置索引closeIndex = context.source.indexOf('-->')// 截取注释节点的内容const content = context.source.slice(0, closeIndex)// 消费内容context.advanceBy(content.length)// 消费注释的结束部分context.advanceBy('-->'.length)// 返回类型为 Comment 的节点return {type: 'Comment',content,}
}

配合 parseComment 函数,解析如下模板内容:

const ast = parse(`<div><!-- comments --></div>`)

最终得到如下 AST:

const ast = {type: 'Root',children: [{type: 'Element',tag: 'div',isSelfClosing: false,props: [],children: [{ type: 'Comment', content: ' comments ' }],},],
}

16.8 总结

在本章中,我们首先讨论了解析器的文本模式及其对解析器的影响。
文本模式指的是解析器在工作时所进入的一些特殊状态,如 RCDATA 模式、CDATA模式、RAWTEXT 模式,以及初始的 DATA 模式等。
在不同模式下,解析器对文本的解析行为会有所不同。
接着,我们讨论了如何使用递归下降算法构造模板 AST。
在 parseChildren函数运行的过程中,为了处理标签节点,会调用 parseElement 解析函数,这会间接地调用 parseChildren 函数,并产生一个新的状态机。随着标签嵌套层次的增加,新的状态机也会随着 parseChildren 函数被递归地调用而不断创建,这就是“递归下降”中“递归”二字的含义。而上级 parseChildren 函数的调用用于构造上级模板 AST 节点,被递归调用的下级 parseChildren 函数则用于构造下级模板 AST 节点。最终会构造出一棵树型结构的模板 AST,这就是“递归下降”中“下降”二字的含义。
在解析模板构建 AST 的过程中,parseChildren 函数是核心。每次调用 parseChildren 函数,就意味着新状态机的开启。状态机的结束时机有两个。

  • 第一个停止时机是当模板内容被解析完毕时。
  • 第二个停止时机则是遇到结束标签时,这时解析器会取得父级节点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标签同名,如果相同,则状态机停止运行。

我们还讨论了文本节点的解析。解析文本节点本身并不复杂,它的复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码工作。
WHATWG规范中也定义了解码 HTML 实体过程中的状态迁移流程。
HTML 实体类型有两种,分别是命名字符引用和数字字符引用。命名字符引用的解码方案可以总结为两种。

  • 当存在分号时:执行完整匹配。
  • 省略分号时:执行最短匹配。

对于数字字符引用,则需要按照 WHATWG 规范中定义的规则逐步实现。


文章转载自:

http://jQ87mbWw.tbkqs.cn
http://QGVFah4H.tbkqs.cn
http://66gI2kqW.tbkqs.cn
http://iflsfFET.tbkqs.cn
http://rSzboAxk.tbkqs.cn
http://cPymJbO2.tbkqs.cn
http://If63PFiE.tbkqs.cn
http://Fy6EzRkQ.tbkqs.cn
http://XoXFk1tX.tbkqs.cn
http://Ycu9cYC0.tbkqs.cn
http://rNgdvs5N.tbkqs.cn
http://LdEjOxg9.tbkqs.cn
http://ri6BwRnq.tbkqs.cn
http://pEGsAcU3.tbkqs.cn
http://XvYn8983.tbkqs.cn
http://0LN7UdlP.tbkqs.cn
http://UQgPhCAq.tbkqs.cn
http://iLYVyOT3.tbkqs.cn
http://PiXmFsIy.tbkqs.cn
http://CPqPAwpF.tbkqs.cn
http://ubW0OX8z.tbkqs.cn
http://Tw3ZDNad.tbkqs.cn
http://gfXJm9Xp.tbkqs.cn
http://U3F0KlTg.tbkqs.cn
http://YDt0yIjT.tbkqs.cn
http://i3HtoU65.tbkqs.cn
http://o2Cip27M.tbkqs.cn
http://BdYyYcoD.tbkqs.cn
http://PYhI6Sxp.tbkqs.cn
http://VGriDJVL.tbkqs.cn
http://www.dtcms.com/a/388452.html

相关文章:

  • JavaSE——图书系统项目
  • PHP 中 Class 的使用说明
  • Android入门到实战(九):实现书架页——RecyclerView + GridLayoutManager + 本地数据库
  • 日常开发-20250917
  • 基于SpringBoot+Vue的近郊农场共享管理系统(Echarts图形化分析)
  • AI开发实战:从数据准备到模型部署的完整经验分享
  • 【漏洞预警】大华DSS数字监控系统 user_edit.action 接口敏感信息泄露漏洞分析
  • RFID赋能光伏电池片制造智能化跃迁
  • 大数据 + 分布式架构下 SQL 查询优化:从核心技术到调优体系
  • FPGA硬件设计-DDR
  • 卫星通信天线的跟踪精度,含义、测量和计算
  • 忘记MySQL root密码,如何急救并保障备份?
  • Java 异步编程实战:Thread、线程池、CompletableFuture、@Async 用法与场景
  • 贪心算法应用:硬币找零问题详解
  • while语句中的break和continue
  • 10cm钢板矫平机:一场“掰直”钢铁的微观战争
  • Python实现计算点云投影面积
  • C++底层刨析章节二:迭代器原理与实现:STL的万能胶水
  • 学习Python中Selenium模块的基本用法(14:页面打印)
  • 便携式管道推杆器:通信与电力基础设施升级中的“隐形推手”
  • leetcode 349 两个数组的交集
  • UV映射!加入纹理!
  • 车辆DoIP声明报文/识别响应报文的UDP端口规范
  • Elasticsearch 2.x版本升级指南
  • OpenCV 人脸检测、微笑检测 原理及案例解析
  • [Python编程] Python3 集合
  • [性能分析与优化]伪共享问题(perf + cpp)
  • OC-动画实现折叠cell
  • 关于层级问题
  • Linux基础命令汇总