局部刷新模板那些事

模板是每个前端工作者都会接触到的东西,近几年前端的工程化,发展的如火如荼。从基本的字符串拼接到字符串模板,再到现在各种框架给出的“伪模板”解决方案,前端模板经历了种种变革。

下面就不同时期的模板做一下回顾。

本文假定读者已经对underscore, mustacheangularjsreactjs等技术有了一定的了解。否则请先看看相关资料了解下。

原始的模板

提到模板,不得不提到每个前端都会经历的字符串拼接的阶段。

看下面这段代码:


<ul id="test">


</ul>

<script>

var students = [{
    name:'张三',
    age:'19'
},{
    name:'李四',
    age:'17'
},{
    name:'王五',
    age:'21'
}]

var htmlArray = [],tmplStr

for(var i=0;i<students.length;i++){
    tmplStr = '<li>'
    tmplStr += '姓名:'+ students[i].name +'年龄:' + students[i].age
    tmplStr += '</li>'
    htmlArray.push(tmplStr)
}

document.getElementById('test').innerHTML = htmlArray.join(' ')
</script>

代码逻辑很简单,将一份数据循环拼接好字符串最后组装好html字符串塞到页面上。

可以看到这种写法,模板部分跟逻辑部分很容易耦合在一起,非常的不清晰,可读性也很差。在大规模项目中是不建议这么用的。

字符串模板的兴起

因为上面的写法有太多的缺点,所以先辈们开始实现基本的模板引擎。实现展示与逻辑的分离。

比较典型的是underscore类型的模板,它其实很简单,就是把一个基本的模板语法转换成一个可执行的javascript代码。

我们看下上面的功能使用underscore的语法怎么写:



<ul id="test">


</ul>

<script>

var students = [{
    name:'张三'
},{
    name:'李四'
},{
    name:'王五'
}]


var tmpl = '<% for(var i=0;i<students.length;i++){ %> 姓名: <% students[i].name %>   <% } %>'

//假设template可以实现类似underscore的语法
var tplCompile = template(tmpl) //先生成执行函数

document.getElementById('test').innerHTML = tplCompile(students)//执行渲染
</script>

template,作为实现了underscore功能的函数,传入一个模板还有一份数据,就可以把模板渲染出来。

这样写的好处是,实现了模板与字符串逻辑的分离。ui层,也就是渲染逻辑看起来会比较清晰。

下面我们来看看如何实现一个最基本的template:


