使用DOM解析來實現PHP模版引擎

liangwent發表於2019-03-02

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樹結構

使用DOM解析來實現PHP模版引擎

示例來源於知乎

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,它代表了整個文件,也是整個文件樹的根結點。其中繼承自基類的屬性$nodeTypeXML_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,它的$nodeTypeXML_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閱讀更多其他文章

也可以關注我的微博@不會涼的涼涼與我交流

相關文章