parseHTML 函数源码解析(六) chars、end、comment钩子函数

接上文:

接下来我们主要讲解当解析器遇到一个文本节点时会如何为文本节点创建元素描述对象,又会如何对文本节点做哪些特殊的处理。

parseHTML(template, {
	chars: function(){
		//...
	},
	//...
})

chars源码:

chars: function chars(text) {
	if (!currentParent) {
		{
			if (text === template) {
				warnOnce(
					'Component template requires a root element, rather than just text.'
				);
			} else if ((text = text.trim())) {
				warnOnce(
					("text \"" + text + "\" outside root element will be ignored.")
				);
			}
		}
		return
	}
	// IE textarea placeholder bug
	/* istanbul ignore if */
	if (isIE &&
		currentParent.tag === 'textarea' &&
		currentParent.attrsMap.placeholder === text
	) {
		return
	}
	var children = currentParent.children;
	text = inPre || text.trim() ?
		isTextTag(currentParent) ? text : decodeHTMLCached(text)
		// only preserve whitespace if its not right after a starting tag
		:
		preserveWhitespace && children.length ? ' ' : '';
	if (text) {
		var res;
		if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
			children.push({
				type: 2,
				expression: res.expression,
				tokens: res.tokens,
				text: text
			});
		} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
			children.push({
				type: 3,
				text: text
			});
		}
	}
}

当解析器遇到文本节点时,如上代码中的 chars 钩子函数就会被调用,并且接收该文本节点的文本内容作为参数。

我们来看chars钩子函数最开始的这段代码:

if (!currentParent) {
	{
		if (text === template) {
			warnOnce(
				'Component template requires a root element, rather than just text.'
			);
		} else if ((text = text.trim())) {
			warnOnce(
				("text \"" + text + "\" outside root element will be ignored.")
			);
		}
	}
	return
}

首先判断了 currentParent 变量是否存在,我们知道 currentParent 变量指向的是当前节点的父节点:。

如果 currentParent 变量不存在说明什么问题?

  • 1:没有根元素,只有文本。
  • 2: 文本在根元素之外。

当遇到第一种情况打印警告信息:"模板必须要有根元素",第二种情况打印警告信息:" 根元素外的文本将会被忽略"。

接下来:

if (isIE &&
	currentParent.tag === 'textarea' &&
	currentParent.attrsMap.placeholder === text
) {
	return
}

这段代码是用来解决 IE 浏览器中渲染 <textarea> 标签的 placeholder 属性时存在的 bug 的。具体的问题大家可以在这个 issue 查看。

接下来是个嵌套三元表达式:

var children = currentParent.children;
text = inPre || text.trim() ?
	isTextTag(currentParent) ? text : decodeHTMLCached(text)
	// only preserve whitespace if its not right after a starting tag
	:
	preserveWhitespace && children.length ? ' ' : '';

这个嵌套三元表达式判断了条件 inPre || text.trim() 的真假,如果为 true,检测了当前文本节点的父节点是否是文本标签,如果是文本标签则直接使用原始文本,否则使用decodeHTMLCached 函数对文本进行解码。

inPre || text.trim() 如果为 false,检测 preserveWhitespace 是否为 true 。preserveWhitespace 是一个布尔值代表着是否保留空格,只有它为真的情况下才会保留空格。但即使 preserveWhitespace 常量的值为真,如果当前节点的父节点没有子元素则也不会保留空格,换句话说,编译器只会保留那些 不存在于开始标签之后的空格

接下来:

if (text) {
	var res;
	if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
		children.push({
			type: 2,
			expression: res.expression,
			tokens: res.tokens,
			text: text
		});
	} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
		children.push({
			type: 3,
			text: text
		});
	}
}

这里相当就比较简单了一个 if else if 操作,第一个 if 判断当前元素未使用v-pre 指令,text不为空,使用 parseText 函数成功解析当前文本节点的内容。

对于前两个条件很好理解,关键在于 parseText 函数能够成功解析文本节点的内容说明了什么,如下示例代码:

<div> hello: {{ message }} </div>

