Продолжаем создавать виджет в yii2. Добавим события и замыкания.
  • 1509

Создание виджета популярных материалов в yii2. (часть 2)

Автор: admin | 19 июня (Вт.) 2018г. в 17ч.44м.

Продолжаем создавать виджет для вывода популярных материалов блога. Если Вы не читали
первую часть урока, то прочитайте обязательно, чтобы было понятно о чем идет речь.

Вот краткое содержание данной статьи:

- На сколько гибким должен быть виджет. Настройки.
- Как работать с моделью в виджете.
- Начинаем разработку.
- Использование анонимных функций и замыканий.
- Добавление событий в виджет.
- Вызов виджета. Установка параметров.
- Вывод.

Ну прежде, чем приступить к улучшению нашего виджета напомню, что этот урок практический.
Поэтому, если у Вас не хватает теоретических знаний, а именно - что такое виджет в yii2 и какая
его основная структура, как выводить виджет в файлах видов, то лучше сначала ознакомиться
с данной информацией в документации по yii2.
Ну а моя задача - показать создание виджета на практическом примере.
Приступим.

Вот такой блок будет собирать виджет:
Виджет yii2 для популярных статей.

На сколько гибким должен быть виджет. Настройки.

Этот виджет создается для многоразового использования. Значит он должен быть спроектирован так,
чтобы можно было передавать данные для любых видов материалов(статьи блога, новости, товары для интернет магазина).

Главное, чтобы материалы соответствовали требованиям:
- Должна быть картинка, ссылка на материал, заголовок, категория и дата создания.

- Если в виджете используется сетка bootstrap для адаптивности, то нужно также предусмотреть настройку размера колонок путем изменения классов каждого элемента.

- Должна быть возможность установки заголовка всей секции материалов.

- Еще добавим события в виджет для момента, перед генерацией каждого блока материала, и
после генерации, для того, чтобы можно было например устанавливать в html clear блок в нужном
месте (т.к. если блоки будут разной высоты, то они будут выстраиваться "ступеньками").
<div class="clearfix visible-lg visible-md"></div>

Для нашего виджета этогих настроек достаточно, ну а расширить функционал при желании можно позже.

Как работать с моделью в виджете.

Но как это сделать, если в виджет передается выборка нескольких записей по какому-то условию
$sql = BlogArticles::find()->where(['status' => 1])->limit(6)->orderBy(['viewCount' => SORT_DESC]);​

А потом блоки в файле вида виджета views/index.php выводятся в цикле:

<?php foreach($query as $q): ?>
    <div class="col-sm-4 col-xs-6 linkbox-elem" data-link="<?= Url::toRoute(['/blog/article', 'alias' => $q->alias]);?>"> 
        <div class="linkbox-elem-header">
            <?php echo Html::img("@img-web-blog-path/{$q->image}", ['alt' => '', 'title' => '', 'class' => 'linkbox-elem-img']); ?>
        </div>
        //и т.п.

Ведь если этот виджет применить для вывода товаров или новостей, то все переменные будут
названы по другому, да и урл явно не из той оперы:

Url::toRoute(['/blog/article', 'alias' => $q->alias]);

Не создавать же кучу виджетов или отдельных файлов видов в виджете, как articles.php,
news.php и т.п. со своими переменными, ссылками и путями к картинкам.

Нет мы пойдем другим путем.
Можно подготавливать данные заранее, создавая где-нибудь в контроллере массив и уже его передавать
в виджет. Для создания массива нужно в цикле перебрать выборку и сохранить все данные по каким-то
стандартизированным для виджета ключам:

$sql = BlogArticles::find()->where(['status' => 1])->limit(6)->orderBy(['viewCount' => SORT_DESC]);
$arrayToWidget = [];
foreach($sql as $index => $q){
    $arrayToWidget[index]['url'] = Url::toRoute(['/blog/article', 'alias' => $q->alias]);
    $arrayToWidget[index]['link'] = $q->alias;
    //и т.п.
} 

Так же делать и для других видов материалов, используя такие же названия ключей. Тогда в виджете
все переменные будут иметь стандартизированные имена.

    <?php foreach($arrayToWidget as $q): ?>
    <div class="col-sm-4 col-xs-6 linkbox-elem" data-link="<?= $q['url']);?>"> 
        //и т.п.

Недостаток в таком подходе очевиден - необходимость дополнительной подготовки данных, увеличение кода
и еще раз нужно перегонять все данные в цикле.

