Отладка плагина

Разработка собственных плагинов для Webpack почти всегда сопровождается сложной отладкой. В отличие от обычного JavaScript-кода, плагины работают внутри жизненного цикла сборщика, взаимодействуют с внутренними хуами, файловой системой, компиляцией модулей и асинхронными процессами. Ошибки могут проявляться неявно:

  • плагин не вызывается;
  • хуки не срабатывают;
  • изменённые ассеты не попадают в сборку;
  • возникает бесконечная перекомпиляция;
  • нарушается порядок выполнения;
  • появляются race condition в асинхронном коде.

Грамотная отладка позволяет контролировать состояние компиляции, анализировать внутренние структуры Webpack и локализовать проблемы на раннем этапе.


Базовая структура плагина

Типичная структура плагина выглядит следующим образом:

class MyPlugin {
    apply(compiler) {
        compiler.hooks.emit.tap('MyPlugin', compilation => {
            console.log('emit');
        });
    }
}

module.exports = MyPlugin;

Главная точка входа — метод apply. Именно внутри него происходит подключение к хукам compiler и compilation.

При отладке необходимо понимать:

  • вызывается ли apply;
  • подключён ли плагин в конфигурации;
  • достигает ли выполнение нужного хука;
  • не происходит ли ошибка внутри callback-функции.

Проверка подключения плагина

Первый этап отладки — убедиться, что плагин действительно зарегистрирован.

class DebugPlugin {
    apply(compiler) {
        console.log('Plugin initialized');
    }
}

Если сообщение не появляется:

  • плагин не подключён в webpack.config.js;
  • экспорт указан неправильно;
  • используется неверный путь;
  • конфигурация не активна;
  • выполняется другой webpack-конфиг.

Пример подключения:

const DebugPlugin = require('./plugins/DebugPlugin');

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

Отладка жизненного цикла compiler

Webpack содержит большое количество compiler hooks.

Для анализа порядка работы удобно логировать каждый этап:

class LifecyclePlugin {
    apply(compiler) {
        compiler.hooks.initialize.tap('LifecyclePlugin', () => {
            console.log('initialize');
        });

        compiler.hooks.beforeRun.tap('LifecyclePlugin', () => {
            console.log('beforeRun');
        });

        compiler.hooks.run.tap('LifecyclePlugin', () => {
            console.log('run');
        });

        compiler.hooks.emit.tap('LifecyclePlugin', () => {
            console.log('emit');
        });

        compiler.hooks.done.tap('LifecyclePlugin', () => {
            console.log('done');
        });
    }
}

Такой подход помогает определить:

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

Использование debugger

Наиболее эффективный способ отладки — использование debugger.

class DebugPlugin {
    apply(compiler) {
        compiler.hooks.emit.tap('DebugPlugin', compilation => {
            debugger;
        });
    }
}

Webpack можно запускать через Node Inspector:

node --inspect-brk ./node_modules/webpack/bin/webpack.js

Либо:

node --inspect ./node_modules/webpack/bin/webpack.js

После запуска инспектор подключается через:

  • Chrome DevTools;
  • VSCode;
  • WebStorm;
  • PhpStorm.

Во время остановки можно анализировать:

  • объект compiler;
  • объект compilation;
  • список модулей;
  • assets;
  • зависимости;
  • chunk graph;
  • compilation errors.

Отладка через VSCode

Пример launch.json:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "Webpack Debug",
            "program": "${workspaceFolder}/node_modules/webpack/bin/webpack.js",
            "args": [
                "--config",
                "webpack.config.js"
            ]
        }
    ]
}

Теперь breakpoints внутри плагина будут работать напрямую.


Анализ объекта compiler

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

Полезные свойства:

console.log(compiler.options);
console.log(compiler.context);
console.log(compiler.outputPath);
console.log(compiler.name);

Через compiler.options можно проверить:

  • entry;
  • output;
  • mode;
  • plugins;
  • optimization;
  • devtool;
  • resolve;
  • module rules.

Пример:

