<? phpukraine СТАТТІ
⌕Пошук по платформі
SYMFONY 30 вересня 2026 · 7 хв читання

AssetMapper: фронтенд у Symfony без Node і збирання

Import map замість бандла: браузер сам розв'язує `import 'chart.js'`, а Symfony лише роздає файли з дайджестом у назві. Розбираємо, що саме рендерить `importmap()`, як пакети з jsDelivr опиняються в `assets/vendor/`, як сюди вписуються CSS і Tailwind без Node, який вигляд має деплой після `asset-map:compile` і в яких випадках Encore чи Vite все одно лишаються правильною відповіддю.

РP
Редакція phpukraine
Редакція платформи

Перевірено на Symfony 7.4 LTS і 8.1, symfony/asset-mapper, PHP 8.4. У webapp-паку AssetMapper стоїть за замовчуванням, тож у свіжому проєкті ви отримуєте його, навіть якщо не просили.

Ідея компонента вміщається в одне речення: файли з assets/ віддаються браузеру як є, а всі import розв'язуються через import map, який Symfony генерує з вашого коду. Немає ні node_modules, ні package.json, ні кроку збирання в CI. Натомість з'являються свої правила гри, і половина статті саме про них.

Що насправді робить import map

Браузер не вміє виконувати import 'chart.js'. Специфікатор без слеша й розширення для нього нічого не означає, потрібен URL. Import map закриває цю дірку: це JSON у <script type="importmap">, який каже, у який шлях перетворити кожне «голе» ім'я.

Виклик {{ importmap('app') }} у базовому шаблоні розгортається приблизно в таке:

<script type="importmap">{
    "imports": {
        "app": "/assets/app-4e986c1a.js",
        "/assets/filters.js": "/assets/filters-1b7a64b3.js",
        "chart.js": "/assets/vendor/chart.js/auto-f093544d.js"
    }
}</script>
<link rel="modulepreload" href="/assets/app-4e986c1a.js">
<link rel="modulepreload" href="/assets/filters-1b7a64b3.js">
<script type="module">import 'app';</script>

Три деталі тут важливіші, ніж здається на перший погляд. Перша: локальні файли теж потрапляють у мапу. Компілятор не переписує import './filters.js' усередині app.js на шлях із хешем, а лишає логічний шлях /assets/filters.js, і розв'язує його вже мапа. Друга: modulepreload виписується на весь граф імпортів, порахований під час компіляції. Без нього браузер дізнався б про filters.js лише після того, як завантажив і розпарсив app.js, а про його залежності - ще на крок пізніше. Третя: мапа рендериться один раз на сторінку, тому importmap() живе в базовому шаблоні, а не в блоці кожного темплейта.

Кожен модуль їде окремим запитом, тож без HTTP/2 нормальної роботи тут не буде. Подивитися, що взагалі бачить компонент, можна через php bin/console debug:asset-map - він друкує пари «логічний шлях → файл на диску», зокрема для вендорних файлів (--vendor).

Пакети з jsDelivr

Замість npm install тут importmap:require:

php bin/console importmap:require chart.js
php bin/console importmap:require highlight.js/lib/core highlight.js/lib/languages/php

Команда звертається до ESM-збірки jsDelivr, визначає конкретну версію, тягне файл і всі його транзитивні залежності в assets/vendor/, переписуючи імпорти всередині них на локальні шляхи. У importmap.php лишається запис:

return [
    'app' => [
        'path' => './assets/app.js',
        'entrypoint' => true,
    ],
    'chart.js' => [
        'version' => '4.4.7',
    ],
    'highlight.js/lib/core' => [
        'version' => '11.11.1',
    ],
];

importmap.php виконує роль лок-файлу: там точні версії, без діапазонів. Далі є розвилка, і вибрати треба свідомо. Або ви комітите assets/vendor/ і деплой не залежить від доступності CDN, або тримаєте теку в .gitignore і додаєте php bin/console importmap:install у деплойний скрипт. Змішувати не варто: половина команди з закоміченим вендором, половина без - і ви отримуєте конфлікти в бінарних за духом файлах на кожному оновленні.

Оновлення й безпека закриваються трьома командами: importmap:outdated показує відставання, importmap:update chart.js піднімає конкретний пакет, importmap:audit звіряє версії з базою вразливостей і має --format=json для CI.

Тепер про те, що ламається. Tree shaking не існує: що пакет віддав одним файлом, те й поїде до браузера цілком. Саме тому пишуть highlight.js/lib/core, а не highlight.js: інакше приїде сотня граматик. Не запрацює нічого, що вимагає компіляції: JSX, Vue SFC, .ts-файли (для TypeScript є окремий sensiolabs/typescript-bundle). Погано почуваються бібліотеки, які розраховують на process.env.NODE_ENV або на вбудовані модулі Node. Якщо ESM-збірка з CDN непридатна, лишається шлях через локальний node_modules:

# config/packages/asset_mapper.yaml
framework:
    asset_mapper:
        paths:
            assets/: ''
            node_modules/@hpcc-js/wasm-graphviz/dist/: 'hpcc'
php bin/console importmap:require @hpcc-js/wasm-graphviz --path=hpcc/index.js

Так, це вже Node у проєкті. Зате локально, для одного пакета, і без збирання.

CSS і Tailwind

