解析文本
先看一下模板:
const template = '<div>Text</div>'
经过前面对标签的处理,当模板内容来到下面这个状态的时候:
const template = 'Text</div>'
parseText函数会尝试在这段模板内容中中找到第一个出现的字符<的位置索引。在上面的例子中,字符<的索引值为4。然后,parseText函数会截取介于索引[0,4)
的内容作为文本内容。在上面这个例子中,文本内容就是字符串’Text’
假设模板中存在插值,如下面的模板所示:
const template = 'Text-{{val}}</div>'
在处理这段模板时,parseText 函数会找到第一个插值定界符{{出现的位置索引。
下面的parseText函数给出了具体实现
function parseText(context){
// endIndex为文本内容的结尾索引,默认将整个模板剩余内容都作为文本内容
let endIndex = context.source.length
// 寻找字符<的位置索引
const ltIndex = context.source.indexOf('<')
// 寻找定界符 {{ 的位置索引
const delimiterIndex = context.source.index0f('{{')
// 取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
}
}
如上面的代码所示,由于字符<与定界符 {{ 的出现顺序是未知的,所以我们需要取两者中较小的一个作为文本截取的终点。最后,要创建一个类型为 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实体。
解码命名字符引用
HTML实体是一段以字符&开始的文本内容。实体用来描述HTML中的保留字符和一些难以通过普通键盘输入的字符,以及一些不可见的字符。
例如,在HTML 中,字符<具有特殊含义,如果希望以普通文本的方式来显示字符<,需要通过实体来表达:
<div>A<B</div>
其中字符串<
就是一个HTML实体,用来表示字符<。
HTML实体总是以字符&
开头,以字符;
结尾。
HTML实体有两类,一类叫作命名字符引用(named character reference),也叫命名实体。顾名思义,这类实体具有特定的名称,例如上文中的<
。
除了命名字符引用之外,还有一类字符引用没有特定的名称,只能用数字表示,这类实体叫作数字字符引用。与命名字符用不同,数字字符引用以字符串&#
开头,比命名字符引用的开头部分多出了字符#,例如<
。实际上,<
对应的字符也是<,换句话说,<
与 <
是等价的。
因为Vue.js解析的文本节点所包含的HTML实体会被当作字符串不会被浏览器解析,因此Vue要对HTML实体进行处理。
具体实现的代码如下:
//第二个参数是一个布尔值,代表文本内容是否作为属性值
function decodeHtml(rawText, asAttr=false){
let offset = 0
const end = rawText.length
//经过解码后的文本将作为返回值被返回
let decodedText = ''
//引用表中实体名称的最大长度
let maxCRNameLength = 0
// advance 函数用于消费指定长度的文本
function advance(length){
offset += length
rawText = 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(namedCharacterReference).reduce((max,name)=>Math.max(max,name.length),0)
}
//从最大长度开始对文本进行截取,并试图去引用表中找到对应的项
for(let length = maxCRNameLegnth; !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 上
decodedText += value
advance(1+name.length)
}
}else{
// 如果没有找到对应的值,说明解码失败
decodedText += '&'+name
advance(1+name.length)
}
}else{
//如果字符 &的下一个字符不是 ASCII字母或数字,则将字符 &作为普通文本
decodedText += '&'
advance(1)
}
}
}
return decodedText
}
有了 decodeHtml 函数之后,就可以在解析文本节点时通过它对文本内容进行解码:
function parseText(context){
// 省略部分代码
return {
type: 'Text',
content: decodeHtml(content)
}
}
解码数字字符引用
在上一节中,使用下面的正则表达式来匹配一个文本中字符引用的开始部分。
我们可以根据该正则的匹配结果,来判断字符引用的类型。
数字字符引用的格式是:前缀+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规范中对此也有明确的定义。
关于对码点的值进行合法性检查,这里就不做详解,知道有这个概念即可
关于码点合法性检查的具体实现如下:
if(body){
// 根据对应的进制,将码点字符串转换为数字
const cp = parseInt(body[1],hex?16:10)
// 检查码点的合法性
if(cp === 0){
// 如果码点值为 0x00,替换为 0xfffd
cp = 0xfffd
}else if(cp > 0x10ffff){
// 如果码点值超过 Unicode 的最大值,替换为 0xfffd
cp = 0xfffd
}else if((cp >= 0xd800 && cp <= 0xdfff){
// 如果码点值处于 surrogate pair 范围内,替换为 0xfffd
cp = 0xfffd
}else if((cp >= 0xfdd && 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)
}
最后,我们将上述代码整合到 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)
if(body){
// 根据对应的进制,将码点字符串转换为数字
const cp = parseInt(body[1],hex?16:10)
// 检查码点的合法性
if(cp === 0){
// 如果码点值为 0x00,替换为 0xfffd
cp = 0xfffd
}else if(cp > 0x10ffff){
// 如果码点值超过 Unicode 的最大值,替换为 0xfffd
cp = 0xfffd
}else if((cp >= 0xd800 && cp <= 0xdfff){
// 如果码点值处于 surrogate pair 范围内,替换为 0xfffd
cp = 0xfffd
}else if((cp >= 0xfdd && 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)
//消费整个数字字符引用的内容
advance(body[0].length)
}else{
//如果没有匹配,则不进行解码操作,只是把 head[0] 追加到 decodedText 上并消费
decodedText += head[0]
advance(head[0].length)
}
}
}
return decodedText
}
解析插值与注释
文本插值是 Vue;js 模板中用来渲染动态数据的常用方法:
([ count ))
解析器在遇到文本插值的起始定界符{{时,会进人文本“插值状态6”,并调用parseInterpolation函数来解析插值内容
解析器在解析插值时,只需要将文本插值的开始定界符与结束定界符之间的内容提取出来,作为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 '}
]
}
]
}