Изменения в API плагинов и загрузчиков

Архитектура Webpack построена вокруг системы плагинов и загрузчиков. Любое серьёзное расширение функциональности сборщика связано либо с обработкой модулей через loaders, либо с вмешательством в жизненный цикл компиляции через plugins. По мере развития Webpack API этих механизмов существенно изменялся: часть интерфейсов устарела, некоторые были полностью удалены, появились новые хуки, изменилась модель асинхронности и внутренняя организация компиляции.

Понимание изменений API особенно важно при:

  • миграции со старых версий Webpack;
  • поддержке legacy-плагинов;
  • разработке собственных loaders и plugins;
  • интеграции со сторонними инструментами;
  • анализе производительности сборки.

Изменение архитектуры плагинов

Старый API через .plugin()

В Webpack 1–3 основным способом подключения к жизненному циклу компиляции был метод:

compiler.plugin(name, callback)

Пример:

class MyPlugin {
    apply(compiler) {
        compiler.plugin('emit', (compilation, callback) => {
            console.log('Emit phase');
            callback();
        });
    }
}

Проблемы такого подхода:

  • строки-хуки не проверялись;
  • отсутствовала типизация;
  • невозможно было понять сигнатуру hook;
  • смешивались sync и async хуки;
  • не было прозрачной модели исполнения;
  • отсутствовала композиция хуков.

Переход на Tapable

Начиная с Webpack 4 ядро полностью перешло на новую систему хуков, основанную на библиотеке Tapable.

Теперь вместо:

compiler.plugin('emit', handler)

используется:

compiler.hooks.emit.tap(...)

или:

compiler.hooks.emit.tapAsync(...)

Новая модель хуков

Синхронные хуки

class MyPlugin {
    apply(compiler) {
        compiler.hooks.compile.tap(
            'MyPlugin',
            (params) => {
                console.log('Compile started');
            }
        );
    }
}

Первый аргумент — имя плагина.


Асинхронные хуки

Callback-based API

compiler.hooks.emit.tapAsync(
    'MyPlugin',
    (compilation, callback) => {
        setTimeout(() => {
            console.log('Async emit');
            callback();
        }, 100);
    }
);

Promise-based API

Webpack 5 активно использует Promise-модель.

compiler.hooks.emit.tapPromise(
    'MyPlugin',
    async (compilation) => {
        await saveAssets();
    }
);

Такой подход оказался значительно удобнее:

  • лучше поддерживается async/await;
  • упрощается обработка ошибок;
  • уменьшается callback hell;
  • повышается читаемость.

Типы хуков в Tapable

Система Tapable предоставляет разные типы hook-объектов.

SyncHook

Синхронный вызов всех подписчиков.

new SyncHook(['compilation'])

AsyncSeriesHook

Асинхронное последовательное выполнение.

new AsyncSeriesHook(['compilation'])

Каждый обработчик ждёт завершения предыдущего.


AsyncParallelHook

Параллельное выполнение async-хендлеров.

new AsyncParallelHook(['compilation'])

Используется для независимых задач.


SyncWaterfallHook

Передача результата между обработчиками.

new SyncWaterfallHook(['source'])

Пример:

hook.tap('A', source => {
    return source + 'A';
});

hook.tap('B', source => {
    return source + 'B';
});

Bail hooks

Останавливают цепочку при возврате значения.

new SyncBailHook(['module'])

Активно применяются внутри resolver-системы.


Изменение структуры Compiler и Compilation

Compiler

Объект compiler представляет глобальный процесс сборки.

Содержит:

  • конфигурацию;
  • файловую систему;
  • watch-механизмы;
  • lifecycle hooks;
  • cache;
  • resolver factory.

Compilation

Compilation — конкретный цикл сборки.

Содержит:

  • modules;
  • chunks;
  • assets;
  • dependency graph;
  • build info;
  • warnings/errors.

Изменения хуков Compilation

В старых версиях многие операции выполнялись через compiler hooks. В Webpack 5 значительная часть логики переместилась внутрь compilation.

Например:

compiler.hooks.compilation.tap(
    'MyPlugin',
    (compilation) => {

    }
);

Внутри compilation теперь доступны:

compilation.hooks.processAssets

Удаление optimize-assets