Мы пойдем другим путем. Эти данные будем вставлять прямо при создании виджета в его параметрах.
Для этого будем использовать анонимную функцию. Данный прием еще называется "замыкание".

Начинаем разработку.

Самое первое, что я сделаю, это разделю html шаблон виджета на два файла. Первый - заголовок
всей секции материалов _header.php, второй - вид отдельного блока материала (без цикла) _item.php.
_header.php:
<div class="linkbox-name">
    <h6 class="h_style_decoration"><?= $headerText; ?></h6> 
</div>
_item.php:
<!--<div class="col-md-4 col-sm-6 linkbox-elem" data-link="<?php //echo $link; ?>">--> 
    <?php if(isset($image)): ?>
    <div class="linkbox-elem-header">
        <?= $image; ?>
    </div>
    <?php endif; ?>
    
    <div class="linkbox-elem-footer">
        <?php if(isset($category)): ?>
        <p class="linkbox-elem-category">
            <a href=""><?= $category; ?></a>
        </p>
        <?php endif; ?>
        
        <?php if(isset($title)): ?>
        <p class="linkbox-elem-title">
            <a href=""><?= $title; ?></a>

        </p>
        <?php endif; ?>
        
        <?php if(isset($date)): ?>
        <p class="linkbox-elem-data">
            <span><?= $date; ?></span>
        </p>
        <?php endif; ?>
    </div>
<!--</div>-->

Тут сразу выводятся переменные в нужных местах, которые мы потом будем перодавать из класса виджета.
Обратите внимание, что в _item.php корневой блок закомментирован и цикла никакого нет.
Так нужно потому, что цикл будет в классе виджета, тамже будет формироваться корневой блок и
в него будет вставляться этот шаблон с передачей переменных сюда.

Файлы видов подготовлены. Теперь займемся виджетом. Сначала установим все необходимые свойства и
опишем метод init():

<?php
namespace common\widgets\materialList;

use Yii;
use yii\base\InvalidConfigException;
use yii\base\Widget;
use yii\helpers\Html;
use yii\helpers\Url;
use yii\web\View;
use yii\helpers\ArrayHelper;
use Closure;

/**
 * Виджет 
 */

class MaterialListWidget extends Widget
{
    /**
     * @var string 
     * Содержит идентификатор виджета. 
     * Применяется для присвоения корневому элементу html виджета имени атрибута id.
     */
    public $widgetId;
    
    /**
     * @var \yii\db\ActiveQuery
     * Тут хранится запрос без вызова all()
     */
    public $query;
    
    /**
     * @var string 
     * Содержит текст, который будет выводиться если материалов выбрано не будет по данному запросу.
     */
    public $emptyText;
    
    /**
     * @var array Массив, который содержит настройки тега обертки текста $emptyText
     */
    public $emptyTextOptions = ['class' => 'empty'];
    
    /**
     * @var string Содержит шаблон, по которому будут выстраиваться файлы видов. Так как у нас 
     * два вида -_header.php и  _item.php, то и в шаблоне две части. Фигурные скобки нужны для того,
     * чтобы потом использовать их в регулярном выражении для идентификации раздела.
     */
    public $layout = "{header}\n{items}";
    
    /**
     * @var string Имя файла вида для одного блока материала
     */
    public $itemView = "_item";
    
    /**
     * @var string Имя файла вида для заголовка секции
     */
    public $headerView = "_header";
    
    /**
     * @var array Это настройки тега обертки для блока материала, который был закомментирован в 
     * файле вида _item.
     */
    public $itemOptions = ['class' => 'col-md-4 col-sm-6 linkbox-elem'];
    
    /**
     * @var array содержит имена переменных, которые будут использованы в файле вида.
     */
    public $itemViewParams = ['image', 'category', 'title', 'date', 'link'];
    
    /**
     * @var (string|callable) Текст заголовка всей секции виджета.
     */
    public $headerText;
    
    /**
     * @var string - константа содержит имя события перед тем, как будет выводиться каждый отделтный 
     * блок материала в цикле.
     */
    const EVENT_BEFORE_RENDER_ITEM = 'beforeRenderItem';
    
    /**
     * @var string - константа содержит имя события после того, как будет выводиться каждый отделтный 
     * блок материала в цикле.
     */
    const EVENT_AFTER_RENDER_ITEM = 'afterRenderItem';
    
    /**
     * @var string разделитель для добавления в html коде.
     */
    public $separator = "\n";
    
