[Vue源码]一起来学Vue模板编译原理(一)-Template生成AST
本文我们一起通过学习vue模板编译原理(一)-template生成ast来分析vue源码。预计接下来会围绕vue源码来整理一些文章,如下。
- 一起来学vue双向绑定原理-数据劫持和发布订阅
- 一起来学vue模板编译原理(一)-template生成ast
- 一起来学vue模板编译原理(二)-ast生成render字符串
- 一起来学vue虚拟dom解析-virtual dom实现和dom-diff算法
这些文章统一放在我的git仓库:。觉得有用记得star收藏。
编译过程
模板编译是vue中比较核心的一部分。关于vue编译原理这块的整体逻辑主要分三个部分,也可以说是分三步,前后关系如下:
第一步:将模板字符串转换成element asts(解析器)
第二步:对 ast 进行静态节点标记,主要用来做虚拟dom的渲染优化(优化器)
第三步:使用element asts生成render函数代码字符串(代码生成器)
对应的vue源码如下,源码位置在src/compiler/index.js
export const createcompiler = createcompilercreator(function basecompile ( template: string, options: compileroptions ): compiledresult { // 1.parse,模板字符串 转换成 抽象语法树(ast) const ast = parse(template.trim(), options) // 2.optimize,对 ast 进行静态节点标记 if (options.optimize !== false) { optimize(ast, options) } // 3.generate,抽象语法树(ast) 生成 render函数代码字符串 const code = generate(ast, options) return { ast, render: code.render, staticrenderfns: code.staticrenderfns } })
这篇文档主要讲第一步将模板字符串转换成对象语法树(element asts),对应的源码实现我们通常称之为解析器。
解析器运行过程
在分析解析器的原理前,我们先举例看下解析器的具体作用。
来一个最简单的实例:
<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并不是什么很神奇的东西,不要被它的名字吓倒。它只是用js中的对象来描述一个节点,一个对象代表一个节点,对象中的属性用来保存节点所需的各种数据。
事实上,解析器内部也分了好几个子解析器,比如html解析器、文本解析器以及过滤器解析器,其中最主要的是html解析器。顾名思义,html解析器的作用是解析html,它在解析html的过程中会不断触发各种钩子函数。这些钩子函数包括开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数。
我们先看下解析器整体的代码结构,源码位置src/compiler/parser/index.js
parsehtml(template, { warn, expecthtml: options.expecthtml, isunarytag: options.isunarytag, canbeleftopentag: options.canbeleftopentag, shoulddecodenewlines: options.shoulddecodenewlines, shoulddecodenewlinesforhref: options.shoulddecodenewlinesforhref, shouldkeepcomment: options.comments, outputsourcerange: options.outputsourcerange, // 每当解析到标签的开始位置时,触发该函数 start (tag, attrs, unary, start, end) { //... }, // 每当解析到标签的结束位置时,触发该函数 end (tag, start, end) { //... }, // 每当解析到文本时,触发该函数 chars (text: string, start: number, end: number) { //... }, // 每当解析到注释时,触发该函数 comment (text: string, start, end) { //... } })
实际上,模板解析的过程就是不断调用钩子函数的处理过程。整个过程,读取template字符串,使用不同的正则表达式,匹配到不同的内容,然后触发对应不同的钩子函数处理匹配到的截取片段,比如开始标签正则匹配到开始标签,触发start钩子函数,钩子函数处理匹配到的开始标签片段,生成一个标签节点添加到抽象语法树上。
还举上面那个例子来说:
<div> <p>{{name}}</p> </div>
整个解析运行过程就是:解析到
时,又触发一次钩子函数start,处理匹配片段,又生成一个标签节点并作为上一个节点的子节点添加到ast上;接着解析到{{name}}这行文本,此时触发了文本钩子函数chars,处理匹配片段,生成一个带变量文本(变量文本下面会讲到)标签节点并作为上一个节点的子节点添加到ast上;然后解析到
,触发了标签结束的钩子函数end;接着继续解析到正则匹配
模板解析过程会涉及到许许多多的正则匹配,知道每个正则有什么用途,会更加方便之后的分析。
那我们先来看看这些正则表达式,源码位置在src/compiler/parser/index.js
export const onre = /^@|^v-on:/ export const dirre = process.env.vbind_prop_shorthand ? /^v-|^@|^:|^\.|^#/ : /^v-|^@|^:|^#/ export const foraliasre = /([\s\s]*?)\s+(?:in|of)\s+([\s\s]*)/ export const foriteratorre = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/ const stripparensre = /^\(|\)$/g const dynamicargre = /^\[.*\]$/ const argre = /:(.*)$/ export const bindre = /^:|^\.|^v-bind:/ const propbindre = /^\./ const modifierre = /\.[^.\]]+(?=[^\]]*$)/g const slotre = /^v-slot(:|$)|^#/ const linebreakre = /[\r\n]/ const whitespacere = /\s+/g const invalidattributere = /[\s"'<>\/=]/
上面这些正则相对来说比较简单,基本上都是用来匹配vue中自定义的一些语法格式,如onre匹配 @ 或 v-on 开头的属性,foraliasre匹配v-for中的属性值,比如item in items、(item, index) of items。
下面这些就是专门针对html的一些正则匹配,源码位置在src/compiler/parser/html-parser.js
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}[^>]*>`) const doctype = /^<!doctype [^>]+>/i const comment = /^<!\--/ const conditionalcomment = /^<!\[/
这些正则表达式相对来说就复杂一些,如attribute用来匹配标签的属性,starttagopen、starttagclose用于匹配标签的开始、结束部分等。这些正则表达式的写法就不多说了,有兴趣的朋友可以针对这些正则一个一个的去测试一下。
html解析器
这里我们来看看html解析器。
事实上,解析html模板的过程就是循环的过程,简单来说就是用html模板字符串来循环,每轮循环都从html模板中截取一小段字符串,然后重复以上过程,直到html模板被截成一个空字符串时结束循环,解析完毕。
我们通过源码,就可以看到整个函数逻辑就是被一个while循环包裹着。源码位置在:src/compiler/parser/html-parser.js
export function parsehtml (html, options) { const stack = [] const expecthtml = options.expecthtml const isunarytag = options.isunarytag || no const canbeleftopentag = options.canbeleftopentag || no let index = 0 let last, lasttag while (html) { //... } parseendtag() //... }
下面我用一个简单的模板,模拟一下html解析的过程,以便于更好的理解。
<div> <p>{{text}}</p> </div>
最初的html模板:
<div> <p>{{text}}</p> </div>
第一轮循环时,截取出一段字符串
<p>{{text}}</p> </div>
第二轮循环时,截取出一段换行空字符串,会触发钩子函数chars,截取后的结果为:
<p>{{text}}</p> </div>
第三轮循环时,截取出一段字符串
,解析出是p开始标签并且触发钩子函数start,截取后的结果为:
{{text}}</p> </div>
第四轮循环时,截取出一段字符串{{name}},解析出是变量字符串并且触发钩子函数chars,截取后的结果为:
</p> </div>
第五轮循环时,截取出一段字符串
,解析出是p闭合标签并且触发钩子函数end,截取后的结果为:</div>
第六轮循环时,截取出一段换行空字符串,会触发钩子函数chars,截取后的结果为:
</div>
第七轮循环时,截取出一段字符串
第八轮循环时,发现只有一个空字符串,解析完毕,循环结束。
现在,是不是就对html解析过程很清楚了。其实循环过程对每次匹配到的片段进行分析记录还是很复杂的,因为被截取的片段分很多种类型,比如:
开始标签,例如
<div>
结束标签,例如
</div>
html注释,例如
<!-- 注释 -->
doctype,例如
<!doctype html>
条件注释,例如
<!--[if !ie]>-->注释<!--<![endif]-->
文本,例如'字符串'
对每个片段的具体处理这里就不说了,有兴趣的直接看源码去。
文本解析器
文本解析器是对html解析器解析出来的文本进行二次加工。文本其实分两种类型,一种是纯文本,另一种是带变量的文本。如下:
这种就是纯文本:
这里有段文本
这种就是带变量的文本:
文本内容:{{text}}
上面html解析器在解析文本时,并不会区分文本是否是带变量的文本。如果是纯文本,不需要进行任何处理;但如果是带变量的文本,那么需要使用文本解析器进一步解析。因为带变量的文本在使用虚拟dom进行渲染时,需要将变量替换成变量中的值。
我们知道,html解析器在碰到文本时,会触发chars钩子函数,我们先来看看钩子函数里面是怎么区分普通文本和变量文本的。
源码位置在:src/compiler/parser/html-parser.js
chars (text: string, start: number, end: number) { //... let child: ?astnode if (!invpre && text !== ' ' && (res = parsetext(text, delimiters))) { child = { type: 2, expression: res.expression, tokens: res.tokens, text } } else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') { child = { type: 3, text } } //... children.push(child) }
我们重点看res = parsetext(text,delimiters)
这一行,通过条件判断设置不同的类型。事实上type=2表示表达式类型,type=3表示普通文本类型。
我们再来看看parsetext函数具体做了什么
export function parsetext ( text: string, delimiters?: [string, string] ): textparseresult | void { const tagre = delimiters ? buildregex(delimiters) : defaulttagre // 匹配不到带变量时直接返回了 if (!tagre.test(text)) { return } const tokens = [] const rawtokens = [] let lastindex = tagre.lastindex = 0 let match, index, tokenvalue // 对匹配到的变量循环处理成表达式 while ((match = tagre.exec(text))) { index = match.index // push text token // 先把 { { 前边的文本添加到tokens中 if (index > lastindex) { rawtokens.push(tokenvalue = text.slice(lastindex, index)) tokens.push(json.stringify(tokenvalue)) } // tag token const exp = parsefilters(match[1].trim()) // 使用_s对变量进行包装 // 把变量改成`_s(x)`这样的形式也添加到数组中 tokens.push(`_s(${exp})`) rawtokens.push({ '@binding': exp }) // 设置lastindex来保证下一轮循环时,正则表达式不再重复匹配已经解析过的文本 lastindex = index + match[0].length } // 当所有变量都处理完毕后,如果最后一个变量右边还有文本,就将文本添加到数组中 if (lastindex < text.length) { rawtokens.push(tokenvalue = text.slice(lastindex)) tokens.push(json.stringify(tokenvalue)) } return { expression: tokens.join('+'), tokens: rawtokens } }
实际上这个函数就是处理带变量的文本,首先如果是纯文本,直接return。如果是带变量的文本,使用正则表达式匹配出文本中的变量,先把变量左边的文本添加到数组中,然后把变量改成_s(x)这样的形式也添加到数组中。如果变量后面还有变量,则重复以上动作,直到所有变量都添加到数组中。如果最后一个变量的后面有文本,就将它添加到数组中。
那么对于上面示例处理结果如下:
parsetext('这里有段文本') // undefined parsetext('文本内容:{{text}}') // '"文本内容:" + _s(text)'
好了,对于文本解析器就这么多内容。
总结一下
模板解析是vue模板编译的第一步,即通过模板得到ast(抽象语法树)。
生成ast的过程核心就是借助html解析器,当html解析器通过正则匹配到不同的片段时会触发对应不同的钩子函数,通过钩子函数对匹配片段进行解析我们可以构建出不同的节点。
文本解析器是对html解析器解析出来的文本进行二次加工,主要是为了处理带变量的文本。