淺析Yii2的view層設計


Yii2.0的view層提供了若干重要的功能:assets資源管理,widgets小組件,layouts布局...

下面將通過對Yii2.0代碼直接進行分析,看一下上述功能都是如何實現的,當然細枝末節的東西不會過多贅述,如果你對此感興趣又懶得自己去翻代碼和文檔,那么這篇博客可以快速的給你一個系統的認識。

基礎渲染

這一節要談的是view層是如何完成基礎工作的,也就是根據用戶傳入的參數渲染出一個html頁面。

用法

我們在controller里調用$this->render方法,第一個參數是要套用的模板文件(別名),第二個參數是用戶數據用於填充模板。

    public function actionIndex()
    {
        return $this->render('index', ['param' => 'hello world']);
    }  

布局模板和子模板的關系

controller會直接將請求代理給view,這個view也就是mvc的中的v,在整個框架中是一個單例對象。首先通過view->render方法渲染出index這個模板得到的結果保存到$content,接着調用了controller->renderContent($content),這是做什么呢?

    public function render($view, $params = [])
    {
        $content = $this->getView()->render($view, $params, $this);
        return $this->renderContent($content);
    }

原來,renderContent會找到controller對應的布局layouts文件,並將$content填充到布局文件中,最終才能渲染出完整的頁面。其實,layouts布局本身也是一個模板文件,它需要的參數就是content,代表了子模板文件渲染后的結果,這個設計很巧妙。

    public function renderContent($content)
    {
        $layoutFile = $this->findLayoutFile($this->getView());
        if ($layoutFile !== false) {
            return $this->getView()->renderFile($layoutFile, ['content' => $content], $this);
        } else {
            return $content;
        }
    }

上述代碼很簡單,先找到布局文件(1個controller可以配置1個),然后調用view->renderFile渲染布局模板,傳入子模板的渲染結果,就得到了完整頁面。

特別提一下,上面子模板渲染用的view->render,而布局模板用的view->renderFile,其區別是render傳入的模板是一個別名(這里是index),而renderFile是直接傳入模板的文件路徑,這里的設計哲學是:view只負責查找模板文件&渲染模板,而布局文件是controller自己設計的概念,所以布局模板的查找是controller負責的,而模板按別名查找是view的職責。

填充模板

無論是布局還是子模板,在填充時都是通過view->renderPhpFile方法實現的,它用到了php的ob庫實現數據的捕捉,實現非常簡單:

    public function renderPhpFile($_file_, $_params_ = [])
    {
        ob_start();
        ob_implicit_flush(false);
        extract($_params_, EXTR_OVERWRITE);
        require($_file_);

        return ob_get_clean();
    }
  • ob_start():創建1個新的用戶級輸出緩沖區,捕獲輸出的內容到內存。
  • ob_implicit_flush(false):設置SAPI級的輸出緩沖區模式,不自動刷新。
  • ob_get_clean():得到當前用戶緩沖區的內容並刪除當前的用戶緩沖區。

如果你對ob系列函數不了解,可以點訪問官方文檔。如果你對用戶,SAPI緩沖區不了解,可以訪問這里

總之,ob_start后所有echo輸出都會被緩存起來,然后通過extract方法可以將用戶參數params解開為局部變量,最后通過require包含模板文件,這樣模板文件就可以直接按局部變量$var1,$var2的方式訪問方便的訪問$params里的數據了,這個函數最后將緩沖的數據全部取出返回,完成了模板的渲染。

舉個例子

這里拿布局文件為例(因為它本身也是一個模板),看看模板文件可以做什么事情:

<?php

/* @var $this \yii\web\View */
/* @var $content string */

use yii\helpers\Html;
use yii\bootstrap\Nav;
use yii\bootstrap\NavBar;
use yii\widgets\Breadcrumbs;
use frontend\assets\AppAsset;
use common\widgets\Alert;

?>

AppAsset::register($this);

<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="<?= Yii::$app->language ?>">
<head>
    <meta charset="<?= Yii::$app->charset ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= Html::encode($this->title) ?></title>
    <?php $this->head() ?>
</head>
<body>
<?php $this->beginBody() ?>

<?php
NavBar::begin([
    'brandLabel' => 'My Company',
    'brandUrl' => Yii::$app->homeUrl,
    'options' => [
        'class' => 'navbar-inverse navbar-fixed-top',
    ],
]);
?>

<div class="wrap">
    <?php echo $content ?>
</div>

<?php
echo Nav::widget([
    'options' => ['class' => 'navbar-nav navbar-right'],
    'items' => $menuItems,
]);

NavBar::end();

?>

<footer class="footer">
    <?php echo "yuerblog.cc" ?>
</footer>

<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>