function template(tpl){
    //用来匹配出我们的特殊语法
    var tplReg = /<%([^%>]+)?%>/g

    var match;
    var cursor=0;
    var regOut = /(^( )?(if|for|else|switch|case|break|{|}))(.*)?/g;

    //需要拼接的代码,这里注意with的用法,用来使渲染的数据应用在模板里
    var code = 'var codes=[];\nwith(renderData){\n';

    var addLine = function(line,js){
        //普通文本,这时候加上一个双引号就可以了
        if(!js){
            code += 'codes.push("' + line.replace(/"/g, '\\"') + '");\n'
            return
        }

        //特殊语法特殊处理
        //对于有js特殊逻辑比如for,if这种代码,不适用push。直接打出。
        if(regOut.test(line)){
            code += line + '\n';
        }else{
            //普通的特殊语法,跟上面的普通的文本唯一的区别就是少了双引号,这样以后执行时就会是变量。
            code += 'codes.push(' + line + ');\n'
        }

    }
    //通过正则不停的去匹配特殊语法
    while(match = tplReg.exec(tpl)) {
        //截取前面的普通文本
        addLine(tpl.slice(cursor, match.index)); //普通文本
        //将当前的特殊语法加入
        addLine(match[1],true);  //特殊逻辑
        cursor = match.index + match[0].length; //更新游标
    }
    //末尾可能还剩下些语法
    addLine(tpl.substr(cursor, tpl.length - cursor)); //剩下的代码

    code += '};return codes.join("")';
    code = code.replace(/[\r\t\n]/g, '')

    var compileFn = function(data){
        //使用function执行拼好的代码,将data传到函数里。
        return new Function('renderData',code)(data);
    }

    return compileFn

}

实现很简单,主要就是通过正则替换,拼凑出最终的js执行语句,然后使用function来执行得到结果。

我们通过tplReg这个正则匹配出所有的<% ... %>特殊语法,addLine负责生成javascript可执行语句。

addLine添加的语句需要分三种情况:

  1. 特殊语法之外的普通文本,比如上面的姓名:这种文本。这个时候直接push到codes里面就行,最后会原样输出。这边需要加上双引号代表是个普通字符串。
  2. 对于特殊语法里面的普通语法,比如上面的students[i].name,这种时候直接push到codes里面,跟上面不同的是,这时不需要加双引号,这样最后这些codes执行时就会把它作为变量去处理。
  3. 还有一种特殊的语法,就是for(var i=0;i<students.length;i++){这种带for的
    。这个时候不能放到codes里面,因为我们本身需要它的循环功能。

最后拼接好codes后,使用function一次执行得到最终渲染好的字符串。

当然我们这边的实现超级简陋,仅供参考原理。实际的模板还需要考虑缓存,兼容性,xss等等。

字符串模板使用很广泛,也踊跃出了若干实现。mustache,Underscore templates,Dust.js等等,前端真的是能折腾。如果有选择困难症,可以看看这里:http://garann.github.io/template-chooser/

这个阶段的模板或多或少都是相同的原理。

这在很长一段时间里对于前端工程师来说已经够用了。通过模板的dsl,很好的实现了前端逻辑与ui的分离。

字符串模板的局部刷新

但是随着前端的发展,特别是富客户端应用的兴起。单纯的字符串模板已经很难满足需求了。

比如一个学生个人信息界面,分为个人姓名,还有个人成绩两块。


<div>个人信息:</div>
<div id="test">


</div>

<script>

var student = {
    name:'张三',
    score:89
}


var tmpl = '<div>姓名: <% name %> </div><div>成绩: <% score %> </div>'

//假设template方法可以实现underscore的语法
document.getElementById('test').innerHTML = template(tmpl)(student)
</script>

如果我们初次渲染后需要去更新成绩。我们期待的情况肯定是只更新成绩,个人姓名不要重复刷新。这对于普通字符串模板是做不到的。只能自己使用dom操作去修改,而这样就违背了我们的初衷,逻辑又跟展现耦合变的不可控了。

实际情况会更复杂,页面内容更多,需要局部刷新的地方更多,这里为了方便,只举一个最简单的例子。

如果不自己使用dom操作,还希望用模板,那么每次要改点东西都需要整个页面全部重新渲染,在通过innerHTML一次性更新。

我们其实是需要一种局部刷新的东西,在初次渲染后仍然保持模板与dom的联系。通过一些方法,只改变页面中的一小部分html。差异化的去更新。其实是一种innerHTML的优化。

我们团队很久之前就做了这方面的尝试,可以先看下我们是怎么用的:


<script>

var student = {
    name:'张三',
    score:89
}

//注意下面的模板里面多了 tpl-name="sc"  tpl-key="score"
var tmpl = '<div>姓名: <% name %> </div><div tpl-name="sc" tpl-key="score">成绩: <% score %> </div>'


//第一次渲染,会自动绑定到test的div上
var node = render('test', tmpl, student)

//如果我们需要更新成绩只需要调用setChunkData来局部刷新页面
node.setChunkData(score,100)

</script>

首先我们会在需要更新的局部dom上打上标签tpl-name="sc" tpl-key="score",代表这里的dom第二次渲染需要依赖score这个变量,并且这个局部模板有了唯一标识sc。然后当我们需要更新成绩的时候只需要调用node.setChunkData(score,100)就可以找到依赖score这个变量对应的dom并且自动使用局部模板去刷新页面了。

可以看到这样带来了不少好处,我们可以继续享受模板带来的便利性,也可以局部刷新页面中的某个区块。而这一切都是建立在数据上的,我们的逻辑处理永远在数据这一层,面向数据编程。而渲染,局部渲染都会自动帮忙完成。

那么达到这样的功能我们需要做什么呢?

我们看一个最简单的实现(基于上面的template增强而来):


//此函数依赖上面的template函数
var render = function(id, tpl,data){

    //初次渲染,直接使用以前的template函数
    document.getElementById(id).innerHTML = template(tpl)(data)


    //下面都是局部渲染需要的逻辑
    var tpls = []//存放局部刷新的模板
    //解析模板,找到局部模板 这边的getSubTpls可以先不管,只需要知道返回结果为:
    //[{name:'sc',key:score,tpl:'<span>成绩:</span> <% score %> '}]
    tpls = getSubTpls(tpl)


    //用于根据依赖的key找到对应的那些模板
    function getChunkTpl(key){
        var results = []

        for(var i= tpls.length-1;i>-1;i--){
            if(tpls[i].key === key){
                results.push(tpls[i])
            }
        }
        return results
    }

    return {
        setChunkData:function(key,value){
            var subData = {}
            subData[key] = value


            var subNode
            var subTpls = getChunkTpl(key) //匹配到对应的局部模板,可能有多个

            for(var i=0;i<subTpls.length;i++){
                //这里为了图方便直接用querySelector了
                subNode = document.querySelector('[tpl-name="' + subTpls[i].name + '"]')
                //渲染好,刷新局部dom
                subNode.innerHTML = template(subTpls[i].tpl)(subData)

            }

        }
    }

}

基本实现了我们需要的功能,逻辑上也不复杂,提前准备好局部模板,在setChunkData时通过key找出对应的局部模板还有dom节点,这样就可以达到局部刷新的目的。

下面看下getSubTpls的实现,这个如果不感兴趣可以直接跳过,毕竟已经是过时的技术了。


//这个方法是用来获取一个tag下面的字符串
//getTagInnerHtml('<div><span></span><div id='1'></div></div>',div,0,0)
//可以匹配出<span></span><div id='1'></div>
function getTagInnerHtml(tpl, tag, s_pos, offset) {
    var s_tag = '<' + tag
    var e_tag = '</' + tag + '>'

    var s_or_pos = s_pos + offset

    var e_pos = s_pos
    var e_next_pos = s_pos

    s_pos = tpl.indexOf(s_tag, s_pos)
    var s_next_pos = s_pos + 1

    while (true) {
        s_pos = tpl.indexOf(s_tag, s_next_pos);
        e_pos = tpl.indexOf(e_tag, e_next_pos);

        if (s_pos == -1 || s_pos > e_pos) {
            break
        }
        s_next_pos = s_pos + 1
        e_next_pos = e_pos + 1
    }
    return {
        html: tpl.substring(s_or_pos, e_pos)
    }
}


function getSubTpls(tpl){
    //用来匹配带有标签的tag的正则
    var tplTagReg = /<([\w]+)\s+[^>]*?tpl-name=["\']([^"\']+)["\']\s+[^>]*?tpl-key=["\']([^"\']+)["\']\s*[^>]*?>/g

    var match,tagInfo
    var tpls = []

    while(match = tplTagReg.exec(tpl)) {

        tagInfo = getTagInnerHtml(tpl,match[1],match.index,match[0].length)

        tpls.push({
            name:match[2],
            key:match[3],
            tpl: tagInfo.html
        })
    }
    return tpls
}

关键是getTagInnerHtml的实现,由于javascript没有平衡组的概念,所以不得不写这么一长串的处理逻辑,具体参考

http://lf-6666.blog.163.com/blog/static/3123705200942155416430/

http://thx.github.io/brix-core/articles/tpl-3/

至此基本功能就完成了。在一段时间里也够用了。

实际的代码,需要处理的问题更多,需要处理好父子级局部模板,多个数据并列依赖等等问题,由于已经是过时的技术了,这里就不详细展开了,有兴趣的可以到这里了解。

但是其实还是有些显而易见的问题:

这个时候我们渐渐发现字符串模板已经走到头了。面对日新月异的前端开发,尤其是单页应用,普通的字符串模板已经不能满足需求了。

新时代的“模板”

因为字符串模板的种种缺陷,在新的大时代背景下,尤其是前端各种框架的井喷时代。各种各样的框架实现了很多很有意思的东西,跳出了传统字符串模板的概念,但是的确实现了模板的功能。这个时候已经不适合称之为模板了。

我们先想想,我们上面探索局部刷新时遇到了什么问题。

首先我们需要保持跟模板的联系,这样我们下次需要局部刷新时才能找到对应的节点。比如我们上面是通过在dom节点上打标来实现的。

其次我们需要监听数据的变化,我们上面是直接使用私有方法setChunkData来通知引擎数据变化了,可以开始更新了。这样其实很不好,需要我们关注太多的东西。

另外如果我们多次调用setChunkData,那么就会渲染多次,除了最后一次前面的渲染都是没有必要的,所以我们还需要个批量更新的东西,前面的那些改动不需要真实的反应到dom上。

事实上,目前的主流框架虽然已经脱离了模板的范畴,但是也是紧紧围绕这几个方面来实现的。

当然目前还兴起了双向绑定的热潮,不过在传统的字符串模板里是难以实现的。

下面我们大概介绍下主流的几个框架的是如何实现模板功能的,具体分为下面这几点:

angularjs

angularjs带来了很多前端界的新概念,指令,脏检测,filter等等等。

原理

我们看下angularjs的大致图形

几个概念:

三个大模块:

dd

Provider:

DOMCompiler:

Scope:

怎么串起来:

<span ng-bind="a + 'hello'"></span>

我们看看ng-bind这个指令的实现:

Provider.directive('ng-bind', function () {
  return {
    link: function (el, scope, exp) {
      //初次渲染的逻辑
      el.innerHTML = scope.$eval(exp);
      //添加一个观察者,当在下一次脏检测发现数据改变时就执行回调逻辑
      scope.$watch(exp, function (val) {
        el.innerHTML = val;
      });
    }
  };
});

初始化时:DOMCompiler遍历dom节点,找到指令定义,执行link,写dom。并且通过scope的方法增加watcher观察者。

更新时:调用scope的digest,这个时候会遍历所有watchers,拿新的值跟旧的值做对比,如果不同就执行回调。

所以对于我们这边的简单例子来说,第一次初始化时,通过link函数我们第一次使用innerHTML来渲染dom。同时添加了一个watcher,这样在框架下次脏检测时,检测到数据变化就会调用回调里的逻辑,这里就是重新innerHTML。

具体可以看看这篇文章了解下它内部的渲染逻辑。

当然这里只是给出了最简单的实现,其实这类模板的难点在于for,if这种指令的实现。这里受限于篇幅就不详细展开了。

分析

所以,angular的局部刷新,就是通过指令的私有逻辑来实现的。提出了一些比较好玩的概念。
我们再对比下之前说的那几个点:

优点

缺点

vuejs

vue在学习angular的基础上,做了些精简还有优化。vue只负责处理view,其实是模板+组件方案+动画。我们这里主要看他的模板方面的原理

原理

原理其实类似,也是有指令还有watcher的概念,只是去掉了scope,去掉了脏检测而是使用get set来做数据的变化监听。

222

首先你需要先了解下defineProperty,这个特性,可以达到在你对一个属性读或者写的时候写上自己的钩子函数。

整个原理是这样:

可以看到跟angular不同的地方主要在于,指令职责更细,另外有暴力的全部watcher检测,变成了通过set的钩子来指向性的找到对应的watcher做数据变更检测。所以在更新性能上会明显优于脏检测。

具体原理可以参考:http://cn.vuejs.org/guide/reactivity.html
还有:http://jiongks.name/blog/vue-code-review/

分析

特点

缺点

其实这些缺点也都是angular会有的缺点,可以说vue已经解决了大部分的angular会有的问题。不过为了使用defineproperties放弃了支持ie8,对于国内的环境来说,ie8还是难以割舍。

reactjs

reactjs创造性的提出了虚拟dom的概念,完全改变了前端的开发方式

原理

react,其实就是虚拟dom与真实dom的互动。

ddd

最上面的text,basic element,custom element都是我们通常说的virtual dom。我们先不管红色的自定义组件节点custom element

每一个virtual dom都有一个对应的Component来管理。Component具有两个方法:mountComponent还有receiveComponent分别负责处理初次渲染还有更新的逻辑。

react初次渲染就是拼接出字符串。每种virtual dom会调用自己的Component对应的mountComponent来得到渲染之后的内容。比如对于text就直接返回个span包裹的文本,对于basic element,需要先处理自身属性,再调用子节点对应的Component的mountComponent,最后全部拼接好,一次性的innerHTMl到页面上。

更新的时候,react里面一般是通过setState来赋予一个新的值,这样内部再调用receiveComponent来处理逻辑。对于basic element,就是先更新属性,再去更新子节点。这里有套算法,能复用的就直接调用子节点的receiveComponent。否则就是一次重新的mountComponent渲染(diff,patch)。对于text节点,直接是innerHTML更新。

具体原理参考:http://purplebamboo.github.io/2015/09/15/reactjs_source_analyze_part_one/

分析

特点

缺点

思考

不管是 angularjs,vuejs,或者是reactjs,总感觉用起来不是那么顺手,原因是我们已经习惯了传统意义上的模板写法。所以这种dom based的模板总会有这样那样的限制,导致我们不能很顺畅的去开发。

上面这几种,目前我是比较喜欢vue的设计理念的,但是它也有着我们难以接受的缺点。不能说vue不优秀,只是的确不适合我们的业务。

那么我们是不是可以改造下,是否可以结合virtual dom 跟 指令的优势?

vue的指令是直接操作dom的,如果加一层virtual dom,实现大部分dom方法。让指令引用virtual dom,由virtual dom来操作真实dom。这样其实就可以解决掉vue的大部分问题。

我们可以先把模板解析成一个ast(抽象语法树)结构的虚拟dom树。然后去解析这个树,分析出各种依赖信息。这比vue直接使用原生dom会好很多。

指令不会直接跟dom打交道,而是跟虚拟dom打交道。

对于初次的渲染来说,各个指令会调用虚拟dom的方法,此时虚拟dom知道是初次渲染,所以只会更新自己,而不会修改真实的dom。在最后全部执行好后,一次性的innerHTML到页面中。有点跟react类似?

而更新的时候,仍然是指令的逻辑,只不过这个时候虚拟dom不仅仅会更新自身,也会同时更新真实的dom。

可以看到因为加了一层虚拟dom,解决了很多问题。

我们的基本原理还是vue的原理,但是通过一层中间虚拟dom层。我们可以做到:

所以 我做了pat,在vue的基础上加上vd的概念,当然还支持了ie8等等,为了支持业务做了一些改动。

一句话概括pat:

listener(defineProperties/dirtycheck) + directive + virtual dom

地址:http://purplebamboo.github.io/pat/doc/views/index.html

与目前主要的框架相比,pat具有以下特点:

结语

目前的前端界各种框架满天飞,但是都或多或少的有所缺陷。有句话说的好,没有最完美的方案只有最适合的自己的方案。

在这样的背景下作为一个前端遇到问题该怎么办呢,我认为可以先找开源技术,但是当开源技术不能满足自己的需求时。可以在开源技术的基础上修改加上自己的东西从而更好的解决问题。

其实把模板解析成ast已经有很多框架在做了。都是看重了virtual dom的优势。比如下面这些:

总之前端的轮子真的太多了,但是无外乎那些解决方案,我们要做的就是了解这些方案,找到合适自己的,当没有特别合适的就拿一个加以改造解决自己的问题(于是又会造个轮子= =)。

相关引用