Files

2 lines
25 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"index":26,"slug":"editorial-2027-04-mechanism-build-evolution","title":"Кэш frontend-сборки: как ключ сохраняет или скрывает устаревший результат","excerpt":"Разбираем границы кэша dependency optimizer и сборщика, составляем ключ из проверяемых входов и доказываем свежесть артефакта через положительный и отрицательный тест.","readingMinutes":9,"contentHtml":"<p>Сборка внезапно стала медленной, хотя CI сообщает <code>cache hit</code>. В другом запуске job заканчивается за секунды, но после изменения lockfile приложение получает старый bundle. Оба симптома выглядят как проблема производительности. На деле команда рискует либо платить временем за постоянные промахи, либо опубликовать артефакт, который не соответствует исходникам.</p><p>Кэш не хранит абстрактный «проект». Он повторяет результат вычисления для конкретных входов. Поэтому совпадение ключа доказывает только, что система выбрала сохранённую запись. Оно не доказывает полноту ключа, согласованность bundle и source map или свежесть уже опубликованного файла. В этой статье разберём, какие границы нужно разделить и как проверить их без доверия к одному статусу hit.</p><h2>Что именно кэшируется</h2><p>Слово «кэш сборки» скрывает несколько механизмов. Vite в режиме разработки предварительно собирает зависимости, чтобы браузер не обходил сотни внутренних модулей. webpack может сохранять разобранные модули и chunks в памяти или на файловой системе. Браузер затем кэширует уже отданные HTTP-ресурсы. Эти слои могут использовать похожие слова, но у них разные владельцы состояния и разные условия инвалидирования.</p><p>Сначала назовите слой, который дал наблюдаемый сигнал. <code>node_modules/.vite</code> относится к кэшу dependency optimizer. <code>node_modules/.cache/webpack</code> относится к filesystem cache webpack по умолчанию. HTTP-заголовок <code>Cache-Control</code> относится к кэшу браузера или CDN. Статус одного слоя нельзя трактовать как доказательство состояния другого: быстрый webpack job не сообщает, какой JS-файл уже есть у пользователя.</p><p>У любого кэша есть функция от входов к результату. Входами могут быть исходный граф, lockfile, конфигурация, плагины, runtime, платформа, путь хранения и namespace. Если значимый вход не участвует в решении, старый результат может выглядеть валидным. Если в ключ попадает timestamp или случайное значение, повторная работа превращается в miss. Состав ключа — часть контракта, а не косметическая оптимизация.</p><figure><img src='/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg' alt='Матрица кэша frontend-сборки связывает commit и lockfile, конфигурацию, состояние кэша и runner с проверкой итогового bundle.' loading='lazy' /><figcaption>Честное сравнение начинается с одинаковых входов и состояния кэша. После hit всё равно нужно сверить артефакт с исходным commit.</figcaption></figure><h2>Ключ должен отвечать на четыре вопроса</h2><p>Хороший ключ не обязан быть длинным, но обязан быть объяснимым. Для каждого поля можно ответить, какую часть результата оно меняет, как его нормализовать и какой тест покажет пропуск. Полезно заранее отделить вход вычисления от места хранения: directory и namespace могут влиять на безопасность обмена записями, но не всегда меняют сам bundle.</p><div class='table-scroll'><table><caption>Минимальная карта входов и проверок</caption><thead><tr><th scope='col'>Слой</th><th scope='col'>Что фиксировать</th><th scope='col'>Как проверить</th><th scope='col'>Риск пропуска</th></tr></thead><tbody><tr><td>Dependency optimizer</td><td>lockfile, patches, relevant config, NODE_ENV</td><td>Изменить один вход и повторить запуск</td><td>Старая pre-bundle после обновления зависимости</td></tr><tr><td>Сборщик</td><td>commit, entry graph, mode, loaders/plugins, target</td><td>Сравнить key и digest output</td><td>Новый исходник не отражается в артефакте</td></tr><tr><td>Runtime</td><td>версия Node, OS, CPU и native toolchain, если они влияют на output</td><td>Сделать два запуска на зафиксированном runner</td><td>Несовместимый или недетерминированный результат</td></tr><tr><td>Хранилище</td><td>cache directory, name, branch namespace, права</td><td>Проверить, кто записал и кто восстановил запись</td><td>Один job получает чужой результат</td></tr><tr><td>Публикация</td><td>bundle, source map, manifest, content hash</td><td>Сверить файлы и ссылки в manifest</td><td>Кэш сборки свежий, а браузер получает старый URL</td></tr></tbody></table></div><p>Такая карта не обещает, что перечислены все поля конкретного инструмента. Она задаёт рабочий вопрос: если поле изменилось, что именно должно измениться — ключ, промежуточный результат, имя файла или только deploy manifest? Например, версия Node может не менять текст bundle в одном проекте, но менять native-расширение, порядок сериализации или minifier output в другом. Решение нужно подтвердить экспериментом.</p><h2>Что действительно обещает Vite</h2><p>В документации Vite dependency pre-bundling относится только к development mode. Файловый кэш по умолчанию лежит в <code>node_modules/.vite</code>. Vite учитывает содержимое lockfile, время изменения каталога patches, релевантные поля <code>vite.config.js</code> и значение <code>NODE_ENV</code>. Это хороший пример явного контракта: изменение каждого перечисленного входа должно привести к повторной оптимизации.</p><p>Есть отдельная ловушка монорепозиториев. Linked dependency, который не разрешается из <code>node_modules</code>, Vite обычно рассматривает как исходный код и не пытается предварительно бандлить. Для ESM-пакета это может быть правильным поведением. Если пакет нужно оптимизировать, его добавляют в <code>optimizeDeps.include</code>. После изменения linked dependency документация рекомендует перезапустить dev server с <code>--force</code>. Это не исправляет неизвестный ключ: команда лишь явно просит повторить оптимизацию.</p><p>У Vite есть и второй кэш — браузерный. Разрешённые dependency-запросы получают длительное кэширование, а изменение установленной версии отражается в version query. Поэтому отладка локального пакета требует согласованного действия: выключить browser cache в DevTools, перезапустить сервер с принудительной оптимизацией и только затем смотреть новый запрос. Удаление каталога само по себе не показывает, почему исходное решение было неверным.</p><p>Практический вывод ограничен этим слоем. Vite-документ подтверждает условия invalidation dependency optimizer, но не описывает ваш CI-кэш, содержимое production bundle и правила деплоя. Если симптом возник только после публикации, проверяйте следующий слой, а не добавляйте <code>--force</code> в каждый локальный запуск.</p><h2>Что проверять в webpack</h2><p>У webpack параметр <code>cache: true</code> является сокращением для memory cache. В development mode memory cache используется по умолчанию, а для production mode значение по умолчанию — без кэширования. Filesystem cache включают явно. Он позволяет пережить процесс и использовать данные между запусками, но добавляет требования к каталогу, namespace, правам и составу build dependencies.</p><p>Для filesystem cache webpack хэширует дополнительные build dependencies и инвалидирует запись при их изменении. В официальном примере конфигурация добавляется через <code>cache.buildDependencies.config: [__filename]</code>. Это важнее, чем просто положить каталог в общий CI-кэш: если конфигурация и loader, влияющий на transform, не попали в зависимость, старый результат может пережить правку.</p><p>У filesystem cache есть имя. Разные значения <code>cache.name</code> создают независимые записи, поэтому им можно разделить несколько конфигураций в одном репозитории. Но имя не заменяет hash входов. В документации также указано, что файлы кэша webpack хранят абсолютные пути и для обмена между CI-запусками нужен одинаковый абсолютный путь. Если runner меняет рабочий каталог, результат следует считать неподтверждённым, пока конкретная конфигурация не доказала обратное.</p><p>Не смешивайте compile cache и browser cache. Настройка <code>output.filename: '[name].[contenthash].js'</code> меняет имя файла на основе содержимого asset и помогает браузеру получить новый URL. Она не доказывает, что compiler использовал свежий модуль. Обратное тоже верно: свежий compile output с прежним именем может остаться у браузера или CDN. Для выпуска нужно проверять и кэш сборки, и ссылку от HTML или manifest к опубликованным файлам.</p><h2>Учебный ключ и два отрицательных теста</h2><p>Ниже — маленькая функция для проверки идеи. Она хэширует только четыре явно названных значения. В реальном проекте порядок полей, нормализацию и список входов нужно согласовать с конкретным bundler. Код не подключается к Vite или webpack, не читает lockfile и не доказывает, что выбранный набор полон.</p><pre><code>import { createHash } from 'node:crypto';\n\nconst keyFields = ['lockfile', 'config', 'runtime', 'sourceDigest'];\n\nexport function makeCacheKey(input) {\n const payload = keyFields\n .map((name) =&gt; name + '=' + String(input[name] ?? ''))\n .join('|');\n\n return createHash('sha256').update(payload).digest('hex');\n}\n\nconst base = {\n lockfile: 'lock-v1',\n config: 'target=es2022;minify=true',\n runtime: 'node-24-linux-x64',\n sourceDigest: 'src-001',\n};\n\nconst sameInputs = { ...base };\nconst changedLockfile = { ...base, lockfile: 'lock-v2' };\nconst changedSource = { ...base, sourceDigest: 'src-002' };\n\nconsole.log(makeCacheKey(base) === makeCacheKey(sameInputs)); // true\nconsole.log(makeCacheKey(base) === makeCacheKey(changedLockfile)); // false\nconsole.log(makeCacheKey(base) === makeCacheKey(changedSource)); // false</code></pre><p>Первый тест проверяет положительный путь: одинаковые нормализованные значения дают одинаковый key. Два следующих теста проверяют отрицательный путь: изменение lockfile и исходного digest не должны сохранить тот же key. Если тест падает, кэш не готов к ускорению — сначала исправьте контракт.</p><p>Но изменения key недостаточно. Нужно проверить, что miss действительно запускает новое вычисление, а не только создаёт другой ярлык для старого каталога. Сохраните commit, key, состояние cold или warm, путь записи и digest каждого публикуемого файла. В отчёте не должны появиться секреты, токены и исходники, которые не нужны для диагностики.</p><h2>Как расследовать ложный hit</h2><p>Начните с повторения симптома на зафиксированном runner. Один запуск с очищенным ограниченным каталогом показывает стоимость cold path. Следующий запуск без изменений показывает warm path. Третий запуск меняет ровно один вход. Если одновременно обновить lockfile, конфигурацию и Node, вы увидите miss, но не узнаете, какой вход его вызвал.</p><p>Сравнивайте не только время. Запишите key и решение кэша, commit и lockfile digest, версии Node и bundler, список плагинов, рабочий каталог, cache name, branch namespace и список output. Для bundle и source map посчитайте digest. Для manifest проверьте, что каждая ссылка указывает на файл из этого же запуска. Для HTML проверьте, что он публикуется атомарно вместе с manifest.</p><p>Если key совпал, а output отличается, ищите скрытый вход или недетерминированность: timestamp в banner, порядок обхода файлов, системный шрифт, native binary, случайный идентификатор или переменную окружения. Если output совпал, но пользователь видит старый код, переходите к HTTP-кэшу, CDN, service worker и маршруту публикации. Ошибка может находиться после сборщика.</p><p>Если разные job используют один filesystem cache, проверьте гонку записи. Ветка и commit должны иметь понятный namespace. Запись от неподходящего target или режима нельзя считать fallback только потому, что её key похож. При сомнении безопаснее отклонить восстановление, чем опубликовать непроверенный артефакт.</p><h2>Порядок проверки перед ускорением</h2><ol><li>Назовите слой кэша и его результат: dependency pre-bundle, модульный cache, итоговый bundle или HTTP-ресурс.</li><li>Выпишите все входы, которые могут изменить граф, transform, target, формат output или правила публикации.</li><li>Разделите входы на обязательные, условные и неизвестные. Для неизвестных добавьте отдельный отрицательный эксперимент.</li><li>Нормализуйте значения и соберите объяснимый key. Не добавляйте timestamp, UUID и абсолютный путь, если они не влияют на вычисление.</li><li>Проведите cold, warm и один-change запуск на одном runner. Зафиксируйте hit или miss и причину решения.</li><li>Сверьте bundle, source map, manifest и HTML с одним commit. Имя файла с <code>contenthash</code> — полезная проверка, но не замена digest-сверке.</li><li>Проверьте конкурентный сценарий: две job не должны читать незавершённую запись или смешивать namespace разных конфигураций.</li><li>Проверьте отрицательный путь: изменённые lockfile, config, source, plugin и runtime дают ожидаемый miss либо явно документированную invalidation.</li><li>Только после этого измерьте экономию времени и решите, оправдывает ли она стоимость хранения, очистки и наблюдаемости.</li></ol><h2>Ограничения применимости</h2><p>Эта модель не выбирает лучший bundler и не обещает детерминированность одного только SHA-256. Хэшируется представление входов, а не их смысл. Два эквивалентных конфига могут дать разные ключи, а одинаковый текст конфига может зависеть от loader, native library или скрытой переменной. Полный список входов должен следовать фактическому pipeline.</p><p>Учебная функция не является готовой настройкой CI. Она не читает дерево файлов, не определяет, какие поля Vite или webpack считают значимыми, не проверяет права на общий cache и не обнаруживает гонку записи. Использовать её как authorization или как единственное доказательство свежести нельзя. Для production нужны тесты конкретной конфигурации и наблюдаемость каждого решения.</p><p>Проверка source map применима только там, где map создаётся и доступна job. В production её может не быть по требованиям безопасности. Тогда сверяйте bundle, manifest, commit и другие доступные артефакты; отсутствие map не следует маскировать как успешную проверку.</p><p>Vite-примеры относятся к dependency optimizer в development mode. Параметры Vite и webpack меняются между версиями. Настройки CI, CDN, service worker и права хранилища в официальных руководствах bundler не описываются. Перед переносом рецепта закрепите версию инструмента и повторите эксперимент в своём runner.</p><h2>Проверяемый критерий готовности</h2><p>Кэш можно считать пригодным для ограниченного применения, когда другой инженер повторяет два запуска с одинаковыми входами и получает одинаковое решение, затем меняет один значимый вход и видит ожидаемый miss или явно объяснённую invalidation. После hit digest bundle и связанных файлов соответствует одному commit, а manifest и HTML ссылаются на тот же выпуск.</p><p>Дополнительный критерий — понятный отказ. Если запись нельзя связать с key, входами, runner или владельцем namespace, она не должна попасть в публикацию. Если на одном слое всё корректно, а пользователь видит старый ресурс, расследование продолжается на следующем слое. Такая граница экономит время: команда меняет конкретный контракт, а не добавляет бесконечные повторные сборки.</p><h2>Проверяемые источники</h2><ul><li><a href='https://vite.dev/guide/dep-pre-bundling.html' target='_blank' rel='noopener noreferrer'>Vite: Dependency Pre-Bundling</a> — официальное руководство описывает development-only dependency pre-bundling, каталог <code>node_modules/.vite</code>, lockfile, patches, relevant config, <code>NODE_ENV</code>, linked dependencies и принудительную оптимизацию.</li><li><a href='https://vite.dev/guide/troubleshooting.html#outdated-pre-bundled-deps-when-linking-to-a-local-package' target='_blank' rel='noopener noreferrer'>Vite: Outdated pre-bundled deps when linking to a local package</a> — официальное объяснение границы npm link и рекомендации по повторной оптимизации; оно не является контрактом CI или production CDN.</li><li><a href='https://webpack.js.org/configuration/cache/' target='_blank' rel='noopener noreferrer'>webpack 5: cache configuration</a> — официальная справка различает memory и filesystem cache, build dependencies, cache directory, cache name и требования к абсолютному пути в CI.</li><li><a href='https://webpack.js.org/guides/caching/' target='_blank' rel='noopener noreferrer'>webpack 5: Caching guide</a> — официальное руководство описывает <code>contenthash</code> в именах output-файлов и связь этого механизма с кэшированием браузером.</li></ul>"}