compiler.hooks.beforeRun.tap('DebugPlugin', () => {
    console.log(compiler.options.mode);
});

Анализ объекта compilation

compilation содержит состояние конкретной сборки.

Пример исследования:

compiler.hooks.emit.tap('DebugPlugin', compilation => {
    console.log(compilation.assets);
});

Полезные свойства:

compilation.modules
compilation.chunks
compilation.assets
compilation.errors
compilation.warnings
compilation.fileDependencies

Проверка assets

Одной из самых частых проблем является отсутствие изменений в итоговых файлах.

Проверка списка ассетов:

compiler.hooks.emit.tap('DebugPlugin', compilation => {
    Object.keys(compilation.assets).forEach(name => {
        console.log(name);
    });
});

Проверка содержимого:

const asset = compilation.assets['main.js'];

console.log(asset.source());

Проверка модулей

Анализ модулей помогает понять:

  • попал ли файл в граф зависимостей;
  • обработался ли loader;
  • существует ли дубликат;
  • как Webpack разрешил импорт.

Пример:

compiler.hooks.compilation.tap('DebugPlugin', compilation => {
    compilation.hooks.buildModule.tap('DebugPlugin', module => {
        console.log(module.resource);
    });
});

Отладка chunk graph

В сложных сборках важен анализ чанков.

compiler.hooks.emit.tap('DebugPlugin', compilation => {
    for (const chunk of compilation.chunks) {
        console.log(chunk.name);

        for (const file of chunk.files) {
            console.log(file);
        }
    }
});

Это помогает понять:

  • почему chunk не генерируется;
  • почему файл попал не в тот bundle;
  • почему code splitting работает некорректно.

Отладка Tapable hooks

Webpack построен на библиотеке Tapable.

Типы хуков:

  • SyncHook
  • AsyncSeriesHook
  • AsyncParallelHook
  • SyncWaterfallHook
  • AsyncSeriesWaterfallHook

Ошибки часто связаны с неправильным использованием async hooks.


Ошибки async hooks

Неверный код:

compiler.hooks.emit.tap('Plugin', async compilation => {
    await saveFile();
});

tap не ожидает Promise.

Правильно:

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

Либо:

compiler.hooks.emit.tapAsync('Plugin', (compilation, callback) => {
    saveFile().then(() => callback());
});

Если выбрать неправильный тип tap:

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

Логирование времени выполнения

Полезно измерять производительность плагина.

compiler.hooks.emit.tapAsync('Plugin', async (compilation, callback) => {
    console.time('plugin');

    await heavyOperation();

    console.timeEnd('plugin');

    callback();
});

Либо:

const start = Date.now();

await task();

console.log(Date.now() - start);

Использование infrastructure logging

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

class LoggerPlugin {
    apply(compiler) {
        const logger = compiler.getInfrastructureLogger('LoggerPlugin');

        logger.info('info');
        logger.warn('warn');
        logger.error('error');
    }
}

В конфигурации:

module.exports = {
    infrastructureLogging: {
        level: 'log'
    }
};

Доступные уровни:

  • none
  • error
  • warn
  • info
  • log
  • verbose

Преимущества:

  • централизованный вывод;
  • фильтрация;
  • интеграция с Webpack CLI;
  • отключение лишних сообщений.

Отладка ошибок компиляции

Плагин может добавлять ошибки вручную.

compilation.errors.push(
    new Error('Plugin error')
);

Для предупреждений:

compilation.warnings.push(
    new Error('Plugin warning')
);

Проверка ошибок:

compiler.hooks.done.tap('Plugin', stats => {
    console.log(stats.hasErrors());
    console.log(stats.compilation.errors);
});

Анализ Stats

Webpack предоставляет объект stats.

compiler.hooks.done.tap('Plugin', stats => {
    console.log(
        stats.toJson({
            assets: true,
            chunks: true,
            modules: true
        })
    );
});

Через stats можно анализировать:

  • размеры файлов;
  • список модулей;
  • tree shaking;
  • splitChunks;
  • warnings;
  • timings.

Генерация подробной статистики

CLI-команда:

webpack --profile --json > stats.json

