Vue3.2 编译原理分析之一:模板转换成AST

823 阅读6分钟

85768805_p0_master1200.jpg

前言

渲染函数除了由模板编译产生的以外,还可以使用用户自己编写的渲染函数,第一个是在setup执行的时候,用户可以在setup中返回一个函数,这个函数就会当成渲染函数赋值给instance.render,第二次,是在setup没有返回函数且没有写template选项时,会拿到用户写的render选项,如果render选项还不存在,才会去进行编译,如果编译流程走完了还是没有,就给一个默认函数。但是这些不是我们关注的点,我们主要关注vue的编译器是如何将模板编译成渲染函数。

前提说明

  • compileinstallWithProxy函数

image.png

complie函数其实是compileToFunction函数,在runtime-core/src/components.ts中有一个registerRuntimeCompiler,他是负责注册运行时编译函数。而installWithProxy是用于在编译完成之后对组件实例进行代理。

  • 编译中用到的常量

在模板转换成AST的过程中,会用到很多用来判断的常量,这里说一些比较常见的,首先是命名空间,Vue template是一个无关平台的HTML超集(仅限语法),更多的命名空间(如SVG和ManthML),这些由特殊的平台编译声明,而在浏览器平台只有HTML。

接着是节点类型,编译流程中常用的有元素、文本、注释、普通表达式、插值等,再接着是标记类型,常用的有元素、组件、插槽和TEMPLATE,如果感兴趣或者想要详细了解的可以自行去源码中查看:ast.ts

  • isEnd函数

image.png

顾名思义,是用来判断是不是判断结束标记,除了要判断是否为结束标记,还要判断是不是正确的结束表情,比如元素标记的结束标记,需要去被解析了的节点中寻找有没有相对于的开始标记,找到了才是正确的结束标记,不然就算是</,依旧不被认为是结束标记。

编译入口

模板编译的位置在setup执行之后,处理v2选项函数finishComponentSetup中,前提是setup返回的不是函数并且template选项存在。

image.png

先是获取模板template,在开启v2兼容并且instance.props['inline-template']获取的就是内联模板,但是大部分情况都是获取组件配置上的template选项。

image.png

接着就是就是处理编译配置,有全局的和局部的,最后会汇聚成一个最终编译配置,具体的编译配置选项这里就不说明,感兴趣的可以去看vue-loader的文档,里面有详细讲解,这里附上链接:complierOptions。处理好一切之后,就可以统统交给complie函数。

编译三部曲

在我之前的文章vue3.2 初始化都做了什么中的一小节中,简单讲过编译的三个流程,本篇文章先详细分析模板转换成AST的流程,

image.png

调用的complie函数是compileToFunction函数,这个函数的位置在vue/src/index.js,函数的开头是校验模板,如果模板不是String类型,判断其是不是元素节点,是就通过innerHTML拿到里面的内容,不是会报警告并且返回一个默认函数,编译流程结束。

并不是每次都要重新编译,在每一次模板编译的时候都会去缓存中找,找到了说明这个模板之前已经编译过了,不需要再编译一遍,可以直接返回之前编译好的渲染函数。

image.png

模板其实并不一定全是HTML结构,判断如果其是#开头说明用户写了一个id,通过 document.querySelecotr获取元素,获取到就一样用innerHTML获取结构,但是没获取到,报警告并给一个空字符。

image.png

做完上述这些,调用真正的compile函数,并传递模板和编译选项,函数的位置在compile-dom/src/index.ts。而compile函数是去调用baseCompile函数,位置在compile-core/src/compile.ts

模板转换成AST

image.png

baseCompile前的一大坨不用看,那是用来判断是不是要转换加前缀,比如{{foo}}转换成_ctx.foo,主要是看第85行转换模板成AST,调用baseParse转换,最终转换使用的是parseChildren,并且会用createRoot创建一个根节点。

image.png

patchChildren的开头,parent是里当前解析的节点的最近的父节点,第一次进来时null,ns是当前解析的节点的父节点的命名空间,nodes是当前解析节点的中的子节点数组。

image.png

