Живой Layout или Упрощаем темизацию в Yii

Каждый «ленивый» разработчик в глубине души желает упростить себе работу с создаваемой им системой. Как вы, наверное, заметили, в каждом совете этого блога автор руководствуется привычкой выносить всё общее и всё изменяемое в отдельные самодостаточные компоненты (в поведения, действия, валидаторы, фильтры, виджеты, переопределённые базовые классы). В итоге такая практика приводит к состоянию, когда порой несколько разнородных проектов работают на одном и том же коде с различием только в теме оформления. В этот раз поговорим именно о темах.
Использование тем в Yii
Известно, что для включения темы оформления необходимо указать её имя в параметре theme:
return array( 'basePath'=>dirname(dirname(__FILE__)), 'name'=>'My Site', 'theme'=>'classic', // ... );
Работа с темами описывается в руководстве, но мы рассмотрим несколько моментов и здесь.
Итак, если у нас есть контроллер ShopController
class ShopController extends Controller { public function actionIndex(){ $this->render('index'); } public function actionCategory($id){...} public function actionSearch(){...} public function actionCart(){...} public function actionShow($id){...} }
то мы можем положить представление index.php либо в исходную директорию protected/views:
protected/views/shop/index.php
либо в директорию представлений темы protected/views:
themes/classic/views/shop/index.php
Надеюсь, что Вы уже перешли на использование модулей в своих проектах, поэтому дальше будем говорить о них.
Пусть у нас имеется модуль shop, содержащий три контроллера:
class DefaultController extends Controller { public function actionIndex(){ $this->render('index'); } public function actionCategory($id){...} public function actionSearch(){...} } class ProductController extends Controller { public function actionShow($id){...} } class CartController extends Controller { public function actionIndex(){...} public function actionOrder(){...} }
Представления для модуля можно также помещать либо в папку views самого модуля, либо в папку views/shop темы.
Если в модуле есть файл представления
protected/modules/shop/views/default/index.php
то для его замены нужно создать одноимённый файл в папке темы:
themes/classic/views/shop/default/index.php
И при вызове
$this->render('index');
вместо представления модуля подставится представление из темы.
Здесь «/shop/» – имя модуля, а «/default/» – имя контроллера.
То есть если нам нужно сделать несколько тем для одного сайта (например, полная и мобильная версия) или несколько сайтов на одном движке, то достаточно создать несколько тем и перекрывать в них представления любых модулей.
Какие нюансы нам нужно знать
Используя $this->render(...) или $this->renderPartial(...) можно порой пойти на некоторые хитрости.
Стандартное включение – указание имени файла представления:
$this->render('index', array('model'=>$model));
Если представлений много, то их можно разложить по папкам и вызывать по относительному пути, используя точку или слэш:
<?php $this->renderPartial('_filter'); $this->renderPartial('forms/_form1'); $this->renderPartial('forms._form2');
Лучше использовать сразу слэши, так как иначе Yii попытается по точкам разобрать псевдоним (а именно будет пробовать разные варианты: искать модуль forms и т.д.).
Аналогично можно «гулять» по иерархиям папок:
<?php $this->renderPartial('../filters/_filter');
Можно в папке protected/views или themes/classic/views насоздавать папок и накидать туда общих шаблонов, и включать их вызовом от корня, используя два слэша:
<?php $this->renderPartial('//sidebars/shop_sidebar_with_categories');
Например, там удобно хранить лэйауты и сайдбары для них. Мы, кстати, это уже рассматривали для организации разных сайдбаров.
Представим, что на сайте есть комментарии для записей блога, для товаров и для новостей, а мы хотим управлять ими отдельно в разделе новостей, блогов и товаров.
В простейшем случае можно просто скопировать контроллеры и представления в каждый модуль. Но вместо этого можно использовать один общий контроллер и общие представления из модуля comment.
Создадим базовый класс CommentAdminControllerBase для контроллеров, управляющих комментариями, и создадим общее представление comment.views.commentAdmin.index:
// protected/modules/comment/components/CommentAdminControllerBase.php abstract class CommentAdminControllerBase { protected $type = ''; public function actionIndex() { $model = new Comment('search'); $model->unsetAttributes(); if(isset($_POST['Comment'])) $model->attributes = $_POST['Comment']; $model->type = $this->type; $this->render('comment.views.commentAdmin.index', array('model'=>$model); } }
Теперь в модулях блога, новостей и товаров поместим пустые контоллеры-наследники:
// protected/modules/blog/controllers/CommentAdminController.php Yii::import('comment.components.CommentAdminControllerBase'); class CommentAdminController extends CommentAdminControllerBase { protected $type = 'Post'; }
Теперь в каком бы модуле мы не управляли комментариями, будет использоваться для вывода единственное представление comment.views.commentAdmin.index.
Таким образом, мы можем использовать стандартную адресацию с использованием псевдонимов для подключения представлений, расположенных в другом месте.
Нужно иметь в виду тот факт, что при доступе к представлениям модулей по жёстким псевдонимам
'comment.views.commentAdmin.index' 'application.views.superview' 'web.foo.bar'не будет работать темизация. То есть не будет возможности переопределить файл
index.phpв теме. Если возникнет такая необходимость, то лучше полностью вынести ленту комментариев в файл в темеthemes/<имя_темы>/views/comment/commentAdmin/index.phpи подключать по пути от корня уже его:'//comment/commentAdmin/index'
От этого теперь можно перейти непосредственно к сути проблемы.
Где указывать layout в Yii
От отдельных представлений пора перейти к работе с шаблонами. На разных сайтах, да и порой в разных разделах одного сайта могут быть разные шаблоны. Стандартной пары column1.php и column2.php здесь явно не хватит. Где же хранить шаблоны? В общей папке layouts или внутри модулей? Где именно указывать, какой шаблон на нужен?
Рассмотрим несколько подходов к задаче и выберем наиболее удобный.
Указание шаблона в контроллере
Демо-блог и руководство учит нас указывать layout в контроллере. Это естественно, так как это на самом деле и есть публичное поле контроллера. Стандартное подключение шаблона column2.php из папки protected/views/layouts или themes/<theme>/views/layouts выглядит так:
class Controller extends СController { public $layout = '//layouts/column2'; }
Все наши контроллеры наследуются от Controller, поэтому мы можем легко изменить шаблон оформления для любого контроллера. Например, магазин мы выведем в шаблоне views/layouts/shop.php:
class ShopController extends Controller { public $layout = '//layouts/shop'; }
И даже больше: для корзины, заказа, поиска и страницы товара сделаем свои шаблоны:
class ShopController extends Controller { public $layout = '//layouts/shop/all'; public function actionIndex(){...} public function actionCategory($id){...} public function actionBrand($id){...} public function actionSearch(){ $this->layout = '//layouts/shop/search'; // ... } public function actionCart(){ $this->layout = '//layouts/shop/cart'; // ... } public function actionOrder(){ $this->layout = '//layouts/shop/order'; // ... } public function actionShow($id){ $this->layout = '//layouts/shop/show'; // ... } }
А ещё мы используем модули, поэтому можем запросто создать папку protected/modules/shop/views/layouts внутри модуля, накидать туда наши лэйауты и брать прямо оттуда:
class DefaultController extends Controller { public $layout = 'all'; public function actionIndex(){...} public function actionCategory($id){...} public function actionBrand($id){...} public function actionSearch(){ $this->layout = 'search'; // ... } } class ProductController extends Controller { public $layout = 'all'; public function actionShow($id){ $this->layout = 'show'; // ... } } class CartController extends Controller { public $layout = 'all'; public function actionIndex(){ $this->layout = 'cart'; // ... } public function actionOrder(){ $this->layout = 'order'; // ... } }
Теперь для другой темы можно переопределить необходимые файлы в папке темы, а именно в themes/<имя_темы>/views/shop/layouts.
Но что если в другой теме для другого сайта нужно указать специфический шаблон для вывода списка производителей (действие actionBrand)?
Придётся поместить строку
$this->layout = 'brand'
в метод DefaultController::actionBrand и добавить заглушку brand.php во все остальные сайты. чтобы на них не выскакивала ошибка, что представление brand.php не найдено.
Вот мы и подошли к проблеме. Одному сайту нужно «украсить» десять действий в трёх контроллерах, второму только корзину магазина, а третьему и одного шаблона хватит. Но всё равно в каждую тему нужно добавить десять. Если новая тема «захочет» использовать одиннадцатый лэйаут, то контроллер опять изменится, и во все остальные темы снова придётся добавлять одиннадцатую заглушку.
Указание шаблонов в представлении
Контроллеры у нас общие для всех сайтов, поэтому трогать их не стоит. А что если указывать шаблон в самих представлениях?
Действительно, в представлении themes/<theme>/views/shop/default/index.php присваиваем нужное значение полю $this->layout и всё:
<?php $this->layout = 'index'; $this->pageTitle = 'Магазин'; $this->breadcrumbs=array( 'Магазин', );
Теперь вместо themes/<theme>/views/layouts/column2.php главная страница каталога товаров выведется в шаблоне themes/<theme>/views/shop/layouts/index.php из папки модуля в теме.
Такое избавление от жёстко вписанных литералов в контроллере позволяет нам использовать один и тот же контроллер в разных сайтах без изменения его кода.
Но вот в чём парадокс:
Мы договорились раньше, что у нас есть три контроллера
class DefaultController extends Controller { public function actionIndex(){...} public function actionCategory($id){...} public function actionBrand($id){...} public function actionSearch(){...} } class ProductController extends Controller { public function actionShow($id){...} } class CartController extends Controller { public function actionCart(){...} public function actionOrder(){...} }
На самом деле в модуле интернет-магазина их может быть намного больше. Но так как присвоение значения полю $this->layout производится в представлениях, то чтобы изменить шаблон всего модуля магазина в нашей теме нам нужно переопределить абсолютно все представления всех наших контроллеров. То есть не один-два, а несколько десятков! И так при необходимости для каждого модуля.
Недостатки статической темизации
Итак, мы рассмотрели присваивание имени шаблона в контроллере и в представлении.
Первый способ слишком «хардкорный», так как литералы (имена шаблонов) вписаны прямо в код нашего приложения. А это сама по себе не очень хорошая практика в разработке системы, которую ожидается использовать в более чем одном проекте.
Второй способ более гибкий, так как представления легко переопределяются в теме (в отличие от контроллеров). Но представлений очень много, и порой их надо переопределять большими пачками, так как чтобы изменить тему любого раздела сайта нужно переопределить все представления этого модуля.
Какой может быть выход?
Выход – наличие возможности указывать шаблон вне конкретного контроллера и вне представления.
Это может быть таблица соответствия шаблонов конкретным модулям, контроллерам и действиям, оформленная в виде хэш-массива в конфигурационном файле:
array( 'Модуль1' => 'Шаблон1', 'Модуль1:Контроллер1' => 'Шаблон2', 'Модуль1:Контроллер1:Действие1' => 'Шаблон3', 'Модуль2' => 'Шаблон1', 'Модуль3:Контроллер1:Действие1' => 'Шаблон4', );
Соответственно, эта таблица должна храниться в файле, помещённом в папку темы, а базовый контроллер в событии beforeRender должен выбрать по этой таблице нужный шаблон.
Автозагрузка layout'ов
В идеале система темизации должна всё делать автоматически. С одноимёнными представлениями и шаблонами это так и происходит. Достаточно закинуть в папку с темой переопределённое представление, так оно сразу используется вместо оригинального.
По подобному пути «автоподхвата» мы и пойдём.
Для начала уберём присваивание $this->layout из контроллеров и из представлений.
В простейшем случае наш модуль магазина будет иметь такую структуру:
shop/
controllers/
DefaultController.php
ProductController.php
CartController.php
models/
views/
default/
product/
cart/
ShopModule.php
И в текущей теме мы переопределили, например, представление действия DefaultController::actionIndex:
images/
css/
views/
shop/
default/
index.php
А теперь было бы неплохо создать папку layouts для шаблонов и поместить их туда:
images/
css/
views/
shop/
default/
index.php
layouts/
shop.php
shop_default_search.php
shop_cart.php
Для удобства мы назвали файлы по принципу
<модуль> <модуль>_<контроллер> <модуль>_<контроллер>_<действие>
Такая нотация позволяет с одного взгляда понять, к чему должен применяться каждый шаблон: ко всему модулю, к конкретному контроллеру или к определённому действию.
Теперь напишем систему автоподгрузки шаблона:
/** * @author ElisDN <mail@elisdn.ru> * @link https://elisdn.ru */ class DLiveLayoutBehavior extends CBehavior { public function initLayout() { $owner = $this->getOwner(); if (empty($owner->layout)) { $theme = Yii::app()->theme->getName(); $module = $owner->getModule()->getId(); $controller = $owner->getId(); $action = $owner->getAction()->getId(); $cacheId = "layout_{$theme}_{$module}_{$controller}_{$action}"; if (!$owner->layout = Yii::app()->cache->get($cacheId)) { $layouts = array( "webroot.themes.{$theme}.views.{$module}.layouts.{$module}_{$controller}_{$action}", "application.modules.{$module}.views.layouts.{$module}_{$controller}_{$action}", "webroot.themes.{$theme}.views.{$module}.layouts.{$module}_{$controller}", "application.modules.{$module}.views.layouts.{$module}_{$controller}", "webroot.themes.{$theme}.views.{$module}.layouts.{$module}", "application.modules.{$module}.views.layouts.{$module}", "webroot.themes.{$theme}.views.layouts.default", "application.views.layouts.default", ); foreach ($layouts as $layout) { if (file_exists(Yii::getPathOfAlias($layout) . '.php')) { $owner->layout = $layout; break; } } Yii::app()->cache->set($cacheId, $owner->layout, 3600 * 24); } } } }
Это поведение добавляет к контроллеру метод initLayout, который, как мы видим, по очереди ищет подходящий шаблон в теме и в оригинальном модуле и присваивает первый найденный. Последние две строки списка должны указывать на шаблон по умолчанию. Также, чтобы не искать каждый раз заново, история найденных файлов кешируется.
Поведение мы должны подключить к базовому контроллеру и вызывать его метод перед генерацией страницы, то есть в событии beforeRender контроллера:
class Controller extends СController { public function behaviors() { return array_merge(parent::behaviors(), array( 'DLiveLayoutBehavior'=>array('class'=>'DLiveLayoutBehavior'), )); } protected function beforeRender($viev) { $this->initLayout(); return parent::beforeRender($viev); } }
Заметим, что метод сработает только когда
CController::layoutпустой. Так что Вы как и раньше сможете присвоить$this->layoutлюбое значение в контроллере или в представлении вручную и оно не перезапишется.
Если Вы не хотите возиться с поведением, то скопируйте код метода initLayout прямо в контроллер, заменив в нём $onwer на $this.
Итак, чтобы заменить шаблон магазина на сайте с двухколоночного на трёхколоночный, просто добавьте в тему один файл:
themes/<имя_темы>/views/shop/layouts/shop.php
А чтобы сделать страницу корзины во всю ширину добавьте один шаблон конкретно для соответствующего действия:
themes/<имя_темы>/views/shop/layouts/shop_cart_index.php
В итоге теперь шаблоны подхватываются из папки layuots самого модуля и из папки layuots темы этого модуля автоматически, и почти всё оформление любого из ваших сайтов можно настроить прямо в теме.
Виталий ИвановИнтересное решение
МихаилА где тогда используется файл с хэш массивом соответствий модулей/контроллера/действий шаблонам? В итоге ведь поведение ищет шаблон по маске имени файла...
Дмитрий ЕлисеевНигде, так как файлы ищутся прямо через file_exists() по именам модуля/контроллера/действия:
"layouts.{$module}_{$controller}_{$action}", "layouts.{$module}_{$controller}", "layouts.{$module}",То есть для страницы поста блога (BlogModule/PostController/actionView) шаблон выбирается из вариантов
И если ни в теме, ни в модуле ни одного из них не нашлось, то используется default.php из темы или ядра.
icemen – icemen.ruВы будете смеяться, но вы написали единственный толковый пост по темизации YII во всем рунете). Разрешите остаться вашим преданным читателем.
Дмитрий ЕлисеевУхты! Разрешаю :)
Евгений – e-aktec.orgНет, действительно замечательный, думаю почти полностью исчерпывающий пост. Прекрасно разясняете!)
Кстате в чем-то теперь способ выбора шаблона напоминает Drupal-овскую систему)
Igor – www.y-wave.ruА куда пихать код системы автоподгрузки шаблонов?
Дмитрий ЕлисеевВ отдельный класс. Потом подключить к базовому контроллеру как поведение и вызывать его в beforeRender. Всё как в последнем примере кода.
ИгорьГлупый вопрос, а класс в какую папку пихать?
Дмитрий Елисеевprotected/components
Igor – www.y-wave.ruЕще вопросик. А как заменить шаблоны которые в project/protected/views/layouts/column1.php или column2.php
В смысле в какю папку в themes.
Я правильно понимаю project/protected/themes/theme_name/views/layouts/column1.php?
Igor – www.y-wave.ruВсе заработало!
almix – loco.ruДмитрий, спасибо за классную статью. Какой вариант лучше, если несколько сайтов и они используют одну и ту же тему, но различаются их layouts?
Дмитрий ЕлисеевМожно добавить выбор по имени домена:
switch (Yii::app()->request->hostInfo) { case 'http://site1.com': $this->layout = '//layouts/layout1'; break; case 'http://site2.com': $this->layout = '//layouts/layout2'; break; }
kevin7Дмитрий, у меня два вопроса:
1) Файлы в папке site/views и site/themes/theme_name/views должны быть полностью одинаковыми или site/views должен только обращаться за всем необходимым к site/themes/theme_name/views?
2) Если у меня главный сайт и субдомены, тогда основная папка с темой должна находиться на главном сайте, а на субдоменах должно быть просто обращение к теме на главном сайте или как? (смысл: на главном сайте должна быть общая тема, а на субдоменах она может использоваться полностью или частично).
Дмитрий Елисеев1) Одинаковые не нужны. Yii ищет каждый файл сначала в теме, а если не находит, то берёт из protected/views. Как для render(), так и для renderPartial().
2) Yii не поддерживает наследование одной темы от другой. Общие файлы оставьте прямо в protected/views, тему главного сайта оставьте без файлов представлений, а в темы themes/subdomain/views для субдоменов добавьте только те файлы, которые нужно перекрыть.
kevin7Спасибо, сразу стало все понятно)
МаксимВ DLiveLayoutBehavior стоит добавить проверку:
if (empty($owner->layout) && ($owner->getModule() instanceof CModule)) {...}Тогда при наследовании Controller-а контроллерами из //protected/controllers не будет выдана ошибка, мол нет $owner->getModule()
Павелpublic $layout - везде $ забыл:)
Дмитрий ЕлисеевСпасибо! Исправил.
AkulenokСпасибо, отличный пост, выручили!
bobppsБольшое спасибо!
ИгорьА есть для YII2?
des1roerПо моему, вы не знаете что означает слово лень.
Ленивая это - качаем тему, устанавливаем
return array( 'theme'=>'shadow_dancer', );PROFIT!
Игорь МастерДмитрий, возможно ли написать статью про организацию контроллеров, сервисов к ним и репеозиториев с моделями?
Дмитрий ЕлисеевМного чего об этом уже на форуме обсудили.
Игорь МастерТам всего много, но все преподносится в разброс, не последовательно и больше похоже на дискуссию. Статья была бы очень актуальна, где без воды бы описывался принцип такой архитектуры.
Дмитрий ЕлисеевУже написаны кучи книг и статей по принципам программирования и архитектуре ПО за 20+ лет. Копипастить всё в ещё одну статью не вижу смысла.
Игорь МастерНет, так нет .