    public function init()
    {
        parent::init();

        if ($this->query === null) {
            throw new InvalidConfigException('The "query" property must be set.');
        }
        if ($this->emptyText === null) {
            $this->emptyText = Yii::t('yii', 'No results found.');
        }
        if (!($this->widgetId)) {
            $this->widgetId = $this->getId();
        }
        
    }

Код хорошо закомментирован, поэтому разбирать не будем, чтобы не запутаться.

Далее переходим к методу run(), который будет формировать виджет.

public function run()
{
    
    $this->registerAssets();

    if ($this->query->count() > 0) {
        $content = preg_replace_callback('/{\\w+}/', function ($matches) {
            $content = $this->renderSection($matches[0]);

            return $content === false ? $matches[0] : $content;
        }, $this->layout);
    } else {
        $content = $this->renderEmpty();
    }


    $row =  Html::tag('div', $content, ["class" => "row linkbox", "id" => $this->widgetId]);
    echo Html::tag('div', $row, ["class" => "container-fluid"]);

}   

/**
* Register assets.
*/
protected function registerAssets()
{
   $view = $this->getView();
   MaterialListAsset::register($view);

}

Самое первое - это регестрируем стили и скрипты. Для этого создан отдельный метод registerAssets()

Далее проверяем, если считаем количество результатов в запросе. Если больше нуля, то строим
список материалов, если результатов нет, формируем блок с текстом, что материалов нет в $this->renderEmpty()
и далее выводим его внутри динамичнски созданных блоков div, которые являются родительскими
для всего виджета:

//run()
//...    
} else {
        $content = $this->renderEmpty();
    }

$row =  Html::tag('div', $content, ["class" => "row linkbox", "id" => $this->widgetId]);
echo Html::tag('div', $row, ["class" => "container-fluid"]);  

Метод renderEmpty(); выглядет так:

public function renderEmpty()
{
    if ($this->emptyText === false) {
        return '';
    }
    $options = $this->emptyTextOptions;
    $tag = ArrayHelper::remove($options, 'tag', 'div');
    return Html::tag($tag, $this->emptyText, $options);
}

Теперь вернемся к первой части if, когда записи в результате запроса есть и формируется
список материалов:

$content = preg_replace_callback('/{\\w+}/', function ($matches) {
    $content = $this->renderSection($matches[0]);

    return $content === false ? $matches[0] : $content;
}, $this->layout);

Тут также как и с текстом в renderEmpty() в переменную $content попадает весь сгенерированный
контент, который потом оборачивается двумя дивами и выводится как результат работы виджета.

Как происходит генерация. Используется функция preg_replace_callback для поиска по шаблону '/{\\w+}/'.
Ищем в строке $this->layout, что содержит наш шаблон:

/**
* @var string Содержит шаблон, по которому будут выстраиваться файлы видов. Так как у нас 
* два вида -_header.php и  _item.php, то и в шаблоне две части. Фигурные скобки нужны для того,
* чтобы потом использовать их в регулярном выражении для идентификации раздела.
*/
public $layout = "{header}\n{items}";  

В $matches[0] попадает '{header}' и потом '{items}', которые устанавливаются как параметры для 
метода  $content = $this->renderSection($matches[0]);

Далее рассмотрим этот метод:
    
public function renderSection($name)
{
    
    switch ($name) {
        case '{header}':
            return $this->renderHeader();
        case '{items}':
            return $this->renderItems();

        default:
            return false;
    }
}  

Таким образом шаблон формируется в очередности с public $layout = "{header}\n{items}" .

 $this->renderHeader() формирует заголовок блока всего виджета:

/**
* @var string Имя файла вида для заголовка секции
*/
public $headerView = "_header";    
    
public function renderHeader()
{        
    if(!$this->headerText){
        $content = '';

    }elseif (is_string($this->headerText)){
        $content =  $this->render($this->headerView, ['headerText' => $this->headerText]);
    }else {
        $content = call_user_func($this->headerView, $this->query);
    }

    return $content;
} 

Тут то и используется $headerText:

/**
* @var (string|callable) Текст заголовка всей секции виджета.
*/
public $headerText;  

Использование анонимных функций и замыканий.

Обратите внимание на вызов функции с помощью конструкции call_user_func. Тут мы передаем в анонимную функцию
которая содержится в $this->headerView результат запроса $this->query
 call_user_func($this->headerView, $this->query);

