深入浅出Vue.js阅读——模板编译原理——代码生成器
代码生成器是模板编译的最后一步,它的作用作用是将AST转换成渲染函数中的内容,这个内容可以称为代码字符串。
代码字符串可以被包装在函数中执行,这个函数就是我们通常说的渲染函数。
渲染函数被执行之后,可以生成一份VNode,而虚拟DOM可以通过这个VNode来渲染视图。
假设现在有这样一个简单的模板:
<div id="el">Hello {{name}}</div>
它转换成AST并且经过优化器的优化之后是下面这个样子的:
{
"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",
"valeu":"'el'"
}
],
"static":false,
"staticRoot":false
}
代码生成器可以通过上面这个AST来生成代码字符串,生成后的代码字符串是这样的:
with(this) {
return _c("div", {
attrs: {
"id": "el"
}
}, [_v('Hello ' + _s(name))])
}
仔细观察生成后的代码字符串,我们会发现,这其实是一个嵌套的函数调用。函数_c
的参数中执行了函数_v
,而函数_v
的参数中又执行了函数_s
。
代码字符串中的_c
其实是createElement
的别名。crearteElement
是虚拟DOM中所提到的方法,它的作用是创建虚拟节点,有三个参数,分别是:
- 标签名
- 一个包含模板相关属性的数据对象
- 子节点列表
调用createElement
方法,我们可以得到一个VNode。
这也就知道了渲染函数可以生成VNode的原因:渲染函数其实是执行了createElement
,而createElement
可以创建一个VNode。
1. 通过AST生成代码字符串
生成代码字符串是一个递归的过程,从顶向下一次处理,每一个AST节点。
节点有三种类型,分别对应三种不同的创建方法与别名:
类型 | 创建方法 | 别名 |
---|---|---|
元素节点 | createElement | _c |
文本节点 | createTextVNode | _v |
元素节点 | createEmptyVNode | _e |
在递归的过程中,每处理一个AST节点,就会生成一个与节点类型相对应的代码字符串。
如果节点是元素节点,那么代码字符串是这样的:
_c(<tagname>,<data>,<children>)
元素节点通常有子节点,当处理它的子节点时,创建出来的代码字符串会放在上面例子中<children>
的位置。
使用AST生成代码字符串时,最先生成根节点div
。
生成后是这样的:
_c('div',{attrs:{"id":"el"}})
然后继续生成它的子节点,生成出来的子节点字符串会放在_c
函数第三个参数的位置。
在前面的例子中,根节点div
下面又是一个div
节点,所以会生成一个div
节点放在_c
函数的第三个参数的位置。生成后的代码字符串如下:
_C('div',{attrs:{"id":"el"}},[_c('div')])
可以看到,在_c
的第三个参数位置,多了一个数组,里面又有一个_c
。
在模板中,第二个div
下面是一个p
节点,所以会产生一个p
节点的代码字符串,如下:
_c('p')
当这一段p
节点的代码字符串被插入到整体代码字符串中之后,是下面这个样子:
_c('div',{attrs:{"id":"el"}},[_c('div',[_c('p')])])
p
节点下面是一个带变量的文本,生成的代码字符串如下:
_v('Hello '+_s(name))
同样,它会插入到p
节点的子节点列表的位置。插入之后的代码字符串如下:
_c('div',{attrs:{"id":"el"}},[_c('div',[_c('p',[_v("Hello "+_s(name))])])])
当递归结束时,我们就可以得到一个完整的代码字符串。这段代码字符串会被包裹在with
语句中,伪代码如下:
`with(this){return ${code}}`
在代码中,code
是我们通过递归得到的完整的代码字符串。代码生成器的作用就是生成上面伪代码中所展示的一段字符串。
2.代码生成器的原理
节点有不同的类型,例如元素节点、文本节点和注释节点。
不同类型节点的生成方式是不一样的,下面我们分别介绍如何生成每个类型的节点。
1. 元素节点
生成元素节点,其实就是生成一个_c
的函数调用字符串,相关代码如下:
function genElement(el,state){
// 如果el.plain是true,则说明节点没有属性
const data = el.plain ? undefined : genElement(el,state)
const children = genElement(el,state)
code = `_c('${el.tag}'${
data ? `,${data}` : '' //dara
}${
children ? `,${children}` : '' // children
})`
return code;
}
代码中el
的plain
属性是在编译时发现的。如果节点没有属性,就会把plain
设置为true
。这里我们可以通过plain
来判断是否需要获取节点的属性数据。
代码中的主要逻辑是用getData
和genChildren
分别获取data
和children
,然后将它们分别拼到字符串中指定的位置。最后把拼好的“_c(tagName,data,children)”
返回,这样一个元素节点的代码字符串就生成好了。
data
和children
也是字符串,那么它们是如何生成的呢?
我们先看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;
}
其实也是拼字符串。先给data
赋值一个’{’,然后发现节点存在哪些属性数据,就将这些数据拼接到data
中,最后拼接一个’}’,此时一个完整的data
就拼接好了。
生成子节点列表字符串的逻辑也是拼字符串。通过循环子节点列表,根据不同的子节点类型生成不同的节点字符串并将其拼接在一起,具体实现如下:
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)
}
}
从上面的代码可以看到,通过循环子节点列表,然后分别调用不同节点类型的生成方法来生成字符串,最后将其凭借到一起并返回。
这其实是一个递归过程。如果子节点存在子节点,那么会重复这个过程来生成子节点的子节点。
从代码中可以看到,生成子节点时,会根据其类型的不同调用不同的生成方法。
2. 文本节点
生成文本节点很简单,我们只需要把文本放在_v
这个函数的参数中即可:
function genText(text){
return `_v(${text.type===2
? text.expression
:JSON.stringify(text.text)
})`
}
在上面的代码中,我们会把文本放在_v
的参数中。这里会判断文本的类型:如果是动态文本,则使用expression
;如果是静态文本,则使用text
。
3. 注释节点
注释节点与文本节点相同,只需要把文本放在_e
参数中即可,代码如下:
function genComment(comment){
return `_e(${JSON.stringify(comment.text)})`
}
3. 总结
我们介绍了代码生成器的作用及其内部原理,了解了代码生成器其实就是字符串拼接的过程。通过递归AST来生成字符串,最先生成根节点,然后在子节点字符串生成后,将其拼接在根节点的参数上,子节点的子节点拼接在子节点的参数上中,这样一层一层地拼接,直到最后拼接完整的字符串。
同时还介绍了三种类型的节点,分别是元素节点、文本节点和注释节点。而不同类型的节点生成字符串的方式是不同的。
最后,我们介绍了当字符串拼接好后,会将字符串拼接在with
中返回给调用者。