Hard Source и другие устаревшие подходы

До появления полноценного файлового кэша в Webpack 5 проблема скорости повторных сборок оставалась одной из наиболее болезненных. Большие проекты с Babel, TypeScript, Sass, PostCSS и множеством зависимостей могли пересобираться десятки секунд даже после минимальных изменений.

Webpack 3 и Webpack 4 имели ограниченные встроенные механизмы оптимизации:

  • in-memory cache внутри процесса;
  • кэширование отдельных loader’ов;
  • DLL-сборки;
  • persistent cache через сторонние решения;
  • ускорение за счёт thread-loader;
  • aggressive splitting и optimization plugins.

На этом фоне появился плагин HardSourceWebpackPlugin — один из самых известных экспериментов по реализации постоянного кэша компиляции.


HardSourceWebpackPlugin

Основная идея Hard Source заключалась в сохранении промежуточных результатов сборки на диск с последующим повторным использованием.

Плагин пытался кэшировать:

  • результаты обработки loader’ов;
  • AST и трансформации модулей;
  • зависимости;
  • информацию о чанках;
  • внутренние структуры compilation.

Установка

npm install --save-dev hard-source-webpack-plugin

Подключение

const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');

module.exports = {
  plugins: [
    new HardSourceWebpackPlugin()
  ]
};

Как работал Hard Source

Во время первой сборки Webpack выполнял полный цикл обработки:

  1. Разрешение модулей.
  2. Загрузку файлов.
  3. Трансформации loader’ами.
  4. Построение dependency graph.
  5. Генерацию чанков.
  6. Оптимизации.
  7. Эмиссию assets.

Hard Source вмешивался во внутренние этапы compilation и сериализовал результаты на диск.

При следующей сборке:

  • плагин проверял хэши файлов;
  • определял изменённые зависимости;
  • восстанавливал сериализованные данные;
  • пропускал часть тяжёлых вычислений.

В теории это позволяло сокращать время rebuild в несколько раз.


Структура кэша

По умолчанию кэш сохранялся в:

node_modules/.cache/hard-source

Внутри находились:

.cache/
└── hard-source/
    ├── assets/
    ├── records/
    ├── module/
    ├── resolver/
    └── stamp/

Разделение выполнялось по типам данных:

  • module cache;
  • resolver cache;
  • dependency cache;
  • asset cache;
  • environment stamps.

Настройка cacheDirectory

Плагин позволял переопределять расположение кэша.

new HardSourceWebpackPlugin({
  cacheDirectory: 'node_modules/.cache/hard-source/[confighash]'
})

[confighash] использовался для разделения кэша между разными конфигурациями.

Например:

  • production;
  • development;
  • server bundle;
  • client bundle.

Без разделения часто возникали конфликты между различными compilation pipeline.


Концепция environment hash

Одной из основных проблем persistent cache являлась инвалидизация.

Hard Source пытался определить:

  • изменился ли webpack config;
  • изменились ли зависимости;
  • изменилась ли версия loader’ов;
  • обновился ли Babel;
  • изменились ли environment variables.

Для этого использовался environmentHash.

Пример

new HardSourceWebpackPlugin({
  environmentHash: {
    root: process.cwd(),
    directories: [],
    files: ['package-lock.json', 'yarn.lock']
  }
})

Плагин вычислял hash:

  • lock-файлов;
  • package.json;
  • части окружения.

Если hash менялся — кэш считался невалидным.


Почему проблема кэширования оказалась сложной

Persistent cache в bundler’е значительно сложнее обычного файлового кэша.

Необходимо учитывать:

  • зависимости между модулями;
  • loader pipeline;
  • side effects;
  • динамические imports;
  • loader options;
  • plugin hooks;
  • virtual modules;
  • timestamps;
  • symlink resolution;
  • snapshot filesystem state.

Даже минимальное изменение конфигурации могло приводить к некорректному восстановлению compilation state.


Типичные ускорения

На крупных проектах Hard Source действительно давал заметный прирост.

Примерные показатели:

Тип сборки Без Hard Source С Hard Source
Initial build 45 сек 47 сек
Rebuild 38 сек 8 сек
Incremental build 25 сек 5 сек

Главное ускорение происходило именно при повторных сборках.


Проблемы стабильности

Главная причина отказа сообщества от Hard Source — нестабильность.

Плагин глубоко внедрялся во внутренности Webpack:

  • compilation internals;
  • dependency factories;
  • parser state;
  • module graph;
  • resolver state.

Webpack не предоставлял стабильного API для подобных операций.

После обновлений:

  • Webpack;
  • Babel;
  • loader’ов;
  • Node.js;

кэш часто становился повреждённым.


Типичные ошибки Hard Source

