【vue设计与实现】解析器 - 解析文本与解码HTML实体

文章详细介绍了Vue.js在解析模板时,如何处理文本节点,包括对HTML实体的解码,如命名字符引用和数字字符引用的解码过程,以及解析文本插值和注释的方法,展示了parseText和decodeHtml等关键函数的实现。
摘要由CSDN通过智能技术生成

解析文本

先看一下模板:

 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&lt;B</div>

其中字符串&lt;就是一个HTML实体,用来表示字符<。
HTML实体总是以字符&开头,以字符;结尾。

HTML实体有两类,一类叫作命名字符引用(named character reference),也叫命名实体。顾名思义,这类实体具有特定的名称,例如上文中的&lt;
除了命名字符引用之外,还有一类字符引用没有特定的名称,只能用数字表示,这类实体叫作数字字符引用。与命名字符用不同,数字字符引用以字符串&#开头,比命名字符引用的开头部分多出了字符#,例如&#60;。实际上,&#60;对应的字符也是<,换句话说,&#60;&lt;是等价的。

因为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 '}
			]
		}
	]
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值