引子
virtual-dom
(简称vdom
)的概念大规模的推广还是得益于react
出现。
相比于频繁的手动去操作dom
,vdom
出现后,
框架根据标签结构生成虚拟dom
树(virtual-dom-tree
简称vtree
),然后保留着这个vtree
不能释放,当下一次数据变化后,再生成一个新的vtree
与上一个比较,然后把差异部分更新到真实dom
上
我们只需要改变数据,由框架自动diff vdom
然后再更新到dom
上。所以后续我们只关注数据的变化即可,不再与dom
直接打交道。
目前许多前端框架使用vdom
已然是标配了。
问题
vdom
虽好,但不是在任何场景下都适用,比如在寸土寸金的移动端。
由于框架需要保留着完整的vtree
、变化后需要整个diff vtree
,所以整体来看性能并不高(网上一些文章说引入vdom
可以提升性能的说法是不准确的),在移动端的某些场景下,vdom
的使用反而会带来性能问题。
移动端的列表场景,活动页面等,通常数据变化时需要对大块区域进行更新,在追求性能的要求下,我们能否仍然保留对开发人员友好,也能达到更高性能的刷新方式呢?
方案
原生dom
操作一片html
最高效的方法是innerHTML
再看一下我们的模板写法(这里以underscote.template的写法为例,其它写法也是同样的道理)
<div class="teachers">
<%for(var i=0;i<teachers.length;i++){%>
<span><%=teachers[i].name%></span>
<%}%>
</div>
<div class="divider"></div>
<ul class="students">
<%for(var i=0;i<students.length;i++){%>
<li><%=students[i].name%></li>
<%}%>
</ul>
我们会发现大多数模板命令(我们把<% expr %>
这样的称之为模板命令)都是穿插在HTML
标签中,对于上述示例,我们会发现模板中有2
个变量teachers students
。即当teachers
或students
数据有变化时,界面上只会有div[class="teachers"]
和div[class="students"]
这2
个节点会变化,其它节点是固定不变的,即当students
变化时,我们只需要更新div[class="students"]
节点即可。
我们把完整的模板根据变量所在的HTML
标签进行子模板拆分,当有数据变化时,我们只更新对应的子模板即可,这样不用保留vtree
对象,同时使用innerHTML
的API
,在大块更新html
时可以达到最好的性能
实现
我们用以下简单的模板进行解释实现
<div>
<%for(var i=0;i<list.length;i++){%>
<span><%=list[i].name%></span>
<%}%>
</div>
当我们拿到这样的模板后,主要识别html中的模板命令,我们要把前面的模板字符串变成合法的js语句,使用的是es6中的标签模板,即上述模板经简单处理后变成如下的字符串
`<div>
<%`;for(var i=0;i<list.length;i++){`%>
<span><%=`;list[i].name`%></span>
<%`;}`%>
</div>`
加上反撇号(`),这一步使用正则即可完成。
变成上面的字符串后,它本身就是合法的js代码了。我们为了识别局部变量和全局变量,使用acorn
这个工具进行语法分析
然后对语法树遍历Identifier
和VariableDeclarator
,找到变量使用和变量声明的地方,这时候要自己做一下处理。因为从外到内,从上到下遍历语法树,如果当前出现的变量未进行声明,则认为是全局变量,反之则认为局部变量
我们为了后续的其它分析,对模板做了入侵修改,即识别完后我们对全局变量和局部变量做了“手脚“,我们在全局变量前增加了 \u0003.
,在使用变量的地方增加了\u0001
这个字符 ,在变量声明的地方增加了 \u0002
这个字符。这样在做其它分析时会比较方便。
处理后即为
`<div>
<%`;for(var \u0002i=0;\u0001i<\u0003.list.length;i++){`%>
<span><%=`;\u0003.list[\u0001i].name`%></span>
<%`;}`%>
</div>`
这时候已经不是合法的js
代码了,不过这已经不重要了。我们再把反撇号(`)去掉,则最终为
<div>
<%for(var \u0002i=0;\u0001i<\u0003.list.length;i++){%>
<span><%=\u0003.list[\u0001i].name%></span>
<%}%>
</div>
接下来为了要分析上面的模板,我们要把它变成合法的html
标签(因为underscore.template的命令使用<%%>
的形式和标签<tag>
有冲突,所以要移除)。对模板命令进行移除,移除后的结果为
<div>
&0
<span>&1</span>
&2
</div>
我们把命令移除,同时使用特殊的占位符占位,因为我们还要把命令还原回去,所以我们还要知道每个占位符对应的模板命令。我们用一个对象来记录每个占位符对应的原始模板内容
此时用于记录占位符对应模板命令的对象如下
{
'&0':'<%for(var \u0002i=0;\u0001i<\u0003.list.length;i++){%>',
'&1':'<%=\u0003.list[\u0001i].name%>',
'&2':'<%}%>'
}
这个时候剩余的html
就是合法的html
了。
接下来给这段html
添加guid
的操作。
先给每个节点都添加一个guid
,当然除了特别的节点如:没有模板命令的自闭合标签
添加完guid
后变成为:
<div mx-guid="g0">
&0
<span mx-guid="g1">&1</span>
&2
</div>
然后再次遍历,对不符合要求的guid进行移除。
移除保留规则如下:
1.如果移除子节点后,属性和剩余的内容中不存在模板命令,则移除guid
2.如果剩余内容+属性中的模板命令,变量声明和变量使用不配对,则删除guid
3.如果剩余内容+属性中的模板命令,变量声明和使用配对,则保留guid
即上述带guid
的html
第一次被处理时
对外层的div移除配对的子标签后(为什么要先移除子标签?因为我们要把局部刷新做到最近的节点上),在本例中把子标签span
移除
<div mx-guid="g0">
&0
&2
</div>
这时候我们把这个html
片断还原模板命令语句,即把&0
和&2
还原回旧样子
<div mx-guid="g0">
<%for(var \u0002i=0;\u0001i<\u0003.list.length;i++){%>
<%}%>
</div>
然后对这段代码进行变量声明和变量使用分析。因为每个变量前都有特殊的前缀,所以我们可以很方便的用正则识别出来:
在当前范围内声明的变量有 i
使用的局部变量有 i
,当然还有一个全局变量list
,全局变量我们不用管它,因为它在整个模板中都可以访问到。
我们可以看出声明和使用的变量是配对的。即:没有出现使用的局部变量未声明的情况
那么这个div
标签的guid
则可以保留
然后再分析子标签,这时候进行第二次的处理
子标签为
<span mx-guid="g1">&1</span>
同样命令还原
<span mx-guid="g1"><%=\u0003.list[\u0001i].name%></span>
同样变量识别,我们发现当前范围内使用到了局部变量i
,但在当前范围内并未找到声明它的地方,则说明这段html
不能被单独存在,只能做为其它标签的一部分。所以span
上的guid
要移除。
最终添加的guid
如下
<div mx-guid="g0">
&0
<span>&1</span>
&2
</div>
最后删除其中的\u0002
、\u0001
占位符,最终存放模板命令的对象中存放的结果如下
{
'&0':'<%for(var i=0;i<\u0003.list.length;i++){%>',
'&1':'<%=\u0003.list[i].name%>',
'&2':'<%}%>'
}
打guid
标识后,然后在进行子模板拆分分析时,只需要匹配带mx-guid的标签即可,因为只有这些节点才能做为局部刷新的容器节点,识别出相应的标签和内容,再从模板命令中提取出根节点对应的数据key
,即完成了整个识别内容
当然在这一步还要考虑嵌套刷新等细节,比如
<div mx-guid="g0">
<%=x%>
<div mx-guid="g1"><%=y%></div>
</div>
当数据x,y
都发生改变时,mx-guid="g1"
的不需要更新,因为x
的变化会让g1
变到最新
当y
变化时,只需要更新g1
即可
细节处理
关于以下代码
<div>
<%for(var i=0;i<10;i++){%>
<span><%=i%></span>
<%}%>
</div>
这段代码从div
的角度看,确实变量声明和使用都是配对的,但也会被移除mx-guid
,凡是不带mx-guid
标识的节点都不会被局部刷新。局部刷新的首要条件是当前代码片断中要包含全局变量
再比如这段代码
<div>
<%for(var i=0;i<10;i++){%>
<span><%=x%></span>
<%}%>
</div>
这段代码只有span
会被识别为局部刷新
再比如这样的情况
<%for(var i=0;i<10;i++){%>
<span><%=x%></span>
<%}%>
外层缺少包括的标签,这种情况就需要整体做为子模板了
变量追踪
前面讲模板拆分时提到使用
acorn
对变量识别问题,这里详细讲一下一些变量的识别与处理
先把模板变成合法的js
代码,前文已说方案,然后使用acron
把这个js
代码转成ast
,然后多次遍历处理这个ast
第一次遍历
- 遇到变量声明时,记录下当前模板中声明了这个变量。记录声明变量的对象为
gv
,同时对这个变量所在的位置做特殊标记,标明它是声明语句 - 遇到变量使用时,如果这个变量存在
gv
中,则是在使用局部变量,否则就是在使用全局变量 - 遇到
function
时,function
体内出现的变量需要看是否在function
的参数中 - 当直接对某个变量赋值时,这个变量也将是全局变量
第二次遍历
- 记录变量声明时的
id
与初始化的值(如果有的话) - 记录赋值语句,哪个变量在哪个位置赋了什么样的值(后期追踪变量时,会根据位置信息取合适的值,解决同一个变量被反复赋值的问题)
经过2
次遍历后,这些变量都会被打上标记及它们出现的位置。
比如这样的代码
<%=list%>
<input <%:list%> />
<%list=a%>
<input <%:list%> />
<%list=b%>
<input <%:list%> />
处理后可能是:
<%=全.list%>
<input <%:全.list%> />
<%list=全.a%>
<input <%:用32.list%> />
<%list=全.b%>
<input <%:用43.list%> />
追踪变量赋值对象的结果可能是下面这样的对象
{
list:[{
pos:27,
value:"全.a"
},{
pos:36,
value:"全.b"
}]
}
当接下来处理模板中的变量时,遇到"用pos.var"
时,从变量列表中倒序查找比pos
小的那一个对应的value
即可
当变量追踪遇上函数
函数是一个独立的上下文
考虑如下的模板片断
<%var a=usr.name%>
<%_.each(function(){%>
<%var a=usr.age%>
<input <%:a%> />
<%x(function(){%>
<%var a=usr.sex%>
<input <%:a%> />
<%})%>
<%})%>
<input <%:a%> />
我们在反复声明、使用同一个变量a
,每一个使用a
的地方所对应的数据都是不一样的。
按前面我们的变量追踪所描述的,变量追踪对象可能如下
{
a:[{
pos:4,
value:"全.usr.name"
},{
pos:20,
value:"全.usr.age"
},{
pos:35,
value:"全.usr.sex"
}]
}
同时我们有一个记录函数区间的对象,如
[{
start:9,
end:50
},{
start:25,
end:45
}]
当我们在使用变量a
的地方,我们可以找到当前a
所在的函数内,即使函数嵌套也可以。
比如我们找到当前a
所在的函数范围是[start=25,end=45]
,那么在根据这个范围,去变量追踪对象上找到这个范围内声明的变量a
,我们只能查出{pos:35,value:"全.usr.sex"}
在这个函数内,那么这个变量a
对应的值即为全.usr.sex
。
如果查找到对应多个值,则a对应哪个值根据前述“变量的追踪”进行确定
后记
当然这里面提到的思路和方案不是一朝一夕完成的,这篇文章中也只讲了一些原理。完整的方案和代码可以参考