@wy
2020-12-09T10:31:18.000000Z
字数 22432
阅读 748
vue3源码
parse 用来将模板字符串,解析成 AST (Abstract Syntax Tree, 抽象语法树) 结构,该结构是用对象来描述与之对应的模板中的每个节点信息,后续进行转换、优化、生成渲染函数时,便基于该对象信息进行。
安装 @vue/compiler-core 模块, 创建 app.js, 运行查看。
// app.js
const {baseParse} = require('@vue/compiler-core');
const template = `
<div>hello world</div>
<div>
<span>{{hello}}</span>
</div>
`
const ast = baseParse(template);
console.log(JSON.stringify(ast, null, 2));
在命令行中输入 node app.js > ast.json,运行后,会创建 ast.json 文件,打开 ast.json 查看结构如下:
{
"type": 0,
"children": [
{
"type": 1,
"ns": 0,
"tag": "div",
"tagType": 0,
"props": [],
"isSelfClosing": false,
"children": [
{
"type": 2,
"content": "hello world",
"loc": {
"start": {
"column": 6,
"line": 2,
"offset": 6
},
"end": {
"column": 17,
"line": 2,
"offset": 17
},
"source": "hello world"
}
}
],
"loc": {
"start": {
"column": 1,
"line": 2,
"offset": 1
},
"end": {
"column": 23,
"line": 2,
"offset": 23
},
"source": "<div>hello world</div>"
}
},
{
"type": 1,
"ns": 0,
"tag": "div",
"tagType": 0,
"props": [],
"isSelfClosing": false,
"children": [
{
"type": 1,
"ns": 0,
"tag": "span",
"tagType": 0,
"props": [],
"isSelfClosing": false,
"children": [
{
"type": 5,
"content": {
"type": 4,
"isStatic": false,
"isConstant": false,
"content": "hello",
"loc": {
"start": {
"column": 11,
"line": 4,
"offset": 40
},
"end": {
"column": 16,
"line": 4,
"offset": 45
},
"source": "hello"
}
},
"loc": {
"start": {
"column": 9,
"line": 4,
"offset": 38
},
"end": {
"column": 18,
"line": 4,
"offset": 47
},
"source": "{{hello}}"
}
}
],
"loc": {
"start": {
"column": 3,
"line": 4,
"offset": 32
},
"end": {
"column": 25,
"line": 4,
"offset": 54
},
"source": "<span>{{hello}}</span>"
}
}
],
"loc": {
"start": {
"column": 1,
"line": 3,
"offset": 24
},
"end": {
"column": 7,
"line": 5,
"offset": 61
},
"source": "<div>\n <span>{{hello}}</span>\n</div>"
}
}
],
"helpers": [],
"components": [],
"directives": [],
"hoists": [],
"imports": [],
"cached": 0,
"temps": 0,
"loc": {
"start": {
"column": 1,
"line": 1,
"offset": 0
},
"end": {
"column": 1,
"line": 6,
"offset": 62
},
"source": "\n<div>hello world</div>\n<div>\n <span>{{hello}}</span>\n</div>\n"
}
}
每个对象用来描述一个节点信息,其中 type 为节点类型,用数字表示;tag 是标签的名称;props 为行间属性的集合;children 为嵌套的子级节点对象集合;loc 为解析到该字符串在整个源模板中的位置信息。还包含其他的属性就不一一列举说明了,之后分析到每个解析方法会有涉及。
可以看出来 AST 对象也是个嵌套结构,与模板的嵌套关系一致。唯一不同的是最顶层是一个虚拟节点 type 为 0,作为整个 AST 的根对象, 不映射模板中任何一个具体的节点。
为什么要把根节点设计成一个虚拟节点呢?
因为在 Vue3 中支持 Fragment 语法,也就是一个组件可以有多个根节点,而不是像 Vue2 那样只能有一个根节点。
<template>
<div>hello</div>
<div>world</div>
</template>
这在 Vue2 中会报错,提示模板只能有一个 root 节点。而在 Vue3 中是被允许的,正是在最外层设计的虚拟根节点发挥的作用。
这个根节点上还有其他的属性,像 helpers、hoists、imports 等属性会在后续的对 AST 转换时进行赋值,到生成渲染函数时会用到。
解析器 parse 在 Vue 中属于核心功能,那它是如何将模板解析为 AST 的呢?本文将逐行揭示解析器内部的运行原理。
在 @vue/compiler-core 目录文件的 compile.ts 文件,找到编译器入口:
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
// 其他省略
// 模板解析器 => ast 对象
const ast = isString(template) ? baseParse(template, options) : template
// 转换、优化
transform(ast, {})
// 生成渲染函数
return generate(ast,{})
}
整个编译器主要做了三件事,
- 模板字符串解析为 AST 对象,
- 对 AST 进行转换、优化,
- 生成渲染函数。
本章主要解析解析器的实现。
baseCompile 接收两个参数,从 ts 定义的类型中能够看到,template 为字符串,或者是一个已经解析后的 AST 对象。我们把目标主要集中在解析模板字符串上。
第二个参数 options 的类型是一个 CompilerOptions,编译辅助选项,,。
找到定义它的位置,在 compiler-core/src/options.ts 文件中:
export type CompilerOptions = ParserOptions & TransformOptions & CodegenOptions
定义了多种类型,从名字上可以看出来是给不同阶段使用的参数,我们具体来看 ParserOptions 类型:
当在不同平台调用 baseCompile 函数时,可以根据需要传入以上的解析辅助选项,会在解析模板阶段使用到,不传也没有关系,内部会定义默认的选项。
找到文件 parse.ts,定位到 baseParse 函数,整个解析器的入口。
export function baseParse(
content: string,
options: ParserOptions = {}
): RootNode {
const context = createParserContext(content, options)
const start = getCursor(context)
return createRoot(
parseChildren(context, TextModes.DATA, []),
getSelection(context, start)
)
}
此函数主要做三件事,创建解析上下文 context,parseChildren 解析模板字符串,创建根节点 root。
参数说明,content 待解析的模板字符串;options 类型是 ParserOptions,为解析配置项。
返回值是解析后的 AST 对象。
baseParse 函数解析模板字符串与平台无关,只要调用最终都能生成 AST 对象,只是在不同的平台对标签的规定有差异,需要通过特定的配置项来实现。配置项会在解析阶段被调用,能够影响最终的解析结果。例如 input 这个标签,在浏览器平台属于自闭合标签,通过配置项告知解析器,这样在解析到这类标签时,便不会继续解析子级了。
首先看下有哪些配置项,找到定义它的位置,在 compiler-core/src/options.ts 文件中:
export interface ParserOptions {
/**
* 是否是平台原生的元素,例如在浏览器中有 div、span、input等。
*/
isNativeTag?: (tag: string) => boolean
/**
* 是否是自闭合标签。例如:<img />, <input />, <hr /> 等
*/
isVoidTag?: (tag: string) => boolean
/**
* 是否是pre标签,作用是保留标签内的空格和换行符
*/
isPreTag?: (tag: string) => boolean
/**
* 是否是跟平台相关指定的内置组件. 例如:Vue内置组件`<Transition>`、<TransitionGroup>
*/
isBuiltInComponent?: (tag: string) => symbol | void
/**
* 是否是扩展的自定义标签,避免当成组件来解析。
*/
isCustomElement?: (tag: string) => boolean | void
/**
* 获取到元素的命名空间,例如针对浏览器,在compiler-dom中定义了 HTML、SVG、MATH_ML
*/
getNamespace?: (tag: string, parent: ElementNode | undefined) => Namespace
/**
* 获取文本的解析模式
*/
getTextMode?: (
node: ElementNode,
parent: ElementNode | undefined
) => TextModes
/**
* 自定义插值分隔符,默认为 ['{{', '}}']
*/
delimiters?: [string, string]
/**
* 仅DOM编译器需要,解析实体字符,例如:$gt 解析为 > $lt 解析为 <
*/
decodeEntities?: (rawText: string, asAttr: boolean) => string
onError?: (error: CompilerError) => void
/**
* 是否将注释保留在AST树中,生产环境也保留
*/
comments?: boolean
}
从语义化属性名上很清晰的表达了配置项的作用,可以结合解析浏览器平台的 DOM 标签来理解。
找到 @vue/compiler-dom 模块的 parserOptions.ts 文件,可以看出定义的配置项,都是和浏览器平台相关。拿 isNativeTag 这个来说,用来判断是不是原生标签。
isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag),
原生标签有 html 标签和 SVG 标签,通过 isHTMLTag 继续找,能看到定义:
const HTML_TAGS =
'html,body,base,head,link,meta,style,title,address,article,aside,footer,' +
'header,h1,h2,h3,h4,h5,h6,hgroup,nav,section,div,dd,dl,dt,figcaption,' +
'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' +
'data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby,s,samp,small,span,strong,sub,sup,' +
'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' +
'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' +
'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' +
'option,output,progress,select,textarea,details,dialog,menu,' +
'summary,template,blockquote,iframe,tfoot'
这里列出了 html 规定的标签,只要在解析模板字符串的过程中,标签名称不属于上述的原生标签,可以判定为是组件标签了。
以上只举了一个例子来说明配置项的作用,如果你要基于该解析器指定自己需要的平台的标签,可通过配置项来完成。
createParserContext 用来创建解析上下文对象,作用是记录解析过程中的解析状态,这个对象将贯穿解析的整个过程,会不断传递给不同的解析函数使用,来看下 createParserContext 内部做了哪些事:
function createParserContext(
content: string,
rawOptions: ParserOptions
): ParserContext {
// 克隆一份默认的编译辅助选项
const options = extend({}, defaultParserOptions)
// 将默认编译辅助选项覆盖为传入的选项
for (const key in rawOptions) {
// @ts-ignore
options[key] = rawOptions[key] || defaultParserOptions[key]
}
return {
options, // 编译辅助选项
column: 1, // 列
line: 1, // 行
offset: 0, // 偏移位置
originalSource: content, // 保留原始字符串
source: content, // 记录字符串,之后会不断的截取
inPre: false, // 是否是pre标签,或在pre标签内
inVPre: false // 是否在 v-pre 指令所在标签,或在其内
}
}
此函数主要是将 传入的解析配置项覆盖默认的解析配置项,返回初始一些列值的对象。
defaultParserOptions 定义了默认的解析配置项,会被传入的配置项覆盖。看一下 defaultParserOptions的定义:
const decodeRE = /&(gt|lt|amp|apos|quot);/g
const decodeMap: Record<string, string> = {
gt: '>',
lt: '<',
amp: '&',
apos: "'",
quot: '"'
}
export const defaultParserOptions: MergedParserOptions = {
delimiters: [`{{`, `}}`], // 默认的插值符号
getNamespace: () => Namespaces.HTML, // 默认命名空间,为 0
getTextMode: () => TextModes.DATA, // 默认的文本模式, 为0
isVoidTag: NO, // 自闭标签判断函数
isPreTag: NO, // pre 标签判断函数
isCustomElement: NO, // 自定义标签判断函数
// 实体字符转对应的符号
decodeEntities: (rawText: string): string =>
rawText.replace(decodeRE, (_, p1) => decodeMap[p1]),
onError: defaultOnError, // 错误提示函数
comments: false // 是否保留注释节点,默认不保留
}
NO 是一个函数,作用就是返回 false,这种方式可以借鉴,当多个变量需要定义默认的函数时,可以只定义一个函数,多次赋值,节省创建函数的成本,减少内存占有。
export const NO = () => false
decodeEntities 的作用就是匹配到以 & 开头的实体字符,替换为对应的字符标识。
回到 baseParse,来看 getCursor 函数,作用很简单,拿到调用时解析到的列、行、偏移信息。
function getCursor(context: ParserContext): Position {
const { column, line, offset } = context
return { column, line, offset }
}
此时调用,其实获取的就是第1列、第1行、第0个偏移,也就是模板字符串的开头位置。随着解析的进行,这些值会不断发生变化。
createRoot,用来创建根对象,代码在 ast.ts 中,只需要记住返回的对象类型 type 为 NodeTypes.ROOT 。子级是一个数组,存的是传入的模板字符串解析后的 AST 对象。
调用 parseChildren,才开始真正的解析模板字符串。
parseChildren(context, TextModes.DATA, [])
context 解析上下文对象,其中的 source 存的就是要解析的模板字符串。
TextModes.DATA,是文本模式。
找到 TextModes 的定义:
export const enum TextModes {
// | Elements | Entities | End sign | Inside of
DATA, // | ✔ | ✔ | End tags of ancestors |
RCDATA, // | ✘ | ✔ | End tag of the parent | <textarea>
RAWTEXT, // | ✘ | ✘ | End tag of the parent | <style>,<script>
CDATA,
ATTRIBUTE_VALUE
}
RAWTEXT 原始文本元素,标签内可以容纳为文本形式,例如 \\,可以写写 css 样式和 js 代码。
第三个参数,是一个空数组,当做栈来用,后进先出,作用是保存开始解析,却还没完成的标签,当解析时,遇到开始标签时,将标签推入栈中,遇到结束标签时,才将标签从栈中弹出。还有个作用在解析某个标签时,通过 **stack[stack.length - 1]
**获取它的父元素。举个例子,有以下嵌套模板:
<div>
<span>hello</span>
<ul>
<li>1</li>
<li>2</li>
</ul>
</section>
结束标签故意写错,解析过程为:
解析到 div 开始标签,标签名入栈:
['div']
继续解析,到 span 开始标签,标签名入栈:
['div','span']
继续解析,到 span 结束标签,解析完毕,则出栈:
['div']
继续解析,到 ul 开始标签,标签名入栈:
['div', 'ul']
继续解析,第一个 li 开始标签,标签名入栈:
['div', 'ul', 'li']
继续解析,第一个 li 结束标签,解析完毕,则出栈:
['div', 'ul']
继续解析,第二个 li 开始标签,标签名入栈:
['div', 'ul', 'li']
继续解析,第二个 li 结束标签,解析完毕,则出栈:
['div', 'ul']
继续解析,到 ul 结束标签,解析完毕,则出栈:
['div']
继续解析,到 section 结束标签,解析完毕,此时和栈内最后一个标签 'div', 不相同,则报错提醒。
通过不断将开始标签入栈,结束出栈,来判断标签是否解析完毕,并能在解析结束之后,找到该元素的父级,即为数组中最后一个元素。例如上面过程,当每个 li 解析完毕时,数组存的最后一个 ul,就是当前 li 的父级;ul 解析完毕时,数组之后一个 div,就是 ul 的父级。
举例说明入栈为标签,在实际的解析过程,入栈的是解析当前标签的对象形式,我们这里为了说明这个过程,简化成了入栈为标签,为了更方便理解。
通过名字 parseChildren,能看出来,用来解析子级。每当解析到一个标签,进入到子级就会调用此函数。在内部会通过传入的模板字符串,命中解析规则,来决定调用那个解析函数进行解析。解析函数内部如果需要解析子级,又会调用 parseChildren 函数,这样会形成递归调用。
代码量比较庞大,分段来说明。
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
const parent = last(ancestors)
const ns = parent ? parent.ns : Namespaces.HTML
const nodes: TemplateChildNode[] = []
while (!isEnd(context, mode, ancestors)) {
// 判断解析的字符传不能为空
__TEST__ && assert(context.source.length > 0)
const s = context.source
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
// 解析规则
// 省略....
if(){}
// node为空,没有命中上面的规则,则为文本形式,进入文本解析流程
if (!node) {
node = parseText(context, mode)
}
// 解析可能是兄弟节点,为数组形式,则分别放入到结果集中
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}
let removedWhitespace = false
// 去掉空白字符
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
该函数主要做的事情:
- 不断循环模板字符串,直到该标签解析完毕
- 命中不同规则,调用对应解析函数
- 将解析后的对象返回
此函数接收的三个参数,在上面解释过了,可以回看。特别要说明的是 ancestors,实际上就是上面说的存储嵌套关系的栈,存的是 ElementNode 类型,也就是解析后的对象。
找到当前开始解析的子级的父对象,也就是栈内最后一个值。
const parent = last(ancestors)
last 函数的实现代码:
function last<T>(xs: T[]): T | undefined {
return xs[xs.length - 1]
}
一开始没有父级,返回为 undefined。
先看循环的大框架。
while (!isEnd(context, mode, ancestors)) {
const s = context.source
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
// 判断类型,进入不同解析流程,下面会解释
// 规则省略....
if(){}
}
循环的作用是不断解析模板字符串,直到将一个标签解析完毕为止。
通过 context.source 存的字符串的第一个字符,来命中指定的规则,走不同分支调用函数解析。规则比较多,稍后解释。
先来看 isEnd 做的事,在什么样的情况下,该标签算是解析完毕。
function isEnd(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): boolean {
const s = context.source
switch (mode) {
case TextModes.DATA:
if (startsWith(s, '</')) {
//TODO: probably bad performance
for (let i = ancestors.length - 1; i >= 0; --i) {
if (startsWithEndTagOpen(s, ancestors[i].tag)) {
return true
}
}
}
break
case TextModes.RCDATA:
case TextModes.RAWTEXT: {
const parent = last(ancestors)
if (parent && startsWithEndTagOpen(s, parent.tag)) {
return true
}
break
}
case TextModes.CDATA:
if (startsWith(s, ']]>')) {
return true
}
break
}
return !s
}
该函数的作用是在不同文本模式下,判断标签是否解析完毕。
参数说明,context 是解析上下文对象,拿到在 context.source 中的字符串;mode 文本模式,不同的模式结束方式不一样,需要区分判断;ancestors 解析的标签栈。
下面来看 switch 中对不同文本模式,是如何进行处理的。
如果文本模式为 TextModes.DATA,同时 startsWith(s, </
),判断标签结束,看代码:
case TextModes.DATA:
if (startsWith(s, '</')) {
for (let i = ancestors.length - 1; i >= 0; --i) {
if (startsWithEndTagOpen(s, ancestors[i].tag)) {
return true
}
}
}
break
从后向前找 ancestors 解析的标签栈,该结束标签名是否和栈中某个标签名相同,如果有相同的话,视为结束。
以上文本模式为 TextModes.DATA 判断过程,要求结束标签是以 </
开头,既判断为结束,那该标签就解析完成了。
文本模式如果是 TextModes.RCDATA 或者 TextModes.RAWTEXT,看代码:
case TextModes.RCDATA:
case TextModes.RAWTEXT: {
const parent = last(ancestors)
if (parent && startsWithEndTagOpen(s, parent.tag)) {
return true
}
break
}
这两种模式,TextModes.RCDATA 模式元素 <textarea>
、<title>
,TextModes.RAWTEXT 模式元素 <script>
和<style>
,要求开始标签必须存在,并且结束标签要和开始标签保持一致。
如果是 TextModes.CDATA 模式,很简单,结束必须是 ]]> 。
case TextModes.CDATA:
if (startsWith(s, ']]>')) {
return true
}
break
满足以上的模式都视为结束当前解析。但如果很不幸没命中就结束,则在最后判断传入的字符串是否为空,不为空,继续解析;为空,则不需要解析了。
return !s
传入一个将要解析的模板字符串,会根据模板的前几个字符,来决定进入到那个解析函数进行处理。遇到不合规的字符串,则会抛出错误提示。
先来看解析规则源码:
while (!isEnd(context, mode, ancestors)) {
__TEST__ && assert(context.source.length > 0)
const s = context.source
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
// 文本、正常标签
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
// 不在v-pre指令元素内,并且开始是 {{,也就是插值语法
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// '{{'
// 处理插值语法,返回处理后的结果
node = parseInterpolation(context, mode)
} else if (mode === TextModes.DATA && s[0] === '<') { // 处理标签
// 字符串长度为1个,也就是只有 < 不符合标签定义,抛出错误:意外的文本流
if (s.length === 1) {
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 1)
} else if (s[1] === '!') { // 后面跟着感叹号 !
if (startsWith(s, '<!--')) { // 解析注释
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) { // 文档声明,当做注释节点
node = parseBogusComment(context)
} else if (startsWith(s, '<![CDATA[')) { //
if (ns !== Namespaces.HTML) {
node = parseCDATA(context, ancestors)
} else {
// 抛出错误,只允许在XML上下文中使用。
emitError(context, ErrorCodes.CDATA_IN_HTML_CONTENT)
// 当做注释节点
node = parseBogusComment(context)
}
} else {
// 不符合上面的规则,则抛出:错误的注释
emitError(context, ErrorCodes.INCORRECTLY_OPENED_COMMENT)
// 都当做注释解析
node = parseBogusComment(context)
}
} else if (s[1] === '/') {
if (s.length === 2) { // 只有两个字符 </
// 抛出意外的文本流
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 2)
} else if (s[2] === '>') { // 有结束,但没有标签名 </>
// 抛错,预期的结束标签
emitError(context, ErrorCodes.MISSING_END_TAG_NAME, 2)
// 截取
advanceBy(context, 3)
// 解析</>没意义,直接跳过
continue
} else if (/[a-z]/i.test(s[2])) { // 第三个字符是字母,说明 isEnd 没有拦住为结束标签
// 抛出错误,为无效的结束标签
emitError(context, ErrorCodes.X_INVALID_END_TAG)
// 把这个结束标签结束完毕
parseTag(context, TagType.End, parent)
// 无效的结束标签,没必要进入到节点对象,跳过
continue
} else {
// 标签名不合法
emitError(
context,
ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME,
2
)
// 解析为注释节点
node = parseBogusComment(context)
}
} else if (/[a-z]/i.test(s[1])) { // 第二个字符是字母,则当做标签解析
node = parseElement(context, ancestors)
} else if (s[1] === '?') {
// 抛错 <? 只能在 xml 中使用
emitError(
context,
ErrorCodes.UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
1
)
// 当做注释节点
node = parseBogusComment(context)
} else {
// 抛出:标签名不合法
emitError(context, ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME, 1)
}
}
}
// node为空,说明没有命中上面规则,则当做文本解析
if (!node) {
node = parseText(context, mode)
}
// 解析后放在集合中
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}
上面的规则看似多,实际的主线以 <
开头,判断后面跟的字符,进入到解析函数中。
解析函数总结下来分为几类:
每调用一个方法,解析完毕后,会返回解析后的 AST 对象,并把已经解析后的模板字符串截断。
解析的 AST 对象,会标记为不同的节点类型,方便在后续转换时,根据不同的类型做操作,例如解析到标签,则标记为 NodeTypes.ELEMENT,解析到属性时,标记为 NodeTypes.ATTRIBUTE。
NodeTypes 的定义在 ast.ts 文件中,类型为 enum,实际上拿到的值为从0开始依次递增的数字:
export const enum NodeTypes {
ROOT, // 根节点 0
ELEMENT, // 元素节点 1
TEXT, // 文本节点 2
COMMENT, // 注释节点 3
SIMPLE_EXPRESSION, // 表达式 4
INTERPOLATION, // 插值 {{}} 5
ATTRIBUTE, // 属性 6
DIRECTIVE, // 指令 7
}
命中该解析函数的规则,是以 <
开头,后面跟上一个字母。
我们先从解析元素标签开始去分析。【需要张图】
function parseElement(
context: ParserContext,
ancestors: ElementNode[]
): ElementNode | undefined {
// 判断字符串开头必须是以 < 开头,后面跟着一个字母,忽略大小写
__TEST__ && assert(/^<[a-z]/i.test(context.source))
// Start tag.
// 是否在 pre 标签内
const wasInPre = context.inPre
// 是否在有 v-pre 属性的标签内
const wasInVPre = context.inVPre
// 拿到父级
const parent = last(ancestors)
// 开始解析标签
const element = parseTag(context, TagType.Start, parent)
const isPreBoundary = context.inPre && !wasInPre
const isVPreBoundary = context.inVPre && !wasInVPre
// 自闭合标签,或者调用解析配置项来判断
if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
return element
}
// 存入栈中
// Children.
ancestors.push(element)
// 拿到元素的模式文本模式
const mode = context.options.getTextMode(element, parent)
// 继续解析子级
const children = parseChildren(context, mode, ancestors)
// 子级解析完毕后,从栈中推出
ancestors.pop()
// 存子级
element.children = children
// 剩下结束标签
// End tag.
if (startsWithEndTagOpen(context.source, element.tag)) {
// 解析结束标签
parseTag(context, TagType.End, parent)
} else {
// 报错“元素没有结束标签”
emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
const first = children[0]
if (first && startsWith(first.loc.source, '<!--')) {
emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
}
}
}
// 选取解析范围的字符串和位置信息
element.loc = getSelection(context, element.loc.start)
// // 不在在 pre 中,设置为false
if (isPreBoundary) {
context.inPre = false
}
// 不在 v-pre 指令标签内,设置为 false
if (isVPreBoundary) {
context.inVPre = false
}
// 返回元素对象
return element
}
这个函数实际上做的事就是调度解析标签的其他方法,我们知道,一个完整的标签,包含标签名、行间属性、子级结构(闭合标签除外)。
解析标签名和行间属性交给了 parseTag 去做,稍后再详细分析该函数,这里看下接收的参数。
const element = parseTag(context, TagType.Start, parent)
参数说明,context 为解析上下文对象;TagType.Start 是一个标识,代表传入标签类型是开始标签,后面解析结束标签也会调用这个方法,传入的是TagType.End 标识; parent 是解析当前标签的父级,通过 last(ancestors) 获取解析标签栈的最后一个元素。
返回值是一个解析后的元素对象。
元素标签按照闭合的方式,有自闭合标签(self-closing)和非闭合标签之分,自闭合标签如 <input />
、<img />
等,这类没有子元素;非闭合标签,如 <div></div>
、<span></span>
,这类标签是双标签形式,之间可以包含子元素。如果有子元素的情况,还要继续解析下去。
判断闭合标签,通过解析后的元素对象属性判定,或者通过传入的解析配置项来判定:
// 自闭和标签,或者调用辅助方法来判定
if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
return element
}
是闭合标签,没有结束标签,到这里标签解析完毕了,返回解析后的元素对象即可。
非闭合标签,要有解析子元素的操作,此时标签还没解析完毕,需要先将当前元素对象存入到栈中,然后走解析子结构流程,等子结构解析完毕,当前标签也就解析完毕了,从栈中弹出。
代码:
// 子级没解析完毕,存入栈中,解析子级时会用
ancestors.push(element)
// 拿到元素的文本模式
const mode = context.options.getTextMode(element, parent)
// 解析子级
const children = parseChildren(context, mode, ancestors)
// 子级解析完毕后,从栈中推出
ancestors.pop()
解析子级结构进入到 parseChildren,通过 last 函数拿到栈中最后一个元素对象,其实就是未解析完毕的父级,通过这段代码可以清晰的理解。进入 parseChildren 后字符串依然要命中解析规则,来决定执行解析函数。
调用解析配置项来得到当前结构的文本模式,默认都是 TextModes.DATA 。在浏览器平台,标签为 textarea、title,文本模式为 TextModes.RCDATA;标签为 style,iframe,script,noscript 文本模式为 TextModes.RAWTEXT;其他的都是 TextModes.DATA。
无论是开始标签还是结束标签,都需要此函数进行解析,在调用时需要告知函数要解析的是开始还是结束标签。
先看代码代码的参数和返回值代码:
function parseTag(
context: ParserContext,
type: TagType,
parent: ElementNode | undefined
): ElementNode {
// 获取解析前的位置信息,将来通过这个信息和解析后的信息,找出该标签在源模板中字符串
const start = getCursor(context)
// 匹配标签名称
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
const tag = match[1]
// 获取标签的命名空间
const ns = context.options.getNamespace(tag, parent)
// 标签名字解析后,模板字符串向前移动,标签名称个长度
advanceBy(context, match[0].length)
// 删除匹配的空格,向后移动
advanceSpaces(context)
// 得到一份位置信息,目的是在下面遇到 pre 时, 会重新解析标签,重置到这个位置
const cursor = getCursor(context)
// 存一份解析的模板字符串,方便下面重置时用
const currentSource = context.source
// 解析行间属性
let props = parseAttributes(context, type)
// 调用选项辅助方法,检查是否是 pre 标签
if (context.options.isPreTag(tag)) {
context.inPre = true
}
// 如果不在 pre 标签内,同时存在指令 v-pre,则要重新解析一遍行间属性
if (
!context.inVPre &&
props.some(p => p.type === NodeTypes.DIRECTIVE && p.name === 'pre')
) {
// 标记为 true,方便继续解析时,判断是在 v-pre 标签内
context.inVPre = true
// 重置解析字符串的位置
extend(context, cursor)
context.source = currentSource
// 重新解析一遍 pre 中的行间属性,把行间有 v-pre 指令过滤掉
props = parseAttributes(context, type).filter(p => p.name !== 'v-pre')
}
// 是否是自闭合标签
let isSelfClosing = false
// 标签解析完毕,剩余的模板字符串空了,说明没有结束标签,提醒错误
if (context.source.length === 0) {
emitError(context, ErrorCodes.EOF_IN_TAG)
} else {
// 如果剩下的模板字符串,是以 "/>" 开头,判定为自闭合标签
isSelfClosing = startsWith(context.source, '/>')
// 如果传入的是要解析双标签的结束标签,却遇到了是自闭合标签,则提醒错误
if (type === TagType.End && isSelfClosing) {
emitError(context, ErrorCodes.END_TAG_WITH_TRAILING_SOLIDUS)
}
// 截断字符传,自闭合标签向后截取两位,以为是 /> ,不是的话截一位 是 >
advanceBy(context, isSelfClosing ? 2 : 1)
}
let tagType = ElementTypes.ELEMENT
const options = context.options
// 不在 pre 中,不是自定义标签
if (!context.inVPre && !options.isCustomElement(tag)) {
// 某些属性是否有 v-is指令
const hasVIs = props.some(
p => p.type === NodeTypes.DIRECTIVE && p.name === 'is'
)
// 判断原生标签方法存在,并且没有v-is指令
if (options.isNativeTag && !hasVIs) {
// 不是原生标签,则被标记为组件标签
if (!options.isNativeTag(tag)) tagType = ElementTypes.COMPONENT
} else if (
hasVIs || // 有 v-is指令
isCoreComponent(tag) || // 是内置的组件名称
(options.isBuiltInComponent && options.isBuiltInComponent(tag)) || // 如果辅助方法判断是内置标签
/^[A-Z]/.test(tag) ||
tag === 'component' // 或标签名为 "component"
) {
tagType = ElementTypes.COMPONENT // 类型设置为组件
}
// 如果是 slot 标签
if (tag === 'slot') {
tagType = ElementTypes.SLOT
} else if (
tag === 'template' &&
props.some(p => {
return (
p.type === NodeTypes.DIRECTIVE && isSpecialTemplateDirective(p.name)
)
})
) {
tagType = ElementTypes.TEMPLATE
}
}
return {
type: NodeTypes.ELEMENT, // 标签类型
ns, // 命名空间
tag, // 标签名称
tagType, // 标签名称类型
props, // 行间属性集合
isSelfClosing, // 是否是自闭合标签
children: [], // 子级
loc: getSelection(context, start), // 当前标签在源模板字符串的位置信息
codegenNode: undefined
}
}
参数说明,context 是解析上下文对象,type 是指定解析的标签是开始还是结束标签,有两个值 TagType.Start,或者是以 ;parent 解析当前标签的父级对象。
返回值是一个元素节点,类型都为 NodeTypes.ELEMENT。根据标签名字的不同,将标签再进行细分,存在 tagType 中,是标签的类型,有以下几种,以及满足什么样的条件:
通过正则来匹配标签名。
// 开始标签
const source = '<div test="1"></div>'
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(source)
console.log(match); // 打印:["<div", "div"]
// 结束标签
const source = '</div>'
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(source)
console.log(match); // 打印:["</div", "div"]
开头必须以 < 开头。后面 /,可以有,可以没有,因为此函数是用来解析标签的,不分前后标签,也就是 \
匹配完之后向后移动,具体参考:
解析行间属性比较复杂,调用函数 parseAttributes,进行解析,这个后面会降到。行间属性可以存在多个,返回是一个数组形式。
pre 标签可定义预格式化的文本,被包围在 \ 标签 元素中的文本通常会保留空格和换行符。在 pre 标签内的插值运算只当成文本处理,而不会解析为表达式。
例如模板:
const source = '<pre> {{hello}} <div>hello</div> </pre>'
显示在浏览器中时,还保留双大括号,而不是作为插值表达式解析。
一单判定为 pre 标签,就会把解析上下文对象 context 的 inPre 设置为 true,这样在解析到 pre 包含的子级时,就会做特殊处理了。当 pre 解析完毕,又会把 inPre 设置为 false,具体可以看上面 parseElement 函数的注释。
调用选项辅助方法 isPreTag,来判断是否是 pre 标签,在浏览器就是 pre 这个名字,在别的平台可能是别的名字,可通过选项辅助方法 isPreTag 自己定义。
有时会遇到是自闭合标签,这样向后截取的位数是不同的,例如解析 \\\,img 是自闭合标签,解析完要向后截取2个位置才到标签 a。非闭合标签 a,结束为 >,只需要向后截取1个位置。
如果遇到异常情况,则需要提醒,具体看代码注释。
这个函数相对来说比较简单。
function parseAttributes(
context: ParserContext,
type: TagType
): (AttributeNode | DirectiveNode)[] {
// 存放多个解析后的行间属性
const props = []
// 存放行间属性,去重用
const attributeNames = new Set<string>()
while (
context.source.length > 0 && // 要解析的字符串不能为空
!startsWith(context.source, '>') && // 字符串开头不能是 > 。遇到 > 说明行间属性解析完毕了
!startsWith(context.source, '/>') // 也不能是 /> ,这也是解析完毕
) {
if (startsWith(context.source, '/')) { // 如果字符串以 / 开头,提示非法的,并向后截取1位,不进行解析了
emitError(context, ErrorCodes.UNEXPECTED_SOLIDUS_IN_TAG)
advanceBy(context, 1)
advanceSpaces(context)
continue
}
// 如果要解析的是结束标签,则提醒:“结束标签不能有行间属性”
if (type === TagType.End) {
emitError(context, ErrorCodes.END_TAG_WITH_ATTRIBUTES)
}
// 开始解析行间属性
const attr = parseAttribute(context, attributeNames)
// 只有开始标签,才存解析后的行间属性
if (type === TagType.Start) {
props.push(attr)
}
// 解析完一个属性后,遇到正则中的字符,则是在预期中,这其实就是个提醒
if (/^[^\t\r\n\f />]/.test(context.source)) {
emitError(context, ErrorCodes.MISSING_WHITESPACE_BETWEEN_ATTRIBUTES)
}
// 将空白消除掉
advanceSpaces(context)
}
// 最后返回解析后的属性数组
return props
}
接收参数说明,context 为解析上线文对象;type 为开始或结束标签的标识。
返回值是一个数组,存的是通过 parseAttribute 函数解析后的行间属性对象形式,这个稍后进函数再看。
定义了个 Set 数据结构,用来存已经解析的行间属性,如果定义了相同行间属性名称,则会报错,具体会在 parseAttribute 中判断。
行间属性可以写多个,用了 while 循环挨个遍历解析,直到解析的字符串为空了,同时解析到了 > 或 />,被视为行间属性解析完毕。
只有当 type 为 TagType.Start 时,也就是开始标签时,行间属性才有意义,如果为结束标签,写在结束标签内的属性没有意义。
这个函数是用来解析行间属性。
可以看到只有当传入为字符串 template 时,才调用 baseParse 方法进行解析,返回是 AST 对象。options 为解析过程中,根据不同平台传入的辅助属性或方法,这个到 baseParse 函数中来看具体可以传入什么样的辅助属性。