theme: fancy
highlight: a11y-light
编译器的第一步是将模板字符串
解析为抽象语法树(AST)
。这个AST表示模板的结构和层次关系,它包含了模板中的标签、属性、文本内容等等,并且将它们组织成一个树状结构
。
下面给出了将模板"<div><p></p><span></span>Hi,{
{message}}</div>"
转化成AST的主要结构
- type 用来表示插值,文本,元素等类型。
type:'Root'
是默认添加的根节点 - children 子节点
- tag 元素的标签名
- props
标签上的属性这里没有给出
这个结构和VNode很像,注意区分。
const content = ; { type: "Root", children: [ { type: "Element", tag: "div", children: [ { type: "Element", tag: "p", children: [ ], }, { type: "Element", tag: "span", children: [ ], }, { type: "Text", content: "Hi,", }, { type: "Interpolation", content: "message", }, ], }, ], }
下面来介绍一下解析思路
先来创建一个parse
函数,接受content(模板字符串)
作为参数。
- 首先创建一个上下文对象
context
,为了在之后的递归中共享这个对象的使用。function parse(content) { const context = { source: content }; return { type: "Root", children: parseChild(context), }; }
parseChild
是执行递归操作的核心函数。可以理解为:对于树的下一级调用这个函数,再根据子节点的类型调用不同的方法进行处理- 很明显
parseChild
会返回一个数组类型,进入while循环,它需要一个停止判断,这个isEnd
待会再讲。 - 进入循环,如果以
<
开头并且第二个元素是一个字母,表示将用parseElement
处理一个标签元素。如果第二个元素是/
,需要处理标签结束。parseTag
负责处理其中的标签头/尾。 - 当以
{ {
开头表示处理插值parseInterpolation
,其他情况当成文本处理parseText
另外补充一下,
< div>< /div>
,这种前面有空格的是不合法的,但是后面有空字符是合法的例如 :<div >
function parseChild(context) {
const nodes = [];
while (!isEnd(context)) {
let node;
if (context.source[0] === "<") {
if (/[a-z]/i.test(context.source[1])) {
node = parseElement(context);
} else if (context.source[1] === "/") {
parseTag(context, "End");
}
} else if (context.source.startsWith("{
{")) {
node = parseInterpolation(context);
} else {
node = parseText(context);
}
}
return nodes;
}
用图表示就是:
下面讲讲parseElement
(处理标签)
parseTag
用来处理标签名,第二个参数用来区分处理的是开始还是结束标签当
处理完开始标签
,意味着可以进行下一个递归。即调用parseChild
,将它的返回值赋给element.children
function parseElement(context, ancestors) { const element = parseTag(context, "Start"); element.children = parseChild(context, ancestors); return element; }
parseTag
的实现advanceBy
只是为了消费字符。因为数据已经被记录。- 当type为'End',即
结束标签不需要返回
,直接调用advanceBy
将结束标签消费。 - 里面这个正则很重要,不熟悉正则的可以看看。
/^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source); 这个正则的意思是匹配以`<`开头,有一个,或者没有`/` 把后面的小括号当成一个整体,/i忽略大小写 再来看小括号里面的:*作用于[^\t\r\n\f />],表示零个或多个 也就是表示匹配一个字母后面跟着零个或多个除制表符、回车符、换行符、进纸符、斜杠 `/` 或尖括号 `>` 之外的字符
- 对于这个match正则处理
<div>
结果为[<div,div,...]
,处理</div>
结果为[</div,div,...]
,得到的tag
就是标签名了。 exec
方法会返回一个数组,数组的第一个元素是与整个正则表达式匹配的文本,接下来的元素是与每个捕获组匹配的文本,捕获组(小括号里的内容)
就是匹配文本的子串
function parseTag(context, type) {
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source);
const tag = match[1];
advanceBy(context, type === "Start" ? 2 + tag.length : 3 + tag.length);
return type === "Start"
? {
type: "Element",
tag,
}
: null;
}
function advanceBy(context, nums) {
context.source = context.source.slice(nums);
}
parseInterpolation
的实现
- 找到插值的结束符号
}}
对应的下标。 - 很容易可以把其中的内容截取下来,再消费字符
function parseInterpolation(context) {
const index = context.source.indexOf("}}");
const content = context.source.slice(2, index);
advanceBy(context, index + 2);
return {
type: "Interpolation",
content,
};
}
parseText
的实现
- 只需要比较离
{ {
,还是<
近,获取文本内容,再消费字符即可function parseText(context) { const indexI = context.source.indexOf("{ {"); const indexE = context.source.indexOf("<"); const index = Math.min(indexI, indexE); const content = context.source.slice(0, index); advanceBy(context, content.length); return { type: "Text", content, }; }
最后思考一下循环的终止条件即实现isEnd
函数,它返回一个布尔值。while 循环应该要遇到父级节点的结束标签才会停止
所以应该维护一个父节点栈ancestors
,判断一下在剩余字符中能够找到最新的栈节点的标签名对应的结束标签。另一种结束情况是字符全都被消费。
function isEnd(context, ancestors) {
const s = context.source;
if (ancestors.length !== 0) {
const tag = ancestors[ancestors.length - 1]?.tag;
if (tag === s.slice(2, 2 + tag.length)) return true;
}
return !s;
}
因为需要维护ancestors
,需要稍稍修改一下
- 在parse函数中给parseChildren再传入一个
[]
,初始化ancestors
parseChilren
和parseElement
都需要传入这个参数。- 在
parseElement
中处理完开始标签之后,将parseTag
的返回值push到栈里,执行完parseChildren
也就是意味着递归结束,再退栈即可
另外还需要考虑文本模式
对解析的影响,默认是DATA。比如:
<title>
标签、<textarea>
标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式,此时会将当前字符 < 作为普通字符处理
,然后继续处理后面的字符。由此可知,在 RCDATA 状 态下,解析器不能识别标签元素
。这里了解一下即可,下面给出了这几种模式对应的表格
模式 | 能否解析标签 | 是否支持 HTML 实体 |
---|---|---|
DATA | Y | Y |
RCDATA|N|Y
RAWTEXT|N|N
CDATA|N|N
所以需要在parseChild
进行模式的判断
function parseChild(context, ancestors) {
//省略
while (!isEnd(context, ancestors)) {
let node;
// 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
if (context.mode === "DATA" || context.mode === "RCDATA") {
// 只有 DATA 模式才支持标签节点的解析
if (context.mode === "DATA" && context.source[0] === "<") {
//省略
}
}
//省略
}
接下来处理没有结束标签
的情况,并能给出提示具体是哪个标签的没有结束标签
- 首先改造一下
isEnd
,这种情况下会造成死循环,因为永远找不到对应的结束标签。 - 现在的判断条件改成
遍历父节点栈,只要找到有与之相对应的结束标签
则结束这个循环 - 在
parseElement
也需要做处理,例如<div><p></div>
- 第一次进入parseChildren,进入while,使用parseElement处理标签,
ancestors.push({type:Element,tag:div})
,剩余字符<p></div>
- 进入递归parsechildren,再进入parseElement,
ancestors.push({type:Element,tag:p})
,剩余字符,进入while判断,为flase,退出循环。 ancestors.push()
,剩余字符对应标签是div,而element.tag是p,所以就知道这个标签缺少结束标签。
//省略代码
ancestors.pop();
if (context.source.startsWith(`</${
element.tag}`)) {
parseTag(context, "End");
} else {
// 缺少闭合标签
console.error(`${
element.tag} 标签缺少闭合标签`);
}
return element;
下一篇会在此基础上再完善一些内容,并且会解析标签上的属性props