template 模板技术

文首的话

这篇文章总结和自己尝试实现一下模板。然后总结下业界当前各种先进的模板技术。
算是一种锻炼吧,写了那么多业务,也用那么多轮子。自己来造轮子看看。

字符串模板的分析

模板也有很多种,但是这里仅仅分析和实现基于字符串的模板。为了方便,这里就基于artTemplate的语法来分析模板应该具备怎样的功能。

  1. 字符串中变量解析
  2. 条件表达式
  3. 遍历输出

字符串中变量解析

先来弄第一个最简单功能,变量替换:
目标是实现下面代码:

1
2
3
4
5
6
7
8
var data = {
name:"张三",
age:"19"
};
var tplString = "{{name}}今年{{age}}岁";
var string = tpl(tplString,data);
console.log(string);
//张三今年19岁

下面是个人实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function tpl(str,data){
//定义抽出变量正则
var valReg = /[\{]{2}\s*\w+\s*[\}]{2}/g;
//获取字符串数组
var strArr = str.match(valReg);
//获取变量数组
var valArr = strArr.join("-").replace(/[\{]{2}\s*/g,"").replace(/\s*[\}]{2}/g,"").split("-");

for(var i=0,len=strArr.length;i<len;i++){
str=str.replace(strArr[i],data[valArr[i]]);
}
return str;
}

整个函数很简单,只是简单的使用replace对变量进行了简单的替换。匹配和替换过程中对花括号之间的变量进行了去空格处理。
这个函数还比较粗糙,比如,它仅仅可以处理第一层的数据,假如使用zhangsan.name就会失败。这里先放放。我们继续看条件表达式的处理。

条件表达式

效果大概是这样的:

1
2
3
4
5
6
7
{{if admin}}
<p>admin</p>
{{else if code > 0}}
<p>master</p>
{{else}}
<p>error!</p>
{{/if}}

发现这个做起来挺麻烦的。主要要点:

  1. 抽出多行模板文本
  2. 模板文本转换成函数
  3. 函数求值

抽出多行表达式

首先要抽出从if开始到else到/if之间的文本。暂时写个可以用的正则匹配一下,不太严格:

