解析器
1. 解析器的作用
解析器要实现的功能是将模板解析成AST
例如:
<div>
<p>{{name}}</p>
</div>
转换成AST后的样子如下:
{
"tag":"div",
"type":1,
"staticRoot":false,
"static":false,
"plain":true,
"parent":undefined,
"attrsList":[],
"attrsMap":{},
"children":[
{
"tag":"p",
"type":1,
"staticRoot":false,
"static":false,
"plain":true,
"parent":{
"tag":"div"
},
"attrsList":[],
"attrsMap":{},
"children":[
{
"type":2,
"text":{{name}}",
"static":false,
expression:"_s(name)"
}
]
}
]
}
AST :
用JavaScript中的一个对象来描述一个节点,一个对象表示一个节点,对象中的属性用来保存节点所需的各种数据。
例如,parsen
属性保存了父节点的描述对象,children
属性是一个数组,里面保存了一些子节点的描述对象,tyoe
属性表示一个节点的类型。当很多独立的属性通过parent
属性和children
属性连接在一起时,就变成了一个树,而这样一个用对象描述的节点树其实就是AST。
2. 解析器内部运行原理
解析器内部分为好多子解析器,比如HTML解析器、文本解析器、过滤器解析器等,最主要的是HTML解析器。HTML解析器的作用是解析HTML,它在解析HTML的过程中会不断触发各种钩子函数。这些钩子函数包括开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数。
伪代码如下:
parseHTML(template,{
start(tag,attrs,unary){
// 每当解析到标签的开始位置时,触发该函数
},
end(){
// 每当解析到标签的结束位置时,触发该函数
},
chars(text){
// 每当解析到文本时,触发该函数
},
comment(text){
// 每当解析到注释时,触发该函数
}
})
举个例子:
<div><p>我是mgd</p></div>
当这个模板被HTML解析器解析时,所触发的钩子函数依次是:start、start、插入式、end、end。
解析器其实是从前向后解析的。解析到<div>
时,会触发一个标签开始的钩子函数start
;然后解析到<p>
,又触发一次钩子函数start
;接着解析到文本,触发文本钩子函数chars
;然后解析到</p>
,触发标签结束的钩子函数end
;接着继续解析到</div>
;又触发一个标签结束的钩子函数end
,解析结束。
3. HTML解析器
1. 运行原理
解析HTML模板的过程就是循环的过程,简单来说就是用HTML模板字符串来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复上述过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕。
在截取一小段字符串时,有可能截取到开始标签,也有可能截取到结束标签,又或者是文本或者注释,我们可以根据截取的字符串的类型来触发不同的钩子函数。
2.截取开始标签
每一轮循环都是从模板的最前面截取,所以只有模板以开始标签开头,才需要进行开始标签的截取操作。
如何确定模板是不是以开始标签开头?
判断HTML模板是不是以<
开头:
HTML的第一个字符是不是<
?
- 不是:
一定不是以开始标签开头的模板,所以不需要进行开始标签的截取操作。 - 是:
- 以开始标签开头的模板
- 以结束标签开头的模板
- 注释
需要进一步确定模板是不是以开始标签开头,还需要借助正则表达式来分辨模板的开始位置是否符合开始标签的特征。
如何使用正则表达式来匹配模板以开始标签开头:
const ncname = '[a-zA-Z_][\\w\\-\\.]*';
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 以开始标签开始的模板
let str = '<div></div>';
let obj = str.match(startTagOpen)
console.log(obj); //["<div", "div", index: 0, input: "<div></div>", groups: undefined]
在分辨出模板以开始标签开始后,需要将标签名、属性以及自闭合标识解析出来。
当完成解析之后,我们可以得到这样一个数据结构:
const start = str.match(startTagOpen)
if(start){
const match = {
tagName:start[1],
attrs:[]
}
}
事实上,开始标签被拆分成三个小部分,分别是标签名、属性和结尾。
通过“标签名”这一字段,就可以分辨出模板是否以开始标签开头,此后想要得到属性和自闭合标识需要进一步解析。
1. 解析标签属性
在分辨出模板以开始标签开头之后,会将开始标签中的标签名这一小部分截取掉,因此在解析标签属性时,我们得到的模板是下面的样子:
` class="box"></div>`
下面的伪代码展示了如何解析开始标签中的属性,但是只能解析一个:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|+'([^']*)'+|([^\s"'=<div>`]+)))?/
let html = ` class="box"></div>`;
let attr = html.match(attribute);
html = html.substring(attr[0].length);
console.log(attr,html);
为了解决只能解析一个属性的问题,可以每解析一个属性就截取一个属性。如果截取完后,剩下的HTML模板依然符合标签属性的正则表达式,那么说明还有剩余的属性需要处理,此时就重复执行前面的流程,知道剩余的,模板不存在属性。也就是剩余的模板不存在符合正则表达式所预设的规则。
const startTagClose = /^\s*(\/?)>/;
const attribute = /^\s*([^\s"'<div>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<div>`]+)))?/
let html = ` class="box" id="el"></div>`;
let end,attr
const match = {tagName:'div',attrs:[]}
while(!(end=html.match(startTagClose)) && (attr = html.match(attribute))){
html = html.substring(attr[0].length);
match.attrs.push(attr);
}
console.log(match,html);
如果剩余HTML模板不符合开始标签结尾部分的特征,并且符合标签属性的特征,那么进入到循环中进行解析与截取操作。
2. 解析自闭合标识
自闭和标签是没有子节点的,所以我们提到构建AST层级时,需要维护一个栈,而一个节点是否需要推入到栈中,可以使用这个自闭合标识来判断。
如何解析开始标签中的结尾部分:
function parseStartTagEnd(html){
const startTagClose = /^s*(\/?)/;
const end = html.match(startTagClose);
const match = {};
if(end){
match.unanySlash = end[1];
html = html.substring(end[0].length);
return match;
}
}
console.log(parseStartTagEnd('></div>'));//{unarySlash:""}
console.log(parseStartTagEnd('/><div></div>'));//{unarySlash:"/"}
从代码中打印出来的结果看,闭合标签解析后的unartSlash
属性为/
,而非闭合标签为空字符串。
3. 实现源码
下面的代码是Vue.js中解析开始标签的源码:
const ncname = '[a-zA-Z_][\\w\\-\\.]';
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`);
const startTagClose = /^\s*(\/?)>/;
function advance(n){
html = html.substring(n);
}
function parseStartTag(){
// 解析标签名,判断模板是否符合开始标签的特征
const start = html.match(startTagOpen);
if(start){
const match= {
tagName:start[1],
attrs:[]
}
advance(start[0].length);
// 解析标签属性
let end,attr;
while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))){
advance(attr[0].length);
match.attrs.push(attr);
}
// 判断该标签是否是自闭和属性
if(end){
match.unarySlash = end[1];
advance(end[0].length);
return match;
}
}
}
调用parseStartTag
就可以将剩余模板开始部分的开始标签解析出来。如果剩余HTML模板的开始部分不符合开始标签的正则表达式规则,那么调用parseStartTag
就会返回undefined
。因此,判断剩余模板是否符合开始标签的规则,只需要调用parseStartTag
即可。如果调用它之后得到了解析结果,那么说明剩余模板的开始部分符合开始标签的规则,此时将解析出来的结果取出来并调用钩子函数start
即可:
// 开始标签
const startTagMatch = parseStartTag();
if(startTagMatch){
handleStartTag(startTagMatch);
continue;
}
从代码中可以,如果调用parseStartTag
之后有返回值,那么会进行开始标签的处理,其处理逻辑主要在handleStartTag
中。这个函数的主要目的就是tagName
、attrs
、unary
等数据取出来,然后调用钩子函数将这些数据放到参数中。
3. 截取结束标签
如何分辨模板已经截取到结束标签:
道理和开始标签的原理相同,只有HTML模板的第一个字符是<时,我们才需要进一步确认它到底是不是结束标签。
我们只需要判断剩余HTML模板的开始位置是否符合正则表达式中定义的规则即可:
// 截取结束标签
const ncname = '[a-zA-Z_][\\w\\-\\.]*';
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
const endTagMatch = '</div>'.match(endTag);
const endTagMatch2 = '<div>'.match(endTag);
console.log(endTagMatch); //["</div>", "div", index: 0, input: "</div>", groups: undefined]
console.log(endTagMatch2); //null
以上代码可以分辨出剩余模板是否是结束标签,当分辨出结束标签后,需要做两件事,一件事是截取模板,另一件事是触发钩子函数。Vue.js中相关源码被精简后如下:
const endTagMatch =html.match(endTag);
if(endTagMatch){
html = html.substring(endTagMatch[0].length);
options.end(endTagMatch[1]);
continue;
}
可以看出,先对模板进行截取,然后触发钩子函数。
4. 截取注释
分辨模板是否已经截取到注释的原理与开始标签和结束标签的原理相同,先判断剩余HTML模板的第一个字符是不是<,如果是,再用正则表达式来进一步匹配:
// 注释截取
const comment = /^<!--/;
if(comment.test(html)){
const commentEnd = html.indexOf('-->');
if(commentEnd>=0){
if(options.shouldKeepComment){
options.comment(html.substring(4,commentEnd));
}
}
html = html.substring(commentEnd+3);
continue;
}
在上面的代码中,我们使用正则表达式来判断剩余的模板是否符合注释的规则,如果符合,就将这段注释文本截取出来。
这里有一个有意思的地方,那就是注释的钩子函数可以通过选项来配置,只有options
,shouldKeepComment
为真时,才会触发钩子函数,否则只截取模板,不触发钩子函数。
5. 截取条件注释
条件注释不需要触发钩子函数,我们只需要把它截取掉就可以了。
截取条件注释的原理与截取注释非常相似,如果模板的第一个字符是<,并且符合我们事先用正则表达式定义好的规则,就说明需要进行条件注释的截取操作。
在下面的代码中,我们通过indexOf
找到条件注释结束位置的下标,然后将结束位置前的字符都截取掉:
// 截取条件注释
const conditionalComment = /^<!\[/;
if(conditionalComment.test(html)){
const conditionalEnd = html.indexOf(']>');
if(conditionalEnd>=0){
html = html.substring(conditionalEnd+2);
continue;
}
}
举个例子:
// 举个例子
const conditionalComment = /^<!\[/;
let html = '<![if !IE]><link href="non-ie.css" rel="stylesheet"><![endif]>';
if(conditionalComment.test(html)){
const conditionalEnd = html.indexOf(']>');
if(conditionalEnd>=0){
html = html.substring(conditionalEnd+2);
// continue;
}
}
console.log(html); //<link href="non-ie.css" rel="stylesheet"><![endif]>
从打印出的结果看,HTML中的条件注释部分被截取掉了。
通过这个逻辑可以发现,在Vue.js中条件注释其实没有用,写了也会被截取掉,通俗一点说就是写了也白写。
6. 截取DOCTYPE
DOCTYPE与条件注释相同,都是不需要触发钩子函数的,只需要将匹配到的这一字段截取掉即可。下面的代码将DOCTYPE这=段字符匹配出来后,根据它们的length属性来决定要截取多长的字符串:
// 截取DOCTYPE
const doctype = /^<!DOCTYPE [^>]+>/i;
const doctypeMatch = html.match(doctype);
if(doctypeMatch){
html = html.substring(doctypeMatch[0].length);
continue;
}
举个例子:
const doctype = /^<!DOCTYPE [^>]+>/i;
let html = '<!DOCTYPE html><html lang="en"><head></head><body</body></html>'
const doctypeMatch = html.match(doctype);
if(doctypeMatch){
html = html.substring(doctypeMatch[0].length);
}
console.log(html); //<html lang="en"><head></head><body</body></html>
7. 截取文本
在前面的其他标签类型中,我们都会判断剩余HTML模板的第一个字符是否是<,如果是,再进一步确认到底是哪一种类型。这是因为以<开头的标签类型太多了。然而文本只有一种,如果HTML,模板的第一个字符不是<,那么它一定是文本了。
如何从模板中将文本解析出来呢?我们只需要找到下一个<在什么位置,这之前的所有字符都属于文本。
while(html){
let text;
let textEnd = html.indexOf('<');
// 截取文本
if(textEnd >= 0){
text = html.substring(0,textEnd);
html = html.substring(textEnd);
}
// 如果模板中找不到<,就说明整个模板都是文本
if(textEnd<0){
text = html;
html = '';
}
// 触发钩子函数
if(options.chars && text){
options.chars(text);
}
}
上面的代码逻辑有三部分:
- 截取文本:
<之前的所有字符都是文本,直接使用html.substring从模板的最开始位置截取到<之前的位置,就可以将文本截取出来。 - 如果在整个模板中都找不到<,那么说明整个模板全是文本。
- 触发钩子函数,并将截取出来的文本放到参数中。
问题:如果<是文本的一部分,该如何处理?
while(html){
let text,rest,next;
let textEnd = html.indexOf('<');
// 截取文本
if(textEnd>=0){
rest = html.slice(textEnd);
while(
!endTag.test(rest)&&
!startTagOpen.test(rest)&&
!comment.test(rest)&&
!conditionalComment.test(rest)
){
// 如果'<'在纯文本中,将它视为纯文本对待
next = rest.indexOf('<',1);
if(next<0) break;
textEnd +=next;
rest =html.slice(textEnd);
}
text = html.substring(0,textEnd);
html = html.substring(textEnd);
}
// 如果模板中找不到<,那么说明整个模板都是文本
if(textEnd<0){
text =html;
html = '';
}
// 触发钩子函数
if(options.chars && text){
options.chars(text);
}
}
在代码中,我们通过while来解决这个问题(注意是里面的while)。如果剩余的模板不符合任何被解析的类型,那么重复解析文本,直到剩余模板符合被解析的类型为止。
在上面的代码中,endTag
、startTagOrigin
、comment
、conditionalComment
都是正则表达式,分别匹配结束标签、开始标签、注释和条件注释。
8. 纯文本内容元素的处理
前面介绍开始标签、结束标签、文本、注释的截取时,其实都是默认当前需要截取的元素的父级元素不是纯文本内容元素。事实上,要截取元素的父级元素是纯文本内容元素的话,处理逻辑将完全不一样。在while循环中,就是父级元素是不是纯文本元素内容。
while(html){
if(!lastTag || !isPlainTextElement(lastTag)){
// 父元素为正常元素的处理逻辑
}else{
// 父元素为script、style、textarea的处理逻辑
}
}
当父元素是script这种纯文本内容元素时,会进入到else这个语句里面。由于纯文本内容元素都被视作文本处理,所以我们的处理逻辑就变得很简单,只需要把这些文本截取出来并触发钩子函数chars
,然后再将结束标签截取出来并触发钩子函数end
。
也就是说,如果父标签是纯文本内容元素,那么本轮循环会一次性将这个父标签给处理完毕。
伪代码如下:
while(html){
if(!lastTag || !isPlainTextElement(lastTag)){
// 父元素为正常元素的处理逻辑
}else{
// 父元素为 script、style、textarea的处理逻辑
const stackedTag = lastTag.toLowerCase();
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</'+stackedTag + '[^>]*>','i'))
const rest = html.replace(reStackedTag,function(all,text){
if(options.chars){
options.chars(test);
}
return '';
})
html = rest;
options.end(stackedTag);
}
}
上面代码中的正则表达式可以匹配结束标签前包括结束标签自身在内的所有文本。
我们可以给replace
方法的第二个参数传递一个函数,在函数中,我们得到了参数text
(表示结束标签前的所有内容),触发了钩子函数chars
并把text
放到钩子函数的参数中传出去。最后,返回了一个空字符串,说明将匹配到的内容都截掉了。注意,这里的截掉会将内容和结束标签一起截取掉。
最后,调用钩子函数end
并将标签名放到参数中传出去,这说明本轮循环中的所有逻辑都已经处理完毕。
9. 使用栈维护DOM层级
HTML解析器内部也有一个栈来维护DOM的关系,就是每解析到开始标签,就向栈中推进去一个;每解析到结束标签,就弹出来一个。所以,想取到父元素并不难,只需要拿到栈中的最后一个就行。
HTML解析器中的栈还有一个作用,它可以检测出HTML标签是否正确闭合。
10. 整体逻辑
HTML解析器最终的目的是实现这样的功能:
parseHTML(template,{
start(tag,attrs,unary){
// 每当解析到标签的开始位置时,触发该函数
},
end(){
// 每当解析到标签的结束位置时,触发该函数
},
chars(text){
// 每当解析到文本时触发该函数
},
comment(text){
// 每当解析到注释时,触发该函数
}
});
在循环中,首先要判断父元素是不是纯文本内容元素,因为不同类型父节点的解析方式将完全不同:
export function parseHTML(html,options){
while(html){
if(!lastTag || !isPlainTextElement(lastTag)){
// 父元素为正常元素的处理逻辑
}else{
// 父元素为script、sytle、textarea的处理逻辑
}
}
}
如果父元素为正常的元素,那么有几种情况需要分别处理,比如需要分辨出当前要解析的一小段模板到底是什么类型。
我们把所有需要处理的情况都列出来,有下面几种情况:
- 文本
- 注释
- 条件注释
- DOCTYPE
- 结束标签
- 开始标签
我们会发现这些需要处理的类型中,处理文本之外,其他都是以标签形式存在的,而标签是以<开头的。
所以逻辑就很清晰了,我们先根据<判断需要解析的字符是文本还是其他的:
export function parseHTML(html,options){
while(html){
if(!lastTag || !isPlainTextElement(lastTag)){
let textEnd = html.indexOf("<");
if(textEnd=== 0){
// do something
}
let text,rest,next;
if(textEnd < 0){
text = html;
html = '';
}
if(options.chars && text){
options.chars(text)
}
}else{
// 父元素为script、sytle、textarea的处理逻辑
}
}
}
如果通过<分辨出即将解析的这一小部分字符不是文本而是标签类,那么标签类有那么多类型,我们需要进一步分辨具体是哪种类型:
export function parseHTML(html,options){
while(html){
if(!lastTag || !isPlainTextElement(lastTag)){
let textEnd = html.indexOf("<");
if(textEnd === 0){
// 注释
if(connent.test(html)){
// 注释的处理逻辑
continue;
}
// 条件注释
if(conditionalComment.test(html)){
// 条件注释的处理逻辑
continue;
}
//DOCTYPE
const doctypeMatch = html.match(doctype);
if(doctypeMatch){
// DOCTYPE的处理逻辑
continue;
}
// 结束标签
const endTagMatch = html.match(endTag)
if(endTagMatch){
// 结束标签的处理逻辑
continue;
}
// 开始标签
const startTagMatch = parseStartTag();
if(startTagMatch){
// 开始标签的处理逻辑
continue;
}
let text,rest,next;
if(textEnd>=0){
// 解析文本
}
if(textEnd<0){
text =html;
html = '';
}
if(options.chars && text){
options.chars(text);
}
}else{
// 父元素为script、style、textarea的处理逻辑
}
}
}
}
4. 文本解析器
文本解析器的作用是解析文本,文本解析器是对HTML解析器解析出来的文本进行二次加工。
文本分为两种类型:
- 纯文本
- 带变量的文本
HTML解析器在解析文本时,并不会区分文本是否带变量。
如果是纯文本,不需要进行任何处理;如果是带变量的文本,那么需要使用文本解析器进一步解析。带变量的文本在使用虚拟DOM进行渲染时,需要将变量替换成变量中的值。
之前说过,当HTML解析器解析到文本的时候,都会触发chars
函数,并且从参数中得到解析出的文。在chars
函数中,我们需要构建文本类型的AST,并将它添加到父节点的children
属性中。
在构建文本类型的AST时,纯文本和带变量的文本是不同的处理方式。如果是带变量的文本,我们需要借助文本解析器对它进行二次加工,代码如下:
parseHTML(template,{
start(tag,attrs,unary){
// 每当解析到标签的开始位置时,触发该函数
},
end(){
// 每当解析到标签的结束位置时,触发该函数
},
chars(text){
text = text.trim();
if(text){
const children = currentParent.children;
let expression;
if(expression = parseText(text)){
children.push({
type:2,
expression,
text
})
}else{
children.psuh({
type:3;
text
})
}
}
},
comment(text){
// 每当解析到注释时,触发该函数
}
})
在chars
函数中,如果执行parseText
后有返回结果,则说明文本是带变量的文本,而且已经通过文本解析器(parseText)二次加工,此时构建一个带变量的文本类型的AST并将其添加到父节点的children
属性中。而代码中的currentParent
是当前节点的父节点,也就是前面介绍的栈中的最后一个节点。
假设chars
函数被触发后,我们得到的text
是一个带变量的文本:
'Hello {{name}}'
这个带变量的文本被解析器触发之后,得到的expression
变量是这样的:
'Hello '+_s(name)
_s函数:
function toString(val){
return val ==null
? ""
:typeof val === 'object'
? JSON.stringify(val,null,2)
:String(val)
}
假设当前上下文中有一个变量name
,值为:mgd,那么expression
中的内容被执行时,它的内容是不是就是Hello mgd了?
举个栗子:
var obj = {name:'mgd'};
with(obj){
function toString(val){
return val ==null
? ""
:typeof val === 'object'
? JSON.stringify(val,null,2)
:String(val)
}
console.log("Hello " + toString(name));//Hello mgd
}
在文本解析器中,第一步要做的事情就是使用正则表达式来判断文本是否为带变量的文本,也就是检测文本中是否包含{{xxx}}这样的语法。如果是纯文本,则直接返回undefined;如果是带变量的文本,再进行二次加工,所以代码长这样:
function parseText(text){
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
if(!tagRE(text)){
return;
}
}
思考: 如果是一个带变量的文本,该如何处理?
思路是使用正则表达式匹配出文本中的变量,先把变量左边的文本添加到数组中,然后把变量改成_s(x)这样的形式也添加到数组中。如果变量后面还有变量,则重复以上动作,直到所有变量都添加到数组中。如果最后一个变量后面有文本,就将它添加到数组中。
这时我们其中已经有一个数组,数组元素的顺序和文本的顺序是一致的,此时将这些数组元素用+连接起来变成字符串,就可以得到最终想要的效果。
代码实现如下:
function parseText(text){
const tagRE = /\{\{((?:.|\n)+?)\}\}/g
if(!tagRE(text)){
return;
}
const tokens = [];
let lastIndex = tagRE.lastIndex = 0;
let match,index;
while(match = tagRE.exec(text)){
index = match.index;
// 先把{{前面的文本添加到数组中
if(index>lastIndex){
tokens.push(JSON.stringify(text.slice(lastIndex,index)))
}
// 把变量改成_s(x)这样的形式页添加到数组中
tokens.push(`_s(${match[1].trim()})`);
// 设置lastIndex来保证下一轮循环时,正则表达式不再重复匹配已经解析过的文本
lastIndex = index+match[0].length;
}
// 当所有变量都处理完毕后,如果最后一个变量右边还有文本,那就将文本添加到数组中'
if(lastIndex<text.length){
tokens.push(JSON.stringify(text.slice(lastIndex)));
}
return tokens.join("+");
}
这段代码有一个很关键的地方在lastIndex:每处理完一个变量后,会重新设置lastIndex的位置,这样可以保证如果后面还有其他变量,那么下一轮循环时可以从lastInedex的位置开始向后匹配,而lastIndex之前的文本将不再被匹配。
5. 总结
- 解析器的作用是通过模板得到AST(抽象语法树)
- 生成AST的过程需要借助HTML解析器,当HTML解析器触发不同的钩子函数时,我们可以构建出不同的节点。
- 随后,我们可以通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面。
- 最终,HTML解析器运行完毕后,我们就可以得到一个完整的带DOM层级关系的AST。
- HTML解析器的内部原理是一小段一小段地截取模板字符串,每截取一小段模板字符串,就会根据截取出来的字符串类型触发不同的钩子函数,直到模板字符串截空停止运行。
- 文本分为两种类型,不带变量的纯文本和带变量的文本,后者需要文本解析器进行二次加工。