compile
大家在使用vue的时候,我们经常编写一些template模板,
举个栗子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue模板编译</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<div id="demo">
<h1>Vue模板编译</h1>
<p v-if="txt">xx{{name}}yy</p>
<div :hhh="name" xxx="123"></div>
<ul>
<li v-for="item in names">{{item}}</li>
</ul>
<p v-text="txt" @click="addText"></p>
</div>
<script>
// 创建实例
const app = new Vue({
el: '#demo',
data: {foo:'foo', txt: "text"}
})
</script>
</body>
</html>
在添加vue.js后,我们在<div id="#demo"></div>里面的内容和我们在真实Dom里面看到的元素是不一样的,查看一下经过Vue的处理后,Dom元素变成了这样:
在上文中,编写的Vue的双向数据绑定的demo中是通过Compile来完成模板到真实Dom节点的转换。其中使用document.createDocumentFragment()来创建一个文档碎片,即存在内存中的Dom节点。当时我们只是做了一个简单的处理。但是Vue实际的编译比这个复杂的多:我们需要解析v-if,v-for这样的指令,还需要去解析v-bind,v-on,v-text等。
这本文中去探索一下Vue是如何完成<div id="demo"></div>内部节点的转换。
Vue.prototype.$mount
根据上节的源码分析(一),我们知道new Vue(options)时会进行相应的初始化过程。在上文的关注点主要是对于数据的初始化。在数据进行初始化的过程中,会对数据进行响应式处理,对数据处理完成后,我们还是没有办法再浏览器上看到正确的视图,这个时候需要调用Vue原型上的$mount方法实现挂载操作,从而在浏览器上显示正确的视图。
为了实现挂载这个操作,我们需要去解析options.el或者options.template中编写的模板字符串。什么是模板字符串呢?简单来说就是用字符串表示的包含Vue指令的html内容:
// 在options.el中,我们是指定option.el 该节点内容作为模板
// 例如option.el = 'demo'
<div id="demo">
<h1>初始化流程</h1>
<p>{{foo}}</p>
</div>
// 在vue中,会获取该节点转化为如下的形式,template就是模板字符串
let template = '<div id="demo"><h1>初始化流程</h1><p>{{foo}}</p></div>'
在开发项目中会发现,我们在
new Vue(options)时其实有时候 是没有调用$mount方法的。在本文上一篇的内容中我们知道Vue的构造函数中执行了_init方法,initMixin()中实现了_init方法,该方法实现了Vue相关的初始化。在_init函数最后判断options是否存在el属性,如果存在则调用vm.$mount方法(这也是为什么在options中编写了el后不用手动调用$mount方法的原因)。在项目开发中,项目的main.js文件中,经常能看到new Vue(options).$mount('#app')这种写法,其实就是将Vue中的内容挂载到根节点'#app'上,然后就可以在浏览器中显示Vue相关的内容。因此可以猜测$mount能够将我们的模板字符串转化成真正的Dom节点显示在浏览器上,并且在遍历的过程中还创建了相应的watcher来进行视图的响应式更新。
再Vue中,当执行$mount方法时才会进行字符串模板编译相关的操作,让我们查看一下该方法在web平台的具体实现:
import { compileToFunctions } from './compiler/index'
// web环境下的扩展$mount
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 获取dom节点
el = el && query(el)
const options = this.$options
// resolve template/el and convert to render function
if (!options.render) {
// 没有render才找template,并且将template处理成模板字符串
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
return this
}
} else if (el) {
// 没有render和template的情况下才会去找el,并将el节点的内容转化成模板字符串
template = getOuterHTML(el)
}
// 编译模板字符串,返回render函数
if (template) {
// 获取render
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
// 将编译返回的render设置到options.render上
options.render = render
options.staticRenderFns = staticRenderFns
}
}
// 执行Vue原型上的mount方法
return mount.call(this, el, hydrating)
}
// 将el节点转化为模板字符串,即 el => template
function getOuterHTML (el: Element): string {
if (el.outerHTML) {
return el.outerHTML
} else {
const container = document.createElement('div')
container.appendChild(el.cloneNode(true))
return container.innerHTML
}
}
在web平台扩展的$mount方法中,进行如下操作:
- 获取
render函数,并且将其放置到vm实例对象的render上。- 当存在options.render时,忽略
options.template和options.el,跳过本步骤后续所有内容 - 当存在
options.template时忽略options.el,得到template的模板字符串,然后调用compileToFunctions方法,获取template模板字符串对应的render方法,将其放置到vm.options.render上 - 当存在options.el时,得到el对应节点的模板字符串,同样的,也去调用
compileToFunctions方法,获取对应的render方法,将其放置到vm.options.render上
- 当存在options.render时,忽略
- 调用Vue实例原型上的
$mount方法。
从代码可以看出options中el,template,render它们三个彼此互斥。优先级为render>template>el。即存在render的时候优先使用render函数,没有render优先template。最后才考虑使用el参数。
Vue.prototype.$mount的实现实际上对不同的平台做了相应的扩展,目前源码中有weex和web两种平台,本文只讲web平台的具体实现。有兴趣的可以去vue源码查看weex的具体实现。
我们看完web平台对$mount做的扩展,继续查看Vue.prototype.$mount原型方法的实现。web平台下该方法源码中位置:src\platforms\web\runtime\index.js:
import { mountComponent } from 'core/instance/lifecycle'
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 获取el元素
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
执行了core/instance/lifecycle下 mountComponent方法:
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
// 当不存在render时,render为创建一个空节点的函数。
if (!vm.$options.render) {
vm.$options.render = createEmptyVNode
}
// 调用beforeMount生命周期钩子函数
callHook(vm, 'beforeMount')
// 传入Watcher的更新视图的函数
let updateComponent = () => {
vm._update(vm._render(), hydrating)
}
// 为组件创建Watcher,将updateComponent传入第二个参数
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
// 执行mounted生命周期钩子函数
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
该函数做了如下的事情:
- 调用
beforeMount生命周期钩子函数 - 定义更新Dom的函数
updateComponent - 创建
watcher,并将得到的updateComponent传入watcher,作为其exprOrFn的参数。 - 创建
watcher时,会将Dep.target指向自己,然后Wathcer构造函数内部会执行一次传入的updateComponent函数。该函数内部使用options.data中的数据时会触发Object.Object.defineProperty中的get,对其进行依赖收集,updateComponent同时也完成了Dom视图的渲染。 - 最后调用
mounted这个生命周期钩子函数。
每次解析Vue的字符串模板时,即解析Vue组件时,我们会去创建一个watcher。传入updateComponet函数。毫无疑问,那么该函数就是更新Dom的方法。其中,最重要的无疑时获得更新模板的函数。在上面的源码中,我们可以看到其实该函数做了两件事情:
- 调用
Vue.prototype._render,返回的结果作为Vue.prototype._update的参数。该方法返回一个虚拟Dom。虚拟Dom就是一个JS对象,用来表示一个真实的Dom节点,后面会详细介绍。 - 调用
Vue.prototype._update,其中会执行patch方法,实现视图的更新。
看到在调用
vm.$mount时Vue创建了一个watcher,回想在上节Vue源码分析(一)中实现的编译器。我们直接通过递归,遍历所有的元素节点和文本节点,当发现存在Vue相关指令时,我们会去解析相关的指令,并且生成一个Watcher实例
那么问题来了,我们编写100个Vue指令的时候,我们会生成100个实例,在实际开发中,我们在template中使用的Vue指令可不止100个,如果为每一个指令生成 一个Watcher指令,会对内存造成十分大的消耗。当我们在做大型的项目时,这个开销就会变得十分庞大。那么Vue2.0中是怎么解决的呢?
其实本文源码分析(一)中编写那个简单的Demo就是vue1.0中的思想:为每个指令创建一个watcher实例来监听。在Vue2.0中,为了解决数据变化追踪粒度太细的问题,引入了虚拟Dom。
在Vue2.0中为每个Vue组件创建一个watcher.即以组件为基本单位创建watcher。将watcher的粒度从元素节点变为组件。那么watcher如何知道组件内部的元素的更新呢? 在这里其实Vue借鉴了React的虚拟Dom.在组件内部通过虚拟Dom的diff算法完成新老节点的比对,完成组件的更新。即在组件内部通过diff来更新Dom视图。
其中,该方法中我们最看重的部分无疑是定义更新模板的函数updateComponent。在上面的源码中,我们可以看到其实该函数做了两件事情
- 调用
Vue.prototype._render,返回的结果作为Vue.prototype._update的第一个参数。 - 调用
Vue.prototype._update,其中会执行diff算法,完成视图的更新。
这就是Vue.prototype.$mount的大致流程。总结一下Web平台下的Vue.prototype.$mount干了什么:
- 利用
compileToFunctions方法,根据Vue构造函数的options中el或者template转化成相应的render函数 - 执行
boforemount钩子函数 - 创建一个
updateComponet函数用于更新组件的视图 - 创建一个
watcher,触发数据的依赖收集,将updateComponent传入自己的exprOrFn参数,当数据发生变化时进行视图的更新。 - 在创建
watcher的过程中,构造函数调用一次updateCompont,该函数会将render对应的虚拟Dom转化为真实的Dom节点渲染在浏览器上 - 执行
mounted钩子函数
编译的重点毫无疑问是compileToFunctions,而更新视图则肯定去查看_update以及_render方法。_update方法在Vue源码分析(三)-----更新策略中会讲到,本文主要讲述编译部分内容。
Compile
在上面的方法中,我们知道compileToFunctions是将传入的模板字符串解析成render函数。为了达到这个要求,Vue中单独建立了一个文件夹Compile来做这个事情。我们可以将其对字符串模板的解析主要分为三部分:
- parse 解析:解析字符串模板,生成AST。
- optimize 优化:遍历AST标记静态节点。
- generate 代码生成:根据AST生成渲染函数。
这里有一个参考网站,你在左侧编写的Vue模板,在右侧会转化成相应的render函数,有兴趣可以去编写自己的小Demo: Vue模板编译。
1. parse
上面我们知道,parse主要是将模板字符串解析为AST(abstract syntax tree)。AST中文名叫做抽象语法树:是指源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。听起来很高大上的样子,那么在Vue中的AST长什么样子呢?
举个栗子,假设我们创建了一个<div id="demo"></div>元素:
<div id="demo">
<h1>Vue模板编译</h1>
<p v-if="txt">xx{{name}}yy</p>
<div :hhh="name" xxx="123"></div>
<ul>
<li v-for="item in names">{{item}}</li>
</ul>
<p v-text="txt" @click="addText"></p>
</div>
Vue中生成的AST部分属性如下:
{
type: 1,
tag: "div",
parent: undefined;
attrsMap: { id: "demo" },
attrList: [{name: "id", value: "demo"}],
children: [
{
type: 1,
tag: "h1",
parent: {type:1,tag: "div"....},
attrsMap: {},
attrsList: []
children: [{type: 3, text: "Vue模板"}]
},{
type: 3,
text: ""
},{
type: 1,
tag: "p",
attrsMap: {v-if: "txt"},
children: [
{
type:2,
expression: ""xx"+_s(name)+"yy"",
text: "xx{{name}}yy",
tokens: ["xx",{@binding:"name"}, "yy"]
}
],
if: "txt"
ifConditions: [{exp:"txt", block:{type: 1, tag:"p"...} }],
parent: {type:1, tag:"div"....}
},{
type:3,
text: ""
},{
type: 1,
tag: "div",
attrs: [{name: "hhh", value: "name"}, {name: "xxx", value: "123"}],
attrsList: [{name: ":hhh", value: "name"}, {name: "xxx", value: "123"}],
attrsMap:{:hhh: "name", xxx: "123"},
hasBindings: true,
parent: {type: 1, tag: "div", attrsList:....}
},{
type:3,
text: " "
},{
type: 1,
tag: "ul",
parent:{type: 1, tag: div...},
children: [{
type: 1,
tag: "li",
attrsMap: {v-for: "item in names"},
for: "names",
alias: "item",
children: [{
type: 2,
expression: "_s(item)",
tokens: [{@binding: "item"}],
text: "{{item}}",
}],
parent: {type: 1, tag: "ul"...}
}]
},{
type: 3,
text: " "
},{
type: 1,
tag: "p",
hasBingding: true,
directives: {name: "text", rawName: "v-text", value: "txt"},
events: {click: {value: "addText"}},
parent: {type: 1, tag: "div"...}
}
]
}
最终通过一个树形结构来表示我们的根节点,其中能够清晰的描述标签的相关属性和标签之间的依赖关系。如何将一个模板字符串解析成AST呢?用一副图来表示其大致过程:
当我们匹配<开头的字符串时,说明我们遇到了开始标签,我们匹配到</开头的字符串时,说明是结束标签。其中,我们匹配到开始和结束标签后,就字符串进行截取。截取到我们遇到的第一个>讲<到>中的所有字符串从整个html中截取出来,其中开始标签会存在属性,我们对其进行解析。
当我们的字符串不是以<开头时,在整个字符串中找到第一个<的下标,讲开头到<下标的所有字符截取出来,当作文本处理(暂不考虑字符中存在<的情况)。
根据图的流程,我们需要去匹配开始标签,结束标签和文本内容。其中,当我们匹配到开始标签的时候我们需要去解析开始标签的属性,解析到文本内容和结束标签时,我们需要对其制作相应的处理。我们需要首先一定能想到去用正则去匹配字符串,那么我们应该至少会需要如下的正则
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)// 匹配开始标签开头 即`<tagName`
const startTagClose = /^\s*(\/?)>/ //匹配开始标签结束 即 >或者 /> 结尾
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`) //匹配结束标签
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 匹配属性
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g // 匹配形如{{xxx}}的文本
const forAliasRE = /(.*?)\s+(?:in|of)\s+(.*)/ // 匹配v-for表达式中的内容,例如匹配`item in arr`
const dirRE = /^v-|^@|^:|^#/ //匹配'v-','@'或者 ':'开头的属性
这里有一个便于理解正则表达式的网站:正则解析
我们解析template的时候采用循环的方式进行,因此每次匹配并且解析出来一段内容时,我们需要将已经匹配的内容去掉,然后去匹配剩下的内容。因此我们需要一个截取字符串模板字符串的函数:
function advance (n) {
index += n
html = html.substring(n)
}
然后尝试实现一下解析模板字符串的函数:
export function parseHtml (html, options){
let index = 0;
while(html) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
if (html.match(endTag)) {
advance(endTagMatch[0].length);
// todo: 处理开始标签
continue
}
if (html.match(startTagOpen)) {
advance(endTagMatch[0].length);
// todo: 处理结束标签
continue
}
} else {
if(textEnd > 0){
// 截取文本
text = html.substring(0, textEnd);
}else{
// 整个模板都是文本节点(没有匹配到'<')
text = html
}
if(text){
advance(text.length)
}
// todo: 对文本节点进行处理
continue
}
}
}
在上面的例子中对Vue的编译过程进行了简化, 在这里我们仅对标签,文本进行处理。其中:在while每次循环中,首先找到<字符开头的位置。如果html以<开头(即textEnd为0),则我们当作元素节点去处理,否则当成文本节点处理。处理元素节点时首先匹配结束标签,比如:</p>,</div>,如果匹配不到则尝试匹配开始标签,比如:<p>,<div>。每当匹配到标签时,我们需要对匹配到的内容进行相应的处理.
我们知道最后解析成的AST是具有树结构的JS对象,因此我们需要添加一个栈(先进后出) 来建立父子关系。当我们匹配到startTagOpen时,将解析到的内容以对象的形式放置到栈中。匹配到startTagClose时,进行退栈操作并且建立标签之间的父子关系。解析到文本节点时,直接建立文本节点和当前父亲节点之间的关系。
因此我们在传入parseHtml的options中定义三个函数start,end,chars来帮助我们进行栈的维护,同时帮助我们生成AST的树形结构:
- start:当解析到开始标签时调用此函数,保存标签数据,并且将节点对象入栈。
- end:当解析到的结束标签时调用此函数,进行退栈操作,并且进行绑定父节点操作。
- chars:当解析到的文本节点调用此函数,将文本节点添加到父节点上。
并且我们还需要在parseHtml外部定义三个变量进行栈的维护
stack: 用于保存解析好的标签(以JS对象保存),是一个栈(先进后出,在js中可以看作一个只能使用pop和push方法的数组)currentParent: 存放当前标签父标签节点引用root:存放根节点标签 由此,我们可以编写下面的代码:
options:
{
start: (startTagMatch) =>{
// todo: 用js对象保存标签的内容,并将js对象入栈
// 进行入栈操作
// 对currentParent 和 `root`变量进行更新
},
end () {
// todo: 进行退栈,并且建立父子节点直接的关系
},
chars (text) {
// todo: 对字符串进行处理
}
}
像
<input />,<br />这种没有结束标签的属于自闭合标签;<div></div>,<p></p>属于非自闭合标签.
start方法: 当解析到开始标签时调用此方法。该方法参数为解析到的属性,标签名等内容。根据参数的内容创建一个js对象,用于保存标签信息。
end方法: 当解析到结束标签时调用此方法。该方法在栈中进行退栈操作,保存节点之间父子关系。
chars方法:当解析到文本节点时调用此方法。该方法参数为文本内容,该方法首先尝试文本是否匹配defaultTagRE,如果匹配说明文本不是静态内容,否则当做静态的文本内容来处理。最后建立文本节点和父节点之间的引用。
知道了options中的三个方法,可以对parseHtml进行扩展。
const stack = [];
const currentParent = root = null;
export function parseHtml (html, options){
let index = 0;
while(html) {
let textEnd = html.indexOf('<');
if (textEnd === 0) {
// 解析html节点
const endTagMatch = html.match(endTag);
if (endTagMatch) {
advance(endTagMatch[0].length);
parseEndTag(endTagMatch[1]);
continue;
}
if (html.match(startTagOpen)) {
const start = html.match(startTagOpen)
advance(start[0].length);
const startTagMatch = parseStartTag(start);
options.start(startTagMatch);
continue;
}
} else {
// 解析文本节点
let text;
if (textEnd >= 0) {
text = html.substring(0, textEnd);
}
if (textEnd < 0) {
text = html;
}
if (text) {
advance(text.length);
}
options.chars(text);
continue;
}
}
}
先实现一个解析开始标签的方法parseStartTag:
function parseStartTag (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
};
let end, attr;
// 循环匹配开始标签中的属性,直到遇到'>'或者'/>'
while (!(end = html.match(startTagClose)) && html.match(attribute))) {
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
// 对属性数组进行处理
match.attrs = match.attrs.map(args =>{
const value = args[3] || args[4] || args[5] || ''
return {
name: args[1],
value
}
})
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
在该方法中,我们定义了一个match对象来保存我们解析出来的结果,其中包括:
tagName:标签的名称attrs: 用数组保存解析出来的一系列属性start: 标签在模板字符串中的开始下标end: 标签在模板字符串中结束的下标unarySlash:表示该标签是否是自闭合标签
在方法内部定义一个循环,当匹配不到标签的结束并且可以匹配到标签的属性时,进入循环体,在循环体内部,将解析到的属性存到match.attrs上。
最后跳出循环时,我们解析完了元素节点的所有标签,解析完成,最后在match加入unarySlash,end属性。然后将解析到match返回。最后在外层parseHtml调用options.start函数将match传入。尝试编写options.start方法。
start(startTagMatch) {
const element = {
type: 1,
tag: startTagMatch.tagName,
lowerCasedTag: startTagMatch.tagName.toLowerCase(),
attrsList: startTagMatch.attrs,
attrsMap: makeAttrsMap(startTagMatch.attrs),
parent: currentParent,
children: []
}
if (!root) {
root = element
}
if(!match.unarySlash){
// 非自闭合标签
currentParent = element
stack.push(element)
}else{
// 自闭合标签
// 直接保存该标签和父节点之间的关系
currentParent.children.push(element)
element.parent = currentParent
}
}
// 将attrs数组转化成map(key-value键值对)
function makeAttrsMap (attrs) {
const map = {}
for (let i = 0, l = attrs.length; i < l; i++) {
map[attrs[i].name] = attrs[i].value;
}
return map
}
在start方法中定义了一个js对象element来保存标签的信息:
type:节点的类型tag:标签的名称lowerCasedTag:标签名称的小写attrsList:以数组保存属性列表attrsMap:以key-value保存属性(JS对象)parent:当前标签的父标签节点对象引用children: 保存当前标签的孩子节点引用
存在标签的节点type值均为1.在该函数中首先查看root是否存在。如果不存在,进入该函数的root设置为element,该判断用于第一次进入该函数时设置根节点。然后去判断该标签是否是自闭合标签,如果是自闭合标签则不存在孩子节点,直接将自己(element)加入到currentParent.children(当前父节点的孩子数组)中;如果不是自闭合标签则将当前element对象加入到栈(stack)中,并将currentParent设置为自己(element)。
同理,我们继续完善parseHtml中的parseEndTag
function parseEndTag(tagName){
let pos, lowerCasedTagName
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
pos = 0
}
for (let i = stack.length - 1; i >= pos; i--) {
options.end(stack[i].tag, start, end)
}
}
在parseHtml中,我们将闭合标签的标签名作为参数传入parseEndTag,然后用toLowerCase获取到标签名的小写lowerCasedTagName.从栈顶(数组的最后一个元素)到栈底(数组的最后一个元素)遍历栈.找到和该标签名相同的节点元素的下标(如果没找到则将下标赋值为0).然后循环栈顶到该节点元素下标,执行options.end进行退栈并且绑定父子节点的操作.
end () {
// 建立父子关系,并且维护栈
const element = stack[stack.length - 1]
stack.length -= 1
currentParent = stack[stack.length - 1]
currentParent.children.push(element)
element.parent = currentParent
}
end方法:将当前栈顶元素退栈,退出的栈顶的元素赋值给element,改变栈顶指向(stack.length-=1),将element加入当前栈顶元素的children数组,最后将element的parent属性指向当前栈顶元素的JS对象.
有人可能会问了:在parseEndTag时直接进行退栈操作不就行了,为什么要写一个循环呢? 一般情况下是可以直接退栈的,但是编写代码可能存在写了<span>而忘记写</span>的情况,因此我们要找到栈中离自己最近的相同标签名.这样,即使忘记写结束标签也能完成层级关系的建立.
最后我们来实现一下parseText以及options.chars
options.chars
chars(text){
let element = {}
if(defaultTagRE.test(text)){
let res = parseText(text)
element = {
type: 2,
text,
parent: currentParent
...res
}
}else{
element = {
type: 3,
text,
parent: currentParent
}
}
currentParent.children.push(element)
}
在parseHtml中调用option.charts传入文本字符串text.如果用defaultTagRE能够匹配到text的内容,即text中存在{{xxx}} 的内容则说明该文本节点不是静态的文本,去调用parseText(text)解析字符串,将element对象的type设置为2,将解析内容保存在element上;如果不存在{{xx}}的内容,则说明该文本节点的内容是静态的,不会发生改变,将element.type设置为3,将text放置到element上.最后将element加入到当前父节点currentParent.children数组中.
parseText解析动态文本节点:
function parseText(text){
// 如果存在`{{}}`则将文本内容拆分成多个部分保存在数组中
const tokens = []
let lastIndex = defaultTagRE.lastIndex = 0
let match, index, tokenValue
// 对文本中所有符合defaultTagRE的内容进行处理,将文本进行切割放置到数组中
while ((match = defaultTagRE.exec(text))) {
index = match.index
// 将xxx{{yyy}}zzz切分成['xxx','yyy','zzz']
if (index > lastIndex) {
tokenValue = text.slice(lastIndex, index)
tokens.push(JSON.stringify(tokenValue))
}
// 获取{{xxx}}中的xxx
const exp = match[1].trim()
tokens.push(`_s(${exp})`)
lastIndex = index + match[0].length
}
// 处理字符串中最后一个'}}'到字符串的内容(如果存在)
if (lastIndex < text.length) {
tokens.push(JSON.stringify(text.slice(lastIndex)))
}
return {
expression: tokens.join('+'),
tokens
}
}
如果我们有这样一个文本:
// 假设name的值为'Evan'
hello {{name}}!welcome back
我们最后返回的内容为:
{
expression: 'hello Evan! welcome back',
tokens: ['hello', '_s(name)', "!welcome back" ]
}
_s是toString()的简写.在实际运行_s(name)的时候,name会根据上文找到vm.$data.name上.
最后介绍一下如何实现v-text和v-if以及v-for: 我们需要在options.start添加如下内容
start(startTagMatch) {
const element = {
type: 1,
tag: startTagMatch.tagName,
lowerCasedTag: startTagMatch.tagName.toLowerCase(),
attrsList: startTagMatch.attrs,
attrsMap: makeAttrsMap(startTagMatch.attrs),
parent: currentParent,
children: []
}
// ---- 添加内容
processIf(element); // 尝试解析v-if
processFor(element); // 尝试解析v-for
processAttr(element); //尝试解析v-text,v-html...
// ----
// ....
}
由于Vue中对有v-if节点中添加if和ifConditions来保存判断条件和影响范围,并将v-if属性从attrList和attrMap中删除。
因此我们需要一个删除函数getAndRemoveAttr
function getAndRemoveAttr(element, name){
var val;
if ((val = el.attrsMap[name]) != null) {
var list = el.attrsList;
for (var i = 0, l = list.length; i < l; i++) {
if (list[i].name === name) {
list.splice(i, 1);
delete el.attrsMap[name];
break
}
}
}
return val
}
我们定义一个getAndRemoveAttr来尝试从节点的属性数组中找出为name的属性,并将该属性从数组中删除,最后返回name属性对应的值或者返回undefined
有了这个函数我们尝试实现一下processIf函数:
function processIf (el) {
const exp = getAndRemoveAttr(el, 'v-if');
if (exp) {
el.if = exp;
if (!el.ifConditions) {
el.ifConditions = [];
}
el.ifConditions.push({
exp: exp,
block: el
});
}
}
同样的道理,v-for指令最后保存在对象的for和alias属性上,分别是循环的数组,循环数组中元素名称. 按照v-f的逻辑,我们实现以下v-for:
function processFor (el) {
let exp = getAndRemoveAttr(el, 'v-for');
if (exp) {
const inMatch = exp.match(forAliasRE);
el.for = inMatch[2].trim();
el.alias = inMatch[1].trim();
}
}
在实现完processIf和processFor后,属性数组中可能还存在v-text,v-html这样的内容,我们用processAttrs来解析其他的指令。简单起见我们只实现一下v-text,v-on, v-bind。其他的实现读者有兴趣和精力可以去阅读源码。
实现v-text我们需要需要一个函数addDirective,将节点使用的指令相关内容保存到AST节点的directive属性上
export function addDirective (el, name, rawName, value) {
(el.directives || (el.directives = [])).push({
name,
rawName,
value,
})
}
然后我们尝试编写processAttrs
function processAttrs (el) {
const list = el.attrsList
let i, l, name, rawName, value
for (i = 0, l = list.length; i < l; i++) {
name = rawName = list[i].name
value = list[i].value
if (dirRE.test(name)) {
el.hasBindings = true
name = name.replace(dirRE, '')
const argMatch = name.match(argRE)
addDirective(el, name, rawName, value)
}
}
}
}
对于v-bind和v-on我们需要做特殊处理
v-on在AST节点创建一个event属性保存相关内容.编写addHandler协助完成v-bind在AST节点创建一个attr属性保存绑定的相关内容.编写addAttr协助完成
实现一下addHandler和addAttr函数:
addAttr(el, name, value){
const attrs = el.attrs || (el.attrs = [])
attrs.push({name, value})
}
addHandler(el, name, value){
const event = el.event || (el.event = {})
event[name] = {value}
}
因此对processAttrs进行扩展:
function processAttrs (el) {
const list = el.attrsList
let i, l, name, rawName, value
for (i = 0, l = list.length; i < l; i++) {
name = rawName = list[i].name
value = list[i].value
if (dirRE.test(name)) {
el.hasBindings = true
if (/^:|^v-bind:/.test(name)) { // v-bind
name = name.replace(/^:|^v-bind:/, '')
addAttr(el, name, value, list[i], isDynamic)
}else if(/^@|^v-on:/.test(name)){ // v-on
name = name.replace(/^@|^v-on:/, '')
addHandler(el, name, value)
}else{ // v-text, v-html
name = name.replace(dirRE, '')
const argMatch = name.match(argRE)
addDirective(el, name, rawName, value)
}
}
}
}
一个简单的解析过程我们便实现了。
2. optimize
优化器的作用在是在parse完成后生成的AST树中,找到静态子树并且标记。这个步骤涉及到后面的patch函数,在后续通过虚拟Dom比对更新视图的时候,某些静态节点(即内容不会发生变化的节点)我们可以不去进行比对,这样我们可以节省一些性能消耗。
因此这样做的好处:
- 每次对Dom进行渲染时,不需要为静态子树创建新的节点。
- 在虚拟Dom进行
Diff算法时,我们不需要去比对静态节点。
经过optimize我们要将parse过程得到的AST树每个节点加上static 来标志该节点是否为静态节点.
在上面的parse解析过程中,不同的节点会生成基于不同的type值。
| type | 说明 |
|---|---|
| 1 | 元素节点 |
| 2 | 带变量的动态文本节点 |
| 3 | 不带变量的纯文本节点 |
很明显当type为2时,我们能够确定它不可能时动态节点,当type为3时,它一定为静态节点。而当type为1时,需要同时满足以下所有条件,才能判断它是一个静态节点。
- 不能有
v-,@,:开头的属性 - 不能使用
v-if,v-for或者v-else的指令 - 标签名不能是
slot或者component等Vue内置标签 - 标签名必须是Vue的保留标签(分为HTML保留标签和SVG保留标签)
- 当前节点的父节点不能是
v-for指令的标签 - 节点上不存在动态节点才会有的属性,看到时代码在讨论.
在这里我们简化判断,只判断元素是否存在v-开头的属性.因此我们可以编写一个函数isStatic来判断某个节点是否为静态节点.
function isStatic (astNode) {
if (astNode.type === 2) {
return false
}
if (astNode.type === 3) {
return true
}
return (!astNode.if && !astNode.for && !astNode.hasBindings);
}
Vue中optimize核心实现只有两个函数markStatic 以及markStaticRoots
function optimize (root) {
if (!root) return
markStatic(root)
markStaticRoots(root, false)
}
由于我们知道,AST是树结构,因此我们标记静态节点要分为两步
- 标记所有的静态节点并打标记,即
markStatic(root) - 根据标记所有的静态节点,找出所有的静态根节点并打上标记,即
(root, false)
标记所有的静态节点,那么我们就需要去遍历AST的树结构。遍历树结构一般都是采用递归的方法。
function markStatic (node: ASTNode) {
node.static = isStatic(node)
if (node.type === 1) {
for (let i = 0, l = node.children.length; i < l; i++) {
const child = node.children[i]
markStatic(child)
if (!child.static) {
node.static = false
}
}
}
}
然后是markStaticRoots
function markStaticRoots (node) {
if (node.type === 1) {
if (node.static && node.children.length && !(
node.children.length === 1 &&
node.children[0].type === 3
)) {
node.staticRoot = true;
return;
} else {
node.staticRoot = false;
}
}
}
当某个元素节点是一个静态节点,同时存在孩子节点,并且满足该节点不是只有一个文本类型的静态节点(在这种情况,可能是尤大大认为优化的消耗大于后面diff的消耗),则将节点的staticRoot设置为ture;其他情况将staticRoot设置为false
3. generate
generate将前面我们得到的AST转化为render函数,执行该函数即可得到vDom(虚拟Dom节点).在前面讲述的Vue Template Explorer,我们可以看到编辑后的生成的render函数
其中
_c,_v等是其他方法名的简写:
| 类型 | 创建方法 | 别名 | 参数 |
|---|---|---|---|
| 元素节点 | createElement | _c | tag(标签名), data(指令,属性,事件等), children(孩子节点) |
| 文本节点 | createTextVNode | _v | val(文本内容) |
| 创建列表 | renderList | _l | val(遍历的内容), render(渲染列表的函数) |
| 创建空节点 | createEmptyVNode | _e | 无参数 |
| 这里我们不详细讲解这些函数,在Vue源码分析(三)中会详细介绍。 |
我们现在需要做是利用字符串,根据表格中的函数拼接成一个可执行的函数内部代码code,然后使用new Function(code)生成render函数。
在前面我们知道AST大概长什么样子了,我们如何把AST再次转化为render函数呢?在经过1,2步之后,我们得到了一个标记了静态节点的AST树。还差最后一步,将AST转化成render函数,当执行render函数的时候,我们可以得到虚拟Dom。那么如何去实现呢?
首先一定要有去处理元素节点的函数genElement,文本节点的函数genText。在处理元素节点的函数中,我们需要对v-if,v-for做处理的函数genFor,genIf.
然后我们需要对v-on,v-bind这些指令叫你下处理。genData进行处理最后我们需要去处理孩子节点,因此我们还需要genChildren
然后我们开始尝试编写genElement:
function genElement (el) {
if (el.for && !el.forProcessed) {
return genFor(el)
} else if (el.if && !el.ifProcessed) {
return genIf(el)
} else {
data = genDate(el)
const children = genChildren(el)
let code
code = `_c('${el.tag}'${
data ? `,${data}` : '' // data
}${
children ? `,${children}` : '' // children
})`
return code
}
}
然后依次完善函数,先完善一下genFor和genIf
function genFor (el) {
el.forProcessed = true
const exp = el.for
const alias = el.alias
const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''
return `_l((${exp}),` +
`function(${alias}${iterator1}${iterator2}){` +
`return ${genElement(el)}` +
'})'
}
function genIf (el) {
el.ifProcessed = true
if (!el.ifConditions.length) {
return '_e()'
}
return `(${el.ifConditions[0].exp})?${genElement(el.ifConditions[0].block)}: _e()`
}
完善genData:
// 处理指令
function genDirectives(el){
const dirs = el.directives
if (!dirs) return
let res = 'directives:['
for (i = 0, l = dirs.length; i < l; i++) {
res += `{name:"${dir.name}",rawName:"${dir.rawName}"${
dir.value ? `,value:(${dir.value}),expression:${JSON.stringify(dir.value)}` : ''
}},`
}
return res.slice(0, -1) + ']'
}
function genData (el) {
let data = '{'
// 添加指令
const dirs = genDirectives(el)
if (dirs) data += dirs + ','
// 添加属性
if (el.attrsMap) {
data += `attrs:${JSON.stringify(el.attrsMap)},`
}
// 添加事件
if (el.events) {
data += `on:${JSON.stringify(el.events)},`
}
data = data.replace(/,$/, '') + '}' // 如果存在','结尾则删除逗号并添加'}'
return data
}
最后就是genChildren:
// 该方法调用了genNode
function genChildren (el) {
const children = el.children;
if (children && children.length > 0) {
return `${children.map(genNode).join(',')}`;
}
}
function genNode(el){
if (el.type === 1) {
return genElement(el);
} else {
return genText(el);
}
}
function genText(el){
if(el.text){ // 静态文本节点
reutrn `_v(${el.text})`
}
if(el.expression){ // 动态文本节点
return `_v(${el.expression})`
}
}
至此,我们编写compileToFunctions函数,完成Vue中模板字符串到render函数的转换。
function compile(){
const ast = parse(template.trim(), options)
optimize(ast, options)
const code = generate(ast, options)
return {
ast,
render: code.render,
}
}
总结
在这篇文章中,主要讲述了如何将我们编写的字符串转换成render,执行render函数,便可以得到虚拟Dom。有了虚拟Dom便可以在视图更新编写patch函数比对新老虚拟Dom,以最小的操作步骤去更新Dom视图。
后续我会将本文的内容整合成一个小Demo放到我的github上,有兴趣可以关注我哟~ 代码地址点我
在下篇文章中,我们会讲述关于虚拟Dom,patch比对算法,Vue的异步批量更新策略。敬请期待Vue源码分析(三)-----Vue更新策略
参考文档:
vue.js源码 github.com/vuejs/vue
《深入浅出vue.js》 ----刘博文
《剖析 Vue.js 内部运行机制》 ----掘金小册(作者:染陌同学)
如果觉得写的还不错,记得点个赞哟~,作者每个月至少编写一篇技术博客,球球各位小姐姐,小哥哥们关注~
Faith is the bird that feels the light and sings when the dawn is still dark.
信念是一只鸟,在黎明昏暗之时,就受光明引领而歌唱
——泰戈尔