1
2
3
var blockIfReg = /[\{]{2}\s*if[\s\S]*[\{]{2}\s*\/if[\}]{2}/mg;
//匹配出来的文本是这样的:
//["{{if admin&gt;0}}\n <p>admin</p>\n {{else if code &gt; 0}}\n <p>master</p>\n {{else}}\n <p>error!</p>\n {{/if}}"]

其中↵代表的是换行符号。现在问题是如何优雅的把这个字符串转换为表达式了。。。

先来把这个替换gt什么的符号还原一下,使用replace做这个替换:

1
2
3
4
5
6
7
8
9
10
11
var mapObj = {
"&gt;":">",
"&lt;":"<"
};
var matchStringArr = s.match(blockIfReg)||[];

for(var a in mapObj){
matchStringArr = matchStringArr.map(function(v) {
return v.replace(new RegExp(a,'gm'),mapObj[a]);
})
}

其中s是匹配的字符串数组,重点是replace里面的new RegExp(a,’gm’),这里使用了正则,需要替换所有的——顺便发现”字符串中变量解析”时候没有发现这个。回头补下bug。

继续写个正则抽一抽,主要目的是把逻辑字符串抽出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
HTML:
<div id="tpl">
{{if admin>0}}
<p>admin</p>
{{else if code < 0}}
<p>master</p>
{{else}}
<p>error!</p>
{{/if}}
</div>
JS:
var s = document.getElementById("tpl");
console.log(s.split(/[\{]{2}\s*|\s*[\}]{2}/gm));
["", "if admin>0", "↵admin↵↵", "else if code > 0", "↵master↵↵", "else", "↵error!↵↵", "/if", ""]

这里干完以后发现有Bug:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="tpl">
{{if admin>0}}
<p>admin</p>
{{else if code < 0}}
<p>master</p>
{{else}}
<p>error!</p>
{{/if}}

{{if aaa>0}}
<p>aaa</p>
{{else if bbbb < 0}}
<p>master</p>
{{else}}
<p>error!</p>
{{/if}}
</div>

此时有两个if结构了,但是/[{]{2}\s*if[\s\S]*[{]{2}\s*\/if[}]{2}/mg匹配成了一个,加个?干掉贪婪匹配:/[{]{2}\s*if[\s\S]*?[{]{2}\s*\/if[}]{2}/mg 现在可以了。可以获取匹配到两个包含字符串组成的数组.

下面继续干活:目标是将字符串转换为函数

先将字符串切一切:

1
2
3
//matchStringArr是上面说的两个包含字符串组成的数组
matchStringArr=matchStringArr.map(function(v){return v.split(/[\{]{2}\s*|\s*[\}]{2}/gm)})
console.log(matchStringArr);

结果如图:
1

继续往下处理一下,将其转换成可以用的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
matchStringArr=matchStringArr.map(function(v){
return transform(v);
});

function transform(v){
var targetString = "";
v.forEach(function(val,i){
if(val==""){
}else if(/^if/.test(val)){
targetString+="if("+ val.split(" ")[1]+"){"+v[i+1]+"}";
}else if(val.indexOf("else if")>-1){
targetString+="else if("+val.replace("else if","")+"){"+v[i+1]+"}"
}else if(val.indexOf("else") > -1){
targetString+="else{"+v[i+1]+"}"
}
});
return targetString;
}

console.log之:
2

到这里基本就差不多了。当然,现在这个函数就算执行了也没卵用,不过先不管,先将其弄成函数再说。究竟。。。有问题会报错的,到时候解决。
这里可以使用eval和Function->eval不建议使用,使用Function好了。

字符串转换函数

Function

Function方式构建函数平时几乎没有机会做。不过这里来尝试一下。

1
2
3
4
5
6
7
8
9
//先来个简单的字符串传参
var sayAge = Function("a","b","return a+'的年龄是'+b");
console.log(sayAge("张三","15"))
//张三的年龄是15

//传入对象看看
var sayAge = Function("a","return a['name']+'的年龄是'+a['age']");
console.log(sayAge({name:"李四",age:"18"}));
//李四的年龄是18

这样没啥问题了。完善下上面的东西。

整理下之前弄出来的函数字符串:

1
2
3
4
5
6
7
if(admin>0){
<p>admin</p>
}else if(code < 0){
<p>master</p>
}else{
<p>error!</p>
}

这是一个条件表达式,若干逻辑中取一个返回,先修一修让它可以正常工作
然后使用Function构建一个函数试试,这里要测试一下使用Function构建一下改造过的函数是否可以运行
这里是代码运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*处理好的字符串
if(admin>0){
return '<p>admin</p>';
}else if(code < 0){
return '<p>master</p>';
}else{
return '<p>error!</p>';
};*/
//字符串先并为一行
var str = "if(admin>0){return '<p>admin</p>'}else if(code < 0){return '<p>master</p>'}else{return '<p>error!</p>'}";
var testFun = Function("admin","code",str);
console.log(testFun(1,1));
console.log(testFun(-1,-1));

3

运行效果良好

完善transform

这里为了上面代码正常工作改良一下原来的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function transform(v){
var targetString = "";
v.forEach(function(val,i){
if(val==""){
}else if(/^if/.test(val)){
targetString+="if("+ val.split(" ")[1]+"){return '"+v[i+1].replace(/^\s+|\s+$/g,"")+"'}";
}else if(val.indexOf("else if")>-1){
targetString+="else if("+val.replace("else if","")+"){return '"+v[i+1].replace(/^\s+|\s+$/g,"")+"'}"
}else if(val.indexOf("else") > -1){
targetString+="else{return '"+v[i+1].replace(/^\s+|\s+$/g,"")+"'}"
}
});
return targetString;
}

这里添加了return,同时把无用的换空格干掉了,同时\s顺便把\n也换掉了。这样,上面的函数就可以很好的运行了。

Function内部变量处理

到这里逻辑基本算是好了,现在面临的问题是,如何直接传入对象,让函数里面的表达式可以获取到而不需要加this[“admin”]什么。个人的想法是将该变量放到Function构造出的函数的上一层即可。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function test(obj){
for(var a in obj){
this[a] = obj[a];
}

return {
getString:function(){
var str = "if(admin>0){return '<p>admin</p>'}else if(code < 0){return '<p>master</p>'}else{return '<p>error!</p>'}";
var insertFn = new Function('',str);
return insertFn.apply(this,null);
}
};
}

JS Bin on jsbin.com

还算成功,可以顺便把之前无法读zhangsan.name这种问题统统解决掉。

包装修缮一下门面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var str = "if(admin>0){return '<p>admin</p>'}else if(code < 0){return '<p>master</p>'}else{return '<p>error!</p>'}";

function getTplChunk(obj,fnString){
function tmp(obj,fnString){
for(var a in obj){
this[a] = obj[a];
}
return {
getString:function(){
var insertFn = new Function('',fnString);
return insertFn.apply(this,null);
}
};
};
var target = tmp(obj,fnString).getString();
tmp=null; //销毁闭包
return target;
}

var obj = {
admin:1,
code:1
};

console.log(getTplChunk(obj,str));

demo:
JS Bin on jsbin.com

至此上面两个要点就OK了。

话外之With

这里使用了闭包和作用域链来实现了代码不加前缀实现变量暴露。除此以外,一个看似更可能更好的实现这个功能的,是with语句。with可以创建一个作用域,在作用域内,在引用特定对象属性时候可以不使用前缀。这是几乎更加契合场景的实现。
但是《JavaScript:The Good Parts》作者已经说过”with Statement considered Harmful”,并且ES5的use strict已经禁用了它。那么就放弃使用它吧。反正。。。使用作用域链,我也把它实现了。。。

变量解析和表达式整理

至此前两个逻辑已经OK,但是两个逻辑之间有些碎片化。我们整理一下代码思路进行重构:

  1. 模板函数接受元素id和数据进行内部操作
  2. 内部操作环节对模板字符串进行分解
    • 变量
    • 表达式
    • 遍历
  3. 依次调用不同方法进行替换并返回最终字符串

目前只整理了字符串和表达式的处理方式,下面是,整理好的代码,直接放到jsbin了,贴到博文太长不方便看。
JS Bin on jsbin.com

这里需要说的是,逻辑先处理了表达式再处理了字符串,主要是考虑到这个会通字符串匹配冲突。同时之前发现的若干很明显的bug都修好了。

遍历表达式

遍历表达式的模板语法是这样的:

1
2
3
{{each list as value index}}
<li>{{index}} - {{value.user}}</li>
{{/each}}

首先,要把它抽取出来,这里使用正则抽取它。/[{]{2}each\s.*[}]{2}[\s\S]*[{]{2}\/each[}]{2}/gm,找个了HTML页面测试通过。

接下来要把他换算成表达式。like this:

1
2
3
4
var target = ""
list.forEach(function(value,index){
target += '<li>{{index}} - {{value.user}}</li>';
})

直接改造下transform函数好了,给它加个type参数分类处理。
然后获取下列关注点的变量字符串
4

正则提取: /[{]{2}each\s+(\w+)\s+as\s+(\w+)\s+(\w+)*[}]{2}([\s\S]*)[{]{2}\/each[}]{2}/gm

改造的transform分支2:

1
2
3
4
5
6
7
}else if(type===2){
var reg = /[\{]{2}each\s+(\w+)\s+as\s+(\w+)\s+(\w+)*[\}]{2}([\s\S]*)[\{]{2}\/each[\}]{2}/gm;
v = reg.exec(v);
targetString = 'var a ="";'+v[1]+'.forEach(function('+v[2]+','+v[3]+'){'
+ 'a += "<li>{{index}} - {{value.user}}</li>"'
+'});return a;';
}

现在问题是li里面的变量不解析,那么我们复用一下字符串解析函数好了,之前保留了data参数可以传参进去。
现在是这样:

1
2
3
4
5
6
7
8
9
}else if(type===2){
var reg = /[\{]{2}each\s+(\w+)\s+as\s+(\w+)\s+(\w+)*[\}]{2}([\s\S]*)[\{]{2}\/each[\}]{2}/gm;
v = reg.exec(v);
targetString =

targetString += 'var a ="";'+v[1]+'.forEach(function('+v[2]+','+v[3]+'){'
+ 'a += '+'fn1("'+v[4].replace(/\s/g,"")+'",'+v[2]+')'
+'});return a;';
}

此时问题是fn1获取不到,改下getTplChunk:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function getTplChunk(obj,fnString){
function tmp(obj,fnString){
var isFunction = (Object.prototype.toString.call(fnString)==="[object Function]");
for(var a in obj){
this[a] = obj[a];
};
this["fn1"]=fn1;
return {
getString:function(){
var insertFn = isFunction?fnString:(new Function('',fnString));
return insertFn.apply(this,null);
}
};
}
var target = tmp(obj,fnString).getString();
tmp=null; //销毁闭包
return target;
}

将this[“fn1”]=fn1;假如到作用域上边去;
此时console出来的值:

1
<li>undefined-{{value.user}}</li><li>undefined-{{value.user}}</li>

存在两个问题:

  1. index索引不在作用域链上
  2. 加了点号的变量无法解析&value.user的方式和上文的不太一样。

第2点如此处理:

  • 改下正则匹配: var valReg = /[{]{2}\s*\w+[.\w+]*\s*[}]{2}/g;这样可以获取花括号里面的东西。
  • 使用正则干掉前面的前缀引用,这样可以复用之前的逻辑。
1
2
3
targetString += 'var a ="";'+v[1]+'.forEach(function('+v[2]+','+v[3]+'){'
+ 'a += '+'fn1("'+v[4].replace(/\s/g,"").replace(new RegExp(v[2]+'.',"gm"),"")+'",'+v[2]+')'
+'});return a;';

第1点参照第2点加变量的方式,可以同样处理,由于index是动态的,那么就要动态传参到fn1了。不过fn1本身会接受一个data,我们加到里面去就ok了。但是在这里上下文不是很好理清,同时,一个变量最终解析时候到底是变量,还是字符串,必须要小心处理。

现在是处理后的:

1
2
3
4
5
6
7
8
}else if(type===2){
var reg = /[\{]{2}each\s+(\w+)\s+as\s+(\w+)\s+(\w+)*[\}]{2}([\s\S]*)[\{]{2}\/each[\}]{2}/gm;
v = reg.exec(v);

targetString += 'var a ="";'+v[1]+'.forEach(function('+v[2]+','+v[3]+'){'
+ 'a += '+'fn1("'+v[4].replace(/\s/g,"").replace(new RegExp(v[2]+'.',"gm"),"")+'",addAttr('+ v[2] +',{"'+v[3]+'":'+v[3]+'}))'
+'});return a;';
}

