1. 简介
模板编译的主要目的是将模板 (template) 转换为渲染函数 (render)
<div>
<h1 @click="handler">title</h1>
<p>some content</p>
</div>
渲染函数:
render (h) {
return h('div', [
h('h1', { on: { click: this.handler} }, 'title'),
h('p', 'some content')
])
}
- 模版编译作用
- Vue 2.x 使用 VNode 描述视图以及各种交互,用户自己编写 VNode 比较复杂
- 用户只需要编写类似 HTML 的代码 - Vue 模板,通过编译器将模板转换为返回 VNode 的 render 函数
- .vue 文件会被 webpack 在构建的过程中转换成 render 函数
带编译器版本的 Vue.js 中,使用 template 或 el 的方式设置模板
<div id="app">
<h1>Vue<span>模板编译过程</span></h1>
<p>{{ msg }}</p>
<comp @myclick="handler"></comp>
</div>
<script src="../../dist/vue.js"></script>
<script>
Vue.component('comp', {
template: '<div>I am a comp</div>'
})
const vm = new Vue({
el: '#app',
data: { msg: 'Hello compiler' },
methods: {
handler () {
console.log('test')
}
}
})
console.log(vm.$options.render)
</script>
输入render后的结果:
(function anonymous() {
with (this) {
return _c(
"div",
{ attrs: { id: "app" } },
[
_m(0),
_v(" "), // 创建空白文本节点
_c("p", [_v(_s(msg))]), _v(" "), // _s转为字符串
_c("comp", { on: { myclick: handler } }),
],
1
);
}
});
_c是createElement()方法,定义的位置 instance/render.js 中- 相关的渲染函数(
_开头的方法定义),在 instance/render-helps/index.js 中
// 对编译生成的 render 进行渲染的方法
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
// 对手写 render 函数进行渲染的方法
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
2. 流程
- 解析模板字符串生成 AST(解析器)
- 优化语法树(优化器)
- 生成代码(代码生成器)
1. 解析模板字符串生成 AST(解析器)
- 作用
- 将模版解析为解析器
- 过滤解析器
- 文本解析器
- 解析带变量的文本
- HTML解析器
1. 什么是AST
AST是用JavaScrp中的对象来描述一个节点,一个对象表示一个节点,对象中的属性用来保存节点所需的各种数据。比如,parent属性保存了父节点的描述对象,children 属性是一个数组,里面保存了一些子节点的描述对象。再比如,type 属性表示一个节点的类型等。当很多个独立的节点通过 parent 属性和children属性连在一起时,就变成了一个树,而这样一个用对象描述的节点树其实就是AST。
<div>
<p>{{name}}</p>
</div>
上面一段代码转为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)"
}]
}]
}
2. 内部原理
HTML解析器的作用是解析HTML,它在解析 HTML 的过程中会不断触发各种钩子函数。这些钩子函数包括开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数。
parseHTML(template,{
start(tag,attrs, unary){
// 每当解析到标签的开始位置时,触发该画数
},
end(){
// 每当解析到标签的结束位置时,触发该函数
},
chars(text){
// 每当解析到文本时,触发该函数
},
comment(text){
// 每当解析到注释时,触发该函数
}
}
举个简单的例子:
<div><p>我是Berwin</p></div>
当上面这个模板被 HTML 解析器解析时,所触发的钩子函数依次是∶ start、start、chars、end和 end。
我们可以在钩子函数中构建 AST节点
- 在start钩子函数中构建元素类型的节点,
- 在chars钩子函数中构建文本类型的节点,
- 在coment钩子函数中构建注释类型的节点。
当 HTML解析器不再触发钩子函数时,就说明所有模板都解析完毕,所有类型的节点都在钩子函数中构建完成,即 AST构建完成。
我们发现,钩子函数start有三个参数,分别是tag、attrs和unary,它们分别说明标签名、标签的属性以及是否是自闭合标签。
而文本节点的钩子函数chars和注释节点的钩子函数comment都只有一个参数,只有text。这是因为构建元素节点时需要知道标签名、属性和自闭合标识,而构建注释节点和文本节点时只需要知道文本即可。
创建一个元素节点:
function createASTElement(tag,attrs,parent){
return{
type:1,
tag,
attrsList:attrs,
parent,
children:[]
}
}
parseHTML(template,{
start(tag, attrs,unary){
let element = createASTElement(tag,attrs,currentParent)
}
})
创建文本节点:
parseHTML(template,{
chars(text){
let element = {type:3,text}
}
})
创建注释节点:
parseHTML(template,{
comment(text){
let element = {type:3, text, isComment:true}
}
})
三种节点区别: 标签节点type为1,并且有tag和attrs,文本和注释节点type为3,注释节点isComment为true
- AST层级关系
- AST层级关系可以用栈来模拟
- 栈还可以检测HTML标签是否正确闭合
- HTML解析器在解析HTML时,是从前向后解析。每当遇到开始标签,就触发钩子函数start。每当遇到结束标签,就会触发钩子函数 end。
- 在每次触发钩子函数 start时,把当前构建的节点推人栈中;每当触发钩子函数 end时,就从栈中弹出一个节点。
- 这样就可以保证每当触发钩子函数 start时,栈的最后一个节点就是当前正在构建的节点的父节点
我们需要注意的是:空格会触发文本节点的钩子函数,在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
3. HTML解析器
解析HTML模板的过程就是循环的过程,简单来说就是用HTML模板字符串来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕。
在截取一小段字符串时,有可能截取到开始标签,也有可能截取到结束标签,又或者是文本或者注释,我们可以根据截取的字符串的类型来触发不同的钩子函数。
function parseHTML(html,options){
while(html){
// 截取模板字符串并触发钩子函数
}
}
- 解析器需要处理的几种节点
- 文本
- 注释
- 条件注释
- DOCTYPE
- 结束标签
- 开始标签
export function parseHTML(html, options) {
while(html){
if(!lastTag !isPlainTextElement(lastTag)){
let textEnd= html.indexOf('<')
if(textEnd === 0){
// 注释
if(comment.test(html)){
// 注释的处理逻辑
continue
}
// 条件注释
if(conditionalComment.test(html)){
// 条件注释的处理逻辑
continue
}
// DOCTYPE
const doctypeMatch = html.match(doctype)
if(doctypeMatch){
// DOCTYPE的处理逻辑
continue
}
// 结束标签
const endTagMatch = html.match(endTag)
if(endTagMatch){
// 结束标签的处理逻辑
continue
}
// 开始标签
const startTagMatch = parseStartTag()
if(startTagMatch){
// 开始标签的处理逻辑
continue
}
}
let text, rest,next
if(textEnd >= 0){
// 解析文本
}
if(textEnd < 0){
text=html
html = ''
}
if(options.chars && text){
options.chars(text)
}
} else {
// 父元素为script、style、textarea的处理逻解
}
}
}
4. 文本解析器
HTML解析器在解析文本时,并不会区分文本是否是带变量的文本。如果是纯文本,不需要进行任何处理;但如果是带变量的文本,那么需要使用文本解析器进一步解析。因为带变量的文本在使用虚拟DOM进行渲染时,需要将变量替换成变量中的值。
每当HTML解析器解析到文本时,都会触发 chars函数,并且从参数中得到解析出的文本。在chars 函数中,我们需要构建文本类型的AST,并将它添加到父节点的children属性中。
而在构建文本类型的 AST 时,纯文本和带变量的文本是不同的处理方式。如果是带变量的文本,我们需要借助文本解析器对它进行二次加工
parseHTML(template,{
chars(text){
text= text,trim()
if(text){
const children = currentParent.children
let expression
if(expression = parseText(text)) {
children.push({
type:2,
expression,
text
})
}else{
children.push({
type:3,
text
})
}
}
}
在 chars 函数中,如果执行 parseText 后有返回结果,则说明文本是带变量的文本,并且已经通过文本解析器(parseText)二次加工,此时构建一个带变量的文本类型的AST并将其添加到父节点的children属性中。否则,就直接构建一个普通的文本节点并将其添加到父节点的children 属性中。而代码中的currentParent是当前节点的父节点,也就是前面介绍的栈中的最后一个节点。
假设chars函数被触发后,我们得到的 text是一个带变量的文本∶
"Hello{{name}}"
这个带变量的文本被文本解析器解析之后,得到的expression变量是这样的∶
"Hello"+_s(name)
上面代码中的_s其实是下面这个 tostring 函数的别名∶
function toString(val){
return val == null
? ''
: typeof val === 'object'
? JSON.stringify(val,null,2)
: String(val)
过程如图:
const ast = parse(template.trim(), options)
2. 优化语法树(优化器)
- 作用
- 提升性能
- 遍历AST,检测出所有静态子树
- 重新渲染时,打上标记的静态节点不需要重新创建Vnode,直接克隆即可
- 提升性能
标记静态子树好处
- 每次重新渲染时,不需要为静态子树创建新节点;
- 在虚拟DOM中打补丁(patching)的过程可以跳过。
在生成VNode的过程中,如果发现一个节点被标记为静态子树,那么除了首次渲染会生成节点之外,在重新渲染时并不会生成新的子节点树,而是克隆已存在的静态子树。
优化器的内部实现主要分为两个步骤∶
-
1)在AST中找出所有静态节点并打上标记;
-
2)在AST中找出所有静态根节点并打上标记。
-
永远都不会发生变化的节点就是静态节点
- 在AST中,静态节点指static属性为true的
-
如果一个节点下面的所有子节点都是静态节点,并且它的父级是动态节点,那么它就是静态根节点。
- AST中,静态根节点指的是staticRoot属性为true 的节点
如下:ul就是静态根节点
<ul>
<li>我是静态节点,我不需要发生变化</li>
<li>我是静态节点2,我不需要发生变化</1i>
<li>我是静态节点3,我不需要发生变化</li>
</ul>
export function optimize (root){
if(!root) return
// 第一步∶标记所有静态节点
markStatic(root)
// 第二步∶标记所有静态根节点
markStaticRoots(root)
}
找出并标记静态节点
function markStatic(node){
node.static= isStatic(node)
if(node.type === 1){
for(let i=8,l=node.children.length;i<l;i++t){
const child= node.children[i]
markStatic(child)
}
}
}
function isStatic(node){
if(node.type ===2){
// 带变量的动态文本节点
return false
}
if(node.type ===3){
// 不带变量的纯文本节点
return true
}
return !!(node.pre || (
!node.hasBindings && //没有动态绑定
!node.if && !node.for 8& // 没有v-if或v-for或v-else
!isBuiltInTag(node.tag)8& // 不是内置标签
isPlatformReservedTag(node.tag) &&//不是组件
lisDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isstaticKey)
))
}
- 如果 type等于2,说明节点是带变量的文本节点,那它不可能是静态节点,所以返回 false。
- 如果type等于3,说明节点是不带变量的纯文本节点,那它一定是静态节点,所以返回true。
- 当 type等于1时,说明节点是元素节点。当一个节点是元素节点时,想分辨出它是否是静态节点,就会稍微有点复杂。
- 首先,如果元素节点使用了指令v-pre,那么可以直接断定它是一个静态节点。
- 如果元素节点没有使用指令v-pre,那么它必须同时满足以下条件才会被认为是一个静态节点。
- 不能使用动态绑定语法,也就是说标签上不能有以v-、@、∶开头的属性。□不能使用v-if、v-for或者v-else指令。
- 不能是内置标签,也就是说标签名不能是slot或者 component。
- 不能是组件,即标签名必须是保留标签,例如是保留标签,而</11st>不是保留标签。
- 当前节点的父节点不能是带v-fo指令的 template标签。口节点中不存在动态节点才会有的属性。
动态绑定语法不包括v-for、v-if、v-else、v-else-if和v-once 等。
第六条提到的"节点中不存在动态节点才会有的属性"这里详细解释一下。事实上,如果一个元素节点是静态节点,那么这个节点上的属性其实是有范围的。也就是说,如果这个节点是静态节点,那么它所有的属性都可以在这个范围内找到。这个范围是 type、tag、attrsList、attrsMap、plain、parent、children、attrs、staticclass和staticstyle。
如果一个元素节点上的属性在上面这个范围内找不到相同的属性名,就说明这个节点不是静态节点。
我们已经可以判断一个节点是否是静态节点,并且可以通过递归的方式来标记子节点是否是静态节点。
但是这里会遇到一个问题,递归是从上向下依次标记的,如果父节点被标记为静态节点之后,子节点却被标记为动态节点,这时就会发生矛盾。因为静态子树中不应该只有它自己是静态节点,静态子树的所有子节点应该都是静态节点。
因此,我们需要在子节点被打上标记之后重新校对当前节点的标记是否准确,具体的做法是∶
function markStatic(node){
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
}
}
}
}
在子节点被打完标记之后,我们需要判断它是否是静态节点,如果不是,那么它的父节点也不可能是静态节点,此时需要将父节点的static属性设置为false。
找出并标记所有静态根节点
找出静态根节点的过程与找出静态节点的过程类似,都是从根节点开始向下一层一层地用递归方式去找。不一样的是,如果一个节点被判定为静态根节点,那么将不会继续向它的子级继续寻找。因为静态子树肯定只有一个根,就是最上面的那个静态节点。
有一个逻辑是静态节点的所有子节点也都是静态节点。如果一个静态节点的子节点是动态节点,那么这个节点也是动态节点。因此,我们从上向下找,找到的第一个静态节点一定是静态根节点,而它的所有子节点一定也是静态节点
大部分情况下,我们找到的第一个静态节点会被标记为静态根节点,但是有一种情况,即便它真的是静态根节点,也不会被标记为静态根节点,因为其优化成本大于收益。
这种情况是一个元素节点只有一个文本节点。例如这样的
<p>我是静态节点,我不需要发生变化</p>
代码实现:
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
}
if(node.children){
for(let i=0,l=node.children.length;i< l;i++){
markStaticRoots(node.children[i])
}
}
}
第一部分逻辑中的判断条件很明显∶如果节点是静态节点,并且有子节点,并且子节点不是只有一个文本类型的节点,那么该节点就是静态根节点,否则就不是静态根节点。
这个条件之所以成立,是因为如果当前节点是静态节点,就充分说明该节点的子节点也是静态节点。同时又排除了两种情况∶如果静态节点没有子节点,那么它不是静态根节点;如果静态节点只有一个文本节点,那么它也不是静态根节点。
第二部分的逻辑是处理子节点,这很简单∶循环子节点列表,然后将每一个子节点重复执行同一套逻辑即可。但是这里有一个细节,那就是如果当前节点已经被标记为静态根节点,将不会再处理子节点。只有当前节点不是静态根节点时,才会继续向子节点中查找静态根节点。所以,在代码中,node.staticRoot = true的下一行代码是return语句。
总结:
通过递归的方式从上向下标记静态节点时,如果一个节点被标记为静态节点,但它的子节点却被标记为动态节点,就说明该节点不是静态节点,可以将它改为动态节点。静态节点的特征是它的子节点必须是静态节点。
标记完静态节点之后需要标记静态根节点,其标记方式也是使用递归的方式从上向下寻找,在寻找的过程中遇到的第一个静态节点就为静态根节点,同时不再向下继续查找。
但有两种情况比较特殊∶一种是如果一个静态根节点的子节点只有一个文本节点,那么不会将它标记成静态根节点,即便它也属于静态根节点;另一种是如果找到的静态根节点是一个没有子节点的静态节点,那么也不会将它标记为静态根节点。因为这两种情况下,优化成本大于收益。
optimize(ast, options)
3. 生成代码(代码生成器)
- 作用
- 将AST转换为渲染函数中的内容(注意是代码字符串)
- 通过new Function()转为函数执行
<div id="el">Hello{{name}}</div>
{
'type':1,
'tag':'div',
'attrsList':[{
'name':'id',
'value':'el',
}],
'attrsMap':{
'id':'el'
},
'children':[
{
'type':2,
'expression':'"Hello"+_s(name)',
'text':'Hello{{name}}',
'static':false
'plain':false,
}],
'attrs':[
{
'name':'id',
'value':'"el"',
}],
'static':false,
'staticRoot':false
}
with(this){
return _c(
"div",
{
attrs:{"id":"el"}
},
[
_v("Hello "+_s(name))
]
)
}
代码字符串中的_c其实是createElement的别名。createElement是虚拟 DOM 中所提供的方法,它的作用是创建虚拟节点,有三个参数,分别是∶
- 标签名
- 一个包含模板相关属性的数据对象
- 子节点列表
调用 createElement 方法,我们可以得到一个 VNode。
这也就知道了渲染函数可以生成VNode的原因∶渲染函数其实是执行了createElement,而createElement 可以创建一个 VNode。
<div id="el">
<div>
<p>Hello {{name}}</p>
</div>
</div>
生成出来的子节点字符串会放在_c函数第三个参数的位置
_c('div',{attrs:{"id":"el"}},[_c('div',[_c('p',[_("Hello"+_s(name))])])])
原理:
生成元素节点:
代码中的主要逻辑是用genData和 genChildren分别获取data和children,然后将它们分别拼到字符串中指定的位置,最后把拼好的"_c(tagName,data,children)"返回,这样一个元素节点的代码字符串就生成好了。
function genElement(el,state){
// 如果el.plain是true,则说明节点没有属性
const data = el.plain? undefined : genData(el, state)
const children =genchildren(el,state)
code = `_c('${el.tag}'${data ? `, ${data}` : '' // data
}${
children ? `, ${children}` : '' // children
})`
return code
}
生成data
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},`
}
// pre
if(el.pre){
data += `pre:true`,
}
// 类似的还有很多种情况
data = data.replace(/,$/, ') + '}'
return data
}
生成children
function genchildren(el,state){
const children= el.children
if(children.length){
return`[${children.map(c => genNode(c,state)).join(',')}]`
}
}
function genNode(node, state){
if(node.type ==1)(
return genElement(node, state)
}
if (node.type ==3 && node.isComment){
return genComment(node)
else{
return genText(node)
}
}
文本节点
function genText(text){
return `_v(${text.type === 2
? text.expression
: JSON.stringify(text.text)
})`
}
注释节点:
function genComment(comment){
return `_e(${JSON.stringify(comment.text)})`
}
const code = generate(ast, options)