vue 编译原理 简介

 来源html

tinycompilevue

关于vue的内部原理其实有不少个重要的部分,变化侦测,模板编译,virtualDOM,总体运行流程等。 node

以前写过一篇《深刻浅出 - vue变化侦测原理》 讲了关于变化侦测的实现原理。git

那今天主要把 模板编译这部分的实现原理单独拿出来说一讲。github

本文我可能不会在文章中说太多细节部分的处理,我会把 vue 对模板编译这部分的总体原理讲清楚,主要是让读者读完文章后对模板编译的总体实现原理有一个清晰的思路和理解。express

关于 Vue 编译原理这块的总体逻辑主要分三个部分,也能够说是分三步,这三个部分是有先后关系的:浏览器

  • 第一步是将 模板字符串 转换成 element ASTs(解析器)安全

  • 第二步是对 AST 进行静态节点标记,主要用来作虚拟DOM的渲染优化(优化器)dom

  • 第三步是 使用 element ASTs 生成 render 函数代码字符串(代码生成器)函数

解析器

解析器主要干的事是将 模板字符串 转换成 element ASTs,例如:

< div> < p>{{name}}</ p></ div>

上面这样一个简单的 模板 转换成 element 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)"}] } ]}

咱们先用这个简单的例子来讲明这个解析器的内部究竟发生了什么。

这段模板字符串会扔到 while 中去循环,而后 一段一段的截取,把截取到的 每一小段字符串进行解析,直到最后截没了,也就解析完了。

上面这个简单的模板截取的过程是这样的:

< div> < p>{{name}}</ p></ div> < p>{{name}}</ p></ div> < p>{{name}}</ p></ div> {{name}}</ p></ div> </ p></ div> </ div> </ div>

那是根据什么截的呢?换句话说截取字符串有什么规则么?

固然有

只要判断模板字符串是否是以 < 开头咱们就能够知道咱们接下来要截取的这一小段字符串是 标签 仍是 文本。

举个 🌰 :

<div></div> 这样的一段字符串是以 < 开头的,那么咱们经过正则把 <div> 这一部分 match 出来,就能够拿到这样的数据:

{ tagName :'div', attrs :[], unarySlash :'', start :0, end :5}

好奇如何用正则解析出 tagName 和 attrs 等信息的同窗能够看下面这个demo代码:

constncname='[a-zA-Z_][w-.]*'constqnameCapture=`((?:${ncname}:)?${ncname})`conststartTagOpen=newRegExp( `^<${qnameCapture}`) conststartTagClose=/^s*(/?)>/lethtml =`<div></div>`letindex =0conststart=html. match(startTagOpen) constmatch={ tagName :start[ 1], attrs :[], start :0}html =html. substring(start[ 0]. length)index +=start[ 0]. lengthletend, attr while( !(end =html. match(startTagClose)) &&(attr =html. match(attribute))) { html =html. substring(attr[ 0]. length) index +=attr[ 0]. lengthmatch. attrs. push(attr)} if(end) { match. unarySlash=end[ 1] html =html. substring(end[ 0]. length) index +=end[ 0]. lengthmatch. end=index} console. log(match) Stack

用正则把 开始标签 中包含的数据(attrs, tagName 等)解析出来以后还要作一个很重要的事,就是要维护一个 stack。

那这个 stack 是用来干什么的呢?

这个 stack 是用来记录一个层级关系的,用来记录DOM的深度。

更准确的说,当解析到一个 开始标签 或者 文本,不管是什么, stack 中的最后一项,永远是当前正在被解析的节点的 parentNode 父节点。

经过 stack 解析器就能够把当前解析到的节点 push 到 父节点的 children 中。

也能够把当前正在解析的节点的 parent 属性设置为 父节点。

事实上也确实是这么作的。

但并非只要解析到一个标签的开始部分就把当前标签 push 到 stack 中。

由于在 HTML 中有一种 自闭和标签,好比 input。