可見,模板文件也是一個普通php文件,只不過它寫了很多html標簽而已。在模板文件中可以直接訪問$this,它代表了view對象,因為模板文件是在view對象的方法里require進來的,因此是可以直接訪問的,PHP腳本語言的確夠靈活。

對於布局模板來說,可以直接訪問$content獲取子模板的渲染結果,上面有所體現。另外,beginXXX和endXXX是很核心的函數,后續在assets和widget中會看到具體作用。

 assets資源管理

我們開發各種頁面的時候,一般都需要引入css和js文件,普通的做法就是在模板文件中直接通過<link>和<scipt>來引入就可以了。

現在假想一個問題:如果我們使用了布局文件的話,整個html的head部分是共用同一份代碼的,每個子模板依賴的css和js各不相同,這該怎么引入呢?

這其實就是beginPage,head,beginBody,endBody,endPage存在的意義了,它們相當於在布局文件的合適位置"先占上坑",以便子模板可以通過代碼控制向"坑"里填充需要的東西,也就是實現了父子模板之間的溝通,同時也是一種延遲填充的策略:先占坑,后填坑,從程序角度來講就是先寫占位符,后替換字符串。

知道了beginXXX,endXXX的意義后,那么assets的意義又是什么呢?它其實就是基於上述機制,通過創建assets類的方式,簡化引入css和js的工作,也就是不需要你再去寫<script>和<link>這種代碼了,這就是資源管理。

實現

回頭看上面的布局文件,里面有一行:

AppAsset::register($this);
在布局文件里直接引入這個assets類,說明它引入的資源是所有子模板都需要的,當然你可以在某個子模板里引入其他的assets。

AppAssets是自定義的,它繼承了基類AssetsBundle,配置了引入的資源:

class AppAsset extends AssetBundle
{
    public $basePath = '@webroot';
    public $baseUrl = '@web';
    public $css = [
        'css/site.css',
    ];
    public $js = [
    ];
    public $depends = [
        'yii\web\YiiAsset',
        'yii\bootstrap\BootstrapAsset',
    ];
}

可見,這里指定了css和js文件是相對於@webroot的,這里也就是相對於frontend/web,因此css文件應該部署在fronend/web/css/site.css,並且這里還依賴了2個其他的資源也會被遞歸包含。

那么register方法做了什么呢?最終結果,就是拼裝出site.css的url作為key,然后<link ...>標簽作為value,保存到view對象的一個屬性里暫存,用於后續"填坑"備用。

    public function registerAssetFiles($view)
    {
        $manager = $view->getAssetManager();
        foreach ($this->js as $js) {
            if (is_array($js)) {
                $file = array_shift($js);
                $options = ArrayHelper::merge($this->jsOptions, $js);
                $view->registerJsFile($manager->getAssetUrl($this, $file), $options);
            } else {
                $view->registerJsFile($manager->getAssetUrl($this, $js), $this->jsOptions);
            }
        }
        foreach ($this->css as $css) {
            if (is_array($css)) {
                $file = array_shift($css);
                $options = ArrayHelper::merge($this->cssOptions, $css);
                $view->registerCssFile($manager->getAssetUrl($this, $file), $options);
            } else {
                $view->registerCssFile($manager->getAssetUrl($this, $css), $this->cssOptions);
            }
        }
    }

如果追蹤代碼,會發現上述AppAssets::register最終進入了AssetsBundle基類的這個方法,它將自己的css和js逐個注冊到view方法中,這樣view中就采集了模板文件中所有assets引入的css和js文件,能夠做一個去重避免重復引入相同的文件,因為不同的assets可能引入相同的css or js文件,可以想到這樣也可以實現布局模板和子模板之間的相同資源去重,非常聰明。

另外,$js[]里的每個js文件可以通過position選項配置其引入的位置,也就是可以引入在beginBody之后,或者endBody之前,或者header里,這就體現了此前beginXXX的另外一個存在意義。

Widget

組件,這個東西其實和現在前端開發提倡的組件化開發是一個道理,只不過在PHP里是服務端渲染,因此組件是PHP代碼來實現的,最終運行時widget類輸出的其實就是html代碼了。

組件當然是為了復用性考慮,比如:封裝一個列表組件,然后通過傳入一個數組就可以渲染出<ul>列表了。

組件也有高度的內聚性,它內部可以使用其他widget,可以通過assets引入所需的css/js資源,它是自治的。

實現

回到之前的布局文件,里面用到了2個widget,一個是NavBar是導航列表,一個是Nav是導航項,前者體現了widget::begin,widget::end的widget用法,后者體現了widget::widget的用法,我們分別看看原理既可。

NavBar

