Свой виджет для Yii 2: типичные ошибки и как их избежать
Виджет в Yii 2 живёт дольше одного HTTP-запроса, если не следить за состоянием, ассетами и экранированием. Разбираем ошибки, из-за которых виджет ломается тихо и незаметно.

Вы вынесли повторяющийся блок в виджет, подключили его в двух местах на странице — и получили дублирующийся id в HTML, JavaScript, который применяется только к первому блоку, и XSS через параметр, который казался безопасным. Виджет в Yii 2 — это не просто функция, возвращающая строку. У него есть жизненный цикл, реестр ассетов и общий на весь запрос менеджер состояния. Ниже — приёмы, которые отделяют рабочий виджет от того, что ломается при втором вызове на странице. Материал для Yii 2.0.x; в Yii 1.1 иерархия классов другая.
Как виджет вообще работает
Базовый класс — yii\base\Widget. У него два способа запуска. Первый — статический вызов ::widget(), когда виджет сам рисует себя и возвращает строку. Второй — пара ::begin() и ::end(), когда виджет оборачивает произвольный контент между вызовами (так работает, например, ActiveForm).
При статическом вызове Yii создаёт объект, применяет переданные свойства, вызывает init(), затем run() и возвращает результат. Знать это важно, потому что большинство ошибок — следствие непонимания, что init() и run() вызываются в разное время и для разных задач.
Виджет — объект, а не хелпер. Он переживает несколько вызовов в рамках одного запроса, и всё, что вы положите в статическое свойство, доедет до следующего экземпляра на той же странице.
Ошибка 1. Захардкоженный id элемента
Самая частая. Вы пишете виджет-аккордеон, задаёте в шаблоне id="accordion" и вешаете на него JavaScript. Пока виджет на странице один — всё работает. Как только он вызван дважды, в DOM два элемента с одинаковым id, и поведение непредсказуемое.
У yii\base\Widget есть встроенный счётчик и метод getId(), который выдаёт уникальный автоинкрементный id вида w0, w1 для каждого экземпляра. Используйте его, а не свою строку.
<?php
namespace app\widgets;
use yii\base\Widget;
use yii\helpers\Html;
class Accordion extends Widget
{
/** @var array элементы: ['title' => ..., 'content' => ...] */
public $items = [];
public function run()
{
// getId() вернёт уникальный id для каждого вызова на странице
$containerId = $this->getId();
$rows = [];
foreach ($this->items as $item) {
$rows[] = Html::tag('div', Html::encode($item['title']), [
'class' => 'accordion-title',
]) . Html::tag('div', $item['content'], [
'class' => 'accordion-content',
]);
}
return Html::tag('div', implode('', $rows), ['id' => $containerId]);
}
}
По умолчанию автогенерация id включена. Если вы хотите разрешить пользователю задать свой id, объявите публичное свойство $id и подставляйте $this->getId() только когда оно не задано — getId() сам вернёт заданное значение, если оно установлено через сеттер.
Ошибка 2. Данные без экранирования
В примере выше $item['title'] прогоняется через Html::encode(), а $item['content'] — нет, потому что контент задуман как готовый HTML. Это осознанное решение, и оно должно быть задокументировано в PHPDoc свойства. Опасность в том, что в реальном коде такое разделение делают неявно: часть значений экранируют, часть забывают.
Правило простое: всё, что приходит в виджет как текст и попадает в разметку, экранируйте через yii\helpers\Html::encode(). Атрибуты тегов, которые вы передаёте вторым аргументом в методы Html, экранируются автоматически — руками их трогать не нужно и вредно (получите двойное экранирование).
- Текстовое содержимое тега —
Html::encode()вручную. - Значения атрибутов через
Html::tag($name, $content, $options)— экранируются сами. - Готовый HTML — только если источник доверенный, и это явно помечено в документации свойства.
Ошибка 3. Инициализация не там, где надо
Свойства виджета применяются после вызова конструктора, но до init(). Значит, в конструкторе публичных свойств ещё нет. Проверку и подстановку значений по умолчанию делайте в init(), а не в конструкторе.
public function init()
{
parent::init(); // вызывать обязательно — иначе не сработают behaviors
if (empty($this->items)) {
throw new \yii\base\InvalidConfigException(
'Свойство "items" обязательно.'
);
}
}
Забытый parent::init() — тихая ошибка: виджет вроде работает, но поведения (behaviors) и событие EVENT_INIT не срабатывают. Проявится это не сразу, а когда кто-то навесит на виджет поведение и оно молча ничего не сделает.
Ошибка 4. Ассеты, подключаемые через прямой тег
Соблазн вставить <script src="/js/accordion.js"> прямо в вывод run() велик. Так делать нельзя: при двух виджетах на странице скрипт подключится дважды, а порядок загрузки относительно jQuery и других зависимостей никто не гарантирует.
Правильный путь — Asset Bundle. Регистрируете бандл в run(), Yii сам подключит файл ровно один раз и в правильном месте.
<?php
namespace app\widgets;
use yii\web\AssetBundle;
class AccordionAsset extends AssetBundle
{
public $sourcePath = '@app/widgets/assets';
public $js = ['accordion.js'];
public $css = ['accordion.css'];
public $depends = [
'yii\web\JqueryAsset', // зависимость: jQuery загрузится раньше
];
}
В run() регистрируете бандл и, если нужно, инициализирующий JavaScript, привязанный к конкретному id:
public function run()
{
$view = $this->getView();
AccordionAsset::register($view);
$id = $this->getId();
// регистрируем инициализацию именно для этого экземпляра
$view->registerJs("jQuery('#{$id}').accordion();");
// ...возврат разметки
}
Даже если виджет вызван пять раз, файл accordion.js попадёт в страницу один раз, а registerJs добавит пять строк инициализации — каждую со своим id. Это ровно то поведение, которое нужно.
Ошибка 5. Логика в шаблоне вместо метода
Виджет умеет рендерить view-файлы через $this->render(). Это удобно, но провоцирует складывать в шаблон бизнес-логику: выборки, форматирование, ветвления. Шаблон должен получать готовые данные и только раскладывать их по тегам. Подготовку данных держите в run() или в отдельном приватном методе.
public function run()
{
return $this->render('accordion', [
'items' => $this->prepareItems(), // подготовка — в методе, не в шаблоне
'containerId' => $this->getId(),
]);
}
Файл views/accordion.php при рендере через виджет ищется относительно каталога виджета (getViewPath()), а не относительно текущего контроллера. Это частый источник путаницы: положили шаблон рядом с view контроллера — виджет его не находит.
Сравнение способов вывода
| Способ | Когда применять | Что учесть |
|---|---|---|
Строка в run() через Html | Простая разметка, пара тегов | Экранирование — вручную, разметка мешается с логикой |
$this->render() с view-файлом | Разметки много, нужен дизайнер | Шаблон ищется в каталоге виджета, не контроллера |
begin() / end() | Виджет оборачивает чужой контент | Между вызовами захватывается вывод через ob_start |
Если не получилось
- «Getting unknown property: app\widgets\Accordion::items» — свойство не объявлено как
publicлибо передаётся с опечаткой в ключе конфигурационного массива. - Виджет выводит пустоту —
run()ничего не возвращает. При::widget()строку нужно именноreturn, а неecho. Приbegin/end— наоборот, вывод идёт напрямую. - «The view file does not exist» — шаблон лежит не в каталоге
viewsрядом с классом виджета. Проверьте, что путь совпадает с тем, что возвращаетgetViewPath(). - JavaScript применился только к первому блоку — вы захардкодили id вместо
getId(), и селектор цепляет только первый элемент. - CSS/JS не подключились — забыли
AssetBundle::register()либоsourcePathуказывает на несуществующий каталог. Проверьте, что каталогweb/assetsдоступен на запись.
Что делать дальше
Когда базовый виджет работает, посмотрите на две вещи. Первая — вынесите повторно используемые виджеты в отдельный namespace и покройте run() хотя бы функциональным тестом через Codeception: рендер виджета легко ломается при рефакторинге разметки. Вторая — если виджет тяжёлый (делает запросы к БД), оберните его вывод во фрагментное кеширование через Widget с включённым свойством кеша или через $view->beginCache(); инвалидация фрагментного кеша — отдельная тема, здесь только обозначаю направление.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Yii 2.0 Guide — Widgets, Yii 2.0 Guide — Assets
Частые вопросы
У меня Yii 1.1, а не Yii 2 — эти приёмы подойдут?
Почему нельзя вставить script прямо в HTML виджета?
В чём разница между init() и run()?
Нужно ли вручную экранировать значения атрибутов тегов?
Виджет делает запрос к БД на каждый рендер — это нормально?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.