Случайные invalid rebuilds

Иногда Webpack не замечал изменение файла.

Результат:

  • stale bundle;
  • устаревший код в output;
  • несоответствие source map;
  • потеря обновлений.

Некорректная сериализация

Некоторые loader’ы возвращали сложные структуры:

  • функции;
  • классы;
  • circular references;
  • нестандартные объекты.

Hard Source не всегда мог корректно сериализовать подобные данные.


Проблемы с Babel

Особенно часто ломались:

  • babel-loader;
  • cache-loader;
  • thread-loader;
  • ts-loader;
  • vue-loader.

Причина заключалась в огромном количестве промежуточных трансформаций AST.


Конфликты с HMR

Hot Module Replacement требовал точного отслеживания dependency graph.

Hard Source иногда восстанавливал устаревшие связи между модулями.

Это приводило к:

  • зависанию dev server;
  • бесконечным rebuild;
  • поломанному hot reload;
  • полной перезагрузке страницы вместо HMR.

Пример сложной конфигурации

new HardSourceWebpackPlugin({
  cacheDirectory: 'node_modules/.cache/hard-source/[confighash]',
  environmentHash: {
    root: process.cwd(),
    directories: [],
    files: [
      'package-lock.json',
      'yarn.lock'
    ]
  },
  configHash: function(webpackConfig) {
    return require('node-object-hash')({
      sort: false
    }).hash(webpackConfig);
  }
})

Подобные настройки были попыткой уменьшить вероятность некорректной инвалидизации.


Hard Source и memory leaks

На больших monorepo плагин часто вызывал:

  • утечки памяти;
  • рост heap usage;
  • зависания процесса;
  • excessive GC.

Особенно проблемными были:

  • thousands of modules;
  • dynamic imports;
  • large vendor chunks;
  • TypeScript projects.

Почему Webpack 5 отказался от подобных подходов

Webpack 5 внедрил встроенный persistent cache на уровне архитектуры bundler’а.

Это было принципиально важно.

Hard Source работал как внешний слой поверх Webpack.

Webpack 5 cache:

  • интегрирован в compilation lifecycle;
  • знает о module graph;
  • контролирует snapshots;
  • учитывает file system dependencies;
  • умеет корректно инвалидировать данные;
  • использует стабильные internal APIs.

Встроенный filesystem cache в Webpack 5

Современная замена Hard Source:

module.exports = {
  cache: {
    type: 'filesystem'
  }
};

Дополнительные настройки:

module.exports = {
  cache: {
    type: 'filesystem',
    cacheDirectory: path.resolve(__dirname, '.webpack-cache'),
    buildDependencies: {
      config: [__filename]
    }
  }
};

Отличия встроенного cache от Hard Source

Возможность Hard Source Webpack 5 Cache
Интеграция в core Нет Да
Стабильность Низкая Высокая
Поддержка HMR Частичная Полная
Snapshot system Нет Да
File dependency tracking Ограничено Полноценное
Поддержка module graph Частичная Полная
Поддержка lazy compilation Нет Да
Совместимость с loader’ами Проблемная Значительно лучше

cache-loader

Ещё одним популярным устаревшим подходом был cache-loader.

Идея cache-loader

Loader сохранял результат предыдущего loader pipeline на диск.

Пример:

{
  test: /\.js$/,
  use: [
    'cache-loader',
    'babel-loader'
  ]
}

Как работал cache-loader

При первой обработке:

  1. Babel трансформировал файл.
  2. Результат сериализовался.
  3. Данные сохранялись в cache directory.

При повторной сборке:

  • вычислялся hash файла;
  • проверялись loader options;
  • результат восстанавливался из кэша.

Ограничения cache-loader

cache-loader имел множество недостатков.

Кэшировался только loader output

Не кэшировались:

  • module graph;
  • dependency graph;
  • chunk optimization;
  • parser state;
  • resolver state.

Большие накладные расходы

На SSD прирост был заметен.

На HDD cache-loader иногда замедлял сборку из-за:

-大量 мелких файлов;

  • частых filesystem operations;
  • сериализации/deserialization.

Конфликты с другими loader’ами

Проблемы часто возникали с:

  • thread-loader;
  • babel-loader cache;
  • ts-loader transpileOnly;
  • vue-loader;
  • source maps.

Babel cacheDirectory

babel-loader имел собственный механизм кэширования.

{
  loader: 'babel-loader',
  options: {
    cacheDirectory: true
  }
}

Babel сохранял:

  • transformed code;
  • source maps;
  • AST fragments.

Это решение оказалось значительно стабильнее Hard Source, поскольку кэшировался только этап Babel.


thread-loader

Ещё один подход к ускорению сборок — распараллеливание.