Вот тут работает замыкание. В $this->headerText может содержаться не только строка, но и
анонимная функция, которая передается при создании экземпляра виджета.
<?= MaterialListWidget::widget([
    'query' => BlogArticles::find()
                    ->active()
                    ->category($article->idCategory)
                    ->limit(6)
                    ->orderViewCount(),
    'headerText' => 'Также по теме:',
    
    //или так
    ....
    
    'headerText' => function($model){
        return 'Лучшие материалы. Всего -'.$model->count().' шт.'
    },
    
    ....​

Тут в переменную $model попадает $this->query и поэтому есть возможность посчитать $model->count()
(кстати, также благодаря тому, что запрос был без ->all()).

Теперь разберем формирование блоков материалов в функции $this->renderItems().

public function renderItems()
{
    //формируем выборку
    $models = $this->query->all();
    
    //инициализируем массив, который будет содержать все блоки материалов
    $rows = [];
    
    //создаем отдельный счетчик каждой итерации
    $index = 0;
    
    //перебираем результат запроса
    foreach ($models as $model) {

        //тут устанавливаем триггер события перед рендерингом отдельной записи
        if (!$this->beforeRenderItem($model, $index)) {
            continue;
        }

        //тут собираем отдельный блок материала
        $rows[] = $this->renderItem($model, $index);

        //тут устанавливаем триггер события после рендеринга отдельной записи
        if (($after = $this->afterRenderItem($model, $index)) !== null) {
            $rows[] = $after;
        }

        //увеличиваем счетчик на еденицу
        $index += 1;
    }

    //разбиваем блоки в массиве с помощью разделителя \n
    return implode($this->separator, $rows);
}           

В массив $rows[] = $this->renderItem($model, $index); попадает уже созданный блок одного материала. Вот такой вид имеет отдельный блок:
Отдельный блок материала виджета yii2.

Добавление событий в виджет.

События вызываются в методах $this->beforeRenderItem($model, $index) и $this->afterRenderItem($model, $index)
Вот как они реализованы:
public function beforeRenderItem($model, $index)
{
    $event = new WidgetEvent();
    $event->model = $model;
    $event->indexElement = $index;
    $this->trigger(self::EVENT_BEFORE_RENDER_ITEM, $event);
    return $event->isValid;
}

public function afterRenderItem($model, $index)
{
    $event = new WidgetEvent();
    $event->result = null;
    $event->model = $model;
    $event->indexElement = $index;
    $this->trigger(self::EVENT_AFTER_RENDER_ITEM, $event);
    return $event->result;
} ​

Создадим класс WidgetEvent в папке виджета:

<?php
namespace common\widgets\materialList;

use yii\base\Event; 

class WidgetEvent extends Event
{
    /**
     * @var mixed the widget result. Event handlers may modify this property to change the widget result.
     */
    public $result;
    
    public $model;
    /*
     * Current index in element
     */
    public $indexElement;
    /**
     * @var bool whether to continue running the widget. Event handlers of
     * [[Widget::EVENT_BEFORE_RUN]] may set this property to decide whether
     * to continue running the current widget.
     */
    public $isValid = true;
    
    
}

Обработчик события будет устанавливаться (если нужно) при создании виджета:

<?= MaterialListWidget::widget([
    'query' => BlogArticles::find()
                    ->active()
                    ->category($article->idCategory)
//                    ->limit(6)
                    ->orderViewCount(),
    'headerText' => 'Также по теме:',
    
....
    
    'on beforeRenderItem' => function ($event) use ($article){
        if($event->model->id === $article->id){//чтобы не отобр. текущая запись
            $event->isValid = false;
        }
        
        
    },            
    'on afterRenderItem' => function ($event) {
        $index = $event->indexElement + 1;
        
        if($index % 3 == 0){
            $event->result = '<div class="clearfix visible-lg visible-md"></div>';
        }else if($index % 2 == 0){
            $event->result = '<div class="clearfix visible-sm"></div>';
        }
    },           
    
]); ?> 

Событие 'beforeRenderItem' в данном примере позволяет не выводить блок материала по условию.
Например, если на странице статьи выводится блок популярных статей, чтобы в нем не выводилась
текущая статься $article->id.

$event содержит объект WidgetEvent, который служит неким транзитным хранилищем между обработчиком и
триггером. Для того, как устроены события почитайте официальную документацию yii2.

Событие 'afterRenderItem' позволяет внедрить любой код html после создания одного блока
материала. В данном примере добавляется блок для запрета обтекания в зависимости от того,
какой index текущего блока (тот самый счетчик из renderItems())

Осталось рассмотреть формирование каждого блока материала в методе $this->renderItem($model, $index):

//тут собираем отдельныйе блоки материалов
$rows[] = $this->renderItem($model, $index);

Вот он:

/**
 * @var \yii\db\ActiveQuery $model
 * @var int $index
 * return string
 */
public function renderItem($model, $index)
{
    $params = [];
    
    //Перебираем массив с ключами, заданными в свойстве $itemViewParams = ['image', 'category', 'title', 'date', 'link'];
    //В данные ключи попадают значения функций (замыканий), которые устанавливаются при создании виджета
    foreach($this->itemViewParams as $param => $value){
        //Если значение является замыканием, то запускаем функцию и передаем в нее $model
        if($value instanceof Closure){
            //Сохраняем во вновь созданном массиве результат функции, где ключем будет одно из значений
            //['image', 'category', 'title', 'date', 'link'];
            //Эти самые значения будут передаваться потом в файл вида _item как переменные
            $params[$param] = call_user_func($value, $model);

        //Если строка, то также сохраняем    
        }else if(is_string($value)){
            $params[$param] = $value;
        }
    }

    //Тут обробатывается настройка корневого элемента html
    //$this->itemOptions должен содержать атрибуты тега, такие как класс, data атрибут и т.п.
    if ($this->itemOptions instanceof Closure) {
        $options = call_user_func($this->itemOptions, $model, $index);
    } else {
        $options = $this->itemOptions;
    }

    //По умолчанию наш wrapper над отдельным материалом является элементом div
    //Но если указать при передаче параметров в илдет 'tag' => 'p', то 
    //Обертка будет <p>...</p>
    $tag = ArrayHelper::remove($options, 'tag', 'div');
    
    //Тут 'data-link' ставится в виджете, т.к. в скрипте это поле задействовано в обработке by design
    $options['data-link'] = array_key_exists('link', $params) ? $params['link'] : Url::current();

    //Здесь рендерится шаблон _item.php и в него передаются переменные из массива params
    //в привычном для рендеринга виде ключ => значение
    $content = $this->render($this->itemView, $params);

    //Далее с помощью хелпера формируем корневой блок и передаем в него
    //результат рендеринга шаблона
    return Html::tag($tag, $content, $options);
}
Код закомментировал, поэтому лишний раз повторяться не буду.
<?= MaterialListWidget::widget([
    'query' => BlogArticles::find()
                    ->active()
                    ->category($article->idCategory)
                    ->limit(6)
                    ->orderViewCount(),
    'headerText' => 'Также по теме:',
//    'itemOptions' => function($model){return ['class' => 'col-md-3 col-sm-6 linkbox-elem'];},
    'itemViewParams' => [
        'image' => function($model){
            return Html::img("@img-web-blog-posts/{$model->id}/middle/{$model->faceImg}", ['alt' => '', 'title' => '', 'class' => 'linkbox-elem-img']);;
        }, 
        'category' => function($model){
            return $model->hasCategory() ? $model->category->title : 'Без категории.';
        }, 
        'title' => function($model){
            return $model->title;
        }, 
        'date' => function($model){
            return \Yii::$app->formatter->asDateTime($model->createdAt, $pattern = 'php:d. M Y');
        },
        'link' => function($model){
            return Url::toRoute(['/blog/article', 'alias' => $model->alias]);
        },        
    ],
    'on beforeRenderItem' => function ($event) use ($article){
        if($event->model->id === $article->id){//чтобы не отобр. текущая запись
            $event->isValid = false;
        }
        
        
    },            
    'on afterRenderItem' => function ($event) {
        $index = $event->indexElement + 1;
        
        if($index % 3 == 0){
            $event->result = '<div class="clearfix visible-lg visible-md"></div>';
        }else if($index % 2 == 0){
            $event->result = '<div class="clearfix visible-sm"></div>';
        }
    },           
    
]); ?>​

Вывод.

В этой статье я постарался объяснить как создавать виджет, в который нужно передавать
набор данных из базы, и как используя замыкания присваивать эти данные переменным в цикле
извне, без правки кода самого виджета.
Дальше можно улучшить этот виджет, сделать много полезных изменений, чтобы улучшить структуру кода
и обеспечить большую гибкость в настройке. Но пока остановимся на этом. Продолжение следует...

Читайте также из этой серии:

Приветствую!

Меня зовут Сергей. Я - автор этого блога.

Если Вам был полезен материал на моем сайте, поддержите пожалуйста мой проект, чтобы о нем узнали другие люди - кликните plizz :) на иконку в соц. сети, чтобы поделиться материалом с другими.