模板引擎是 MVC 架構中「視圖層」的核心,將業務邏輯與展示邏輯分離。本文深度解析一個基於正則表達式、支援 {data}/{list} 標簽、遞歸嵌套、條件判斷、循環、PHP 原生代碼嵌入、文件緩存的完整模板引擎實現。
<?php
class Template {
public string $html = '';
public array $vars = [];
public string $bFlag = '{';
public string $eFlag = '}';
public string $pfix = 'zmm:';
private string $folder = '';
private string $file = '';
public function __construct(array $vars = []) {
$this->vars = $vars;
$this->bTag = $this->bFlag . $this->pfix;
$this->eTag = $this->bFlag . '/' . $this->pfix;
Tags::$vars = &$this->vars; // 靜態引用共享變量
}
// 加載模板文件
public function LoadTpl(string $tpl): void {
$this->file = $this->GetTplPath($tpl);
Tags::$file = &$this->file;
if (!is_file($this->file)) exit("模板文件 [{$this->file}] 不存在!");
$html = file_get_contents($this->file);
$html = self::FmtTplHtml($html); // 壓縮:去除換行、制表、多餘空格
// 語法預編譯:{if} {loop} {php} 等轉為 PHP 代碼
$callFunc = Tags::$prefix . 'Syntax';
$this->html = Tags::$callFunc($html, $this);
}
// 壓縮模板 HTML
static public function FmtTplHtml(string $html): string {
return preg_replace('/(\r)|(\n)|(\t)|(\s{2,})/is', '', $html);
}
// 標簽解析入口
private function SetTplTags(): void {
$this->SetPanelTags(); // 單標簽 {zmm:tag attr/}
$this->SetTrunkTags(); // 雙標簽 {zmm:tag attr}content{/zmm:tag}
$this->RegHatchVars(); // 遞歸處理嵌套
}
// 單標簽匹配:{zmm:tag.attr/} 或 {zmm:tag attr/}
private function SetPanelTags(): void {
$rule = $this->bTag . '([^' . $this->eFlag . ']+)\\/' . $this->eFlag;
preg_match_all('/' . $rule . '/ism', $this->html, $matches);
$this->TransTag($matches, 'panel');
}
// 雙標簽匹配:{zmm:tag attr} ... {/zmm:tag}(支援遞歸嵌套)
private function SetTrunkTags(): void {
$rule = $this->bTag . '(\\w+)\\s*([^' . $this->eFlag . ']*?)' . $this->eFlag .
'((?:(?!' . $this->bTag . ')[\\S\\s]*?|(?R))*)' .
$this->eTag . '\\1\\s*' . $this->eFlag;
preg_match_all('/' . $rule . '/ism', $this->html, $matches);
$this->TransTag($matches, 'trunk');
}
// 分發給 Tags 類處理
private function TransTag(array $matches, string $type): void {
if (empty($matches[0])) return;
foreach ($matches[0] as $i => $fullTag) {
$callFunc = Tags::$prefix . ucfirst($type === 'panel' ?
(strpos($matches[1][$i], '.') ? explode('.', $matches[1][$i])[0] :
explode(' ', trim($matches[1][$i]))[0]) :
$matches[1][$i]);
if (method_exists('Tags', $callFunc)) {
$html = $type === 'panel'
? Tags::$callFunc($matches[1][$i])
: Tags::$callFunc($matches[2][$i], $matches[3][$i]);
if ($html !== false) {
$this->html = str_ireplace($fullTag, $html, $this->html);
}
}
}
}
// 遞歸處理嵌套標簽
private function RegHatchVars(): void {
if (preg_match('/' . $this->bTag . '/ism', $this->html)) {
$this->SetTplTags(); // 還有標簽則繼續解析
}
}
// 輸出
public function Display(bool $cache = false, string $name = '', int $time = 0): void {
if ($cache && $name) {
$cacheObj = new Cache($time ?: 600);
$cacheObj->Set($name, $this->html);
}
echo $this->html;
flush();
}
}
?>
<?php
class Tags {
static public string $prefix = 'TAG_';
static public array $vars = [];
static public string $file = '';
static private string $rule = '';
// 語法預編譯:模板控制結構 → PHP 代碼
static public function TAG_Syntax(string $html, Template $tpl): string {
$bTag = $tpl->bTag; $eTag = $tpl->eTag; $eFlag = $tpl->eFlag;
$html = preg_replace('/' . $bTag . 'if\\s+([^' . $eFlag . ']+)\\s*' . $eTag . '/ism', '', $html);
$html = preg_replace('/' . $bTag . 'elseif\\s+([^' . $eFlag . ']+)\\s*' . $eTag . '/ism', '', $html);
$html = preg_replace('/' . $bTag . 'else\\s*' . $eTag . '/ism', '', $html);
$html = preg_replace('/' . $bTag . 'loop\\s+(\\S+)\\s+(\\S+)\\s*' . $eTag . '/ism', '', $html);
$html = preg_replace('/' . $bTag . 'loop\\s+(\\S+)\\s+(\\S+)\\s+(\\S+)\\s*' . $eTag . '/ism', ' \\3) { ?>', $html);
$html = preg_replace('/' . $eTag . '(if|loop)\\s*' . $eTag . '/ism', '', $html);
$html = preg_replace('/' . $bTag . 'php\\s*' . $eTag . '((?:(?!' . $bTag . ')[\\S\\s]*?|(?R))*)' . $eTag . 'php\\s*' . $eTag . '/ism', '', $html);
return self::TAG_Execute($html); // 執行編譯後的 PHP
}
// 執行 PHP 代碼並捕獲輸出
static public function TAG_Execute(string $html): string {
ob_clean(); ob_start();
if (!empty(self::$vars)) extract(self::$vars, EXTR_OVERWRITE);
$file = sys_get_temp_dir() . '/tpl_' . md5(uniqid(rand(), true)) . '.php';
file_put_contents($file, $html);
include $file;
$output = ob_get_contents();
ob_end_clean();
unlink($file);
return $output;
}
// {list} 標簽:列表數據、支援分頁、嵌套
static public function TAG_List(string $attr, string $html): string {
return self::processDataTag($attr, $html, true);
}
// {data} 標簽:單條數據
static public function TAG_Data(string $attr, string $html): string {
return self::processDataTag($attr, $html, false);
}
// 統一數據處理邏輯
static private function processDataTag(string $attr, string $html, bool $isList): string {
if (empty($html)) exit($isList ? '標簽{list}內容為空!' : '標簽{data}內容為空!');
// 若包含子標簽,先佔位遞歸處理
if (self::TAG_HaveTag($html)) {
return self::TAG_DealTag($attr, $html, $isList);
}
return self::TAG_GetData($attr, $html, $isList);
}
// 數據查詢與渲染核心
static private function TAG_GetData(string $attr, string $html, bool $isList): string {
$attrObj = new Attbt($attr); // 屬性解析類
$attrs = $attrObj->attrs;
extract($attrs, EXTR_OVERWRITE); // source, where, order, rows, page, size...
$source = table_name($source, $column); // 表名處理
$dataSql = new DataSql();
// 構建 SQL
$whereSql = $where ? ' WHERE ' . str_replace(',', ' AND ', $where) : '';
$orderSql = $order ? " ORDER BY $order" : self::getDefaultOrder($dataSql, $source);
if ($isList) {
// 列表查詢 + 分頁
$limitSql = $rows > 0 ? " LIMIT 0, $rows" : '';
$sql = $sql ?? "SELECT * FROM `$source`$whereSql$orderSql$limitSql";
if ($page === 'true' && $size > 0) {
$total = $dataSql->GetRecNum($sql);
$pageCount = ceil($total / $size);
$pageNum = max(1, min($GLOBALS['page'] ?? 1, $pageCount));
$sql = "SELECT * FROM `$source`$whereSql$orderSql LIMIT " . ($pageNum-1)*$size . ", $size";
// 設置分頁全局變量供模板使用
$GLOBALS['cfg_page_curr'] = $pageNum;
$GLOBALS['cfg_page_list'] = function_exists('list_pagelink') ? list_pagelink($pageNum, $pageCount, 2) : '';
}
// 逐行渲染
$result = $dataSql->SqlCmdExec($sql);
$output = '';
$idx = 0;
while ($row = $dataSql->GetRecArr($result)) {
$tpl = $html;
$tpl = str_ireplace('@idx', $idx++, $tpl);
// 字段替換 [field:name/]
preg_match_all('/\[field:\\s*(\\w+)\\s*([^\\]]*?)\\s*\\/?\]/is', $tpl, $fields);
foreach ($fields[1] as $i => $field) {
$val = $row[$field] ?? '';
$tpl = str_ireplace($fields[0][$i], $val, $tpl);
}
$output .= $tpl;
}
return $output;
} else {
// 單條查詢
$sql = "SELECT * FROM `$source`$whereSql$orderSql LIMIT 1";
$row = $dataSql->GetOneRec($sql);
if (!$row) return '';
$tpl = $html;
preg_match_all('/\[field:\\s*(\\w+)\\s*([^\\]]*?)\\s*\\/?\]/is', $tpl, $fields);
foreach ($fields[1] as $i => $field) {
$val = $row[$field] ?? '';
// 支援函數處理 [field:name function="htmlspecialchars(@me)"/]
if ($fields[2][$i]) {
$funcAttr = new Attbt($fields[2][$i]);
if ($func = $funcAttr->attrs['function'] ?? '') {
eval('$val = ' . str_ireplace('@me', "'$val'", $func) . ';');
}
}
$tpl = str_ireplace($fields[0][$i], $val, $tpl);
}
return $tpl;
}
}
// 處理嵌套標簽:佔位 → 遞歸 → 還原
static private function TAG_DealTag(string $attr, string $html, bool $isList): string {
preg_match_all('/' . self::$rule . '/ism', $html, $matches);
$placeholders = [];
foreach ($matches[3] as $i => $content) {
$ph = "{-->>child_node_$i<<--}";
$placeholders[$ph] = $content;
$html = str_ireplace($content, $ph, $html);
}
$html = self::TAG_GetData($attr, $html, $isList);
$html = str_ireplace(array_keys($placeholders), array_values($placeholders), $html);
return $html;
}
// 檢測是否包含子標簽
static private function TAG_HaveTag(string $html): bool {
$tpl = new Template();
self::$rule = $tpl->bTag . '([^' . $tpl->eFlag . ']+)\\/' . $tpl->eTag;
if (preg_match('/' . self::$rule . '/ism', $html)) return true;
self::$rule = $tpl->bTag . '(\\w+)\\s*([^' . $tpl->eFlag . ']*?)' . $tpl->eTag .
'((?:(?!' . $tpl->bTag . ')[\\S\\s]*?|(?R))*)' .
$tpl->eTag . '\\1\\s*' . $tpl->eFlag;
return (bool)preg_match('/' . self::$rule . '/ism', $html);
}
// 靜態方法調用兜底
static public function __callStatic(string $name, array $args): void {
exit("標簽 {$name} 不存在!");
}
}
?>
| 標簽類型 | 語法 | 說明 |
|---|---|---|
| 變量輸出 | [field:name/] | 輸出字段值 |
| 函數處理 | [field:name function="htmlspecialchars(@me)"/] | @me 代表字段值 |
| 條件判斷 | {zmm:if $user.vip}...{zmm:elseif $user.level>5}...{zmm:else}...{/zmm:if} | 支援 PHP 表達式 |
| 循環遍歷 | {zmm:loop $list item}...{/zmm:loop} | 或 {zmm:loop $list key item} |
| 原生 PHP | {zmm:php} echo $var; {/zmm:php} | 執行任意 PHP 代碼 |
| 數據塊 | {zmm:list source="article" where="cat=1" order="id desc" rows="10"}...{/zmm:list} | 列表查詢、分頁 |
| 單條數據 | {zmm:data source="user" where="id=1"}...{/zmm:data} | 單行查詢 |
// 雙標簽正則核心(支援遞歸)
$rule = $bTag . '(\\w+)\\s*([^' . $eFlag . ']*?)' . $eFlag .
'((?:(?!' . $bTag . ')[\\S\\s]*?|(?R))*)' . // 關鍵:(?R) 遞歸匹配整個模式
$eTag . '\\1\\s*' . $eFlag;
(?R) 代表遞歸引用整個正則模式,實現「任意層級嵌套」匹配。
{-->>child_node_N<<--}RegHatchVars 循環調用)if/loop/php)預編譯為原生 PHPinclude 執行 → ob_get_contents 捕獲輸出extract($vars) 將變量注入符號表public function Display(bool $cache = false, string $name = '', int $time = 0): void {
if ($cache && $name) {
$cacheObj = new Cache($time ?: 600);
$cacheObj->Set($name, $this->html); // 緩存最終渲染後的 HTML
}
echo $this->html;
}
| 痛點 | 改造方向 | 推薦方案 |
|---|---|---|
| 正則解析脆弱、難維護 | 編譯器模式 | Lexer + Parser 生成 AST,再編譯為 PHP(如 Twig、Blade) |
eval/include 執行不安全 | 沙箱/預編譯 | 預編譯為 PHP 類文件,OpCache 加速,禁用危險函數 |
全局變量 $GLOBALS 污染 | 依賴注入 | 模板類接收 Context 對象,而非靜態變量 |
| SQL 硬編碼在模板屬性中 | 數據層分離 | 控制器準備數據,模板僅負責渲染(MVVM/ViewModel) |
| 無繼承/佈局機制 | 佈局塊 | 實現 {extend}/{block},支援佈局繼承 |
這個模板引擎展示了「正則解析 + 遞歸替換 + PHP 編譯執行」的經典實現路徑。其核心亮點在於:
Tags 類職責單一,易於擴展新標簽現代專案建議直接使用 Twig、Blade(Laravel)、Lathe 等成熟引擎,它們提供沙箱安全、繼承佈局、異步渲染、IDE 提示等企業級特性。但理解這類「手寫引擎」的核心機制,對於維護舊系統、設計 DSL、或構建輕量級框架極有價值。