——我加了一个addAttr函数到getTplChunk了。

1
2
3
4
5
6
function addAttr(target,obj){
for(var a in obj){
target[a] = obj[a];
}
return target;
}

至此模板的工作初步完工了,考虑到字符串渲染如果排在each渲染之前,那么会导致它破坏了each里面字符串,因此我们把fn3放在最前面工作。我们来个Demo渲染包含三种表达式的模板看看。

JS Bin on jsbin.com

至此,模板就算编写完毕了。现在它还存在一个比较明显的bug:

  • if-else之间不能存在变量

有空的话我在补起来,主要是正则需要完善。

总结

这里就实现了不太完善的类似artTemplate的模板——全程没有看过artTemplate源码哦。
这里说下自己对基于字符串模板的体会:

  1. 简单:删掉注释和空行不压缩101行,随便两个花括号放一行就能到100行了,最核心的是三个replace就OK了。
  2. 复杂:
    • 当逻辑从字符串抽出时候需要依赖大量相对复杂的正则
    • with存在性能低下和被ES5严格模式废弃问题,需要使用其他方式规避
    • 使用相对偏门的Function构造函数(不是eval)和构造函数的上下文,以及如何进行变量传递等

其他模板技术&&碎碎念

本来想写一写其他模板技术的观望的,发现一篇资料,大家可以前往观望。
文首有一句话是:

