深入浅出Vue.js阅读——模板编译原理——优化器
静态子树指的是那些在AST中永远都不会发生变化的节点。例如,一个纯文本节点就是静态子树,而带变量的文本节点就不是静态子树,因为它会随着变量的变化而变化。
标记静态子树的好处:
- 每次重新渲染时,不需要为静态子树创建新节点
每次重新渲染都会使用最新的状态生成一份全新的VNode与旧的VNode进行对比。而在生成VNode的过程中,如果发现一个节点被标记为静态子树,那么首次渲染会生成节点之外,在重新渲染时并不会产生新的子节点树,而实克隆已经存在的静态子节点树。 - 在虚拟DOM中打补丁(patching)的过程可以跳过
如果两个节点都是静态子树,就不需要进行对比与更新DOM的操作,直接跳过。因为静态子树是不可变的,不需要对比就知道它不可能发生变化。此外,直接跳过后续的各种对比可以节省Javascript的运算成本。
优化器的内部主要分为两个步骤:
- 在AST中找出所有静态节点并打上标记
- 在AST中找出所有静态根节点并打上标记
先标记静态节点,再标记静态根节点。
什么是静态节点?
永远都不会发生变化的节点属于静态节点:
<p>静态节点</p>
落实到AST中,静态节点指的是static
属性为true
的节点,例如:
{
type:1,
tag:'p',
staticRoot:false,
static:true
}
什么是静态根节点?
如果一个节点下面的所有子节点都是静态节点,并且它的父级是动态节点,那么它就是静态根节点:
<ul>
<li>我是静态节点1</li>
<li>我是静态节点2</li>
<li>我是静态节点3</li>
</ul>
落实到AST中,静态根节点指的是staticRoot
属性为true
的节点,例如:
{
type:1,
tag:"ul",
staticRoot:true,
static:true
}
举个例子:
<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)'
}
],
"plain":false,
"attrs":[
{
"name":"id",
"value":"el"
}
]
}
经过优化器的优化之后,AST是下面的这样:
{
"type":1,
"tag":"div",
"arrtsList":[
{
"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
}
可以看到,AST中多了static
属性和staticRoot
属性,它们分别用来标记节点是否是静态节点与是否是静态根节点。
在源码中,代码是这样实现的:
export function optimize(root){
if(!root) return;
// 第一步:标记所有静态节点
markStatic(root);
// 第二步:标记所有静态根节点
markStaticRoots(root);
}
1. 找出所有静态节点并标记
从根节点开始,先判断根节点是不是静态根节点,再用相同的方式处理子节点,接着用同样的方式去处理子节点的子节点,直到所有节点都被处理之后程序结束,这个过程叫作递归。
下面的代码先使用isStatic函数来判断节点是否是静态节点,然后如果节点的类型等于1,说明节点是元素节点,那么循环该节点的子节点,调用markStatic
函数用同样的处理逻辑来处理子节点:
function markStatic(node){
node.static = isStatic(node)
if(node.type === 1){
for(let i = 0;i<node.children.length;i++){
const child = node.children[i];
markStatic(child)
}
}
}
那么isStatic
函数是如何判断一个节点是否是静态节点?
源码实现如下:
function isStatic(node){
if(node.type === 2){//带变量的动态文本节点
return false;
}
if(node.type === 3){//不带变量的纯文本节点
return true;
}
return !!(node.pre||(
!node.hasBindings && //没有动态绑定
!node.if && !node.for && //没有v-if或 v-else
!isBuiltInTag(node.tag) && //不是内置标签
isPlatformReservervedTag(node.tag) && //不是组件
!isDirectChildOfTemplateFor(node) &&
Object.keys(node).every(isStaticKey)
))
}
当模板被解析器解析成AST时,会根据不同元素类型设置不同的type值。
type的值 | 说明 |
---|---|
1 | 元素节点 |
2 | 带变量的动态文本节点 |
3 | 不带变量的纯文本节点 |
对出现的三种情况做个说明:
- type等于2:
节点是带变量的文本节点,不可能是静态节点,所以返回false - type等于3:
节点是不带变量的纯文本节点,一定是静态节点,所以返回true - type等于1:
节点是元素节点。当一个节点是元素节点时,分辨它是否是静态节点,就会稍微复杂。- 元素节点使用了
v-pre
指令,直接断定是一个静态节点 - 没有使用
v-pre
指令,必须同时满足以下条件才会被认为是一个静态节点:- 不能使用动态绑定语法,也就是说标签上不能有以v-、@、:开头的属性
- 不能使用
v-if
、v-for
或者v-else
指令‘ - 不能是内置标签,也就是说标签名不能是
slot
或component
- 不能是组件,即标签名必须是保留标签,例如 是保留标签,但是不是保留标签。
- 当前节点的父节点不能是带
v-for
指令的template
标签 - 节点中不存在动态节点才会有的属性:
如果一个元素节点是静态节点,那么这个节点上的属性其实是有范围的。也就是说,如果这个节点是静态节点,那么它的所有属性都可以在这个范围内找到。这个范围是type
、tag
、attrsList
、attrsMap
、plain
、parent
、children
、attrs
、staticClass
、staticSytle
。
如果一个元素节点上的属性在上面这个范围内找不到相同的属性名,就说明这个节点不是静态节点。
我们已经可以判断一个节点是否是静态节点,并且可以通过递归的方式来标记子节点是否是静态节点。
但是会遇到一个问题,递归是从上向下依次标记的,如果父节点被标记为静态子节点之后,子节点却被标记为动态节点,这时候就会发生矛盾。因为静态子树中不应该只有它自己是静态节点,静态子树的所有子节点应该都是静态节点。
因此,我们需要在子节点被打上标记之后重新校对当前节点的标记是否准确,具体的做法是:
- 元素节点使用了
function markStatic(node){
node.static = isStatic(node)
if(node.type === 1){
for(let i = 0;i<node.children.length;i++){
const child = node.children[i]
markStatic(child);
// 新增代码
if(!child.static){
node.static = false;
}
}
}
}
在子节点被打完标记之后,我们需要判断它是否是静态节点,如果不是,那么它的父节点也不可能是静态节点,此时需要将父节点的static
属性设置为false。
2. 找出所有静态根节点并标记
找出静态根节点的过程和找出静态节点的过程类似,都是从根节点向下一层一层的用递归方式去找。不一样的是,如果一个节点被判定为静态根节点,那么将不会继续向它的子集继续寻找。因为静态子树肯定只有一个根,就是最上面的那个静态节点。
在上面说过,我们标记静态节点时,有一个逻辑是静态节点的所有子节点都是静态节点。如果一个静态节点的子节点是动态节点,那么这个节点也是动态节点。因此,我们从下向上找,找到的第一个静态节点一定是静态根节点,而它的所有子节点一定也是静态节点。
大部分的情况下,我们找到的第一个静态节点会被标记为静态根节点,但是有一种情况,即便它真的是静态根节点,也不会被标记为静态根节点,因为其优化成本远大于收益。
这种情况是一个元素节点只有一个文本节点,例如这样的:
<p>我是静态节点</p>
这个p元素只有一个文本子节点,此时即便它是静态根节点,也不会被标记。
上面我们介绍的解决思路在代码中的具体实现如下:
function markStaticRoots(node){
if(node.type === 1){
// 要使节点符合静态根节点的要求,它必须有子节点
// 这个子节点不能是只有一个静态文本的子节点,否则优化成本超过收益
if(node.static && node.children.length && !(
node.chaildren.length === 1 &&
node.children[0].type === 3
)){
node.staticRoot = true;
return;
}else{
node.staticRoot = false;
}
if(node.children){
for(let i = 0;i<node.children.length;i++){
markStaticRoots(node.children[i])
}
}
}
}
上面的代码中的逻辑可以分为两部分,一部分是标记当前节点是否是静态根节点,另一部分是标记子节点是否是静态根节点。
第一部分逻辑中的判断条件很明显:如果节点是静态节点,并且有子节点,并且子节点不是只有一个文本类型的节点,那么该节点就是静态根节点,否则就不是静态根节点。
这个条件之所以成立,是因为如果当前节点是静态节点,就充分说明该节点的子节点也是静态节点,同时又排除了两种情况:如果静态节点没有子节点,那么它不是静态根节点;如果静态节点只有一个文本节点,那么它也不是一个静态根节点。
第二部分的逻辑是处理子节点,这很简单:循环子节点列表,然后将每个子节点重复执行同一套逻辑即可。但这里有一个细节,那就是如果当前节点已经被标记为静态根节点,将不会处理子节点。只有当前节点不是静态根节点时,才会继续向子节点中查找静态根节点。所以,在代码中,node.staticRoot = true
的下一行代码是return
语句。
3. 总结
优化器的作用是在AST中找出静态子树并加上标记,这样做两个好处:
1. 每次重新渲染时,不需要为静态子树创建新节点
2. 在虚拟DOM中打补丁的过程可以跳过
优化器的内部实现其实主要分为两个步骤:
1. 在AST中找出所有静态节点并打上标记;
2. 在AST中找出所有静态根节点并打上标记;
通过递归的方式从上向下标记静态节点时,如果一个节点被标记为静态节点时,但它的子节点却被标记为动态节点,就说明静态节点不是静态节点,可以将它改变为动态节点。静态节点的特征是它的子节点必须是静态节点。
标记完静态结点之后要标记静态根节点,其标记方式也是使用递归的方式从上向下寻找,在寻找的过程中遇到的第一个静态节点就为静态根节点,同时不再向下继续查找。
但有两种情况比较特殊:一种是如果一个静态根节点的子节点只有一个文本节点,那么不会将它标记成静态根节点,即便它也属于静态根节点;另一种是如果找到的静态根节点是一个没有子节点的静态节点,那么也不会将它标记为静态根节点。因为这两种情况下,优化成本大于收益。