一,前言
上篇,主要介绍了生成 ast 语法树 - 模板解析部分,主要涉及以下几个点:
使用正则对 html 模板进行解析和处理,匹配到模板中的标签和属性
- 解析开始标签-parseStartTag 方法
- 截取匹配完成的部分-advance 方法
- 解析开始标签中的属性-while 循环
- 开始标签处理过程的详细说明
- 对开始标签、结束标签及文本的发射处理
本篇,生成 ast 语法树 - 构造树形结构
二,构建树形结构
1,需要描述什么
前面提到,将html
模板构造成为ast
语法树,使用js
树形结构来描述html
语法;
对于一个html
模板,主要有以下几点需要被描述和记录:
- 标签信息;
- 属性信息;
- 文本信息;
html
结构的层级关系,即元素间的父子关系;
2,如何构建父子关系
示例:
<div><sapn></span><p></p></div>
html
标签特点:成对出现(开始标签 + 结束标签),如:<sapn>
和 </span>
;
基于模板解析从左到右的顺序和以上html
标签特点,就可以借助栈型的数据结构来辅助构建元素的父子关系;
将解析到的标签名按顺序放入栈中:
[div,span] span 是 div 的儿子
[div,span,span] span 标签闭合,span 出栈,将 span 作为 div 的儿子
[div,p] p 是 div 的儿子
[div,p,p] p 标签闭合,p 出栈,将 p 作为 div 的儿子(即 p 和 span 是同层级的兄弟)
原理同上,略...
备注:结束标签有两种情况:</xxx>和</>,如果是自闭和标签</>,直接出栈即可
通过以上逻辑,借助栈的数据结构,就能够模拟出了html
元素的父子关系了;
3,ast 节点元素的数据结构
在构建父子关系的对象中,需要记录以下信息:
// 构建 AST 元素节点
function createASTElement(tag, attrs, parent) {
return {
tag, // 标签名
type:1, // 元素
children:[], // 儿子
parent, // 父亲
attrs // 属性
}
}
4,处理开始标签
处理开始标签的逻辑
- 如果当前标签是第一个节点,则自动成为整棵树的根节点
root
; - 如果已存在根节点,则取栈中最后一个标签作为父节点,创建
ast
节点元素并入栈;
代码实现
// 处理开始标签,如:[div,p]
function start(tag, attrs) {
// 取栈中最后一个标签,作为父节点
let parent = stack[stack.length-1];
// 创建当前 ast 节点
let element = createASTElement(tag, attrs, parent);
// 如果是第一个标签,则作为根节点
if(root == null) root = element;
// 如果存在父亲,就父子相认(为当前节点设置父亲,同时为父亲设置儿子)
if(parent){
element.parent = parent;
parent.children.push(element)
}
// 当前 ast 元素节点构建完成后,加入到栈中
stack.push(element)
}
5,处理结束标签
处理结束标签的逻辑
- 抛出栈中最后一个标签,即与当前结束标签成对的开始标签;
- 验证栈中抛出的标签是否与当前结束标签成对
代码实现
// 处理结束标签
function end(tagName) {
console.log("发射匹配到的结束标签-end,tagName = " + tagName)
// 从栈中抛出结束标签
let endTag = stack.pop();
// check:抛出的结束标签名与当前结束标签名是否一致
// 开始/结束标签的特点是成对的,当抛出的元素名与当前元素名不一致时报错
if(endTag.tag != tagName)console.log("标签出错")
}
6,处理文本
处理文本的逻辑
- 处理文本:删除文本中可能存在的空白字符;
- 若文本不为空,则取当前栈中最后一个标签作为父节点;
注意:文本不需要入栈,直接绑定为父节点的儿子即可;
代码实现
// 处理文本(文本中可能包含空白字符)
function text(chars) {
console.log("发射匹配到的文本-text,chars = " + chars)
// 找到文本的父亲,即当前栈中的最后一个元素
let parent = stack[stack.length-1];
// 删除文本中可能存在的空白字符:将空格替换为空
chars = chars.replace(/\s/g, "");
if(chars){
// 绑定父子关系
parent.children.push({
type:2, // type=2 表示文本类型
text:chars,
})
}
}
备注:使用
type=2
表示文本类型,与astexplorer.net
一致;
三,代码重构
1,模块化
将parserHTML
相关处理逻辑提取为单独的js
文件并对外抛出parserHTML
方法:src/compile/parser.js
// src/compile/parser.js
// 匹配标签名:aa-xxx
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
// 命名空间标签:aa:aa-xxx
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
// 匹配标签名(索引1):<aa:aa-xxx
const startTagOpen = new RegExp(`^<${qnameCapture}`);
// 匹配标签名(索引1):</aa:aa-xxxdsadsa>
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
// 匹配属性(索引 1 为属性 key、索引 3、4、5 其中一直为属性值):aaa="xxx"、aaa='xxx'、aaa=xxx
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 匹配结束标签:> 或 />
const startTagClose = /^\s*(\/?)>/;
// 匹配 {{ xxx }} ,匹配到 xxx
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
export function parserHTML(html) {
console.log("***** 进入 parserHTML:将模板编译成 AST 语法树 *****")
let stack = [];
let root = null;
// 构建父子关系
function createASTElement(tag, attrs, parent) {
return {
tag, // 标签名
type:1, // 元素类型为 1
children:[], // 儿子
parent, // 父亲
attrs // 属性
}
}
// 开始标签,如:[div,p]
function start(tag, attrs) {
console.log("发射匹配到的开始标签-start,tag = " + tag + ",attrs = " + JSON.stringify(attrs))
// 遇到开始标签,就取栈中最后一个,作为父节点
let parent = stack[stack.length-1];
let element = createASTElement(tag, attrs, parent);
// 还没有根节点时,作为根节点
if(root == null) root = element;
if(parent){ // 父节点存在
element.parent = parent; // 为当前节点设置父节点
parent.children.push(element) // 同时,当前节点也称为父节点的子节点
}
stack.push(element)
}
// 结束标签
function end(tagName) {
console.log("发射匹配到的结束标签-end,tagName = " + tagName)
// 如果是结束标签,就从栈中抛出
let endTag = stack.pop();
// check:抛出的结束标签名与当前结束标签名是否一直
if(endTag.tag != tagName)console.log("标签出错")
}
// 文本
function text(chars) {
console.log("发射匹配到的文本-text,chars = " + chars)
// 文本直接放到前一个中 注意:文本可能有空白字符
let parent = stack[stack.length-1];
chars = chars.replace(/\s/g, ""); // 将空格替换为空,即删除空格
if(chars){
parent.children.push({
type:2, // 文本类型为 2
text:chars,
})
}
}
/**
* 截取字符串
* @param {*} len 截取长度
*/
function advance(len) {
html = html.substring(len);
console.log("截取匹配内容后的 html:" + html)
console.log("===============================")
}
/**
* 匹配开始标签,返回匹配结果
*/
function parseStartTag() {
console.log("***** 进入 parseStartTag,尝试解析开始标签,当前 html: " + html + "*****")
// 匹配开始标签,开始标签名为索引 1
const start = html.match(startTagOpen);
if(start){// 匹配到开始标签再处理
// 构造匹配结果,包含:标签名 + 属性
const match = {
tagName: start[1],
attrs: []
}
console.log("html.match(startTagOpen) 结果:" + JSON.stringify(match))
// 截取匹配到的结果
advance(start[0].length)
let end; // 是否匹配到开始标签的结束符号>或/>
let attr; // 存储属性匹配的结果
// 匹配属性且不能为开始的结束标签,例如:<div>,到>就已经结束了,不再继续匹配该标签内的属性
// attr = html.match(attribute) 匹配属性并赋值当前属性的匹配结果
// !(end = html.match(startTagClose)) 没有匹配到开始标签的关闭符号>或/>
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
// 将匹配到的属性,push到attrs数组中,匹配到关闭符号>,while 就结束
console.log("匹配到属性 attr = " + JSON.stringify(attr))
// console.log("匹配到属性 name = " + attr[1] + "value = " + attr[3] || attr[4] || attr[5])
match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] })
advance(attr[0].length)// 截取匹配到的属性 xxx=xxx
}
// 匹配到关闭符号>,当前标签处理完成 while 结束,
// 此时,<div id="app" 处理完成,需连同关闭符号>一起被截取掉
if (end) {
console.log("匹配关闭符号结果 html.match(startTagClose):" + JSON.stringify(end))
advance(end[0].length)
}
// 开始标签处理完成后,返回匹配结果:tagName标签名 + attrs属性
console.log(">>>>> 开始标签的匹配结果 startTagMatch = " + JSON.stringify(match))
return match;
}
console.log("未匹配到开始标签,返回 false")
console.log("===============================")
return false;
}
// 对模板不停截取,直至全部解析完毕
while (html) {
// 解析标签和文本(看开头是否为<)
let index = html.indexOf('<');
if (index == 0) {// 标签
console.log("解析 html:" + html + ",结果:是标签")
// 如果是标签,继续解析开始标签和属性
const startTagMatch = parseStartTag();// 匹配开始标签,返回匹配结果
if (startTagMatch) { // 匹配到了,说明是开始标签
// 匹配到开始标签,调用start方法,传递标签名和属性
start(startTagMatch.tagName, startTagMatch.attrs)
continue; // 如果是开始标签,就不需要继续向下走了,继续 while 解析后面的部分
}
// 如果开始标签没有匹配到,有可能是结束标签 </div>
let endTagMatch;
if (endTagMatch = html.match(endTag)) {// 匹配到了,说明是结束标签
// 匹配到开始标签,调用start方法,传递标签名和属性
end(endTagMatch[1])
advance(endTagMatch[0].length)
continue; // 如果是结束标签,也不需要继续向下走了,继续 while 解析后面的部分
}
} else {// 文本
console.log("解析 html:" + html + ",结果:是文本")
}
// 文本:index > 0
if(index > 0){
// 将文本取出来并发射出去,再从 html 中拿掉
let chars = html.substring(0,index) // hello</div>
text(chars);
advance(chars.length)
}
}
console.log("当前 template 模板,已全部解析完成")
return root;
}
2,导入模块
在src/compile/index.js
中,导入src/compile/parser.js#parserHTML
:
// src/compile/index.js
import { parserHTML } from "./parser";
export function compileToFunction(template) {
console.log("***** 进入 compileToFunction:将 template 编译为 render 函数 *****")
// 1,解析模板并构建成为 AST 语法树
let ast = parserHTML(template);
console.log("解析 HTML 返回 ast 语法树====>")
console.log(ast)
}
3,测试 ast 构建逻辑
<div><p>{{message}}<sapn>HelloVue</span></p></div>
控制台输出:
对应元素结构如下:
div
p
{{message}}
span
HelloVue
四,结尾
本篇,生成 ast 语法树 - 构造树形结构部分
- 基于 html 特点,使用栈型数据结构记录父子关系;
- 分析开始标签,结束标签及文本的ast构建逻辑和代码实现;
- 重构 html 解析与 ast 语法树构建部分代码;
- 对 ast 语法树构建的过程分析;
下一篇,ast 语法树生成 render 函数
维护记录
- 20230126: 优化内容使表述更加清晰,添加部分代码注释和说明; todo:代码重构部分可以放到之前或在本阶段之后单开一篇做阶段性重构;