接下来的流程从152行开始到252行这100行之间只有是开始标记,node是用来存储解析好的节点,s是还没解析的内容,由于边界判断太多,我们这里分开分析。

image.png

先提前介绍一下context这个对象,这个对象在后面的解析中会经常用到:

  1. line代表匹配到模板中的第几行或者是这个标记在模板中的第几行,
  2. offset表示当匹配到第几个字符或者是这个标记从第几个字符开始,
  3. column代表在当前行匹配到第几个字符或者是这个标记在line行的第几个字符,
  4. source是还没匹配的模板内容
  5. originalSource是原始模板内容
  6. options是在解析过程中用到的数据和方法 7.onWarn是在解析出现错误的是输出错误用的
解析插值

image.png

options配置项中有这么一个对象delimiters,里面存储的是{{}},很明显了,当开头是{{时代表是插值,进入这里解析,调用parseInterpolation

image.png

获取}}出现的位置赋值给cloneIndex,这里是去context.source中获取,这里不仅仅是获取}}出现的位置,还可以获取{{xxx的长度,方便后面可以简单的知道插值的变量名。如果为-1表示没找到,会提示用户缺失插值并直接返回undefined。再从originalSource中获取{{的位置并保存起来,就可以在source中移除插值开始标记。并修改匹配位置。

下面就是找到插值变量的位置,innerStartinnerEnd代表是插值变量名的实际位置,这里先拿到只是插值标志之间的内容,需要进行处理才能知道插值变量名称的位置,所以这里先给一个默认值:当前匹配位置,在后面对插值变量名处理后再重新赋值。

cloneIndex前面说过是插值变量名带上{{的长度,这里需要减去插值开始标记的长度就能得出原始内容rawContent和其原始长度rawContentLength,进行一次实体解码得到preTrimContent,这个时候还是带有空格的,再去除空格。得到最终的插值变量名content

再结合contentpreTrimContent求出startOffsetendOffset,这个两个是原始内容rawContent前和后包含空格的数量,比如原始内容是xxx,插值变量名xxx前后各有一个空格,那么startOffsetendOffset就都是1,便可得出innerEndinnerStart

image.png

最后修改context上的匹配位置,把插值信息对象返回,对象中有两层,外层是插值开始和结束的位置信息,内层是插值变量名的位置信息和变量信息。插值解析完毕。

解析以<打头的

这第二大类基本都是以<开头的,主要分为三种情况:

image.png

1.<后面紧跟着!,可以匹配到HTML注释(<!---->)、HTML文档声明(<!DOCTYPE>)、CDATA标记(<![CDATA)。

image.png

2.<后面紧跟着/,这非常明显,这是属于错误的模板,比如</</></x<?这些。

image.png

3.<后面紧跟着任意数量的字母,不区分大小写,匹配的是<div<p这些元素标签,或者是<Comp<List这些组件标签。这种情况匹配的最多。

解析元素标签和组件标签

匹配最多的是元素标签和组件标签,我们着重分析,当<后面跟着任意数量的字母,不区分大小写,便可进入执行parseElement进行解析。

image.png

函数开始需要做一些准备工作,获取一些数据给后面用,parent不必多说,wasInPrewasInVPre保存旧的inPreinVPre,通过parseTag拿到标签标记的AST树,也可以得到isPreBoundaryisVPreBoundary,进行下一步处理。

image.png

自闭合的标记可以直接返回,它们没有子标记,如果是自闭合的<pre>标记还需要把context.inPrecontext.inVPre变回false。不是自闭合标签就可以需要解析里面的子标记,将标记添加进ancestors,解析完子标记再移除。获取子节点的类型,再次传入parseChildren开始解析子标记。这是一个递归调用。直接跳到解析完标记后。

image.png

在这之前还有一个处理内联模板的,但是v3不再支持该功能,就不去看它,把解析好的子标记的AST树children并入标记的AST树中,恢复inPreinVPre的状态,返回标记的AST树。元素或者组件标记解析完成。

解析其他<开头的内容
  • 注释标记

image.png

parseComment负责解析注释标记,函数的前半部是在校验这个注释是否正确,没有找到-->、注释节点结束少于3、出现--!>都算错误的注释,

image.png

在确保注释节点正确之后,解析注释节点中的内容,把节点的AST树返回。

  • <![CDATA[节点

image.png

这种类型的节点交给parseCDATA解析,主要是把标记的内部的内容拿出来交给parseChildren解析后返回。

  • HTML文档声明

image.png

parseBogusComment这个函数不单单解析这个,还会去解析错误的,所以可能会有奇怪的流程,而处理HTML文档声明的主要流程是拿到DOCTYPE之后,生成AST树返回

解析标记内的内容

除了匹配标记插值最多,还有一种情况,就是匹配标记内的内容,在前面一大片匹配没有匹配中的,会执行到parseText进行解析。

image.png

先查找到内容的位置,确认是以什么标记结束,可能是[]]>][<, {{],进行遍历,在source找最接近的且位置不能大于source长度的,找到内容content之后不能直接返回,需要执行parseTextData进行处理后再返回。

其他的解析流程

在解析的主流程之外,会有一些分支,这些分支解析的有解析标签、解析AttributeAttributeValue、解析文本数据。而这些解析完成的数据都会返回给主流程。

解析标签

执行parseTag。这个函数解析的是标签的开始和结束。

image.png

开始是校验进来的标记和标记的命名空间,就可以开始处理标记的开始,先是保存开始位置,通过正则匹配出标记名称,确认标记空间之后,移除匹配内容和空格,修改当前匹配位置。还需要保存一下当前匹配位置和source,防止需要使用v-pre重新解析,

image.png

检查是不是<pre>,符合修改context.inPretrue,检查标记上的props,是不是存在v-pre,符合就修改context.inVPretrue,然后重新解析props并过滤掉v-pre,这里我们就可以知道inVPre代表的是v-pre指令、inPre代表标记是<pre>标记。

image.png

解析标记的闭合,到这里如果source长度是0 说明出现了意外的标记,isSelfColsing是自闭合标记的标志,如果是结束标签却又是自闭合标签 说出现了错误的结束标签,移除匹配内容,如果标记是结束标记到这里流程就可以结束了。

image.png

继续往下执行说明是开始标记,校验标记上的v-ifv-for指令是否存在,在因为在3.x中,这两个指令的优先级发生了改变,v-if的优先级比v-for的高,在v-if的条件中无法访问v-for定义的变量别名,如果两个指令都存在,会警告用户不要同时这两个标记,最好使用<template>标记避免歧义或使用computed过滤数据。

image.png

最后是确认标记类型、元素类型等,汇总成对象返回。

解析AttributeAttributeValue

在解析之前,先要清楚attribute是那一部分。标签分为单标签和双标签,在双标签中,attribute存在于开始标记,也就是标签名到>之前的都可能是attribute,而单标签它没有结束标记,所以标签名到/>或者>之间都可能是attribute,这里就得出两个条件,不能以/>或者>开头。

image.png

解析attribute会先进入parseAttributes,声明propsattributeNames用来后面保存数据,除了前来两个得出的条件作为循环条件之外,还必须保证context.source长度不为零,进行循环之后进行简单校验:不能以/开头,不能是结束标记。就可以传递给parseAttribute了。

parseAttribute中主要分为两个部分,分别解析attributeNameattributeValue

  • 解析attributeName

image.png

attributeName的匹配条件是一到多个以非\t、\r、\n、\f、 />、=开头的字符,拿到attributeName之后需要在nameSet也就是attributeName中判断是否有重复的,attributeName会报重复的attribute,再添加到其中,在context.source修改信息,attributeName解析完毕。

image.png

attributeValue的处理就比较庞大了,先看这个,移除等号后交给parseAttributeValue处理。

attributeValue1.png

首先我们要清楚attributeValue的组成,可以是key="value",也可以是key='value',所以需要进行判断是用的单引号还是双引号,这样在移除前一个引号之后,可以通过indexOf获取到后面引号的位置,这样就确定了attributeValue的位置。

正常是找到后一个的引号的位置就可以交给parseTextData解析了,但是没有找到,会把context.source.length传过去。

attributeValue2.png

在前面没有找到是用的那种引号,会用正则进行匹配,匹配以非\t、\r、\n、\f、' '、>开头字符,存在多个,没有找到返回undefined,并且匹配结果中不能出现' 、"、<、=、还有反引号字符,把匹配结果的长度传递给parseTextData解析。函数的最后是返回attributeValue的AST树。

directivesName1.png

回到parseAttribute,在拿到keyvalue之后,因为vue中的attrinbute不仅仅只有key="value",还有v-ifv-for这些指令、具名插槽、绑定值、绑定事件的简写、还有一个新东西,还记的在《Vue3.2 vDOM diff流程分析之一:props和attrs的初始化和更新》文中提到过的 特殊及自定义属性处理,里面有一个.开头是直接设置为attrs的,这也会匹配。

匹配结果是:match[0]是整体、match[1]是指令名称、match[2]是属性名称、match[3]是事件修饰符。而指令名称match[1]不一定可以匹配到,没有匹配到可能是采用了简写指令,需要进行判断确认指令名称。

directivesName2.png

确认好指令之后,需要把指令部分去除后重新确认attributeName,首先获取其位置,注意如果是插槽,需要判断是不是带点的插槽名称,比如v-slot:item.name,在前面匹配的时候.name会被放到match[3]中。在确定位置的时候需要加上它。

directivesName3.png

在获取其内容,虽然说attributeName已经在match[2]中了,并且指定好之后就不会发生改变,但是出现中括号就是动态的,比如动态插槽#[name]、动态出现名[key]="value",在处理到时候需要去除中括号,如果是插槽并且出现带点的名称则需要加上match[3]

directivesName4.png

产生属性名的AST,接着就处理attributeValue,在前面拿到的attributeValue其实是带着引号的,这里是未来去除引号。

绑定事件时,可能会带事件修饰符,在事件委托中需要单独使用,这需要当初分出来。如果当前属性时速记属性,那么就会默认添加prop修饰符,(PS:带有prop修饰符说明这个属性是绑定为DOM properties而不是attributes)

directivesResult.png

最后将一切组合成AST树返回。

directivesError.png

如果前面的没有匹配到,说明缺少指令名或指令名非法,报错返回。

attributeNormalize.png

回到parseAttributes中,在拿到属性的AST树之后,进行一些收尾,添加到props中之后,继续循环之后最后把props返回给主流程。

解析文本内容

parseTextData.png

解析文本内容是比较简单的操作,只需要判断是不是存在&,不存在直接返回,存在则需要进行浏览器实体解码。

转换AST的收尾工作:空白处理

image-20220315113152451.png

在循环模板转AST的流程中,是采用深度优先遍历原则,每遇到一个节点会一直往里面匹配并解析,除非遇到结束标记或者没有匹配到条件才会跳出循环,在每次循环的最后会对之前解析到的节点进行空白处理后再返回给外面。

image-20220315155546307.png

开始遍历整个AST树,在非<pre>且是TEXT,匹配其中的内容,如果没有匹配到除\t、\r、\n、\f和空格之外的字符,会进行压缩或者删除,如果当前标记是第一个或者最后一个进行删除,在压缩模式下,如果这个标记与注释相邻或者在两个元素之间,就移除这个标记,其他情况则会将其压缩成一个空格。倘若匹配了,则会将文本中连续的空格压缩成一个空格。如果是注释节点,会根据用户配置,如果配置需要则删除注释节点

image-20220315161223044.png

节点是<pre>元素,需要根据删除前导换行符,也就是紧跟着<pre>元素的换行符。到最后返回的时候需要过滤,过滤掉前面被置空的节点。到此内部的AST树转换完毕,回到baseParse中创建根AST树然后再返回出去。

总结

vue中对模板转换成AST树的过程中,使用了大量的字符串的操作,比如startWithendsWith,在vue中处理的非常的细致,不仅仅只针对完整写法,对各种简写也进行了处理并将其转换成完整形式,甚至于用的是单引号还是双引号都会被进行,这虽然是考虑了很多边界情况,这也提示着我们,在开发的过程中,能够考虑更多的情况。

好了,到了文章的最后,还是希望各位哥哥姐姐能指导指导。有说错或者遗漏的欢迎在评论区讲解,谢谢。