淺析Yii2的view層設計


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

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

基礎渲染

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

用法

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

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

布局模板和子模板的關系

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

1
2
3
4
5
public  function  render( $view $params  = [])
{
     $content  $this ->getView()->render( $view $params $this );
     return  $this ->renderContent( $content );
}

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

1
2
3
4
5
6
7
8
9
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庫實現數據的捕捉,實現非常簡單:

1
2
3
4
5
6
7
8
9
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里的數據了,這個函數最后將緩沖的數據全部取出返回,完成了模板的渲染。

舉個例子

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<?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>這種代碼了,這就是資源管理。

實現

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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對象的一個屬性里暫存,用於后續"填坑"備用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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可以配對。

1
2
3
4
5
6
7
8
9
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>。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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捕獲它的輸出,通過返回值返回到模板文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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頁面中的吧。

占坑部分

簡單看一下占坑的原理。

1
2
3
4
5
6
7
8
9
10
/**
    * 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來完成。

填坑部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()調用輸出的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
  * 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