Далее файл можно анализировать через:

  • webpack-bundle-analyzer;
  • webpack analyse tools;
  • custom scripts.

Отладка файловой системы

Webpack использует абстракцию файловой системы.

Проверка output FS:

compiler.hooks.emit.tap('Plugin', compilation => {
    console.log(compiler.outputFileSystem);
});

При использовании dev-server запись может происходить в память, а не на диск.

Из-за этого часто возникает ошибка:

  • файл существует в памяти;
  • файл отсутствует на диске.

Проблемы webpack-dev-server

Во время разработки сборка работает в watch-режиме.

Плагин может вызывать:

  • бесконечные пересборки;
  • утечки памяти;
  • накопление listeners;
  • дублирование событий.

Отладка watch режима

Полезные хуки:

compiler.hooks.watchRun.tap('Plugin', () => {
    console.log('watchRun');
});

compiler.hooks.invalid.tap('Plugin', file => {
    console.log(file);
});

Бесконечная перекомпиляция

Частая проблема:

fs.writeFileSync('output.txt', 'data');

Если файл попадает в зависимости Webpack, начинается цикл:

  1. сборка;
  2. запись файла;
  3. изменение файла;
  4. новая сборка.

Решение:

  • писать вне monitored directories;
  • исключать файлы;
  • использовать cache;
  • проверять изменилось ли содержимое.

Проверка memory leaks

Ошибки утечек памяти часто появляются в watch-режиме.

Неверный код:

compiler.hooks.emit.tap('Plugin', () => {
    process.on('exit', () => {});
});

Listener добавляется на каждой сборке.


Использование WeakMap

Для хранения состояния между compilation:

const cache = new WeakMap();

class Plugin {
    apply(compiler) {
        compiler.hooks.compilation.tap('Plugin', compilation => {
            cache.set(compilation, {
                started: Date.now()
            });
        });
    }
}

Отладка асинхронных ошибок

Unhandled rejection внутри плагинов особенно опасны.

Полезно подключать:

process.on('unhandledRejection', error => {
    console.error(error);
});

process.on('uncaughtException', error => {
    console.error(error);
});

Проверка порядка плагинов

Некоторые плагины конфликтуют между собой.

Порядок регистрации важен:

plugins: [
    new FirstPlugin(),
    new SecondPlugin()
]

Для анализа:

console.log(
    compiler.options.plugins.map(
        plugin => plugin.constructor.name
    )
);

Использование stage в hooks

Webpack 5 позволяет задавать приоритет.

compiler.hooks.emit.tap(
    {
        name: 'Plugin',
        stage: 100
    },
    compilation => {
        console.log('emit');
    }
);

Это помогает диагностировать:

  • слишком ранний запуск;
  • слишком поздний запуск;
  • перезапись ассетов другими плагинами.

Проверка hook interception

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

compiler.hooks.emit.intercept({
    register(tap) {
        console.log(tap.name);

        return tap;
    },

    call() {
        console.log('emit called');
    }
});

Это мощный инструмент анализа внутренних вызовов.


Отладка emit и processAssets

В Webpack 5 рекомендуется использовать processAssets.

compiler.hooks.thisCompilation.tap('Plugin', compilation => {
    compilation.hooks.processAssets.tap(
        {
            name: 'Plugin',
            stage: compilation.constructor.PROCESS_ASSETS_STAGE_SUMMARIZE
        },
        assets => {
            console.log(Object.keys(assets));
        }
    );
});

Диагностика зависаний сборки

Если Webpack не завершает процесс:

  • остались открытые сокеты;
  • не закрыты file watchers;
  • unresolved Promise;
  • не вызван callback;
  • активен setInterval.

Пример проблемы:

compiler.hooks.emit.tapAsync('Plugin', (compilation, callback) => {

});

callback() никогда не вызывается.


Проверка активных handles Node.js

Полезный приём:

setInterval(() => {
    console.log(process._getActiveHandles());
}, 5000);

Позволяет обнаружить:

  • таймеры;
  • watchers;
  • сокеты;
  • stream handles.

