手写 Vue2 源码之模板解析成AST语法树
前言
Vue
源码一直是前端面试必问的题目,熟悉源码能让我们在面试过程中脱颖而出,熟悉源码也能让我们在开发过程中知道整个框架是如何运转的,遇到了问题,我们能第一时间知道是哪里出了问题。
过去的章节,已经实现了源码:
现在我们开始从数据转向视图,看看如何将 html
模板编译成 Dom
元素。
模板编译一般有这么几个步骤:
- 模板解析成
AST
语法树。 AST
语法树转成render
函数。render
函数构建虚拟Dom
。- 虚拟
Dom
转成真实Dom
。
这篇文章,我们用代码实现如何将 HTML
模板转化为 AST
语法树。
AST 语法树
概念
AST
全称为 Abstract Syntax Tree
,译为抽象语法树。在 JavaScript
中,任何一个对象(变量、函数、表达式等)都可以转化为一个抽象语法树的形式,是源代码语法结构的一种抽象表示。抽象语法树本质就是一个树形结构的对象。
可以在 ASTexplorer 工具上测试,看看我们的表达式转换为 AST
语法树是什么样子的。
JavaScript 编译执行流程
JS
执行的第一步是读取 js
文件中的字符流,然后通过词法分析生成令牌流 Tokens
,之后再通过语法分析生成 AST
,最后生成机器码执行。
解析过程主要分为词法分析
和语法分析
。
词法分析
词法分析也称之为扫描(scanner)
,指的是将对象逐个扫描,获取每一个字母的信息,生成由对象组成的一维数组。
const a = 5;
//词法分析
[{value:'const',type:'keyword'},{value:'a',type:'identifier'}...]
语法分析
语法分析指的是将有关联的对象整合成树形结构的表达形式。
const a = 5;
//语法分析
{
"type": "Program",
"start": 0,
"end": 12,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 12,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"name": "a"
},
"init": {
"type": "Literal",
"start": 10,
"end": 11,
"value": 5,
"raw": "5"
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
应用
工作中每天都在使用它
- React
- 将JSX语法转为React.createElement语法
- Vue
- 将模板语法转为JavaScript代码
- webpack、Vite、rollup
- 依赖关系树
- 代码打包
- loader
- TypeScript
- 将TypeScript语法转为JavaScript语法
- Sass、Less
- 将Sass、Less语法转为CSS语法
- Babel
- 将 ES6+ 语法的代码转为 ES5 语法
- ESLint、Prettier
- 语法检查、代码格式化
- …
操作AST的包
- babel
- @babel/parser
- Code 转 AST
- @babel/traverse
- 遍历 AST
- @babel/types
- 判断节点类型和生成新节点
- @babel/generator
- AST 转 Code
- @babel/parser
- acorn
- recast
- esprima
- …
获取模板内容
我们先按照常规 vue
写法,写一份基本的 html
模板:
public/index.html:
<div id="app">
<div class="person">
<span class="person-name">{{name}}</span>
<span class="person-age">{{age}}</span>
</div>
</div>
<script src="../dist/vue.js"></script>
<script>
let vm = new Vue({
el: '#app',
data() {
return {
name: 'ts',
age: 18,
sports: ['basketball', 'football'],
}
}
})
</script>
我们需要先获取到模板内容,才能将模板解析成 AST
语法树,模板可以有几种存在的方式:
new Vue
里面render
函数:
HTML
标签:
new Vue
里面template
属性:
我们需要先判断是否有根元素,然后去执行挂载元素的方法 $mount
。
src/instance/init.js
// 判断传入的options是否有根元素
if (vm.$options.el) {
// 执行挂载元素方法
vm.$mount(vm.$options.el)
}
在 Vue
原型上添加一个 $mount
方法。
src/instance/init.js:
Vue.prototype.$mount = function (el) {}
在获取模板内容之前,我们还需要判断 el
不能为空以及不能直接是 html
和 body
标签。
src/instance/init.js:
Vue.prototype.$mount = function (el) {
// 获取元素
el = el && query(el)
// 判断元素是否直接挂载到body上
if (el === document.body || el === document.documentElement) {
console.warn(`Do not mount Vue to <html> or <body> - mount to normal elements instead.`)
return this
}
}
比如,当我们直接挂载到 body
上面时:
能在控制台看到我们输出的 warn
内容。
接下来,我们从 render
函数、 template
或者 el
中拿到模板内容,需要做一些 if
判断操作:
src/instance/init.js:
// 没有render函数
if (!options.render) {
let template
// 有template属性,采用template
if (options.template) {
template = options.template
} else if (el) {
// 没有template属性,采用el.outerHTML
template = el.outerHTML
}
console.log(template, 'template')
}
我们做下测试,比如没有 template
属性:
成功拿到了模板内容。
我们加入 template
属性试试:
同样也拿到了模板内容。
下面,我们就需要将我们拿到的模板内容解析成 AST
语法树。
模板内容解析成 AST 语法树
我们在 src
文件下新建 compiler
编译文件,index.js
文件里面放入我们将模板解析成 AST
语法树的逻辑,定义一个方法 compileToFunctions
处理解析模板并对外导出,在 src/instance/init.js
里引入使用。
src/compiler/index.js:
export const compileToFunctions = (template) => {
console.log(template)
}
src/instance/init.js:
if (template) {
options.render = compileToFunctions(template)
}
解析过程
Vue2
中主要有三种解析器: HTML 解析器
、文本解析器
以及过滤解析器
。其中 HTML 解析器
是最主要的,它的作用就是解析 template
模板。
源码位置 src/compiler/parser/index.js
:
模板解析的过程就是不断调用函数的处理过程。使用正则表达式去匹配,匹配到不同的内容就去触发处理对应片段的方法。比如开始标签正则匹配到开始标签,触发 start
函数,函数处理匹配到的开始标签片段,生成一个标签节点添加到抽象语法树上。
正则表达式
这里准备了几个正则表达式用于匹配标签和文本等:
// 解析标签和属性的正则表达式
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ //匹配到的是属性,如 a=b,key 是匹配到第一个分组,value 的值可能是 Group 3、Group 4、Group 5 其中的一个,即第二个分组,第三个分组和第四个分组其中一个。
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 匹配到的分组是标签开始部分,如:<div
const startTagClose = /^\s*(\/?)>/ //匹配到的是开始标签的结束部分,如 > 或者 />。
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配到的分组是标签结束部分,如 </div>
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配到的是我们表达式的内容,如 {{ name }}
可以结合正则可视化看看主要是匹配哪内容:
startTagOpen:
匹配到的是标签开始部分,如 <div
。
endTag:
匹配到的是标签结束部分,如 </div>
。
attribute:
匹配到的是属性,如 a=b
,属性 key
和 value
之间可以存在空格, value
可以使单引号、双引号和没有引号等。key
是匹配到第一个分组,value
的值可能是 Group 3
、Group 4
、Group 5
其中的一个,即第二个分组,第三个分组和第四个分组其中一个。
startTagClose:
匹配到的是开始标签的结束部分,如 >
或者 />
。
defaultTagRE:
这个也比较简单,匹配到的是我们表达式的内容,如 {{ name }}
。
下面,我们介绍最重要的解析器 - HTML 解析器
。
HTML 解析器
解析 HTML
模板的过程就是 while
循环的过程,简单来说就是用 HTML
模板字符串来循环,每轮循环都从 HTML
模板中截取一小段字符串,然后重复以上过程,直到 HTML
模板被截成一个空字符串时结束循环,解析完毕。
源码位置 src/compiler/parser/html-parser.js
:
这里举一个简单的 HTML
模板来模拟解析的过程:
<div>
<div>ts</div>
</div>
第一次循环,正则匹配到开始标签,截取 <div>
,触发 start
方法,剩下模板为:
<div>ts</div>
</div>
第二次循环,正则匹配到空文本,截取出一段换行空字符串,触发 chars
方法,剩下模板为:
<div>ts</div>
</div>
第三次循环,正则匹配到开始标签,截取 <div>
,触发 start
方法,剩下模板为:
ts</div>
</div>
第四次循环,正则匹配到文本 ts
,截取 ts
,触发 chars
方法,剩下模板为:
</div>
</div>
第五次循环,正则匹配到结束标签,截取 </div>
,触发 end
方法,剩下模板为:
</div>
第六次循环,正则匹配到空文本,截取出一段换行空字符串,触发 chars
方法,剩下模板为:
</div>
第七次循环,正则匹配到结束标签,截取 </div>
,触发 end
方法,剩下模板为:
第八次循环,发现只有一个空字符串,解析完毕,循环结束。
这就是大致的解析流程,下面我们开始结合代码实现。
截取模板
前面已经了解了解析过程,现在我们可以开始对模板进行 while
循环然后匹配不同的正则进行处理。
HTML
开始肯定是以 <``
开头,所以我们可以根据 <
的位置判断目前解析到的是什么位置。
src/compiler/index.js:
function parseHTML(html) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
}
if (textEnd > 0) {
}
if (textEnd < 0) {
}
}
用变量 textEnd
表示 <
第一次出现的位置:
textEnd === 0
,表示的是标签开始位置或者结束位置,如<div>hello world</div>
或者</div>
。textEnd > 0
,表示的是文本结束位置,如hello world</div>
。textEnd < 0
,表示的是文本,如hello world
。
基于这三种条件,我们可以对解析进一步处理。
textEnd === 0
对标签开始位置或者结束位置,我们将处理逻辑放在 parseStartTag
方法里面:
function parseStartTag() {
const start = html.match(startTagOpen)
console.log(start)
if (start) {
const match = {
tagName: start[1],
attrs: []
}
console.log(match)
advance(start[0].length)
console.log(html)
}
}
在 parseStartTag
方法中,我们得到了开始标签匹配的内容 start
:
start[0]
就是标签开始部分,start[1]
为标签名。然后将 start
内容组装到变量 match
里面,属性有标签名 tagName
和属性 attrs
,attrs
我们后面会处理,暂时放个空数组,这里根据需要,还可以放一些别的属性。
接着,我们需要将匹配到的模板字符串进行截取掉,然后进入下一个循环,截取字符串放在了 advance
方法中。
function advance(n) {
html = html.substring(n)
}
这样,我们就成功将匹配到的开始标签内容 <div
截取掉了。
接下来,开始处理匹配属性 id="app"
,当正则匹配的不是开始标签的结束部分如 >
或者 />
时,我们需要一直匹配属性。我们需要将属性用变量保留下来,放在前面我们定义 match
的 attrs
里。
let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
}
这里我们同样是需要用 advance
方法将匹配到的属性部分截取掉。打印一下目前的 html
和 match
:
可以看到打印出来的 html
已经将属性 id="app"
截取掉了,放到了 match
里面的 attrs
上。
我们还需要将 html
前面的 >
去掉:
if (end) {
advance(end[0].length)
}
我们处理完了匹配到标签开始位置这种情况,textEnd === 0
还有一种情况就是匹配到标签结束位置,这种情况比较简单,我们直接讲结束标签从模板里面截取掉就行。
const endTagMatch = html.match(endTag)
if (endTagMatch) {
advance(endTagMatch[0].length)
continue
}
这样我们截取完了 textEnd === 0
的所有情况。
textEnd > 0 和 textEnd < 0
这里我将 textEnd > 0
和 textEnd < 0
放在一起处理,是因为两种情况都是表示需要截取掉是的文本内容。
textEnd > 0
表示文本结束位置如: 文本内容...</div>
,我们需要截取内容为'文本内容...'即 <
之前的内容。
textEnd < 0
表示的是内容全是文本如: 文本内容...,没有 >
,我们需要截取掉所有内容。
两种情况都比较简单,所以放在一起处理:
let text
// 表示的是文本结束位置,截取到文本结束位置
if (textEnd > 0) {
text = html.substring(0, textEnd)
}
// 表示的是内容全是文本,将内容全部都截取掉
if (textEnd < 0) {
text = html
}
if (text) {
advance(text.length)
}
到目前位置,textEnd
所有情况都已经做了截取模板处理,这里贴一下到目前为止 parseHTML
方法的代码:
function parseHTML(html) {
while (html) {
let textEnd = html.indexOf('<')
// 表示的是标签开始位置或者结束位置
if (textEnd === 0) {
// 如果是标签开始位置
const startTagMatch = parseStartTag()
if (startTagMatch) {
continue
}
// 如果是标签结束位置
const endTagMatch = html.match(endTag)
if (endTagMatch) {
advance(endTagMatch[0].length)
continue
}
}
let text
// 表示的是文本结束位置,截取到文本结束位置
if (textEnd > 0) {
text = html.substring(0, textEnd)
}
// 表示的是内容全是文本,将内容全部都截取掉
if (textEnd < 0) {
text = html
}
if (text) {
advance(text.length)
}
}
console.log(html)
// 截取掉被匹配到的模板部分
function advance(n) {
html = html.substring(n)
}
function parseStartTag() {
// 匹配开始标签
const start = html.match(startTagOpen)
if (start) {
// 定义match 存放标签名和属性
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里
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
}
// 如果有匹配到开始标签的结束部分,需要截取掉
if (end) {
advance(end[0].length)
}
return match
}
return false
}
}
打印,可以看到,经过截取处理最后剩下的 html
为空,while
循环结束。
到这里,我们实现了模板的截取,当然我们这里只做了最主要的标签、文本的截取,Vue2
源码里面还做了注释 Comment
、 文本类型 Doctype
等的截取。最后还需要将得到的这些截取掉的标签以及属性等转成 AST
语法树。
处理解析 AST
我们需要定义处理开始标签、结束标签以及文本的方法,将匹配到的模板内容转成 AST
语法树。
// 开始标签处理转化ast语法树
function start(tag, attrs) {}
// 结束标签处理转化ast语法树
function end(tag) {}
// 文本处理转化ast语法树
function charts(text) {}
我们需要得到的 AST
语法树的结构是这样:
{
type,
tag,
attrsList,
parent: {},
children: []
}
字段解释:
type
标签的类型,用数字表示,如:1表示元素类型,3表示普通文本类型。tag
标签名称,用字符串表示,如:'div'。attrsList
属性集合,用数组表示,如:[{id, age, name}]。parent
父元素,用对象表示。children
子元素,用数组表示。
知道了树结构之后,我们应该怎么去创建这种数结构的数据?难点主要是如何确定元素之间的父子关系。
stack
我们可以定义一个变量 stack
,它表示一个栈,作用是存储开始标签。它的进出遵循遇到开始标签进栈,遇到结束标签,对应的开始标签出栈原则。
进出栈过程图示
比如我们的模板是这样,为了便于分别,第一个 span
标签我们用 span1
表示,第二个用 span2
表示:
<div>
<span>{{name}}</span>
<span>{{age}}</span>
</div>
我们进行 while
循环时,匹配到开始标签就入栈,push
到 stack
里面,然后将当前匹配到开始的标签作为父元素,遇到结束标签就出栈。
这里,我们首先匹配到标签开始 <div>
,截取掉然后入栈到 stack
,将现在的 div
作为父元素同时也是根元素:
然后匹配到第一个 <span>
,将 span1
当做 div
子元素,然后把 span1
作为当前的父元素:
然后匹配到文本 {{name}}
,将文本作为 span1
的子元素:
继续匹配到结束标签 </span>
,遇到结束标签就将 span1
出栈,这时候我们就重新选择父元素,再次将 div
作为父元素。
继续匹配到开始标签 <span>
,将 span2
进栈并当做 div
子元素,然后把 span2
作为当前的父元素:
然后匹配到文本 {{age}}
,将文本作为 span2
的子元素:
继续匹配到结束标签 </span>
,遇到结束标签就将 span2
出栈,这时候我们就重新选择父元素,再次将 div
作为父元素。
继续匹配到结束标签 </div>
,遇到结束标签就将 <div>
出栈,这时候栈里面已经没有内容了,while
循环也结束了。
创建生成 AST 语法树的方法:
function createASTElement(tag, attrs) {
return {
type: 1,
tag,
attrsList: attrs,
parent: null,
children: []
}
}
还需要声明栈 stack
,当前父元素 currentParent
以及根节点 root
。
let stack = [], root, currentParent;
下面就开始根据构建树的过程去用代码实现。
start 方法
function start(tag, attrs) {
let node = createASTElement(tag, attrs)
if (!root) {
root = node
}
if (currentParent) {
node.parent = currentParent
currentParent.children.push(node)
}
stack.push(node)
currentParent = node
}
start
方法会生成具有 AST
结构的 node
节点,然后判断是否有根节点 root
,没有说明当前元素就是根节点,将当前元素赋值给 root
。接着判断是否有父元素,如果有父元素,将父元素关联到当前元素的父元素同时给父元素的 children
赋值 node
,随后将元素入栈并将当前元素作为父元素。
end 方法
function end(tag) {
stack.pop()
currentParent = stack[stack.length - 1]
}
end
方法处理比较简单,将 stack
最后一个元素出栈,继续将栈中最后一个元素作为父元素。
chars 方法
function charts(text) {
text = text.replace(/\s/g, '')
text &&
currentParent.children.push({
type: 3,
text,
parent: currentParent
})
}
遇到文本内容,直接放在父元素的 children
里面就行了。
最后,我们写一个 html
模板来测试一下:
<div id="app">
<div class="person">
<span class="person-name">{{name}}</span>
<span class="person-age">{{age}}</span>
</div>
</div>
这样我们就完成了整个模板解析成 AST
语法树的流程,贴一下完整代码:
src/compiler/index.js:
// 解析标签和属性的正则表达式
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ //匹配到的是属性,如 a=b,key 是匹配到第一个分组,value 的值可能是 Group 3、Group 4、Group 5 其中的一个,即第三个分组,第四个分组和第五个分组其中一个。
const startTagOpen = new RegExp(`^<${qnameCapture}`) // 匹配到的分组是标签开始部分,如:<div
const startTagClose = /^\s*(\/?)>/ //匹配到的是开始标签的结束部分,如 > 或者 />。
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) // 匹配到的分组是标签结束部分,如 </div>
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配到的是我们表达式的内容,如 {{ name }}
export const compileToFunctions = (template) => {
const ast = parseHTML(template)
}
let stack = [],
root,
currentParent
function parseHTML(html) {
// 创建AST结构元素
function createASTElement(tag, attrs) {
return {
type: 1, // 1表示元素, 3表示普通文本
tag,
attrsList: attrs,
parent: null,
children: []
}
}
// 开始标签处理转化ast语法树
function start(tag, attrs) {
// 生产AST结构的元素
let node = createASTElement(tag, attrs)
// 没有根节点说明当前元素就是根节点,将当前元素赋值给root
if (!root) {
root = node
}
// 如果有父元素,将父元素关联到当前元素的父元素
if (currentParent) {
node.parent = currentParent
// 给父元素的children赋值node
currentParent.children.push(node)
}
// 将元素入栈
stack.push(node)
// 将当前元素作为父元素
currentParent = node
}
// 结束标签处理转化ast语法树
function end(tag) {
// 将stack最后一个元素出栈
stack.pop()
// 将栈中最后一个元素作为父元素
currentParent = stack[stack.length - 1]
}
// 文本处理转化ast语法树
function charts(text) {
// 文本去空
text = text.replace(/\s/g, '')
// 如果是文本就直接放在父元素的children里面
text &&
currentParent.children.push({
type: 3,
text,
parent: currentParent
})
}
while (html) {
let textEnd = html.indexOf('<')
// 表示的是标签开始位置或者结束位置
if (textEnd === 0) {
// 如果是标签开始位置
const startTagMatch = parseStartTag()
if (startTagMatch) {
start(startTagMatch.tagName, startTagMatch.attrs)
continue
}
// 如果是标签结束位置
const endTagMatch = html.match(endTag)
if (endTagMatch) {
end(endTagMatch[1])
advance(endTagMatch[0].length)
continue
}
}
let text
// 表示的是文本结束位置,截取到文本结束位置
if (textEnd > 0) {
text = html.substring(0, textEnd)
}
// 表示的是内容全是文本,将内容全部都截取掉
if (textEnd < 0) {
text = html
}
if (text) {
charts(text)
advance(text.length)
}
}
console.log(root)
// 截取掉被匹配到的模板部分
function advance(n) {
html = html.substring(n)
}
function parseStartTag() {
// 匹配开始标签
const start = html.match(startTagOpen)
if (start) {
// 定义match 存放标签名和属性
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里
match.attrs.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
}
// 如果有匹配到开始标签的结束部分,需要截取掉
if (end) {
advance(end[0].length)
}
return match
}
return false
}
}
总结
模板解析是 Vue
模板编译的第一步,主要是将模板解析成 AST
语法树,生成 AST
语法树的核心就是解析 html
,解析 html
则是通过不停地正则表达式匹配不同的内容,然后执行相应的处理函数,在处理函数里面会将匹配到的片段解析生成不同的节点。