如上模板中存在的文本节点包含了 Vue 语法中的字面量表达式,而 parseText 函数的作用就是用来解析这段包含了字面量表达式的文本的。此时会执行以下代码创建一个类型为2(type = 2) 的元素描述对象:

children.push({
	type: 2,
	expression: res.expression,
	tokens: res.tokens,
	text: text
});

注意:类型为 2 的元素描述对象拥有三个特殊的属性,分别是 expression 、tokens 以及text ,其中 text 就是原始的文本内容,而 expression 和 tokens 的值是通过 parseText 函数解析的结果中读取的。

后面我们专门会讲讲parseText函数,接下来继续看下如果上列的 if 判断失败出现的三种可能性。

  • 当前解析的元素使用v-pre 指令
  • text 为空
  • parseText 解析失败

只要以上三种情况中,有一种情况出现则代码会来到else...if 分支的判断,如下:

else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
	children.push({
		type: 3,
		text: text
	});
 }

如果满足 else if 中的条件直接,创建一个类型为3(type = 3) 的元素描述对象:类型为3 的元素描述对象只拥有一个的属性text存储原始的文本内容。

在看下要满足 else if 中的这些条件吧!

  • 文本内容不是空格
  • 文本内容是空格,但是该文本节点的父节点还没有子节点(即 !children.length )
  • 文本内容是空格,并且该文本节点的父节点有子节点,但最后一个子节点不是空格

接下来我们来聊聊之前讲到的parseText 函数。

parseText

function parseText(
	text,
	delimiters
) {
	var tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE;
	if (!tagRE.test(text)) {
		return
	}
	var tokens = [];
	var rawTokens = [];
	var lastIndex = tagRE.lastIndex = 0;
	var match, index, tokenValue;
	while ((match = tagRE.exec(text))) {
		index = match.index;
		// push text token
		if (index > lastIndex) {
			rawTokens.push(tokenValue = text.slice(lastIndex, index));
			tokens.push(JSON.stringify(tokenValue));
		}
		// tag token
		var exp = parseFilters(match[1].trim());
		tokens.push(("_s(" + exp + ")"));
		rawTokens.push({
			'@binding': exp
		});
		lastIndex = index + match[0].length;
	}
	if (lastIndex < text.length) {
		rawTokens.push(tokenValue = text.slice(lastIndex));
		tokens.push(JSON.stringify(tokenValue));
	}
	return {
		expression: tokens.join('+'),
		tokens: rawTokens
	}
}

parseText 接收两个参数 text 要解析的文本,delimiters 是编译器的一个用户自定义选项delimiters ,通过它可以改变文本插入分隔符。所以才有了如下代码。

var tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE;

这里是解析文本所用正则之间的一个较量,delimiters 有值就调用buildRegex函数,我们默认是没有值,使用 defaultTagRE 来解析文本。

var defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;

这个正则还是非常简单,接下来会判断,如果文本中没有与正则相匹配的文本直接直接终止函数的执行。

if (!tagRE.test(text)) {
    return
}

接下来代码就有意思了一起看下。

var tokens = [];
var rawTokens = [];
var lastIndex = tagRE.lastIndex = 0;
var match, index, tokenValue;
while ((match = tagRE.exec(text))) {
	index = match.index;
	// push text token
	if (index > lastIndex) {
		rawTokens.push(tokenValue = text.slice(lastIndex, index));
		tokens.push(JSON.stringify(tokenValue));
	}
	// tag token
	var exp = parseFilters(match[1].trim());
	tokens.push(("_s(" + exp + ")"));
	rawTokens.push({
		'@binding': exp
	});
	lastIndex = index + match[0].length;
}
if (lastIndex < text.length) {
	rawTokens.push(tokenValue = text.slice(lastIndex));
	tokens.push(JSON.stringify(tokenValue));
}
return {
	expression: tokens.join('+'),
	tokens: rawTokens
}

这段代码不难,初始定义了一系列变量。 接着开启一个while循环,使用 tagRE 正则匹配文本内容,并将匹配结果保存在 match 变量中,直到匹配失败循环才会终止,这时意味着所有的字面量表达式都已经处理完毕了。

