Vue.js 源码剖析(三) -- 模板编译和组件化
本文主要分析模版编译和组件创建过程的Vue源码,通过思维导图总结代码执行过程
一、简介
在创建vue
实例的过程中,我们可以通过template
属性,指定vue
实例对应的html
结构,或者放到<template></template>
标签中,这种html
结构我们称之为模版,其中不仅包含普通的html
标签,还包含vue
提供的插值表达式{{}}
、指令等用来获取vue
提供的数据,实现数据驱动视图。模版编译的作用就是将我们指定的模版,编译成render()
函数,render()
函数继而实现模版渲染。模版编译根据执行时机分为两种:
🦋 运行时编译。完整版vue
携带模版编译的代码,只不过代码量大,性能比较低
🦋 构建时编译。vue-cli
默认使用的是运行时vue
,模版编译需要由webpack
和vue-loader
插件完成。
接下来我们来简单的体验一下模版编译生成的效果,测试代码如下:
<div id="demo">
<h1>app's demo</h1>
<h2>{{person}}</h2>
</div>
<script>
const vm = new Vue({
el: '#demo',
data() {
return {
person: 'ls'
}
},
mounted() {
console.log('mounted')
console.log(this.$options.render)
}
})
</script>
控制台输出的render()
函数:
(function anonymous() {
with (this) {
return _c(
'div', // vm实例对应的模版的标签为div
{attrs: {"id": "demo"}}, // 增加一个属性id
[ // 子节点序列
_c('h1', [_v("app's demo")]), // 创建文本节点并放到h1标签中
_v(" "), // 空格文本节点
_c('h2', [_v(_s(person))]) // 将属性person对应的值转成文本,然后创建文本节点,并放在h2标签中
]
)
}
})
anonymous
表示它是一个匿名函数,with(){}
语法用于临时扩展作用域链,严格模式下禁用,花括号内部变量的this
都指向with()
方法的实参,这样就可以省略对象,直接使用目标对象的属性,这里的this
指向的是vm
,with()
中的_c
就是vm._c
其中使用到几个函数,在下图中依次对这几个函数进行简单的介绍:
二、模版编译入口
模版编译的过程高阶函数含量过高,过程比较绕。主要做的事情:
🦋:将平台相关的编译选项和用户传入的配置选项合并
🦋:将模版转化为抽象语法树
🦋:根据抽象语法树获取render
函数
🦋:为Vue
添加编译方法,根据配置项和render
函数完成模版编译
下面是从项目入口文件寻找模版编译核心方法的过程,
- 项目入口文件是[platforms/web/entry-runtime-with-compiler.ts],其中从[platforms/web/runtime-with-compiler.ts]中引入了Vue的定义;
- [platforms/web/runtime-with-compiler.ts]中给Vue增加了
compile
属性,这个属性值指向compileToFunctions
,而compileToFunctions
是通过import
引入的,其定义在[platforms/web/compiler/index.ts]文件中; compileToFunctions
是通过结构赋值的形式,通过解析createCompiler(baseOptions)
执行的结果解析出来的,其中baseOptions
参数是和平台相关的配置项,包括模块、组件等;creatComplie()
函数也是通过import
,从[compiler/index.ts]中引入的,这个文件中的代码是和平台无关的通用代码。creatComplie()
函数是createCompilerCreator(baseCompile)
执行返回,参数baseCompile
是一个函数,用于将模板转换成AST 抽象语法树;createCompilerCreator()
在[compiler/create-compiler.ts]中定义,返回值也是一个函数,这个函数的返回值是一个对象,其中有compile
属性和compileToFunctions
属性,这两个属性都是方法compile()
会将平台相关的选项和平台无关的选项合并,然后调用传进来的参数方法baseCompile(合并后的选项)
compileToFunctions()
会将模版编译为render()
函数
接下来我们来逐个击破,看看这几个函数主要的功能。
三、模版编译过程
(一)createCompileToFunctionFn()
与compileToFunctions()
首先看createCompileToFunctionFn()
方法,这个方法的作用是用来创建compileToFunctions()
方法的,compileToFunctions()
方法用来将模版编译为render()
函数,之所以使用高阶函数的形式返回compileToFunctions()
方法,是为了使用闭包对生成的编译结果进行缓存
(二)compile()
上面createCompileToFunctionFn()
方法需要接收compile()
方法作为参数,compile()
方法用来将模版编译成render()
函数,接下来我们看这个方法里面都做了什么。主要三件事:
- 合并选项
- 调用
baseCompile()
进行编译 - 返回编译结果
(二)baseCompile()
baseCompile()
是调用createCompilerCreator()
方法的时候传递的实参,里面的代码并不多,但是每一步都很复杂
主要是三个函数,parse()
、optimize()
、generate()
先讲一下关键的概念----AST
- 抽象语法树简称 AST (Abstract Syntax Tree)
- 使用对象的形式描述树形的代码结构
- 此处的抽象语法树是用来描述树形结构的HTML字符串
为什么要使用抽象语法树
- 模板字符串转换成 AST 后,可以通过 AST 对模板做优化处理
- 标记模板中的静态内容(不依赖数据的内容),在patch(新旧节点对比并更新dom)的时候直接跳过静态内容
- 在patch的过程中静态内容不需要对比和重新渲染
AST在很多的构建工具中都有使用,比如babel可以将es6转成兼容性更强的es5,就是先将代码转换为AST,然后再进行解析转化为es5。
这里介绍一个查看AST的网站,可以查看各种项目中各种构建工具生成的AST
抽象语法树虽然听起来比较难理解,但也只是一个普通的对象,我们来看一下生成AST的方法:
function createASTElement(tag, attrs, parent) {
return {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
rawAttrsMap: {},
parent,
children: []
};
}
💙 parse()
parse()
将模版转换为AST的过程比较的复杂,这里只介绍大体的工作步骤。
💙 optimize()
parse()
将模版转换为AST之后,会调用optimize()
方法优化抽象语法树。这个方法的作用是标记AST中的静态节点,将来在更新DOM的时候就可以直接跳过静态节点。
💙 generate()
第三步是使用generate()
将将AST转换成字符串形式的js代码。generate()
方法里面的代码不多
function generate(ast: ASTElement | void,options: CompilerOptions): CodegenResult {
// 创建CodegenState对象,用来保存生成代码时的一些选项
const state = new CodegenState(options)
// 如果ast存在,且ast的tag是script,说明是根节点的script标签,直接返回null
// 如果ast不存在,说明是纯模版,直接返回_c('div'),_c是createElement的别名
// 如果ast存在,且ast的tag不是script,说明是根节点的template标签,调用genElement生成代码
const code = ast
? ast.tag === 'script'
? 'null'
: genElement(ast, state)
: '_c("div")'
// 返回渲染函数和静态渲染函数
return {
render: `with(this){return ${code}}`,
staticRenderFns: state.staticRenderFns
}
}
但是理解起来很困难。注释写出来的就不啰嗦了,首先解释state
中比较重要的两个属性
staticRenderFns
用来存储静态根节点的渲染函数,这个属性最终会被generate()
函数返回出去pre
用来标识当前处理的节点是否允许跳过编译。AST的pre
属性在parse()
阶段就标记好了,是通过判断标签上有没有v-pre
指令。v-pre
指令是用来标识当前节点跳过编译过程。可利用它跳过:没有使用指令语法、没有使用插值语法的节点,会加快编译
重点解释genElement()
生成代码的过程genElement()
方法最终会返回code
,并且会给state.staticRenderFns
添加元素,generate()
会返回{code,staticRenderFns}
,code
在compileToFunctions()
函数中,通过new Function()
形式,由字符串转换为真正的函数。
四、组件化
(一)全局组件
首先回顾一下全局组件的定义方法:
// 定义一个名为 button-counter 的新组件
Vue.component('button-counter', {
data: function () {
return {
count: 0
}
},
template: '<button v-on:click="count++">You clicked me {{ count }} times.</button>'
})
使用的是Vue
的静态方法component()
注册,注册完就可以在项目中任何一个Vue实例中使用
<button-counter></button-counter>
Vue.component()
的初始化在[src/core/index.ts]initGlobalAPI()
方法中进行,其中的initAssetRegisters(Vue)
事件用来初始化Vue.component
、Vue.directive
、Vue.filter
静态方法,因为这几个方法的使用格式很类似,都是Vue.xx('xx',xx)
的形式。这种定义方法在平时写代码的时候也可以借鉴,但是我感觉好像更麻烦了,因为还需要一个类型知道当前使用的是哪一个方法。首先说一下这几个方法的使用,以component()
方法为例,有两种用法,一种是定义组件,一种是获取已经定义的组件。
🌺 定义组件Vue.component('组件名',options配置对象)
🌺 获取组件Vue.component('组件名')
如果只有一个参数,说明这个方法是用来获取已经定义的组件的。
下面梳理了Vue.component()
方法执行过程中主要做的事情:
(二)局部组件
先回顾一下局部组件的注册
const ButtonCounter = {
data: function () {
return {
count: 0
}
},
template: '<button v-on:click="count++">You clicked me {{ count }} times.</button>'
}
const vm = new Vue({
el: '#demo',
components: {
'button-counter': ButtonCounter
}
})
组件在创建Vue实例的时候作为配置选项传递进去。
五、模版解析
上面讲了一下组件的注册过程,下面讲一下模版渲染过程中,遇到组件<button-counter></button-counter>
,Vue是怎么解析的。假设模版中的内容有一个根标签和一个组件标签:
<div id="demo">
<button-counter></button-counter>
</div>
js中的代码和上边一样
const ButtonCounter = {
data: function () {
return {
count: 0
}
},
template: '<button v-on:click="count++">You clicked me {{ count }} times.</button>'
}
const vm = new Vue({
el: '#demo',
components: {
'button-counter': ButtonCounter
}
})
在执行new Vue()
代码时,会创建Vue实例,创建Vue实例的阶段会通过调用vm.$mount()
方法进行页面初始化。此时传给new Vue()
的配置参数中没有template
属性,会先根据el
属性获取根节点,将根节点的outerHTML
作为template
并且,此时也没有传递render()
函数,就会根据模版生成render()函数:
const { render, staticRenderFns } = compileToFunctions(
template,
{
outputSourceRange: __DEV__,
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
},
this
)
compileToFunctions()
在第三章模版编译过程中有讲到,它会将模版编译成render()
函数,返回值是一个对象,其中包含编译好的render()
函数和静态渲染函数staticRenderFns()
,静态渲染函数用来渲染静态内容,也就是不需要随着数据的改变而更新的内容。此时看一下render()
长啥样:
(function anonymous(
) {
with(this){return _c('div',{attrs:{"id":"demo"}},[_c('button-counter')],1)}
})
render()
函数表示创建一个根节点,标签为div
,这个根节点的id
属性为demo
,并且有一个子元素,子元素的标签为button-counter
,也就是我们自定义的标签。第四个参数是用来表示转化children
参数的类型,虚拟节点的children
属性可能有嵌套,通过这个参数来判断调用哪个方法来处理嵌套的children
。这个值有两种情况,一种是1,表示children
是简单的嵌套,只需要简单的处理,不进行嵌套节点的处理;一种是2,当执行用户传入的render()
时,这个值就是2,表示需要递归处理children
嵌套。render()
函数有两种形式,一种是用户手动传进来,一种是Vue将template
转化为render()
函数,初始化渲染过程涉及到的步骤,可以通过调试的方法进行详细的查看,我这里通过调试,大致梳理了代码在生成虚拟DOM树的过程中的执行过程。要点也使用文字总结一下:
1️⃣ 初始化过程会初始化渲染相关的函数,例如_c()
是当传入模版时用来创建元素的函数、$createElement()
是用户传入render
时创建元素的函数;它们两个都是对传进来的参数进行处理,最后调用一个公用的方法:createElement()
。这个方法是用来生成虚拟节点的,虚拟节点有好几种:浏览器内置标签、自定义组件、未知标签,这个方法内部会根据tag
参数判断,生成对应种类的虚拟节点。对于根节点而言,此时生成的虚拟节点,就是虚拟DOM树。
2️⃣ 如果传入的是自定义组件,在createElement()
方法中会调用创建组件的方法,创建一个继承Vue
构造函数的组件构造函数,并且创建一个自定义组件的虚拟节点。在生成render()
函数的过程中,由于子节点是组件,就会配置render()
函数的最后一个参数为1,表示在创建虚拟节点的时候进行嵌套节点的处理。
(function anonymous(
) {
with(this){return _c('div',{attrs:{"id":"demo"}},[_c('button-counter')],1)}
})
3️⃣ 如果用户没有传入render
配置项,就会根据template
或者el
生成render
函数。此时的render()
函数创建虚拟节点使用的都是_c()
。
4️⃣ 如果用户传入了render
配置项,会把vm.$createElement
作为h
函数传递给render
函数,用来生成虚拟节点。此时children
可能有嵌套,会进行处理。
5️⃣ 如果用户指定了template
,模版中的静态内容会转化为staticRenderFns
静态渲染函数的数组,render()
函数中会通过_m()
调用staticRenderFns
中的函数。
〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️〰️
下面是调试过程中使用的三个示例,分别是自定义render
、传入template
、传入自定义组件的情况
更多推荐
所有评论(0)