前端局部刷新

引子

virtual-dom(简称vdom)的概念大规模的推广还是得益于react出现。

相比于频繁的手动去操作domvdom出现后,
框架根据标签结构生成虚拟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。即当teachersstudents数据有变化时,界面上只会有div[class="teachers"]div[class="students"]2个节点会变化,其它节点是固定不变的,即当students变化时,我们只需要更新div[class="students"]节点即可。

我们把完整的模板根据变量所在的HTML标签进行子模板拆分,当有数据变化时,我们只更新对应的子模板即可,这样不用保留vtree对象,同时使用innerHTMLAPI,在大块更新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这个工具进行语法分析

然后对语法树遍历IdentifierVariableDeclarator,找到变量使用和变量声明的地方,这时候要自己做一下处理。因为从外到内,从上到下遍历语法树,如果当前出现的变量未进行声明,则认为是全局变量,反之则认为局部变量

我们为了后续的其它分析,对模板做了入侵修改,即识别完后我们对全局变量和局部变量做了“手脚“,我们在全局变量前增加了 \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

即上述带guidhtml第一次被处理时
对外层的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

第一次遍历

  1. 遇到变量声明时,记录下当前模板中声明了这个变量。记录声明变量的对象为gv,同时对这个变量所在的位置做特殊标记,标明它是声明语句
  2. 遇到变量使用时,如果这个变量存在gv中,则是在使用局部变量,否则就是在使用全局变量
  3. 遇到function时,function体内出现的变量需要看是否在function的参数中
  4. 当直接对某个变量赋值时,这个变量也将是全局变量

第二次遍历

  1. 记录变量声明时的id与初始化的值(如果有的话)
  2. 记录赋值语句,哪个变量在哪个位置赋了什么样的值(后期追踪变量时,会根据位置信息取合适的值,解决同一个变量被反复赋值的问题)

经过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对应哪个值根据前述“变量的追踪”进行确定

后记

当然这里面提到的思路和方案不是一朝一夕完成的,这篇文章中也只讲了一些原理。完整的方案和代码可以参考

  1. 区块管理框架magix
  2. 离线处理工具magix-combine
  3. 局部更新magix-updater
上一篇:前端做模糊搜索


下一篇:zabbix监控web页面,以及告警配置