CSS модулем не є, і AssetMapper це обходить: рядок import '../styles/app.css'; у app.js виймається з JS-графа під час компіляції, а сам файл додається на сторінку як <link rel="stylesheet"> тим самим викликом importmap(). Порядок тегів відповідає порядку імпортів. Стилі з пакета підключаються так само: importmap:require bootstrap/dist/css/bootstrap.min.css. Якщо JS для стилів вам не потрібен, звичайний <link rel="stylesheet" href="{{ asset('styles/app.css') }}"> теж працює, бо asset() знає логічні шляхи асет-мапи.

Усередині CSS шляхи в url() переписуються на версіоновані: url('../images/logo.png') стає url('../images/logo-3c16d92m.png'). Наслідок для кешу: зміна картинки змінює вміст CSS, а отже і його дайджест.

Tailwind підключається бандлом symfonycasts/tailwind-bundle, і Node йому теж не потрібен - він завантажує standalone-бінарник у var/tailwind/:

composer require symfonycasts/tailwind-bundle
php bin/console tailwind:init
php bin/console tailwind:build --watch

Механіка проста до непристойності: бінарник компілює assets/styles/app.css у var/tailwind/app.built.css, а бандл підміняє вміст вихідного файлу на зібраний у момент, коли асет-мапа його читає. Шаблони далі посилаються на styles/app.css і нічого не знають про підміну. У конфізі зафіксуйте версію, щоб збірка не поїхала сама собою:

# config/packages/symfonycasts_tailwind.yaml
symfonycasts_tailwind:
    binary_version: 'v4.1.11'
    input_css: 'assets/styles/app.css'

Для четвертої версії Tailwind tailwind.config.js більше не потрібен, конфігурація живе директивами @import "tailwindcss", @source і @plugin у самому CSS; опція config_file лишилася тільки для третьої. У продакшені порядок команд жорсткий: спершу tailwind:build --minify, потім asset-map:compile, інакше в public/assets/ поїде незібраний вхідний файл.

Sass закривається сусіднім symfonycasts/sass-bundle за тією ж схемою. Одразу додайте до конфігурації excluded_patterns: ['*/*.scss'], щоб вихідні .scss не роздавалися публічно поруч зі зібраним CSS.

Кеш і версіонування в продакшені

php bin/console asset-map:compile складає всі файли в public/assets/ з дайджестом у назві та кладе поруч manifest.json, importmap.json і кілька службових JSON з графом імпортів. У дев-режимі компіляція не потрібна: опція server за замовчуванням дорівнює %kernel.debug%, і Symfony віддає файли з мапи сама. У проді цього слухача немає, тож забутий asset-map:compile дає рівно 404 на кожен асет, без жодного попередження в логах.

Далі справа веб-сервера:

location /assets/ {
    add_header Cache-Control "public, immutable, max-age=31536000";
}

immutable тут безпечний, бо ім'я файлу містить хеш вмісту. Цікавіше, як це поводиться при змінах. Оскільки JS-імпорти розв'язуються через мапу, правка одного листка графа змінює дайджест лише цього файлу та відповідний рядок у мапі. Решта файлів лишається в кеші браузера. Бандлер на цьому місці інвалідує весь чанк. Зворотний бік: сама мапа лежить інлайном у HTML, тому HTML кешувати надовго не можна, а з ростом кількості модулів мапа збільшується і їде з кожною сторінкою.

Мініфікації в коробці немає. Компонент розраховує на стиснення на транспорті, і для цього є опція precompress, яка під час компіляції створює .br, .zst і .gz поруч з оригіналами (потрібні відповідні CLI-утиліти, а сервер має віддавати їх через brotli_static on чи аналог). Якщо хочеться саме мініфікації, є sensiolabs/minify-bundle, що вбудовується в asset-map:compile.

Ще одна опція, яку варто поставити до першого деплою:

framework:
    asset_mapper:
        missing_import_mode: 'strict'

За замовчуванням warn: опечатка в шляху імпорту тихо потрапляє в лог, а користувач отримує зламану сторінку. У strict компіляція падає, і ви дізнаєтеся про це в CI.

Коли все ж потрібен Encore або Vite

Вирішує тут не розмір проєкту, а потреба трансформувати код. React з JSX, Vue з однофайловими компонентами, будь-який препроцесор поза Sass і Tailwind - тут AssetMapper нічим не допоможе, і чесніше одразу взяти Vite (pentatrion/vite-bundle) або лишитися на Encore.

Другий випадок - масштаб графа залежностей. Кілька десятків модулів по мережі їдуть нормально, кілька сотень перетворюють modulepreload-список на кілограм розмітки, а сторінку на сотні запитів. Плюс відсутність tree shaking: якщо ваш фронтенд тягне важкі бібліотеки, різниця в переданих байтах буде не на користь import maps, навіть із brotli.

Третій - старі браузери. es-module-shims рятує сам синтаксис import map, але динамічний import() поліфілом не закриєш. Якщо у вашій аудиторії є браузери без нативних ES-модулів, потрібен бандлер з транспіляцією.

І банальне: якщо в команді вже налагоджений Vite з HMR, компонентною бібліотекою і звичкою до npm run dev, перехід на AssetMapper не дасть виграшу, вартого переписування.

Де він справді доречний - серверний рендеринг на Twig, Stimulus і Turbo з пачки symfony/ux, десяток невеликих контролерів і бажання прибрати з CI крок, що падає на несумісності версій Node. Ціна питання: ви віддаєте користувачу код рівно таким, яким його написали й опублікували в npm. Перш ніж вважати це перемогою, подивіться у вкладку Network з увімкненим стисненням і порівняйте цифри з тим, що віддає ваш поточний бандл.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті