Живой 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() по именам модуля/контроллера/действия:
То есть для страницы поста блога (BlogModule/PostController/actionView) шаблон выбирается из вариантов
И если ни в теме, ни в модуле ни одного из них не нашлось, то используется default.php из темы или ядра.
Вы будете смеяться, но вы написали единственный толковый пост по темизации YII во всем рунете). Разрешите остаться вашим преданным читателем.
Ухты! Разрешаю :)
Нет, действительно замечательный, думаю почти полностью исчерпывающий пост. Прекрасно разясняете!)
Кстате в чем-то теперь способ выбора шаблона напоминает Drupal-овскую систему)
А куда пихать код системы автоподгрузки шаблонов?
В отдельный класс. Потом подключить к базовому контроллеру как поведение и вызывать его в beforeRender. Всё как в последнем примере кода.
Глупый вопрос, а класс в какую папку пихать?
protected/components
Еще вопросик. А как заменить шаблоны которые в project/protected/views/layouts/column1.php или column2.php
В смысле в какю папку в themes.
Я правильно понимаю project/protected/themes/theme_name/views/layouts/column1.php?
Все заработало!
Дмитрий, спасибо за классную статью. Какой вариант лучше, если несколько сайтов и они используют одну и ту же тему, но различаются их layouts?
Можно добавить выбор по имени домена:
Дмитрий, у меня два вопроса:
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 для субдоменов добавьте только те файлы, которые нужно перекрыть.
Спасибо, сразу стало все понятно)
В DLiveLayoutBehavior стоит добавить проверку:
Тогда при наследовании Controller-а контроллерами из //protected/controllers не будет выдана ошибка, мол нет $owner->getModule()
public $layout - везде $ забыл:)
Спасибо! Исправил.
Спасибо, отличный пост, выручили!
Большое спасибо!
А есть для YII2?
По моему, вы не знаете что означает слово лень.
Ленивая это - качаем тему, устанавливаем
PROFIT!
Дмитрий, возможно ли написать статью про организацию контроллеров, сервисов к ним и репеозиториев с моделями?
Много чего об этом уже на форуме обсудили.
Там всего много, но все преподносится в разброс, не последовательно и больше похоже на дискуссию. Статья была бы очень актуальна, где без воды бы описывался принцип такой архитектуры.
Уже написаны кучи книг и статей по принципам программирования и архитектуре ПО за 20+ лет. Копипастить всё в ещё одну статью не вижу смысла.
Нет, так нет .