Свой виджет для Yii2: чек-лист перед продакшеном
Виджет собирается за час, а потом ломает страницу лишними SQL-запросами и дублирует ассеты на каждой итерации цикла. Разбираем, что проверить перед тем, как выкатывать свой Widget в бой.

Свой виджет в Yii2 пишется быстро: наследуешь yii\base\Widget, кладёшь метод run(), вызываешь через ::widget() в шаблоне — работает. Проблемы приходят позже. Виджет, который дёргает базу в каждой итерации цикла, регистрирует один и тот же JS десять раз или падает на пустых данных, на демо не виден, а под трафиком превращается в источник тормозов и битой вёрстки. Ниже — что проверить в своём виджете до того, как он попадёт в прод.
Материал для Yii2 (2.0.x). Про Yii3 и его компонентный подход отдельно — здесь только классический yii\base\Widget. Инвалидацию кеша и написание тестов не разбираем, фокус на самом коде виджета и его поведении под нагрузкой.
Что вообще проверяем
Виджет — это не просто функция, которая печатает HTML. У него есть жизненный цикл (init() → run()), доступ к базе через модели, регистрация ассетов через View и потенциальная многократность вызова на одной странице. Каждый из этих пунктов — место, где виджет ведёт себя не так, как ожидаешь.
Разложим на семь блоков и пройдём по каждому.
1. Валидация входных свойств в init()
Свойства виджета задаются снаружи, при вызове ::widget(['count' => $x]). Если $x придёт из запроса или из непроверенного источника, а вы полагаетесь, что там всегда положительное число, — под реальными данными виджет либо упадёт, либо тихо отдаст мусор.
Проверять входы нужно в init(), а не в run(): init() вызывается первым и предназначен для инициализации. Обязательные свойства без значения по умолчанию — через исключение.
use yii\base\Widget;
use yii\base\InvalidConfigException;
class LatestPostsWidget extends Widget
{
// Сколько записей показывать
public $count = 5;
// Обязательный ID категории
public $categoryId;
public function init()
{
parent::init();
if ($this->categoryId === null) {
throw new InvalidConfigException('Свойство "categoryId" обязательно.');
}
// Ограничиваем сверху, чтобы вызов с count=10000 не положил базу
$this->count = min(max((int) $this->count, 1), 50);
}
}
Проверка на этапе init() даёт понятную ошибку разработчику вместо загадочного пустого блока в проде.
2. Запросы к базе: не в цикле и с limit
Самая частая проблема продакшена. Виджет выводит список, внутри для каждого элемента подтягивает связанные данные отдельным запросом — и вы получаете N+1. На демо с тремя записями это незаметно, на боевой странице с виджетом в сайдбаре каждого из тысячи URL — заметно очень.
Тяните связи через with() и всегда ставьте limit(). Виджет не должен уметь выбрать всю таблицу.
public function run()
{
// with() грузит автора одним доп. запросом на всю выборку, а не по одному на строку
$posts = Post::find()
->where(['category_id' => $this->categoryId, 'status' => Post::STATUS_ACTIVE])
->with('author')
->orderBy(['created_at' => SORT_DESC])
->limit($this->count)
->all();
return $this->render('latest-posts', ['posts' => $posts]);
}
Проверьте фактическое число запросов через панель отладки — расширение yii2-debug показывает их во вкладке Database. Если виджет на странице один, а запросов от него десяток, ищите обращение к связям внутри цикла в шаблоне.
Виджет, который на демо-странице делает три запроса, на витрине из тысячи товаров с тем же виджетом в блоке рекомендаций делает три тысячи. Считайте запросы там, где виджет реально живёт, а не там, где вы его тестируете.
3. Ассеты регистрируются один раз
Если виджет тянет свой CSS или JS, регистрируйте их через asset bundle, а не строкой в run(). Yii по умолчанию не дублирует один и тот же бандл: даже если виджет вызван на странице пять раз, файлы подключатся один раз.
А вот registerJs() с инлайновым кодом при пяти вызовах виджета отработает пять раз — и вы получите пять одинаковых обработчиков. Если инлайновый код всё же нужен, используйте ключ (третий аргумент), чтобы Yii склеил дубли:
use yii\web\View;
public function run()
{
$view = $this->getView();
// Бандл подключится один раз независимо от числа вызовов виджета
LatestPostsAsset::register($view);
// Ключ 'latest-posts-init' не даст задублировать инициализацию
$view->registerJs(
'initLatestPosts();',
View::POS_READY,
'latest-posts-init'
);
return $this->render('latest-posts', ['posts' => $this->getPosts()]);
}
4. Экранирование вывода
Виджет отдаёт HTML, и всё, что в него приходит из базы или из запроса, — потенциальный XSS. Внутри view-файла виджета данные, которые могут содержать пользовательский ввод, пропускайте через yii\helpers\Html::encode(). Не полагайтесь на то, что модель «всегда чистая».
- Текст и заголовки —
Html::encode($post->title); - Ссылки и атрибуты — через
Html::a()иHtml::tag(), они экранируют содержимое сами; - HTML из редактора, который вы намеренно выводите как разметку, — только после серверной санитизации (например, HTMLPurifier), а не сырым.
Отдельно: не собирайте SQL строками из свойств виджета. where(['category_id' => $this->categoryId]) параметризуется автоматически, а where("category_id = {$this->categoryId}") — нет.
5. Поведение на пустых данных
Что виджет отдаёт, когда записей нет? Пустой блок с рамкой и заголовком «Последние статьи» без единой статьи выглядит как баг. Решите заранее: либо не выводить обёртку вовсе, либо показать явное «пока пусто».
public function run()
{
$posts = $this->getPosts();
// Ничего не рисуем, если данных нет — не оставляем пустую рамку
if (empty($posts)) {
return '';
}
return $this->render('latest-posts', ['posts' => $posts]);
}
6. Кеширование результата
Если виджет считает что-то тяжёлое (агрегаты, топ продаж, счётчики) и данные меняются нечасто, оберните вывод во фрагментное кеширование. В Yii2 это делается на уровне view — через beginCache()/endCache() — или на уровне запроса через кеш-компонент.
// В view-файле виджета
<?php if ($this->beginCache('latest-posts-' . $categoryId, ['duration' => 300])): ?>
<?php foreach ($posts as $post): ?>
<div class="post"><?= \yii\helpers\Html::encode($post->title) ?></div>
<?php endforeach; ?>
<?php $this->endCache(); endif; ?>
Ключ кеша должен включать всё, от чего зависит вывод: ID категории, число записей, при необходимости — язык и роль пользователя. Кеш с ключом без учёта категории покажет одному разделу данные другого.
7. Читаемость шаблона и отсутствие логики в нём
Логику — в run() и приватные методы, во view только вывод. Если во view-файле виджета есть запросы к базе, ветвления по ролям и склейка строк — это неудобно ревьюить и легко сломать. View должен получать готовый массив и печатать его.
Сводный чек-лист
| Что проверить | Признак проблемы | Где чинить |
|---|---|---|
| Обязательные свойства | Пустой блок при отсутствии параметра | init(), исключение |
| N+1 запросы | Число запросов растёт с числом строк | with(), панель отладки |
| Лимит выборки | Виджет может выбрать всю таблицу | limit() в запросе |
| Дубли ассетов | Один JS подключён N раз | Asset bundle, ключ в registerJs() |
| XSS | Данные из БД выводятся сырыми | Html::encode() во view |
| Пустые данные | Рамка без содержимого | Ранний return '' |
| Кеш | Тяжёлый расчёт на каждый рендер | beginCache() с полным ключом |
Что делать дальше
Прогоните виджет через yii2-debug на странице, где он вызывается несколько раз, и посмотрите вкладку Database: число запросов не должно расти пропорционально числу вызовов. Дальше — вынесите виджет в отдельный namespace или пакет, если он используется в нескольких местах, и зафиксируйте версию Yii в composer.json, чтобы поведение Widget не менялось под ногами при обновлении. Инвалидацию фрагментного кеша при изменении данных настройте отдельно — через зависимости кеша (yii\caching\DbDependency или ручной сброс тега), это тема самостоятельного разбора.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Yii 2.0 Guide — Widgets, Yii 2.0 Guide — Fragment Caching
Частые вопросы
У меня Yii2 версии 2.0.13, а в примерах свежее — сработает?
Виджет всё равно делает много запросов после with() — что не так?
Инлайновый JS всё равно дублируется, хотя я добавил ключ
Нужно ли писать init(), если у виджета нет обязательных свойств?
Можно ли вызывать один виджет много раз на странице без проблем?
Где хранить view-файлы виджета?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.