{
  test: /\.js$/,
  use: [
    'thread-loader',
    'babel-loader'
  ]
}

thread-loader запускал worker pool.

Проблемы:

  • expensive IPC;
  • overhead serialization;
  • memory duplication;
  • нестабильность на небольших проектах.

Иногда thread-loader замедлял build вместо ускорения.


DLLPlugin

До появления persistent cache широко использовался DLLPlugin.

Основная идея

Vendor dependencies собирались отдельно:

plugins: [
  new webpack.DllPlugin({
    name: '[name]',
    path: path.join(__dirname, '[name]-manifest.json')
  })
]

Затем основная сборка ссылалась на готовые vendor bundles.


Проблемы DLLPlugin

Подход был сложным в поддержке:

  • отдельный lifecycle;
  • ручная пересборка DLL;
  • сложная интеграция с dev server;
  • проблемы cache invalidation;
  • конфликты с dynamic imports.

Webpack 5 практически устранил необходимость DLL-сборок.


Happypack

До thread-loader популярностью пользовался Happypack.

new HappyPack({
  loaders: ['babel-loader']
})

Плагин распределял loader execution между worker processes.

Проблемы Happypack:

  • race conditions;
  • сложная отладка;
  • memory overhead;
  • incompatibility с некоторыми loader’ами;
  • прекращение активной поддержки.

Устаревание экосистемы оптимизаций Webpack 4

Во времена Webpack 4 экосистема ускорения сборки выглядела фрагментированной.

Типичный production stack мог содержать одновременно:

  • HardSourceWebpackPlugin;
  • cache-loader;
  • thread-loader;
  • babel-loader cache;
  • DLLPlugin;
  • terser parallel mode;
  • happypack.

Поддержка подобной конфигурации была крайне сложной.


Главная архитектурная проблема старых решений

Практически все старые плагины пытались:

  • перехватывать внутренние хуки;
  • сериализовать внутренние структуры Webpack;
  • обходить ограничения core API;
  • внедрять собственную логику invalidation.

Но Webpack долгое время не проектировался как система с persistent incremental cache.

В результате:

  • плагины конфликтовали;
  • появлялись race conditions;
  • ломался rebuild graph;
  • возникали stale modules;
  • нарушалась корректность compilation state.

Snapshot system в Webpack 5

Одним из важнейших нововведений стала snapshot system.

Webpack начал отслеживать:

  • timestamps;
  • hashes;
  • file metadata;
  • directory metadata;
  • managed paths;
  • immutable paths;
  • build dependencies.

Это позволило реализовать безопасную инвалидизацию кэша.


Managed Paths и Immutable Paths

Webpack 5 научился различать:

  • изменяемые директории;
  • неизменяемые зависимости.

Пример:

snapshot: {
  managedPaths: [
    /node_modules/
  ],
  immutablePaths: []
}

Это резко уменьшило объём filesystem checks.


Почему старые подходы больше не нужны

Современный Webpack уже содержит:

  • persistent filesystem cache;
  • module graph cache;
  • snapshot invalidation;
  • optimized resolver cache;
  • lazy compilation;
  • improved incremental builds;
  • deterministic chunking;
  • optimized serialization.

Большинство внешних решений:

  • deprecated;
  • archived;
  • не поддерживаются;
  • несовместимы с Webpack 5+.

Какие legacy-подходы ещё встречаются

В старых enterprise-проектах всё ещё можно встретить:

HardSourceWebpackPlugin
cache-loader
HappyPack
DLLPlugin

Особенно в:

  • legacy monolith;
  • старых Vue 2 проектах;
  • Angular CLI старых версий;
  • React-проектах эпохи Webpack 3/4.

Миграция со старых решений

Наиболее распространённая стратегия миграции:

Удаление Hard Source

npm remove hard-source-webpack-plugin

Замена:

cache: {
  type: 'filesystem'
}

Удаление cache-loader

Было:

use: [
  'cache-loader',
  'babel-loader'
]

Стало:

use: [
  {
    loader: 'babel-loader',
    options: {
      cacheDirectory: true
    }
  }
]

Или полный переход на встроенный cache Webpack 5.


Удаление DLLPlugin

Современные replacement-механизмы:

  • SplitChunksPlugin;
  • Module Federation;
  • persistent cache;
  • long term caching;
  • optimized chunk graph.

Признаки проблем из-за legacy cache plugins

Характерные симптомы:

  • rebuild работает медленнее initial build;
  • случайно исчезают изменения;
  • HMR обновляет не тот модуль;
  • bundle содержит старый код;
  • появляются phantom dependencies;
  • помогает только удаление .cache;
  • build становится нестабильным после обновления Node.js.

Во многих случаях причиной оказываются именно устаревшие механизмы кэширования.