Живой 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->renderPrtial(...) можно порой пойти на некоторые хитрости.

Стандартное включение – указание имени файла представления:

$this->render('index', array('model'=>$model));

Если представлений много, то их можно разложить по папкам и вызывать по относительному пути, используя точку или слэш:

<?php $this->renderPartial('_filter'); ?>
<?php $this->renderPartial('forms/_form1'); ?>
<?php $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 http://www.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) шаблон выбирается из вариантов

/layouts/blog_post_view.php
/layouts/blog_post.php
/layouts/blog.php

И если ни в теме, ни в модуле ни одного из них не нашлось, то используется default.php из темы или ядра.

Ответить

 

icemen

Вы будете смеяться, но вы написали единственный толковый пост по темизации YII во всем рунете). Разрешите остаться вашим преданным читателем.

Ответить

 

Дмитрий Елисеев

Ухты! Разрешаю :)

Ответить

 

Евгений

Нет, действительно замечательный, думаю почти полностью исчерпывающий пост. Прекрасно разясняете!)

Кстате в чем-то теперь способ выбора шаблона напоминает Drupal-овскую систему)

Ответить

 

Igor

А куда пихать код системы автоподгрузки шаблонов?

Ответить

 

Дмитрий Елисеев

В отдельный класс. Потом подключить к базовому контроллеру как поведение и вызывать его в beforeRender. Всё как в последнем примере кода.

Ответить

 

Игорь

Глупый вопрос, а класс в какую папку пихать?

Ответить

 

Дмитрий Елисеев

protected/components

Ответить

 

Igor

Еще вопросик. А как заменить шаблоны которые в project/protected/views/layouts/column1.php или column2.php
В смысле в какю папку в themes.
Я правильно понимаю project/protected/themes/theme_name/views/layouts/column1.php?

Ответить

 

Igor

Все заработало!

Ответить

 

almix

Дмитрий, спасибо за классную статью. Какой вариант лучше, если несколько сайтов и они используют одну и ту же тему, но различаются их 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+ лет. Копипастить всё в ещё одну статью не вижу смысла.

Ответить

 

Игорь Мастер

Нет, так нет .

Ответить

Оставить комментарий

Войти | Завести аккаунт


(никто не увидит)



Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>