В Webpack 4 активно использовались:

optimize-assets
after-optimize-assets

В Webpack 5 они заменены на:

processAssets

Новый API processAssets

Базовый пример

class MyPlugin {
    apply(compiler) {
        compiler.hooks.thisCompilation.tap(
            'MyPlugin',
            (compilation) => {

                compilation.hooks.processAssets.tap(
                    {
                        name: 'MyPlugin',
                        stage: compilation.PROCESS_ASSETS_STAGE_OPTIMIZE
                    },
                    (assets) => {
                        console.log(Object.keys(assets));
                    }
                );

            }
        );
    }
}

Stages в processAssets

Webpack 5 ввёл многоступенчатую обработку assets.

Основные стадии

ADDITIONS

Добавление новых ресурсов.

PROCESS_ASSETS_STAGE_ADDITIONS

PRE_PROCESS

Предварительная обработка.

PROCESS_ASSETS_STAGE_PRE_PROCESS

OPTIMIZE

Оптимизация ресурсов.

PROCESS_ASSETS_STAGE_OPTIMIZE

OPTIMIZE_SIZE

Минификация.

PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE

SUMMARIZE

Финализация.

PROCESS_ASSETS_STAGE_SUMMARIZE

REPORT

Формирование отчётов.

PROCESS_ASSETS_STAGE_REPORT

Причины перехода на staged assets pipeline

Старая модель имела ряд проблем:

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

Stage-модель решила эти ограничения.


Изменение работы с assets

Старый API

Ранее assets были обычными объектами:

compilation.assets['bundle.js'] = {
    source() {
        return code;
    },

    size() {
        return code.length;
    }
};

Новый Source API

Webpack 5 использует абстракцию Source.

Пример:

const { RawSource } = webpack.sources;

compilation.emitAsset(
    'file.txt',
    new RawSource('Hello')
);

emitAsset и updateAsset

Добавление ресурса

compilation.emitAsset(
    'data.json',
    new RawSource(json)
);

Обновление ресурса

compilation.updateAsset(
    'bundle.js',
    old => new RawSource(modify(old.source()))
);

Удаление ресурса

compilation.deleteAsset('old.js');

Причины отказа от прямой мутации assets

Старая модель:

compilation.assets[name] = ...

создавала проблемы:

  • отсутствие отслеживания изменений;
  • невозможность кэширования;
  • нарушение asset graph;
  • проблемы incremental compilation;
  • конфликты между плагинами.

Новый API сделал обработку ресурсов декларативной.


Изменения в loader API

Старый loader-интерфейс

Ранее большинство загрузчиков выглядело так:

module.exports = function(source) {
    return transform(source);
};

Контекст загрузчика

Webpack предоставляет loader context через this.

Пример:

module.exports = function(source) {

    console.log(this.resourcePath);

    return source;
};

Основные поля loader context

resourcePath

Путь к файлу.

this.resourcePath

query

Старый механизм параметров.

this.query

Устарел.


Переход на getOptions

Webpack 5 использует:

this.getOptions()

Современный пример

module.exports = function(source) {

    const options = this.getOptions();

    return transform(source, options);
};

Schema validation

Теперь параметры loader можно валидировать.

const schema = {
    type: 'object',
    properties: {
        minimize: {
            type: 'boolean'
        }
    }
};

Использование schema-utils

const { validate } = require('schema-utils');

module.exports = function(source) {

    const options = this.getOptions();

    validate(schema, options);

    return source;
};

Изменения асинхронных loaders

Старый callback API

module.exports = function(source) {

    const callback = this.async();

    process(source, result => {
        callback(null, result);
    });
};

Promise loaders

Webpack 5 лучше поддерживает Promise-подход.

module.exports = async function(source) {

    const result = await transform(source);

    return result;
};

Pitching loaders

Pitch-фаза позволяет перехватывать цепочку загрузчиков.

Пример

module.exports.pitch = function(
    remainingRequest,
    precedingRequest,
    data
) {
    console.log('Pitch');
};

Порядок выполнения loaders

При обычной обработке:

style-loader
css-loader
sass-loader

основная фаза:

sass-loader
css-loader
style-loader

pitch-фаза:

style-loader
css-loader
sass-loader

