使用DOM解析來實現PHP模版引擎
0. 前言: 傳統模版語法的不利之處
目前市面上有很多PHP的模版引擎,如smarty、blade等。其中大部分都是基於正則表示式將其中的模版語法轉換成PHP程式碼,並進行快取。模版程式碼所經歷的過程如下:
template -> php -> html
複製程式碼
使用正則替換或者直接使用PHP原生有什麼問題呢?以下我們以blade為例來看一些具體例子:
<html>
<body>
<div>
<div class="items" >
@if (count($records) === 1)
<p>我有一個記錄!</p>
@elseif (count($records) > 1)
<p>我有多個記錄!</p>
@else
<p>我沒有任何記錄!</p>
@endif
</div>
</div>
</body>
</html>
複製程式碼
問題一: 編輯器格式化和語法高亮的問題
如上,我們面臨的第一個問題是html和blade語法混雜在一起。在閱讀邏輯上,我們需要來回的在blade和html之間做轉化。 當然,當你熟悉了blade的語法並熟練掌握這個能力的時候,這種轉化並不會對你的閱讀構成障礙。
但是,對於編輯器來說,如果不使用合適的外掛,無論是程式碼高亮還是自動格式化都會產生意想不到結果
問題二: html中渲染class等屬性
其實以上還不是最令人眼花繚亂的,在我有限的工作經歷中,使用PHP渲染html中的class或者其他屬性時,經常會看到如下令人恐怖的程式碼
<html>
<body>
<div>
<ul class="items" >
<li <?= $cur==1 ? 'class="active"' : ''?>>NO.1</li>
<li <?= $cur==2 ? 'class="active"' : ''?>>NO.2</li>
<li <?= $cur==3 ? 'class="active"' : ''?>>NO.3</li>
<li <?= $cur==4 ? 'class="active"' : ''?>>NO.4</li>
</ul>
</div>
</body>
</html>
複製程式碼
以上還不是最恐怖的,當有的人既不使用<?= ?>
又不使用三元運算時...簡直不可想象。
問題三: 公共模版中程式碼程式碼的不完整
對於大部分網頁的頭部和尾部,我們單獨抽離出來以供複用。對於blade這種支援類似插槽的模版引擎,情況並不算太糟,但對於不支援類似特性的模版引擎,如下的程式碼也是非常常見
#./header.phtml 標頭檔案
<html>
<body>
<div class="nav">
</div>
<div class="content">
複製程式碼
#./bottom.phtml 尾檔案
</div>
<div class="bottom">
</div>
</body>
</html>
複製程式碼
如上的問題在於什麼呢,每個部分模版都不是標籤閉合的,每一部分並不完整。在獨立模版存在非常多的情況下,正確的讓html標籤閉合也成為開發負擔之一。
好了,說完了這麼多問題,我們來想一想是否有解決的辦法。要知道以前前端js程式碼合併也是基於正則,但是新的三大框架都是基於dom解析來實現。那如果說,我們在寫php渲染頁面的時候也可以和Vue一樣,使用類似如下的語法,是不是就能解決以上的問題呢? 當然本文只是給大家提供一個最基本的思路,和最基礎的實現,僅供娛樂和思路拓展吧。
<!-- ./tpl.html -->
<html>
<body>
<div class="title">
<div p-if="is_author">
<p>{{ author }}</p>
</div>
<div p-else>
<p>{{ vistor }}</p>
</div>
</div>
<div p-for="(value, idx) in items">
<p>{{ value }} - {{ idx }}</p>
<p>{{ value }}</p>
</div>
</body>
</html>
複製程式碼
$params = [
"is_author" => true,
"author" => "liangwt",
"vistor" => "Welcome",
"items" => [
"A",
"B",
"C",
],
];
csRender("./tpl.html", $params);
複製程式碼
<!-- out -->
<html>
<body>
<div class="title">
<div>
<p>liangwt</p>
</div>
<div>
<p>Welcome</p>
</div>
</div>
<div>
<p>A - 0</p>
<p>A</p>
<p>B - 1</p>
<p>B</p>
<p>C - 2</p>
<p>C</p>
</div>
</body>
</html>
複製程式碼
1. DOM基本知識
- D: Document 代表裡文件
- O: Object 代表了物件
- M: Model 代表了模型
DOM把整個文件表示為一棵樹,確切的說是一個家譜樹。家譜樹中我們使用 parent(父)、child(子)、sibling(兄弟)來描述成員之間的關係。 對於一個普通的如下的xml來說
<?xml version="1.0" encoding="utf-8"?>
<bookstore>
<book category="children">
<title lang="en">Harry Potter</title>
<author>J K. Rowling</author>
<year>2005</year>
<price>29.99</price>
</book>
<book category="cooking">
<title lang="en">Everyday Italian</title>
<author>Giada De Laurentiis</author>
<year>2005</year>
<price>30.00</price>
</book>
<book category="web">
<title lang="en">Learning XML</title>
<author>Erik T. Ray</author>
<year>2003</year>
<price>39.95</price>
</book>
<book category="web">
<title lang="en">XQuery Kick Start</title>
<author>James McGovern</author>
<author>Per Bothner</author>
<author>Kurt Cagle</author>
<author>James Linn</author>
<author>Vaidyanathan Nagarajan</author>
<year>2003</year>
<price>49.99</price>
</book>
</bookstore>
複製程式碼
我們可以生成如下的dom樹結構
示例來源於知乎
2. PHP中DomDocument的使用
PHP中原生提供了xml文件解析的拓展,它使用起來非常簡單。網上資料大多介紹基於此拓展的封裝包,因此這裡稍微詳細介紹下。
(1). DOM中的基類節點: The DOMNode class
前面介紹dom樹的時候說過,文件是由不同型別的節點構成的集合,所以DomDocument中絕大多數的類都繼承於此。
它的類屬性除了描述了自身名稱($nodeName
)、值($nodeValue
)、型別($nodeType
)等,還描述了其父節點($parentNode
)、子節點($childNodes
)、同級節點($previousSibling
、$nextSibling
)等。
它的類方法除了包括對子節點的插入(appendChild()
)、替換(replaceChild()
)、 移除(removeChild()
)之外,還有諸多用於判斷自身屬性的函式。
作為任何型別的節點基類我們需要重點關注它的每一個屬性和方法,參考官方文件。
(2). 整個文件: DOMDocument extends DOMNode
DOMDocument繼承自DOMNode,它代表了整個文件,也是整個文件樹的根結點。其中繼承自基類的屬性$nodeType
是XML_DOCUMENT_NODE(9)
我們通常使用它的load*()
來建立dom樹,和save*()
系列方法將dom轉換成文字
我們的程式碼也是如此開頭和結束
function csRender(string $tpl, array $params)
{
$dom = new DomDocument("1.0", "UTF-8");
$dom->loadHTMLFile($tpl);
// ...
echo $dom->saveHTML();
}
複製程式碼
(3). 元素節點 DOMElement extends DOMNode
DOMElement繼承自DOMNode,它代表了
之類的標籤,是構成dom結構的基本節點.其中標籤的名字就是節點的屬性tagName
,它的$nodeType
是XML_ELEMENT_NODE = 1
元素可以包含其他的元素,元素節點中也包含了其他型別的節點。
我們可以使用getAttributeNode()
或者getAttribute()
來獲取元素節點的屬性或者屬性名,使用getElementsByTagName(string $name)
獲取元素包含的標籤名$name
為的節點.以及使用remove*()
和set*()
函式來刪除和修改指定屬性
我們在實現上面p-if的時候需要進行判斷if條件是否成立,並在之後刪除掉這個屬性
if ($item->nodeType == XML_ELEMENT_NODE
&& $if_value = $item->getAttribute("p-if") {
if ($if_result) {
$item->removeAttribute("p-if");
}
}
複製程式碼
(4). 屬性節點 DOMAttr extends DOMNode
DOMAttr繼承自DOMNode,它代表了標籤class="one"
之類的屬性,如上面所講對元素節點呼叫getAttributeNode()
即可獲取此元素的屬性節點。屬性節點的nodeType是XML_ATTRIBUTE_NODE=2
(5). 文字節點 DOMText extends DOMCharacterData
DOMText繼承自DOMCharacterData,DOMCharacterData也是繼承自DOMNode。在dom中它代表了元素節點包含的文字.其中nodeValue屬性就是文字的內容。文字節點的nodeType 是XML_TEXT_NODE = 3
除此之外需要知道的是,文字節點單總是被包含在元素節點中,文字節點的父節點是元素節點。我們通過$elementNode->childNodes
即可獲取(如果有文字節點的話),此函式返回的是 DOMNodeList 型別,它代表節點集合,並實現了Traversable介面
我們在實現mustache語法的時候需要判斷元素的文字節點中是否有{{}}包裹的變數
if ($item->nodeType == XML_TEXT_NODE) {
$str = preg_replace_callback('/\{\{(.*?)\}\}/', function ($matches) use ($params) {
// ...處理邏輯
}, $item->nodeValue);
$item->nodeValue = $str;
}
複製程式碼
(6). 節點遍歷
以上就是最常用的幾種節點型別了,我們下面講一講如何進行節點遍歷.我們需要基於遍歷去實現樹中節點判斷,然後進行樹操作
我們在上面介紹瞭如何載入一個html文件,其中獲取的變數$dom
也是dom樹的根結點
function csRender(string $tpl, array $params)
{
$dom = new DomDocument("1.0", "UTF-8");
$dom->loadHTMLFile($tpl);
traversingtDomNode($dom, $params);
echo $dom->saveHTML();
}
複製程式碼
擁有一個節點之後如何遍歷它的子節點呢,我們獲取其$domNode->childNodes子屬性進行遍歷即可
function traversingtDomNode($dom, $params){
foreach ($domNode->childNodes as $item) {
//...
}
}
複製程式碼
在遍歷每一個節點過程中,可以通過判斷nodeType來對不同型別節點進行操作。同時如果此節點依舊有子節點,我們繼續把節點放入此函式進行遞迴呼叫
function traversingtDomNode($dom, $params){
foreach ($domNode->childNodes as $item) {
if ($item->nodeType == XML_ELEMENT_NODE
&& $if_value = $item->getAttribute("p-if")) {
// ...
}
if ($item->nodeType == XML_ELEMENT_NODE
&& $item->hasAttribute("p-else")) {
// ...
}
if ($item->hasChildNodes()) {
traversingtDomNode($item, $params);
}
}
}
複製程式碼
3. mustache語法實現
{{ key }}
語法實現很簡單,我們只要通過正則拿到{{ key }}
中的key值,然後把連著{{ }}一起替換成$params[$key]
即可
// ...
if ($item->nodeType == XML_TEXT_NODE) {
$str = preg_replace_callback('/\{\{(.*?)\}\}/', function ($matches) use ($params) {
return $params[trim($matches[1])];
}, $item->nodeValue);
$item->nodeValue = $str;
}
// ...
複製程式碼
4. if語法實現
<div p-if="is_author">
<p>{{ author }}</p>
</div>
複製程式碼
if語法實現也很簡單,我們通過$if_value =$item->getAttribute("p-if")
獲取屬性值,並通過判斷$params[$if_value
]`的值,如果成立,則刪掉屬性,展示此元素節點。如果不成立則刪掉此節點。
// ...
if ($item->nodeType == XML_ELEMENT_NODE && $if_value = $item->getAttribute("p-if")) {
$if_result = $params[$if_value] ?? false;
if ($if_result) {
$item->removeAttribute("p-if");
} else {
array_push($elementsToRemove, $item);
}
}
// ...
複製程式碼
注意這裡面有個小坑: 參考文件中的一條評論:notes: NO.1 在遍歷中移除節點會導致dom樹重構,遍歷終止。所以我們採取將要移除的節點單獨記錄到$elementsToRemove
,在迴圈結束後統一移除
$elementsToRemove = [];
foreach ($domNode->childNodes as $item) {
// ..
}
foreach ($elementsToRemove as $item) {
$item->parentNode->removeChild($item);
}
複製程式碼
5. eles語法實現
<div p-if="is_author">
<p>{{ author }}</p>
<div p-if="show_intro">
<p>{{ intro }}</p>
</div>
<div p-else>
<p>{{ vistor }}</p>
</div>
</div>
複製程式碼
else 的實現會用到很有意思的技巧,因為else的真值並不取決於它自身,而是取決於和它配對的if的值。注意!是和它配對的if值,如果你想當然的認為是else之前的那個if值可就錯咯。我們看下面這個例子:
<div p-if="is_author">
<p>{{ author }}</p>
<div p-if="show_intro_one">
<p>{{ intro_one }}</p>
</div>
<div p-if="show_comment_one">
<p>{{ comment_one }}</p>
</div>
<div p-else>
<p>{{ comment_two }}</p>
</div>
<div p-else>
<p>{{ intro_two }}</p>
</div>
</div>
複製程式碼
其中最後一個else屬性的值取決於第一個if "show_intro_one" 的值,即$params[$if_value]
的值.那如何才能實現if-else正確的匹配呢,答案就是: 棧。在我們實現括號匹配,if-else匹配得各種匹配問題中,棧是一個非常好的思路。
我們第一步需要在dom樹同一深度給予不同棧,因為if-else的匹配只會發生在同級元素直接,而不會發生在父子元素之間。
第二步自然是每遇到一個if就把值放入對應棧的棧頂。
第三步在遇到else時,從棧頂取出一個值,它的反值即為else的值
foreach ($domNode->childNodes as $item) {
// 1. 第一步
$if_stack = [];
// ...
if ($item->nodeType == XML_ELEMENT_NODE
&& $if_value = $item->getAttribute("p-if")) {
$if_result = $params[$if_value] ?? false;
// 第二步
array_push($if_stack, $if_result);
// ...
}
if ($item->nodeType == XML_ELEMENT_NODE && $item->hasAttribute("p-else")) {
// 第三步
$if_result = array_pop($if_stack);
if (!$if_result) {
$item->removeAttribute("p-else");
} else {
array_push($elementsToRemove, $item);
}
}
}
複製程式碼
6. for語法實現
<div p-for="(value, idx) in items">
<p>{{ value }} - {{ idx }}</p>
<p>{{ value }}</p>
</div>
複製程式碼
for的語法實現思路很簡單,把含有屬性p-for屬性的元素所有子節點按照遍歷的陣列迴圈賦值即可。其中稍有難度的就是$params
中的值傳遞問題,或者說$params
值的作用域問題,如果恰好$params中也有個欄位叫value或者idx,但很明顯在for的子節點中,value和idx應該是區域性作用域,他們需要在每次迴圈開始賦予新值,並在整個迴圈結束後被銷燬.
所以我們讓一個新值$for_runtime_params
等於外部$params
引數,並在迴圈中繼續遞迴呼叫遍歷函式
if ($item->nodeType == XML_ELEMENT_NODE
&& $for_value = $item->getAttribute("p-for")) {
preg_match("/\((.*?), (.*?)\) in (.*)/", $for_value, $matches);
[, $value, $index, $items] = $matches;
foreach ($params[$items] as $k => $v) {
$for_runtime_params = $params;
$for_runtime_params[$value] = $v;
$for_runtime_params[$index] = $k;
foreach ($item->childNodes as $el) {
$e = $el->cloneNode(true);
if ($e->hasChildNodes()) {
traversingtDomNode($e, $for_runtime_params);
}
}
}
}
複製程式碼
注意: 和刪除節點一樣,我們在遍歷的過程中也不能插入新節點,他會導致獲取的子節點永遠為空。所以也和刪除一樣單純記錄最後統一插入即可
7. 後記
本文實現肯定還有諸多細節未考慮,但是給大家提供一個不錯的思路。對於未來可以嘗試繼續實現v-class
語法,slot
功能,components
功能,都是相當不錯的
更詳細的實現可以可以檢視我的github: cs-render
同時也歡迎在我的部落格-showthink閱讀更多其他文章
也可以關注我的微博@不會涼的涼涼與我交流