原文地址html
关于vue的内部原理其实有不少个重要的部分,变化侦测,模板编译,virtualDOM,总体运行流程等。vue
以前写过一篇《深刻浅出 - vue变化侦测原理》 讲了关于变化侦测的实现原理。node
那今天主要把 模板编译 这部分的实现原理单独拿出来说一讲。git
本文我可能不会在文章中说太多细节部分的处理,我会把 vue 对模板编译这部分的总体原理讲清楚,主要是让读者读完文章后对模板编译的总体实现原理有一个清晰的思路和理解。github
关于 Vue 编译原理这块的总体逻辑主要分三个部分,也能够说是分三步,这三个部分是有先后关系的:express
模板字符串
转换成 element ASTs
(解析器)AST
进行静态节点标记,主要用来作虚拟DOM的渲染优化(优化器)element ASTs
生成 render
函数代码字符串(代码生成器)解析器主要干的事是将 模板字符串
转换成 element ASTs
,例如:swift
<div> <p>{{name}}</p> </div>
上面这样一个简单的 模板
转换成 element AST
后是这样的:api
{ 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代码:
const ncname = '[a-zA-Z_][\\w\\-\\.]*' const qnameCapture = `((?:${ncname}\\:)?${ncname})` const startTagOpen = new RegExp(`^<${qnameCapture}`) const startTagClose = /^\s*(\/?)>/ let html = `<div></div>` let index = 0 const start = html.match(startTagOpen) const match = { tagName: start[1], attrs: [], start: 0 } html = html.substring(start[0].length) index += start[0].length let end, attr while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) { html = html.substring(attr[0].length) index += attr[0].length match.attrs.push(attr) } if (end) { match.unarySlash = end[1] html = html.substring(end[0].length) index += end[0].length match.end = index } console.log(match)
用正则把 开始标签
中包含的数据(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>
<!-- 我是注释 -->
<!DOCTYPE html>
固然咱们解析器在解析的过程当中遇到的最多的是 开始标签
结束标签
和 注释
咱们继续上面的例子解析,div
的 开始标签
解析以后剩余的模板字符串是下面的样子:
<p>{{name}}</p> </div>
这一次咱们在解析发现 模板字符串 不是以 <
开头了。
那么若是模板字符串不是以 <
开头的怎么处理呢??
其实若是字符串不是以 <
开头可能会出现这么几种状况:
我是text <div></div>
或者:
我是text </p>
不管是哪一种状况都会将标签前面的文本部分解析出来,截取这段文本其实并不难,看下面的例子:
// 能够直接将本 demo 放到浏览器 console 中去执行 const html = '我是text </p>' let textEnd = html.indexOf('<') const text = html.substring(0, textEnd) console.log(text)
固然 vue 对文本的截取不仅是这么简单,vue对文本的截取作了很安全的处理,若是 <
是文本的一部分,那上面 DEMO 中截取的内容就不是咱们想要的,例如这样的:
a < b </p>
若是是这样的文本,上面的 demo 确定就挂了,截取出的文本就会遗漏一部分,而 vue 对这部分是进行了处理的,看下面的代码:
let textEnd = html.indexOf('<') let text, 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 text next = rest.indexOf('<', 1) if (next < 0) break textEnd += 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>
被截取出来的文本是这样的:
" "
截取以后就须要对文本进行解析,不过在解析文本以前须要进行预处理,也就是先简单加工一下文本,vue 是这样作的:
const children = currentParent.children text = inPre || text.trim() ? isTextTag(currentParent) ? text : decodeHTMLCached(text) // only preserve whitespace if its not right after a starting tag : preserveWhitespace && children.length ? ' ' : ''
这段代码的意思是:
若是文本不为空,判断父标签是否是script或style,
decode
一下编码,使用github上的 he 这个类库的 decodeHTML
方法若是文本为空,判断有没有兄弟节点,也就是 parent.children.length
是否是为 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: '我是纯文本' })
而带变量的文本要多一个解析文本变量的操做:
const expression = 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>
标记静态节点有两个好处:
优化器的实现原理主要分两步:
static
属性,标识是否是静态节点什么是静态根节点? 答:子节点全是静态节点的节点就是静态根节点,例如:
<ul> <li>我是静态节点,我不须要发生变化</li> <li>我是静态节点2,我不须要发生变化</li> <li>我是静态节点3,我不须要发生变化</li> </ul>
ul 就是静态根节点。
static
属性?vue 判断一个节点是否是静态节点的作法其实并不难:
node.static = isStatic(node)
children
,若是 children
中出现了哪怕一个节点不是静态节点,在将当前节点的标记修改为 false
: node.static = false
。也就是说 isStatic
这个函数是如何判断静态节点的?
function isStatic (node: ASTNode): boolean { if (node.type === 2) { // expression return false } if (node.type === 3) { // text return true } 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-in isPlatformReservedTag(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
。
const dirRE = /^v-|^@|^:/ if (dirRE.test(attr)) { // mark element as dynamic el.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 (let i = 0, l = node.children.length; i < l; i++) { const child = node.children[i] markStatic(child) if (!child.static) { node.static = false } }
markStatic
能够给节点标记,规则上面刚讲过,vue.js 经过循环 children
打标记,而后每一个不一样的子节点又会走相同的逻辑去循环它的 children
这样递归下来全部的节点都会被打上标记。
而后在循环中判断,若是某个子节点不是 静态节点,那么讲当前节点的标记改成 false
。
这样一圈下来以后 AST
上的全部节点都被准确的打上了标记。
标记静态根节点其实也是递归的过程。
vue 中的实现大概是这样的:
function markStaticRoots (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 = true return } else { node.staticRoot = false } if (node.children) { for (let i = 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
对应的是 createElement
,它的做用是建立一个元素。
children
例如:
一个简单的模板:
<p title="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")] ) }
关于 createElement
想了解更多请狠狠的点击我。
_v
的意思是建立一个文本节点。
_s
是返回参数中的字符串。
代码生成器的整体逻辑其实就是使用 element ASTs
去递归,而后拼出这样的 _c('div',[_c('p',[_v(_s(name))])])
字符串。
那如何拼这个字符串呢??
请看下面的代码:
function genElement (el: ASTElement, state: CodegenState) { const data = el.plain ? undefined : genData(el, state) const children = el.inlineTemplate ? null : genChildren(el, state, true) let code = `_c('${el.tag}'${ data ? `,${data}` : '' // data }${ children ? `,${children}` : '' // children })` return code }
由于 _c 的参数须要 tagName
、data
和 children
。
因此上面这段代码的主要逻辑就是用 genData
和 genChildren
获取 data
和 children
,而后拼到 _c
中去,拼完后把拼好的 "_c(tagName, data, children)"
返回。
因此咱们如今比较关心的两个问题:
咱们先看 genData
是怎样的实现逻辑:
function genData (el: ASTElement, state: CodegenState): string { let data = '{' // key if (el.key) { data += `key:${el.key},` } // ref if (el.ref) { data += `ref:${el.ref},` } if (el.refInFor) { data += `refInFor:true,` } // pre if (el.pre) { data += `pre:true,` } // ... 相似的还有不少种状况 data = data.replace(/,$/, '') + '}' return data }
能够看到,就是根据 AST
上当前节点上都有什么属性,而后针对不一样的属性作一些不一样的处理,最后拼出一个字符串~
而后咱们在看看 genChildren
是怎样的实现的:
function genChildren ( el: ASTElement, state: CodegenState ): string | void { const children = el.children if (children.length) { return `[${children.map(c => genNode(c, state)).join(',')}]` } } function genNode (node: ASTNode, state: CodegenState): string { if (node.type === 1) { return genElement(node, state) } if (node.type === 3 && node.isComment) { return genComment(node) } else { return genText(node) } }
从上面代码中能够看出,生成 children
的过程其实就是循环 AST
中当前节点的 children
,而后把每一项在从新按不一样的节点类型去执行 genElement
genComment
genText
。若是 genElement
中又有 children
在循环生成,如此反复递归,最后一圈跑完以后能拿到一个完整的 render
函数代码字符串,就是相似下面这个样子。
"_c('div',[_c('p',[_v(_s(name))])])"
最后把生成的 code
装到 with
里。
export function generate ( ast: ASTElement | void, options: CompilerOptions ): CodegenResult { const state = new CodegenState(options) // 若是ast为空,则建立一个空div const code = 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)。
用一张图来表示:
[图片上传失败...(image-4ad47f-1521111234756)]
解析器(parser)的原理是一小段一小段的去截取字符串,而后维护一个 stack
用来保存DOM深度,每截取到一段标签的开始就 push
到 stack
中,当全部字符串都截取完以后也就解析出了一个完整的 AST
。
优化器(optimizer)的原理是用递归的方式将全部节点打标记,表示是不是一个 静态节点
,而后再次递归一遍把 静态根节点
也标记出来。
代码生成器(code generator)的原理也是经过递归去拼一个函数执行代码的字符串,递归的过程根据不一样的节点类型调用不一样的生成方法,若是发现是一颗元素节点就拼一个 _c(tagName, data, children)
的函数调用字符串,而后 data
和 children
也是使用 AST
中的属性去拼字符串。
若是 children
中还有 children
则递归去拼。
最后拼出一个完整的 render
函数代码。