此文的写作耗时很长,称之为雄文不为过,小心慢用

个人深以为然。说是雄文确实不为过。

前端组件之争,好像有些陷入模板之争。

最早的时候,Backbone让我们从满是jQuery选择器的沼泽里面拖出来。
然后是AngularJS,双向绑定让我们知道了原来操作DOM可以不用去手工获取和修改节点。
然后是React,组件化的特性,让我们知道了,应用可以像积木一样拼起来,有时候HTML都可以不用写了。

MVC,M和C是如此稳定没有见到玩出太多花样,而面向用户的V层发展到越来越深。

innerHTML如此流行,我以为这是IE干过最积极的事情。Backbone这类MVC框架依赖模板来将数据转换为HTML字符串,然后使用innerHTML来数据插入。innerHTML是如此快速,以至于大面积的DOM操作几乎没有别的选择;但是它又是如此慢,大面积的模板渲染里面即使只进行最小粒度的更新,它也会全部更新一次,顺便——撸掉你绑定的事件。

AngularJS如果视为模板,有些不合适,因为它做了模板以外的事情(事实上,我一直认为,只有基于字符串的模板,才能算是纯正的模板)。但是它确实是如此好用——如果你精熟而非遇到问题束手无策的普通用户的话。
AngularJS在双向绑定方案上使用了脏检测方案,每个双向绑定的元素会存在一个watcher,当触发检测时候会一个个进行对比,然后进行View或者Model的更新。显然,这种方式让优雅和性能洁癖的工程师会有看法。

至于React,我个人认为它最大的贡献除了是小粒度的innerHTML,还有组件化开发方式。——webComponent,感觉入前端以来,从未有一个概念如此让人心往神驰。

好了,不碎嘴了。大家看文章去:

点此前往: 一个对前端模板技术的全面总结