當我們調用NavBar::begin()的時候,Widget基類會創建一個NavBar對象並推到數據結構stack中維護,這是因為begin和end是配對使用的,是允許嵌套出現的,例如NavBar中再嵌套一個NavBar,因此必須用stack維護,以便end和begin可以配對。

    public static function begin($config = [])
    {
        $config['class'] = get_called_class();
        /* @var $widget Widget */
        $widget = Yii::createObject($config);
        static::$stack[] = $widget;

        return $widget;
    }
這里注意,createObject實際上會創建NavBar對象並調用它的init,因此NavBar會在自己的init函數中輸出自己的開始標簽,比如:<ul>,同時也可以引入各種需要的assets或者注冊一些head信息到view,這樣后續"填坑"階段可以替換到html中,保證組件想要的東西都可以引入。

在NavBar::end()調用的時候,Widget基類會調用NavBar對象的run()方法,這時候NavBar會輸出自己的結束標簽,例如:</ul>。

    public static function end()
    {
        if (!empty(static::$stack)) {
            $widget = array_pop(static::$stack);
            if (get_class($widget) === get_called_class()) {
                echo $widget->run();
                return $widget;
            } else {
                throw new InvalidCallException('Expecting end() of ' . get_class($widget) . ', found ' . get_called_class());
            }
        } else {
            throw new InvalidCallException('Unexpected ' . get_called_class() . '::end() call. A matching begin() is not found.');
        }
    }

Nav

當我們調用Nav::widget()的時候,Widget基類會立即分配一個Nav對象,調用它的run方法,用ob_start捕獲它的輸出,通過返回值返回到模板文件中。

   public static function widget($config = [])
    {
        ob_start();
        ob_implicit_flush(false);
        try {
            /* @var $widget Widget */
            $config['class'] = get_called_class();
            $widget = Yii::createObject($config);
            $out = $widget->run();
        } catch (\Exception $e) {
            // close the output buffer opened above if it has not been closed already
            if (ob_get_level() > 0) {
                ob_end_clean();
            }
            throw $e;
        }

        return ob_get_clean() . $out;
    }

最后的填坑

當我們知道了view,assets,widget的原理之后,我們最后看一下"填坑階段",view是如何把此前在布局文件、子模板文件以及組件中注冊的css、js、head信息填充到最終html頁面中的吧。

占坑部分

簡單看一下占坑的原理。

  /**
     * Marks the beginning of a page.
     */
    public function beginPage()
    {
        ob_start();
        ob_implicit_flush(false);

        $this->trigger(self::EVENT_BEGIN_PAGE);
    }

此前,renderPhpFile中是在開啟了ob_start后require模板文件的,為什么view->beginPage再次開啟了ob捕獲呢?我想這主要是因為view需要在endPage的時候對html進行"填坑",因此需要在renderPhpFile之前捕捉到輸出。而renderPhpFile能不能免去ob_start()調用呢?不能,因為模板文件可以不使用beginXXX,endXXX,這種情況下輸出的捕捉還是要renderPhpFile來完成。

填坑部分

   public function endPage($ajaxMode = false)
    {
        $this->trigger(self::EVENT_END_PAGE);

        $content = ob_get_clean();

        echo strtr($content, [
            self::PH_HEAD => $this->renderHeadHtml(),
            self::PH_BODY_BEGIN => $this->renderBodyBeginHtml(),
            self::PH_BODY_END => $this->renderBodyEndHtml($ajaxMode),
        ]);

        $this->clear();
    }

在endPage里,從ob取出完整的html輸出后,對$content進行了一次內容替換,也就是"填坑"。它將html中的PH_HEAD,PH_BODY_BEGIN,PH_BODY_END三個占位符替換成了模板渲染過程中注冊到view中的js,css資源和head信息,那么PHP_HEAD這些占位符其實就是通過此前在布局文件中見到的head(),beginBody(),endBody()調用輸出的。

    /**
     * This is internally used as the placeholder for receiving the content registered for the head section.
     */
    const PH_HEAD = '<![CDATA[YII-BLOCK-HEAD]]>';
    /**
     * This is internally used as the placeholder for receiving the content registered for the beginning of the body section.
     */
    const PH_BODY_BEGIN = '<![CDATA[YII-BLOCK-BODY-BEGIN]]>';
    /**
     * This is internally used as the placeholder for receiving the content registered for the end of the body section.
     */
    const PH_BODY_END = '<![CDATA[YII-BLOCK-BODY-END]]>';

        /**
     * Marks the position of an HTML head section.
     */
    public function head()
    {
        echo self::PH_HEAD;
    }

    /**
     * Marks the beginning of an HTML body section.
     */
    public function beginBody()
    {
        echo self::PH_BODY_BEGIN;
        $this->trigger(self::EVENT_BEGIN_BODY);
    }

全文終。

如果閱讀本文后還有疑惑,可以根據魚兒的博客的引導閱讀源碼。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM