В блог
17 июня 2026 Sogerien affectsassetsдисциплинапроизводительностьшаблонизатор

Эффекты только через шаблонизатор - и почему это дисциплина, а не ограничение

Ключевой принцип этого сайта: инлайнить <script> и <link> сторонних библиотек прямо в разметку страницы запрещено. Не "не рекомендуется", а нельзя. Звучит как самоограничение - на деле это то, что держит ассеты под контролем.

Что ломается без единой точки

Как только каждая страница сама тащит свои <script>, происходит три вещи: одна и та же либа подключается дважды с разных страниц, порядок загрузки расходится, версии перестают совпадать. И самый тихий баг из всех: <script defer> в <head> исполняется после инлайновых скриптов в <body>. Инлайн-код, читающий глобал из defer-бандла, падает - без единой ошибки в консоли.

Три легальных канала

  • Affects::get_head_css_urls($domain) / get_head_js_urls($domain) - сторонние либы, привязанные к домену.
  • Скин - через тот же контракт get_head_css_urls() / get_head_js_urls(), если ассет нужен всем страницам этого макета.
  • Page()->add_head_css() / add_head_js() - точечно для одной страницы.

JS всегда описывается как {src, defer}, поэтому defer - централизованное решение, а не атрибут, забытый в одном из тегов:

final class Affects
{
    /** @return array<int, string> */
    public static function get_head_css_urls(string $domain): array
    {
        return match ($domain) {
            'crm.sogerien.top' => ['/vendor/apexcharts/apexcharts.css'],
            default            => [],
        };
    }

    /** @return array<int, array{src:string, defer:bool}> */
    public static function get_head_js_urls(string $domain): array
    {
        return match ($domain) {
            'crm.sogerien.top' => [
                ['src' => '/vendor/apexcharts/apexcharts.js', 'defer' => true],
            ],
            default => [],
        };
    }
}

Тяжёлый ассет не живёт в общем бандле. Апекс-чарты нужны одной странице - подключаем их на этой странице, а не в скине:

// Одна страница с графиком - подключаем точечно, не пихая либу в скин
// и не заставляя весь дашборд тянуть её на каждой вкладке:
Sogerien::Page()->add_head_js('/vendor/apexcharts/apexcharts.js', defer: true);
Sogerien::Page()->add_head_css('/page/dashboard/charts.css');

Это то же правило для мегабайтных артефактов: .wasm, движок canvas, тяжёлый виджет грузятся постранично, defer, вне общего вендорного бандла. Иначе весь дашборд платит трафиком за фичу с одной вкладки.

Как не надо и как надо

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

<!-- ЗАПРЕЩЕНО: сторонняя библиотека инлайном в теле страницы -->
<link rel="stylesheet" href="/vendor/apexcharts/apexcharts.css">
<script src="/vendor/apexcharts/apexcharts.js"></script>
<script>
  // window.ApexCharts тут может быть ещё не определён:
  // defer-скрипт из <head> исполняется ПОСЛЕ инлайна в <body>
  new ApexCharts(document.querySelector('#chart'), opts).render();
</script>

Хорошо - загрузку либы отдаём движку, а свою инициализацию вешаем на DOMContentLoaded:

<?php
// Загрузку библиотеки отдаём движку - defer управляется централизованно:
Sogerien::Page()->add_head_js('/vendor/apexcharts/apexcharts.js', defer: true);
?>
<div id="chart"></div>
<script>
// Свою инициализацию вешаем на DOMContentLoaded:
// глобал из defer-бандла готов только к этому моменту
document.addEventListener('DOMContentLoaded', function () {
    new ApexCharts(document.querySelector('#chart'), opts).render();
});
</script>

Почему это дисциплина, а не клетка

Единая точка подключения даёт то, что вручную не собрать: детектор дублей ассетов, debug-оверлей с JS-ошибками прямо на экране (включается по Sogerien::$debag), контроль порядка вендорных скриптов и версионирование по mtime. Запрет на инлайн - это не про "нельзя", это про то, что порядок, дубли и версии кто-то наконец держит в одном месте.