<input /> 这种 自闭和的标签 是不须要 push 到 stack 中的,由于 input 并不存在子节点。

因此当解析到一个标签的开始时,要判断当前被解析的标签是不是自闭和标签,若是不是自闭和标签才 push 到 stack 中。

if( !unary) { currentParent =element stack. push(element)}

如今有了 DOM 的层级关系,也能够解析出DOM的 开始标签,这样每解析一个 开始标签 就生成一个 ASTElement (存储当前标签的attrs,tagName 等信息的object)

而且把当前的 ASTElement push 到 parentNode 的 children 中,同时给当前 ASTElement 的 parent属性设置为 stack 中的最后一项

currentParent. children. push(element) element. parent=currentParent < 开头的几种状况

但并非全部以 < 开头的字符串都是 开始标签,以 < 开头的字符串有如下几种状况:

  • 开始标签 <div>

  • 结束标签 </div>

  • HTML注释 <!-- 我是注释 -->

  • Doctype <!DOCTYPE html>

  • 条件注释(Downlevel-revealed conditional comment)

固然咱们解析器在解析的过程当中遇到的最多的是 开始标签 结束标签 和 注释

截取文本

咱们继续上面的例子解析,div 的 开始标签 解析以后剩余的模板字符串是下面的样子:

< p>{{name}}</ p></ div>

这一次咱们在解析发现 模板字符串 不是以 < 开头了。

那么若是模板字符串不是以 < 开头的怎么处理呢??

其实若是字符串不是以 < 开头可能会出现这么几种状况:

我是text < div></ div>

或者:

我是text </ p>

不管是哪一种状况都会将标签前面的文本部分解析出来,截取这段文本其实并不难,看下面的例子:

//能够直接将本 demo 放到浏览器 console 中去执行consthtml='我是text </p>'lettextEnd =html. indexOf( '<') consttext=html. substring( 0, textEnd) console. log(text)

固然 vue 对文本的截取不仅是这么简单,vue对文本的截取作了很安全的处理,若是 < 是文本的一部分,那上面 DEMO 中截取的内容就不是咱们想要的,例如这样的:

a < b </ p>

若是是这样的文本,上面的 demo 确定就挂了,截取出的文本就会遗漏一部分,而 vue 对这部分是进行了处理的,看下面的代码:

