是因为vue@2.6.11
的模板编译用到这个库,因此拿过来分析下。
要想将html
转成AST
,首先是要正确的解析(遍历)出html
的结构,simple-html-parser.js
就是做这个事情的(vue@2.6.11
就是用的这个库)。在这个解析的过程中会调用一些回调如start
、end
、chars
等,在这些回调中会完成html的AST
的构造。
在编辑器中的形式
<div id="app" class="container">
<div @click="clickHandler">
before
<span v-if="showSpan">span tag</span>
<div v-for="item in items">
<span> {{ item }}</span>
</div>
</div>
</div>
如果是runtime + compiler
运行时版本上面内容是会先在浏览器中渲染出来的。而vue-loader
版本是直接从template中读出的。不管哪种,都会被转为下面的字符串形式。
显然算法的目的是要遍历完所有的字符,因此有一个指针(index = 0,初始值为0)来推动整个遍历向前不断推进。html字符串的核心标识就是标签的<
符号,因此会查找这个符号,如果找到说明可能存在html标签,因此会继续判断是开始标签(如<div
)还是结束标签(如</div>
)。
显然合法的html中先从一个开始标签开始,如下,当确认是一个开始标签后会进一步从开始标签中找出所有的属性如下面的id="app"
、class="container"
,直到遇到开始标签的结束符>
或者/>
。每次匹配上一个标签指针都会不断往前推进,<div ...>
遍历完后,因为当前标签还没有遇到结束标签</div>
,因此会先保存到stack
中。随后会进入下一次循环。
这一次循环发现开始部分是文本如这里的\n
,获取文本后,指针直接往前推进到有<
字符的位置。
...又经过若干轮的上述步骤,开始标签和文本匹配的场景
来到了一个结束标签如这里的</span>
,这里主要逻辑就是从栈(上面的stack存储着所有的开始标签)中弹出,说明这个标签已经解析结束。
... 按照上面的三种case,指针不断往前推进,直到结束。
上面demo给出了最普通的场景,也是整个html解析过程最核心的过程。其他的一些特殊场景(script, p, br等,自闭和标签,一元标签),在后面可能会补充一下。
// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\-\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(/?)>/
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)
// Special Elements (can contain anything)
export const isPlainTextElement = makeMap('script,style,textarea', true)
[\s\S]
=== [\w\W]
相较于 .
,可以匹配换行符,参考<div
/>
或者 >
</div
export function parseHTML(html, options) {
const stack = []
const isUnaryTag = options.isUnaryTag || no
let index = 0
let last, lastTag
while (html) {
last = html
// Make sure we're not in a plaintext content element like script/style
if (!lastTag || !isPlainTextElement(lastTag)) {
// ... 普通场景(初始时或者,上一次解析的标签不是 scritp、style、textarea时)
} else {
//... lastTag 是 script、style、textarea 场景
// 如 <script> .... </script>
}
// html是纯文本时,会进入下面的if
// 看了半天还是下面的回调options.chars验证了想法
if (html === last) {
options.chars && options.chars(html)
break
}
}
// Clean up any remaining tags
parseEndTag()
function advance(n) {
index += n
html = html.substring(n)
}
function parseStartTag() {
//...
// 找出 `<div id='app' ...各种属性 >` 中间的各种属性,截止到 `>` 或者`/>`
}
function handleStartTag(match) {
//... 转换一下 parseStartTag 收集的属性为[{name, value}]形式
// 如果不是一元标签(<img src='' />),则将该tag入栈
// 一元标签在这里实际上是代表已经闭合了标签,也就是已经处理完的标签,对此不需要入栈
// 而 <div
}
function parseEndTag(tagName, start, end) {
//...
}
}
看到核心流程就一个while
循环,直到html
遍历结束,while
循环中分为if-else
,其中else
是针对scritp
、style
、textarea
的,因此这些标签里面的内容是不需要被解析的。我们重点看下if
里面的逻辑,其实就是我们上面demo
中演示的过程。另外看到解释下这里涉及的几个方法
parseStartTag
:找出开始标签中的各种属性handlerStartTag
:将parseStartTag正则匹配的属性转转换成对象数组格式,然后将开始标签push到stack中。也处理一些异常情况,p标签中不能包含phrase content,比如<p>before<caption>ddd</caption>after</p>
,这种情况是不允许的。parseEndTag
:实际上核心逻辑是找到对应开始标签,然后从栈中弹出,但是这里的逻辑却写的相对复杂,是考虑到html
异常的一些场景,比如<div><span></div>
,此时会把span和div标签都弹出,显然这么做是合理的。advance
:很关键,推动index指针不断往前走下面看下while
中if
中的代码
let textEnd = html.indexOf('<')
// 处理可能是标签的场景,如<div 或者 </div
if (textEnd === 0) {
// ... 注释、条件注释、Doctype 场景,暂忽略
// End tag: 如 </div
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// Start tag: 如 <div
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}
}
// 下面是处理普通文本的场景
let text, rest, next
if (textEnd >= 0) {
rest = html.slice(textEnd)
// ... 处理 text 中 有 < 字符的场景,暂忽略
text = html.substring(0, textEnd)
}
if (textEnd < 0) {
text = html
}
if (text) {
advance(text.length)
}
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
实际上逻辑很清晰,分为两个大的情况
<
的情况,尝试判断是不是标签(开始标签还是结束标签)不是很重要,暂遗留
另外重要的点是:在上面的遍历的过程中,会有三个核心的回调事件:
start
:当找到一个开始标签,并且属性获取完,遇到开始标签的结束标志后,此时说明开始标签已经处理完了(该收集的信息也收集了),发布该事件end
:解析到结束标签时,此时这个整个标签解析完成了,发布该事件chars
:解析到文本时,发布该事件注意,这个过程并没有构造AST
,vue/src/compiler
部分监听了这三个事件,在这些事件中来添加vue
相关的一些特性如指令相关的,并在这些回调中创建AST
节点,并建立父子关系来构建整颗AST