在这个while循环结束返回一个对象,expression、tokens分别存储解析过程中的信息。

假设文本如下:

<div id="app">hello {{ message }}</div>

parseText 解析文本后返回的对象。

{
  expression: "'hello'+_s(message)",
  tokens: [
    'hello',
    {
      '@binding': 'message'
    }
  ]
}

接下来我们聊聊对结束标签的处理。

end 源码:

end: function end() {
	// remove trailing whitespace
	var element = stack[stack.length - 1];
	var lastNode = element.children[element.children.length - 1];
	if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
		element.children.pop();
	}
	// pop stack
	stack.length -= 1;
	currentParent = stack[stack.length - 1];
	closeElement(element);
}

end 钩子函数,当解析 html 字符串遇到结束标签的时候,。那么在 end 钩子函数中都需要做哪些事情呢?

在之前的文章中我们讲过解析器遇到非一元标签的开始标签时,会将该标签的元素描述对象设置给 currentParent 变量,代表后续解析过程中遇到的所有标签都应该是 currentParent 变量所代表的标签的子节点,同时还会将该标签的元素描述对象添加到 stack 栈中。而当遇到结束标签的时候则意味着 currentParent 变量所代表的标签以及其子节点全部解析完毕了,此时我们应该把 currentParent 变量的引用修改为当前标签的父标签,这样我们就将作用域还原给了上层节点,以保证解析过程中正确的父子关系。

下面代码就是来完成这个工作:

stack.length -= 1;
currentParent = stack[stack.length - 1];
closeElement(element);

首先将当前节点出栈:stack.length -= 1 什么意思呢?

看一个代码就懂了。

var arr = [1,2,3,4];
arr.length-=1;

>arr [1,2,3]

接着读取出栈后 stack 栈中的最后一个元素作为 currentParent 变量的值。 那closeElement 函数是做什么用的呢?

closeElement 源码:

function closeElement(element) {
	// check pre state
	if (element.pre) {
		inVPre = false;
	}
	if (platformIsPreTag(element.tag)) {
		inPre = false;
	}
	// apply post-transforms
	for (var i = 0; i < postTransforms.length; i++) {
		postTransforms[i](element, options);
	}
}

closeElement 的作用有两个:第一个是对数据状态的还原,第二个是调用后置处理转换钩子函数。

接下来看下end函数中剩余代码:

// remove trailing whitespace
var element = stack[stack.length - 1];
var lastNode = element.children[element.children.length - 1];
if (lastNode && lastNode.type === 3 && lastNode.text === ' ' && !inPre) {
    element.children.pop();
}

这个代码的作用是去除当前元素最后一个空白子节点,我们在讲解 chars 钩子函数时了解到:preserveWhitespace 只会保留那些不在开始标签之后的空格所以当空白作为标签的最后一个子节点存在时,也会被保留,如下代码所示:

<div><span>test</span> <!-- 空白占位 -->  </div>

如上代码中 <span> 标签的结束标签与 <div> 标签的结束标签之间存在一段空白,这段空白将会被保留。如果这段空白被保留那么就可能对布局产生影响,尤其是对行内元素的影响。为了消除这些影响带来的问题,好的做法是将它们去掉,而如代码就是用来完成这个工作的。

comment 注释节点描述对象

解析器是否会解析并保留注释节点,是由 shouldKeepComment 编译器选项决定的,开发者可以在创建Vue 实例的时候通过设置 comments 选项的值来控制编译器的shouldKeepComment 选项。默认情况下 comments 选项的值为 false ,即不保留注释,假如将其设置为 true ,则当解析器遇到注释节点时会保留该注释节点,此时 parseHTML 函数的 comment 钩子函数会被调用,如下:

comment: function comment(text) {
	currentParent.children.push({
		type: 3,
		text: text,
		isComment: true
	});
}

要注意的是,普通文本节点与注释节点的元素描述对象的类型是一样的都是 3 ,不同的是注释节点的元素描述对象拥有 isComment 属性,并且该属性的值为 true,目的就是用来与普通文本节点作区分的。

本章节完。

编辑于 2019-11-21 17:14