本文主要介绍模板引擎的实现思路,以PHP为例子,也适用于其他脚本语言。
什么是模板引擎
日常任务中,经常有复杂文本的处理场景。例如需要输出以下html片段:
<p>班级:061432</p>
<p>考试人数:50</p>
<table>
<tr><td>学号</td><td>姓名</td><td>成绩</td><tr>
<tr><td>01</td><td>张三</td><td>80</td><tr>
<tr class="grade_c"><td>02</td><td>李四</td><td>56</td><tr>
...
<tr><td>平均分</td><td colspan="2">71.33</td><tr>
</table>
对于以上例子,其中一种直接的实现方法是通过将数据和字符串常量组合成结果输出。
Talk is cheap, show me the code.
function export_report($class_score)
{
$sum = 0;
$count = count($class_score->scores);
$output = [];
$output[] = "<p>班级:" . $class_score->name . "</p>";
$output[] = "<p>考试人数:" . $count . "</p>";
$output[] = "<table>";
$output[] = "<tr><td>学号</td><td>姓名</td><td>成绩</td><tr>";
foreach ($class_score->scores as $score) {
$grade_c_class = $score->score < 60 ? "class=\"grade_c\"" : "";
$output[] = "<tr " . $grade_c_class . "><td>" . $score->num . "</td><td>" . $score->name . "</td><td>" . $score->score . "</td><tr>";
$sum += $score->score;
}
$avg = sprintf("%.2f", $sum / $count);
$output[] = "<tr><td>平均分</td><td colspan=\"2\">" . $avg . "</td><tr>";
$output[] = "</table>";
return implode("", $output);
}
不好意思,这段代码还是很cheap
虽然功能是满足了,但有以下缺点:
- 报表可能不止一种,每种场景都需要一个独立方法处理
- 需求永远在变,构造输出的逻辑会越来越复杂,导致加重维护成本
用程序员的话说,就是
- 代码不能重用
- 可读性差
实际上,这都是一个问题:用代码描述字符串实在是太不方便了
其实这种情况,很类似用汇编语言编写程序,汇编语言门槛高,可读性低,难维护,于是便有了各种高级语言,通过编译器将高级语言转换成汇编语言。如下图:
所以是不是也有一种方式,可以通过将描述文本的语句自动转成代码呢。答案就是模板引擎,这里把描述文本的语句成为模板。模板引擎就相当于编译器将模板“编译”成代码。如下图
如上述例子,可以用模板表示为:
<p>班级:{{name}}</p>
<p>考试人数:{{scores|count}}</p>
<table>
<tr><td>学号</td><td>姓名</td><td>成绩</td><tr>
{%for scores as score%}
{#不及格输出}}
<tr {%if score.score<60 %}class="grade_c"{%end%}><td>{{score.num}}</td><td>{{score.name}}</td><td>{{score.score}}</td><tr>
{%end%}
<tr><td>平均分</td><td colspan="2">{{scores|avg|format("%.2f")}}</td><tr>
</table>
相对于代码,模板是非常直观的。只要正确使用模板标签,即可编写出各种复杂的模板,而且模板标签的学习成本非常低,完全可以把模板交给前端维护,做到和代码解耦。
好了,现在模板有了,接下来需要通过模板引擎把模板转换成代码。
实现
上面说了模板引擎其实就是把模板“编译”成程序代码,这里的编译其实就是解析。我所实现的解析流程是先将模板中的非字符串常量(表达式、逻辑流程控制、注释块等)识别出来,然后再根据渲染顺序拼接成代码。
首先我们要为表达式、逻辑元素、注释块这些片段定义不同的模板标签,模板标签一般有两个原则:不常见,易解析。以下是我设定的标签,也是比较常用的:
- {{}} 包裹的是变量表达式,如 {{name}}
- {%for list as item%} {%end%} 数组遍历
- {%for dict as key=>value%} {%end%} 字典类数组遍历
- {%if condition %}{%end%} if分支判断
- {%if condition %}{%else%}{%end%} if-else分支判断
- {%if condition %}{%elseif%}{%elseif%}...{%end%} if-elseif分支判断
- {%if condition %}{%elseif%}{%elseif%}...{%else%}{%end%} if-elseif-else分支判断
- {#}} 注释块
注:变量都支持管道处理,相当于代码中的调用函数。例如scores|avg|format("%.2f")表示对scores求平均数后格式化输出。
标签定义好后,我们就可以通过正则把标签和文本分割开来,这是我用到的正则表达式:
$tokens = preg_split("/({{.*?}}|{%.*?%}|{#.*?}})/",$template,-1,PREG_SPLIT_DELIM_CAPTURE);
以上述模板为例,下面是它的分割结果:
<p>班级: 文本
{{name}} 表达式
</p><p>考试人数: 文本
{{scores|count}} 表达式
</p><table><tr><td>学号</td><td>姓名</td><td>成绩</td><tr> 文本
{%for scores as score%} for语句片段
{#不及格输出}} 注释
<tr 文本
{%if score.score<60 %} if分支
class="grade_c" 文本
{%end%} 结束标签
><td> 文本
{{score.num}} 表达式
</td><td> 文本
{{score.name}} 表达式
</td><td> 文本
{{score.score}} 表达式
</td><tr> 文本
{%end%} 结束标签
<tr><td>平均分</td><td colspan="2"> 文本
{{scores|avg|format("%.2f")}} 表达式
</td><tr></table> 文本
一旦模板被分割成这样的标记,我们就可以逐个依次处理。
foreach($tokens as $token){
对于文本,我们只需对"转义后放到输出结果
$codes[] = sprintf("\$output[]=\"%s\";",preg_replace("/\"/","\\\"",$token));//例如文本 【你"好".】会生成为 $output[]="你\"好\".";
要记住我们现在是用代码写代码,最终由模板引擎生成的代码必须是语法正确的,所以这里对文本进行了转义操作,避免由于"造成生成代码的语法错误。
对于注释,我们只需直接跳过即可
elseif(preg_match("/{#.*?}}/",$token,$match)){ }
对于if,elseif,我们只需尝试对以下正则进行匹配,并解析其中的expr分组值,最后将结果拼接成if/elseif语句即可。对于if语句,还需要进行入栈操作
if(preg_match("/{%\s*if\s+(?<expr>.*?)%}/",$token,$match)){
$stack_ops->push($token);
//解析expr
}
elseif(preg_match("/{%\s*elseif\s+(?<expr>.*?)\s*%}/",$token,$match)){
//解析expr
}
else由于没有表达式需要计算,所以只需直接输出
elseif(preg_match("/{%\s*else\s*%}/",$token,$match)){
$codes[] = "}else{";
}
for语句和if类似,只需解析表达式的值后拼接成for循环语句即可,同时也需要入栈操作。
elseif(preg_match("/{%\s*for\s+(?<expr>.*?)\s+as\s+(?<loopitem>[a-zA-Z0-9_]*?)\s*%}/",$token,$match)){
$stack_ops->push($token);
//解析表达式
}elseif(preg_match("/{%\s*for\s+(?<expr>.*?)\s+as\s+(?<loopkey>[a-zA-Z0-9_]*?)\s*=>\s*(?<loopvalue>[a-zA-Z0-9_]*?)\s*%}/",$token,$match)){
$stack_ops->push($token);
//解析表达式
}
最后就是end标签了,end标签做的事情很简单,就是检查出栈和添加闭合花括号。
elseif(preg_match("/{%\s*end%}/",$token,$match)){
if($stack_ops->pop() === null){
throw new Exception("unexcept end tag.");
}
$codes[] = "}";
}
下面是完整循环代码
$stack_ops = new Stack;
$codes = [];
$context_vars = [];
$tokens = preg_split("/({{.*?}}|{%.*?%}|{#.*?}})/",$template,-1,PREG_SPLIT_DELIM_CAPTURE);
foreach($tokens as $token){
if(preg_match("/{%\s*if\s+(?<expr>.*?)%}/",$token,$match)){
$stack_ops->push($token);
list($expr,$vars) = Template::eval_expr($match["expr"]);
$context_vars = array_merge($context_vars,$vars);
$codes[] = sprintf("if(%s){",$expr);
}elseif(preg_match("/{%\s*else\s*%}/",$token,$match)){
$codes[] = "}else{";
}elseif(preg_match("/{%\s*elseif\s+(?<expr>.*?)\s*%}/",$token,$match)){
list($expr,$vars) = Template::eval_expr($match["expr"]);
$context_vars = array_merge($context_vars,$vars);
$codes[] = sprintf("}elseif(%s){",$expr);
}elseif(preg_match("/{%\s*for\s+(?<expr>.*?)\s+as\s+(?<loopitem>[a-zA-Z0-9_]*?)\s*%}/",$token,$match)){
$stack_ops->push($token);
list($expr,$vars) = Template::eval_expr($match["expr"]);
$context_vars = array_merge($context_vars,$vars);
list($loopitem,$vars) = Template::eval_expr($match["loopitem"]);
$codes[] = sprintf("foreach(%s as %s){",$expr,$loopitem);
}elseif(preg_match("/{%\s*for\s+(?<expr>.*?)\s+as\s+(?<loopkey>[a-zA-Z0-9_]*?)\s*=>\s*(?<loopvalue>[a-zA-Z0-9_]*?)\s*%}/",$token,$match)){
$stack_ops->push($token);
list($expr,$vars) = Template::eval_expr($match["expr"]);
$context_vars = array_merge($context_vars,$vars);
list($loopkey,$vars) = Template::eval_expr($match["loopkey"]);
list($loopvalue,$vars) = Template::eval_expr($match["loopvalue"]);
$codes[] = sprintf("foreach(%s as %s=>%s){",$expr,$loopkey,$loopvalue);
}elseif(preg_match("/{%\s*end%}/",$token,$match)){
if($stack_ops->pop() === null){
throw new Exception("unexcept end tag.");
}
$codes[] = "}";
}elseif(preg_match("/{{(?<expr>.*?)}}/",$token,$match)){
list($expr,$vars) = Template::eval_expr($match["expr"]);
$context_vars = array_merge($context_vars,$vars);
$codes[] = sprintf("\$output[]=%s;",$expr);
}elseif(preg_match("/{#.*?}}/",$token,$match)){
}else{
$codes[] = sprintf("\$output[]=\"%s\";",preg_replace("/\"/","\\\"",$token));
}
}
上述代码执行完成后,会得到以下内容:
1.要生成代码
2.涉及到的变量
现在代码有了,接来下我们要把数据注入到变量。这一步其实比较简单,我们只需把数据上下文定义成一个变量,如$context,然后再根据变量的名字通过反射或取键值(取决于$context的数据类型)的方式把值注入到变量。
最后我们通过以下方式生成编译结果:
$result[] = "if(is_object(\$context)){";
$result[] = "\$reflect = new ReflectionObject(\$context);";
foreach($context_vars as $context_var){
$result[] = "if(\$reflect->hasProperty('$context_var')) \$___$context_var=\$context->$context_var;";
}
$result[] = "}elseif(is_array(\$context)){";
foreach($context_vars as $context_var){
$result[] = "if(array_key_exists('$context_var',\$context)) \$___$context_var=\$context[\"$context_var\"];";
}
$result[] = "}";
$result[] = "\$output = [];";
foreach($codes as $code){
$result[] = $code;
}
$result[] = "return implode(\"\",\$output);";
$template_compile = implode("\n",$result);
以本文模板为例,下面是它的编译结果:
if(is_object($context)){
$reflect = new ReflectionObject($context);
if($reflect->hasProperty('name')) $___name=$context->name;
if($reflect->hasProperty('scores')) $___scores=$context->scores;
if($reflect->hasProperty('score')) $___score=$context->score;
}elseif(is_array($context)){
if(array_key_exists('name',$context)) $___name=$context["name"];
if(array_key_exists('scores',$context)) $___scores=$context["scores"];
if(array_key_exists('score',$context)) $___score=$context["score"];
}
$output = [];
$output[]="<p>班级:";
$output[]=$___name ;
$output[]="</p>
<p>考试人数:";
$output[]=QATest\Lib\Template::___count($___scores) ;
$output[]="</p>
<table>
<tr><td>学号</td><td>姓名</td><td>成绩</td><tr>
";
foreach($___scores as $___score ){
$output[]="
";
$output[]="
<tr ";
if(QATest\Lib\Template::get_attr($___score,'score') 60 ){
$output[]="class=\"grade_c\"";
}
$output[]="><td>";
$output[]=QATest\Lib\Template::get_attr($___score,'num') ;
$output[]="</td><td>";
$output[]=QATest\Lib\Template::get_attr($___score,'name') ;
$output[]="</td><td>";
$output[]=QATest\Lib\Template::get_attr($___score,'score') ;
$output[]="</td><tr>
";
}
$output[]="
<tr><td>平均分</td><td colspan=\"2\">";
$output[]=QATest\Lib\Template::___format("%.2f",QATest\Lib\Template::___avg($___scores)) ;
$output[]="</td><tr>
</table>";
return implode("",$output);
代码是丑了点,但可用:)
另外可以看到,模板中的变量名字在实际生成的结果都会添加前缀___,这样做纯粹是防止命名冲突。
至此,模板引擎的实现思路介绍完毕。总的下来,核心代码只有200行出头,但已实现了一个基本可用的模板引擎。但对于一个功能完备的模板引擎,目前还缺少很多有趣的特性,例如模板继承、自动防注入等,大家可以根据项目实际需求再具体扩展。
有句老话 talk is cheap,show me the code
代码我这几天整理好后会上传到github,希望能帮到有需要的人。