lettextEnd =html. indexOf( '<') lettext, rest, next if(textEnd >=0) { rest =html. slice(textEnd) //剩余部分的 HTML 不符合标签的格式那确定就是文本//而且仍是以 < 开头的文本while( !endTag. test(rest) &&!startTagOpen. test(rest) &&!comment. test(rest) &&!conditionalComment. test(rest) ) { //< in plain text, be forgiving and treat it as textnext =rest. indexOf( '<', 1) if(next <0) breaktextEnd +=next rest =html. slice(textEnd) } text =html. substring( 0, textEnd) html =html. substring( 0, textEnd)}

这段代码的逻辑是若是文本截取完以后,剩余的 模板字符串 开头不符合标签的格式规则,那么确定就是有没截取完的文本

这个时候只须要循环把 textEnd 累加,直到剩余的 模板字符串 符合标签的规则以后在一次性把 text 从 模板字符串 中截取出来就行了。

继续上面的例子,当前剩余的 模板字符串 是这个样子的:

< p>{{name}}</ p></ div>

截取以后剩余的 模板字符串 是这个样子的:

< p>{{name}}</ p></ div>

被截取出来的文本是这样的:

"n"

截取以后就须要对文本进行解析,不过在解析文本以前须要进行预处理,也就是先简单加工一下文本,vue 是这样作的:

constchildren=currentParent. childrentext =inPre ||text. trim() ?isTextTag(currentParent) ?text :decodeHTMLCached(text) //only preserve whitespace if its not right after a starting tag:preserveWhitespace &&children. length?'':''

这段代码的意思是:

  • 若是文本不为空,判断父标签是否是或style,

    1. 若是是则什么都无论,

    2. 若是不是须要 decode 一下编码,使用github上的 he 这个类库的 decodeHTML 方法

  • 若是文本为空,判断有没有兄弟节点,也就是 parent.children.length 是否是为 0

    1. 若是大于0 返回 ' '

    2. 若是为 0 返回 ''

结果发现这一次的 text 正好命中最后的那个 '',因此这一次就什么都不用作继续下一轮解析就好

继续上面的例子,如今的 模板字符串 变是这个样子:

< p>{{name}}</ p></ div>

接着解析 <p>,解析流程和上面的 <div> 同样就不说了,直接继续:

{{name}}</ p></ div>

经过上面写的文本的截取方式这一次截取出来的文本是这个样子的 "{{name}}"

解析文本

其实解析文本节点并不难,只须要将文本节点 push 到 currentParent.children.push(ast) 就好了。

可是带变量的文本和不带变量的纯文本是不一样的处理方式。

带变量的文本是指 Hello {{ name }} 这个 name 就是变量。

不带变量的文本是这样的 Hello Berwin 这种没有访问数据的纯文本。

纯文本比较简单,直接将 文本节点的ast push 到 parent 节点的 children 中就好了,例如:

children. push({ type :3, text :'我是纯文本'})

而带变量的文本要多一个解析文本变量的操做:

constexpression=parseText(text, delimiters) //对变量解析 {{name}} => _s(name)children. push({ type :2, expression, text})

上面例子中 "{{name}}" 是一个带变量的文本,通过 parseText 解析后 expression 是 _s(name),因此最后 push 到 currentParent.children 中的节点是这个样子的:

{ expression :"_s(name)", text :"{{name}}", type :2} 结束标签的处理

如今文本解析完以后,剩余的 模板字符串 变成了这个样子:

</ p></ div>

这一次仍是用上面说的办法,html.indexOf('<') === 0,发现是 < 开头的,而后用正则去 match 发现符合 结束标签的格式,把它截取出来。

而且还要作一个处理是用当前标签名在 stack 从后往前找,将找到的 stack 中的位置日后的全部标签所有删除(意思是,已经解析到当前的结束标签,那么它的子集确定都是解析过的,试想一下当前标签都关闭了,它的子集确定也都关闭了,因此须要把当前标签位置日后从 stack中都清掉)

结束标签不须要解析,只须要将 stack 中的当前标签删掉就好。

虽然不用解析,但 vue 仍是作了一个优化处理,children 中的最后一项若是是空格 " ",则删除最后这一项:

if(lastNode &&lastNode. type===3&&lastNode. text===''&&!inPre) { element. children. pop()}

由于最后这一项空格是没有用的,举个例子:

< ul> < li></ li></ ul>

上面例子中解析成 element ASTs以后 ul 的结束标签 </ul> 和 li 的结束标签 </li> 之间有一个空格,这个空格也属于文本节点在 ul 的 children 中,这个空格是没有用的,把这个空格删掉每次渲染dom都会少渲染一个文本节点,能够节省必定的性能开销。

如今剩余的 模板字符串 已经很少了,是下面的样子:

</ div>

而后解析文本,就是一个其实就是一个空格的文本节点。

而后再一次解析结束标签 </div>

</ div>

解析完毕退出 while 循环。

解析完以后拿到的 element ASTs 就是文章开头写的那样。

总结一下

其实这样一个模板解析器的原理不是特别难,主要就是两部份内容,一部分是 截取 字符串,一部分是对截取以后的字符串作 解析

每截取一段标签的开头就 push 到 stack中,解析到标签的结束就 pop 出来,当全部的字符串都截没了也就解析完了。

上文中的例子是比较简单的,不涉及一些循环啊,什么的,注释的处理这些也都没有涉及到,但其实这篇文章中想表达的内容也不是来扣细节的,若是扣细节可能要写一本小书才够,一篇文章的字数可能只够把一个大致的逻辑给你们讲清楚,但愿同窗们见谅,若是对细节感兴趣能够在下面评论,我们一块儿讨论共同窗习进步~

优化器

优化器的目标是找出那些静态节点并打上标记,而静态节点指的是 DOM 不须要发生变化的节点,例如:

< p>我是静态节点,我不须要发生变化</ p>

标记静态节点有两个好处:

  1. 每次从新渲染的时候不须要为静态节点建立新节点

  2. 在 Virtual DOM 中 patching 的过程能够被跳过

优化器的实现原理主要分两步:

  • 第一步:用递归的方式将全部节点添加 static 属性,标识是否是静态节点

  • 第二步:标记全部静态根节点

什么是静态根节点? 答:子节点全是静态节点的节点就是静态根节点,例如:

< ul> < li>我是静态节点,我不须要发生变化</ li> < li>我是静态节点2,我不须要发生变化</ li> < li>我是静态节点3,我不须要发生变化</ li></ ul>

ul 就是静态根节点。

如何将全部节点标记 static 属性?

vue 判断一个节点是否是静态节点的作法其实并不难:

  1. 先根据自身是否是静态节点作一个标记 node.static = isStatic(node)

  2. 而后在循环 children,若是 children 中出现了哪怕一个节点不是静态节点,在将当前节点的标记修改为 false: node.static = false。

如何判断一个节点是否是静态节点?

也就是说 isStatic 这个函数是如何判断静态节点的?

functionisStatic( node:ASTNode): boolean { if( node. type===2) { //expressionreturnfalse} if( node. type===3) { //textreturntrue} return!!( node. pre||( !node. hasBindings&&//no dynamic bindings!node. if&&!node. for&&//not v-if or v-for or v-else!isBuiltInTag( node. tag) &&//not a built-inisPlatformReservedTag( node. tag) &&//not a component!isDirectChildOfTemplateFor(node) &&Object. keys(node). every(isStaticKey) ))}

先解释一下,在上文讲的解析器中将 模板字符串 解析成 AST 的时候,会根据不一样的文本类型设置一个 type:

type 说明
1 元素节点
2 带变量的动态文本节点
3 不带变量的纯文本节点

因此上面 isStatic 中的逻辑很明显,若是 type === 2 那确定不是 静态节点 返回 false,若是 type === 3 那就是静态节点,返回 true。

那若是 type === 1,就有点复杂了,元素节点判断是否是静态节点的条件不少,我们先一个个看。

首先若是 node.pre 为 true 直接认为当前节点是静态节点,关于 node.pre 是什么 请狠狠的点击我。

其次 node.hasBindings 不能为 true。

node.hasBindings 属性是在解析器转换 AST 时设置的,若是当前节点的 attrs 中,有 v-、@、:开头的 attr,就会把 node.hasBindings 设置为 true。

constdirRE=/^v-|^@|^:/if( dirRE. test(attr)) { //mark element as dynamicel. hasBindings=true}

而且元素节点不能有 if 和 for属性。

node.if 和 node.for 也是在解析器转换 AST 时设置的。

在解析的时候发现节点使用了 v-if,就会在解析的时候给当前节点设置一个 if 属性。

就是说元素节点不能使用 v-if v-for v-else 等指令。

而且元素节点不能是 slot 和 component。

而且元素节点不能是组件。

例如:

< List></ List>

不能是上面这样的自定义组件

而且元素节点的父级节点不能是带 v-for 的 template,查看详情 请狠狠的点击我。

而且元素节点上不能出现额外的属性。

额外的属性指的是不能出现 type

tag attrsList attrsMap plain parent children attrs staticClass staticStyle 这几个属性以外的其余属性,若是出现其余属性则认为当前节点不是静态节点。

只有符合上面全部条件的节点才会被认为是静态节点。

如何标记全部节点?

上面讲如何判断单个节点是不是静态节点,AST 是一棵树,咱们如何把全部的节点都打上标记(static)呢?

还有一个问题是,判断 元素节点是否是静态节点不能光看它自身是否是静态节点,若是它的子节点不是静态节点,那就算它自身符合上面讲的静态节点的条件,它也不是静态节点。

因此在 vue 中有这样一行代码:

for( leti =0, l =node. children. length; i <l; i ++) { constchild=node. children[i] markStatic(child) if( !child. static) { node. static=false}}

markStatic 能够给节点标记,规则上面刚讲过,vue.js 经过循环 children 打标记,而后每一个不一样的子节点又会走相同的逻辑去循环它的 children 这样递归下来全部的节点都会被打上标记。

而后在循环中判断,若是某个子节点不是 静态节点,那么讲当前节点的标记改成 false。

这样一圈下来以后 AST 上的全部节点都被准确的打上了标记。

如何标记静态根节点?

标记静态根节点其实也是递归的过程。

vue 中的实现大概是这样的:

functionmarkStaticRoots( node:ASTNode, isInFor:boolean) { if( node. type===1) { //For a node to qualify as a static root, it should have children that//are not just static text. Otherwise the cost of hoisting out will//outweigh the benefits and it's better off to just always render it fresh.if( node. static&&node. children. length&&!( node. children. length===1&&node. children[ 0]. type===3)) { node. staticRoot=truereturn} else{ node. staticRoot=false} if( node. children) { for( leti =0, l =node. children. length; i <l; i ++) { markStaticRoots( node. children[i], isInFor ||!!node. for) } } }}

这段代码其实就一个意思:

当前节点是静态节点,而且有子节点,而且子节点不是单个静态文本节点这种状况会将当前节点标记为根静态节点。

额,,可能有点绕口,从新解释下。

上面咱们标记 静态节点的时候有一段逻辑是只有全部 子节点都是 静态节点,当前节点才是真正的 静态节点。

因此这里咱们若是发现一个节点是 静态节点,那就能证实它的全部 子节点也都是静态节点,而咱们要标记的是 静态根节点,因此若是一个静态节点只包含了一个文本节点那就不会被标记为 静态根节点。

其实这么作也是为了性能考虑,vue 在注释中也说了,若是把一个只包含静态文本的节点标记为根节点,那么它的成本会超过收益~

总结一下

总体逻辑其实就是递归 AST 这颗树,而后将 静态节点和 静态根节点找到并打上标记。

代码生成器

代码生成器的做用是使用 element ASTs 生成 render 函数代码字符串。

使用本文开头举的例子中的模板生成后的 AST 来生成 render 后是这样的:

{ render :`with(this){return _c('div',[_c('p',[_v(_s(name))])])}`}

格式化后是这样的:

with( this){ return_c( 'div', [ _c( 'p', [ _v( _s(name)) ] ) ] )}

生成后的代码字符串中看到了有几个函数调用 _c,_v,_s。

_c 对应的是 ,它的做用是建立一个元素。

  1. 第一个参数是一个HTML标签名

  2. 第二个参数是元素上使用的属性所对应的数据对象,可选项

  3. 第三个参数是 children

例如:

一个简单的模板:

< ptitle= "Berwin"@ click= "c">1</ p>

生成后的代码字符串是:

`with(this){return _c('p',{attrs:{"title":"Berwin"},on:{"click":c}},[_v("1")])}`

格式化后:

with( this){ return_c( 'p', { attrs :{ "title":"Berwin"}, on :{ "click":c} }, [ _v( "1")] )}

关于 想了解更多请狠狠的点击我。

_v 的意思是建立一个文本节点。

_s 是返回参数中的字符串。

代码生成器的整体逻辑其实就是使用 element ASTs 去递归,而后拼出这样的 _c('div',[_c('p',[_v(_s(name))])]) 字符串。

那如何拼这个字符串呢??

请看下面的代码:

functiongenElement( el:ASTElement, state:CodegenState) { constdata=el. plain?undefined:genData(el, state) constchildren=el. inlineTemplate?null:genChildren(el, state, true) letcode =`_c('${el.tag}'${data ?`,${data}`:''//data}${children ?`,${children}`:''//children})`returncode}

由于 _c 的参数须要 tagName、data 和 children。

因此上面这段代码的主要逻辑就是用 genData 和 genChildren 获取 data 和 children,而后拼到 _c中去,拼完后把拼好的 "_c(tagName, data, children)" 返回。

因此咱们如今比较关心的两个问题:

  1. data 如何生成的(genData 的实现逻辑)?

  2. children 如何生成的(genChildren 的实现逻辑)?

咱们先看 genData 是怎样的实现逻辑:

functiongenData( el:ASTElement, state:CodegenState): string { letdata ='{'//keyif( el. key) { data +=`key:${el.key},`} //refif( el. ref) { data +=`ref:${el.ref},`} if( el. refInFor) { data +=`refInFor:true,`} //preif( el. pre) { data +=`pre:true,`} //... 相似的还有不少种状况data =data. replace( /,$/, '') +'}'returndata}

能够看到,就是根据 AST 上当前节点上都有什么属性,而后针对不一样的属性作一些不一样的处理,最后拼出一个字符串~

而后咱们在看看 genChildren 是怎样的实现的:

functiongenChildren( el:ASTElement, state:CodegenState): string | void { constchildren=el. childrenif( children. length) { return`[${children.map(c=>genNode(c, state)).join(',')}]`}} functiongenNode( node:ASTNode, state:CodegenState): string { if( node. type===1) { returngenElement(node, state) } if( node. type===3&&node. isComment) { returngenComment(node) } else{ returngenText(node) }}

从上面代码中能够看出,生成 children 的过程其实就是循环 AST 中当前节点的 children,而后把每一项在从新按不一样的节点类型去执行 genElement genComment genText。若是 genElement 中又有 children 在循环生成,如此反复递归,最后一圈跑完以后能拿到一个完整的 render 函数代码字符串,就是相似下面这个样子。

"_c('div',[_c('p',[_v(_s(name))])])"

最后把生成的 code 装到 with 里。

exportfunctiongenerate( ast:ASTElement|void, options:CompilerOptions): CodegenResult { conststate=newCodegenState(options) //若是ast为空,则建立一个空divconstcode=ast ?genElement(ast, state) :'_c("div")'return{ render :`with(this){return ${code}}`}}

关于代码生成器的部分到这里就说完了,其实源码中远不止这么简单,不少细节我都没有去说,我只说了一个大致的流程,对具体细节感兴趣的同窗能够本身去看源码了解详情。

总结

本篇文章咱们说了 vue 对模板编译的总体流程分为三个部分:解析器(parser),优化器(optimizer)和代码生成器(code generator)。

解析器(parser)的做用是将 模板字符串 转换成 element ASTs。

优化器(optimizer)的做用是找出那些静态节点和静态根节点并打上标记。

代码生成器(code generator)的做用是使用 element ASTs 生成 render函数代码(generate render function code from element ASTs)。

用一张图来表示:

解析器(parser)的原理是一小段一小段的去截取字符串,而后维护一个 stack 用来保存DOM深度,每截取到一段标签的开始就 push 到 stack 中,当全部字符串都截取完以后也就解析出了一个完整的 AST。

优化器(optimizer)的原理是用递归的方式将全部节点打标记,表示是不是一个 静态节点,而后再次递归一遍把 静态根节点 也标记出来。

代码生成器(code generator)的原理也是经过递归去拼一个函数执行代码的字符串,递归的过程根据不一样的节点类型调用不一样的生成方法,若是发现是一颗元素节点就拼一个 _c(tagName, data, children) 的函数调用字符串,而后 data 和 children 也是使用 AST 中的属性去拼字符串。

若是 children 中还有 children 则递归去拼。

最后拼出一个完整的 render 函数代码。

相关文章
相关标签/搜索