Перевірено на 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 з увімкненим стисненням і порівняйте цифри з тим, що віддає ваш поточний бандл.