Использование NODE_OPTIONS

Для глубокой диагностики:

NODE_OPTIONS="--trace-warnings --trace-deprecation"

Либо:

NODE_OPTIONS="--inspect"

Трассировка garbage collector

При проблемах памяти:

node --trace-gc ./node_modules/webpack/bin/webpack.js

Анализ heap snapshot

Для поиска утечек:

node --inspect

После этого через Chrome DevTools:

  1. Memory
  2. Heap Snapshot
  3. Comparison snapshots

Можно выявить:

  • неосвобождённые compilation;
  • dangling references;
  • глобальные cache;
  • retained closures.

Логирование через util.inspect

Обычный console.log плохо отображает глубокие объекты Webpack.

Лучше:

const util = require('util');

console.log(
    util.inspect(compilation, {
        depth: 2,
        colors: true
    })
);

Отладка source maps

При модификации кода важно проверять source maps.

const { SourceMapSource } = require('webpack-sources');

Ошибки обычно связаны с:

  • несовпадением mappings;
  • потерей original source;
  • некорректным merge map.

Проверка совместимости Webpack 4 и Webpack 5

Многие плагины ломаются после миграции.

Типичные проблемы:

  • deprecated hooks;
  • изменённый asset API;
  • удалённые свойства compilation;
  • новая chunk graph architecture.

Проверка версии:

const webpack = require('webpack');

console.log(webpack.version);

Использование profiling plugin

Встроенный профайлинг:

const { debug } = require('webpack');

console.log(debug.ProfilingPlugin);

Пример:

plugins: [
    new webpack.debug.ProfilingPlugin({
        outputPath: 'events.json'
    })
]

Файл можно открыть в Chrome tracing tools.


Проверка кэша

Webpack 5 активно использует filesystem cache.

Иногда изменения плагина не видны из-за кэширования.

Отключение:

module.exports = {
    cache: false
};

Либо:

rm -rf node_modules/.cache

Отладка loader и plugin одновременно

Loader и plugin часто взаимодействуют.

Полезно логировать:

module.exports = function(source) {
    console.log(this.resourcePath);

    return source;
};

И сравнивать с хуками compilation.


Минимизация области поиска ошибок

Эффективный подход:

  1. отключить все сторонние плагины;
  2. оставить минимальную конфигурацию;
  3. убрать оптимизации;
  4. отключить cache;
  5. отключить dev-server;
  6. отключить source maps;
  7. запускать single build вместо watch.

Это резко сокращает количество возможных причин ошибки.


Создание тестового стенда

Для сложных плагинов полезно иметь отдельный sandbox-проект.

Минимальная структура:

test-project/
├── src/
├── dist/
├── webpack.config.js
└── plugins/

Такой стенд позволяет:

  • быстро воспроизводить ошибки;
  • проверять совместимость;
  • тестировать edge cases;
  • анализировать performance.

Автоматическое тестирование плагинов

Для проверки поведения удобно запускать Webpack программно.

const webpack = require('webpack');
const config = require('./webpack.config');

webpack(config, (err, stats) => {
    if (err) {
        console.error(err);
    }

    console.log(stats.toString());
});

Это особенно полезно в unit и integration tests.


Проверка состояния compilation hooks

Иногда hook подключается несколько раз.

Диагностика:

console.log(
    compiler.hooks.emit.taps
);

Можно увидеть:

  • зарегистрированные плагины;
  • stages;
  • порядок выполнения;
  • duplicate registrations.

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

Типичные симптомы:

  • сборка зависает;
  • память постоянно растёт;
  • watch mode запускает бесконечные rebuild;
  • asset исчезает;
  • source maps ломаются;
  • compilation never finishes;
  • callback вызывается дважды;
  • Promise никогда не завершается;
  • hook срабатывает многократно;
  • изменяется чужой asset;
  • нарушается cache invalidation.

Каждый из этих симптомов обычно связан с конкретным этапом жизненного цикла Webpack и требует точечной диагностики через hooks, logging, profiling и инспекцию внутренних структур сборщика.