Изменения loader-utils

Ранее большинство loaders использовали:

const loaderUtils = require('loader-utils');

Webpack 5 сократил необходимость этой библиотеки.


Устаревание getOptions из loader-utils

Старый код:

const loaderUtils = require('loader-utils');

const options = loaderUtils.getOptions(this);

Новый:

const options = this.getOptions();

Удаление stringifyRequest

Многие helper-функции были признаны лишними.

Часть функциональности перенесена в ядро Webpack.


Изменения resolver API

Старый доступ

compiler.resolvers.normal.resolve(...)

Новый ResolverFactory

compiler.resolverFactory.get('normal')

Пример

const resolver = compiler.resolverFactory.get('normal');

resolver.resolve(
    {},
    context,
    request,
    {},
    callback
);

Изменения dependency API

Webpack 5 значительно переработал dependency graph.


Старые зависимости

Ранее зависимости были относительно простыми структурами.


Новые классы dependency

Теперь используются:

  • Dependency;
  • ModuleDependency;
  • DependencyTemplate;
  • DependencyReference.

Причины усложнения dependency system

Webpack 5 внедрил:

  • persistent cache;
  • module graph;
  • chunk graph;
  • incremental compilation;
  • tree shaking нового поколения;
  • deterministic ids.

Это потребовало более формальной модели зависимостей.


ModuleGraph

Webpack 5 отказался от большого количества прямых ссылок между модулями.

Теперь используется:

compilation.moduleGraph

Получение импортов

const connections =
    compilation.moduleGraph.getOutgoingConnections(module);

ChunkGraph

Отдельная структура для chunk relationships:

compilation.chunkGraph

Изменения parser hooks

Парсер стал значительно более модульным.


Старый подход

parser.plugin('call require', ...)

Новый API

parser.hooks.call
    .for('require')
    .tap('Plugin', expr => {

    });

Hook maps

Tapable ввёл HookMap — динамические наборы hooks.


Пример HookMap

parser.hooks.evaluate
    .for('Identifier')

JavascriptParser hooks

Webpack предоставляет десятки parser hooks:

  • evaluate;
  • call;
  • import;
  • export;
  • statement;
  • expression;
  • assign;
  • typeof;
  • new;
  • program.

Изменения template API

Ранее плагины напрямую модифицировали template generation.

Webpack 5 существенно ограничил вмешательство в кодогенерацию.


Runtime modules

Вместо прямой генерации строк появился RuntimeModule API.


Пример runtime module

class MyRuntimeModule extends RuntimeModule {

    generate() {
        return `
            console.log('runtime');
        `;
    }
}

Добавление runtime module

compilation.addRuntimeModule(
    chunk,
    new MyRuntimeModule()
);

Причины появления RuntimeModule

Старый template API:

  • был нестабилен;
  • ломался между версиями;
  • создавал конфликты;
  • мешал оптимизациям.

Runtime abstraction решила эти проблемы.


Изменения кеширования плагинов

Webpack 5 внедрил persistent cache.

Теперь плагины должны учитывать:

  • build dependencies;
  • snapshot system;
  • immutable data;
  • serialization;
  • cache invalidation.

Cache API

compiler.getCache('MyPlugin')

Пример

const cache = compiler.getCache('MyPlugin');

const result = await cache.getPromise(identifier);

await cache.storePromise(identifier, etag, data);

Snapshot API

Snapshot позволяет отслеживать изменения файловой системы.

compilation.fileSystemInfo.createSnapshot(...)

Build dependencies

Плагин обязан сообщать Webpack о зависимостях:

compilation.fileDependencies.add(file);

Context dependencies

compilation.contextDependencies.add(dir);

Missing dependencies

compilation.missingDependencies.add(file);

Изменения logging API

Webpack 5 внедрил встроенную систему логирования.


Старый подход

console.log('plugin');

Новый logger API

const logger =
    compiler.getInfrastructureLogger('MyPlugin');

logger.info('Started');

Уровни логирования

Поддерживаются:

  • error;
  • warn;
  • info;
  • log;
  • debug;
  • trace.

Profiling hooks

Webpack 5 улучшил диагностику производительности.


tap options

hook.tap({
    name: 'Plugin',
    stage: 100
}, callback);

before/after ordering

hook.tap({
    name: 'B',
    before: 'A'
}, callback);

Interceptors

Tapable поддерживает interceptors.


Пример

hook.intercept({
    register(tapInfo) {
        console.log(tapInfo.name);

        return tapInfo;
    }
});

Изменения child compilers

Child compilers активно используются:

  • HtmlWebpackPlugin;
  • MiniCssExtractPlugin;
  • ModuleFederationPlugin;
  • SSR tooling.

Создание child compiler

const childCompiler =
    compilation.createChildCompiler(
        'child',
        outputOptions
    );

Изменения watch API

Watch-система стала более сложной из-за persistent cache и snapshot architecture.


Watch hooks

compiler.hooks.watchRun.tapAsync(...)

invalid hook

compiler.hooks.invalid.tap(...)

Изменения NormalModuleFactory

NormalModuleFactory отвечает за создание модулей.

Webpack 5 расширил количество hooks.


Основные hooks NMF

beforeResolve

nmf.hooks.beforeResolve.tap(...)

resolve

nmf.hooks.resolve.tap(...)

afterResolve

nmf.hooks.afterResolve.tap(...)

createModule

nmf.hooks.createModule.tap(...)

Изменения compatibility layer

Webpack некоторое время поддерживал backward compatibility:

compiler.plugin(...)

Но затем старые API были удалены.


Основные проблемы миграции

Смешивание sync/async hooks

Нельзя использовать:

tap()

для async-логики.


Мутация compilation.assets

Старые плагины ломаются в Webpack 5.


Устаревшие parser hooks

Строковые parser.plugin(…) более не поддерживаются.


Прямой доступ к внутренним структурам

Webpack 5 активно скрывает internals.


Типичная миграция плагина

Старый код

compiler.plugin('emit', (compilation, callback) => {

    compilation.assets['a.txt'] = {
        source() {
            return 'hello';
        },

        size() {
            return 5;
        }
    };

    callback();
});

Новый код

const { RawSource } = webpack.sources;

compiler.hooks.thisCompilation.tap(
    'Plugin',
    compilation => {

        compilation.hooks.processAssets.tap(
            {
                name: 'Plugin',
                stage:
                    compilation
                        .PROCESS_ASSETS_STAGE_ADDITIONS
            },
            () => {

                compilation.emitAsset(
                    'a.txt',
                    new RawSource('hello')
                );

            }
        );

    }
);

Изменения loader runner

Loader Runner также был переработан.


Новый pipeline обработки

Теперь Webpack:

  • лучше отслеживает зависимости;
  • кеширует loader results;
  • сериализует build info;
  • учитывает side effects;
  • сохраняет snapshots.

Изменения error handling

Webpack 5 внедрил более строгую модель ошибок.


WebpackError

Многие ошибки теперь наследуются от:

WebpackError

Добавление warning

compilation.warnings.push(error);

Добавление error

compilation.errors.push(error);

Infrastructure logging vs compilation logging

Infrastructure logging

Для внутренней инфраструктуры.

compiler.getInfrastructureLogger()

Compilation logging

Для сообщений сборки.

compilation.getLogger()

Изменения asset info

Webpack 5 хранит метаданные ресурсов.


AssetInfo

compilation.emitAsset(
    'bundle.js',
    source,
    {
        minimized: true
    }
);

Side effects и API плагинов

Tree shaking стал гораздо глубже интегрирован в plugin system.

Плагины обязаны учитывать:

  • sideEffects;
  • usedExports;
  • module concatenation;
  • runtime requirements.

Runtime requirements hooks

compilation.hooks.runtimeRequirementInTree.tap(...)

Изменения serialization API

Persistent cache требует сериализации.


makeSerializable

makeSerializable(
    MyDependency,
    'my/dependency'
);

Почему API Webpack постоянно меняется

Причины эволюции:

  • рост размера приложений;
  • усложнение dependency graph;
  • появление microfrontends;
  • необходимость persistent cache;
  • incremental compilation;
  • parallel compilation;
  • deterministic builds;
  • tree shaking;
  • federation;
  • long-term caching.

Старые API проектировались для значительно более простого bundling pipeline и перестали соответствовать требованиям современных систем сборки.