【原】基于PHP的模板引擎实现

本文主要介绍模板引擎的实现思路,以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
虽然功能是满足了,但有以下缺点:

  • 报表可能不止一种,每种场景都需要一个独立方法处理
  • 需求永远在变,构造输出的逻辑会越来越复杂,导致加重维护成本

用程序员的话说,就是

  • 代码不能重用
  • 可读性差

实际上,这都是一个问题:用代码描述字符串实在是太不方便了
其实这种情况,很类似用汇编语言编写程序,汇编语言门槛高,可读性低,难维护,于是便有了各种高级语言,通过编译器将高级语言转换成汇编语言。如下图:

image.png

所以是不是也有一种方式,可以通过将描述文本的语句自动转成代码呢。答案就是模板引擎,这里把描述文本的语句成为模板。模板引擎就相当于编译器将模板“编译”成代码。如下图
image.png

如上述例子,可以用模板表示为:

<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,希望能帮到有需要的人。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容