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); }
全文終。
如果閱讀本文后還有疑惑,可以根據魚兒的博客的引導閱讀源碼。