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

0. 前言: 傳統模版語法的不利之處

目前市面上有不少PHP的模版引擎,如smarty、blade等。其中大部分都是基於正則表達式將其中的模版語法轉換成PHP代碼,並進行緩存。模版代碼所經歷的過程以下:php

template -> php -> html
複製代碼

使用正則替換或者直接使用PHP原生有什麼問題呢?如下咱們以blade爲例來看一些具體例子:html

<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的語法並熟練掌握這個能力的時候,這種轉化並不會對你的閱讀構成障礙。前端

可是,對於編輯器來講,若是不使用合適的插件,不管是代碼高亮仍是自動格式化都會產生意想不到結果node

問題二: html中渲染class等屬性

其實以上還不是最使人眼花繚亂的,在我有限的工做經歷中,使用PHP渲染html中的class或者其餘屬性時,常常會看到以下使人恐怖的代碼git

<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>
複製代碼

以上還不是最恐怖的,當有的人既不使用<?= ?>又不使用三元運算時...簡直不可想象。github

問題三: 公共模版中代碼代碼的不完整

對於大部分網頁的頭部和尾部,咱們單獨抽離出來以供複用。對於blade這種支持相似插槽的模版引擎,狀況並不算太糟,但對於不支持相似特性的模版引擎,以下的代碼也是很是常見web

#./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,它表明了整個文檔,也是整個文檔樹的根結點。其中繼承自基類的屬性$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閱讀更多其餘文章

也能夠關注個人微博@不會涼的涼涼與我交流

相關文章
相關標籤/搜索