Еще один способ использования Webpack 4 и разделение кода

webpack4 разделение кода

Предыстория

Ни для кого не секрет, что с выходом Webpack 4 стратегия разделения кода сильно поменялась. Тут даже лучше сказать, что она была заново придумана, т.к. старый подход просто перестал работать, а новый не понятно как использовать.

Для тех, кто все еще не в курсе, плагина webpack.optimize.CommonsChunkPlugin больше нет. Совсем. Вместо этого предлагается в конфиге писать следующее:

module.exports = {
  // ...
  optimization: {
    splitChunks: {
      chunks: "all"
    }
  }
  // ...
}

Это должно работать как магия. Т.е. теперь не мы говорим webpack'у что сделать общим чанком, а он сам все сделает, да еще может даже и лучше нас.

И наступит счастье. Шутка. На самом деле нет... <cut />

Базовые приготовления

Вот пример из документации:

module.exports = {
  mode: 'development',
  entry: {
    index: './src/index.js',
    another: './src/another-module.js'
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  }
};

Результатом сборки будут 3 файла: another.bundle.js, index.bundle.js, vendors~another~index.bundle.js

Hash: ac2ac6042ebb4f20ee54
Version: webpack 4.7.0
Time: 316ms
                          Asset      Size                 Chunks             Chunk Names
              another.bundle.js  5.95 KiB                another  [emitted]  another
                index.bundle.js  5.89 KiB                  index  [emitted]  index
vendors~another~index.bundle.js   547 KiB  vendors~another~index  [emitted]  vendors~another~index
Entrypoint index = vendors~another~index.bundle.js index.bundle.js
Entrypoint another = vendors~another~index.bundle.js another.bundle.js
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 489 bytes {vendors~another~index} [built]
[./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 497 bytes {vendors~another~index} [built]
[./src/another-module.js] 88 bytes {another} [built]
[./src/index.js] 86 bytes {index} [built]
    + 1 hidden module

Теперь, для того, чтобы запустить наши веб приложения, мы, в одном случае, должны подключить vendors~another~index.bundle.js и index.bundle.js, а во втором vendors~another~index.bundle.js и another.bundle.js.

В чем проблема?

Проблема в имени vendors~another~index.bundle.js. Пока у нас меньше трех точек входа, ничего страшного не происходит. Здесь все кажется логичным - бандл содержит npm модули (они же vendors) и общие модули для index и another. На каждую из страниц мы подключаем 2 файла и не имеем проблем.

Однако если у нас три и более точки входа, то новых бандлов (они же чанки) может быть куда больше и мы уже не знаем ни их количества, ни имен. Все становится еще веселее, если мы еще и css извлекаем в отдельные файлы. И это проблема.

Как решить эту проблему?

После завершения работы webpack у нас нет никаких файлов, которые содержали бы в себе информацию о том, какие именно бандлы на той или иной странице надо подключать. И в какой последовательности.

Однако в output'е мы можем найти вот такие строки:

Entrypoint index = vendors~another~index.bundle.js index.bundle.js
Entrypoint another = vendors~another~index.bundle.js another.bundle.js

На самом деле это почти то, что нам надо. Т.е. webpack прекрасно знает какие бандлы нужны для каждой точки входа, но почему-то сам не хочет этой информацией с нами делиться.

Манифест нам здесь не помогает. Да, мы знаем что такой (vendors~another~index.bundle.js) бандл есть. Мы знаем где он лежит. Но кому он нужен не знаем. Т.е. манифест бесполезен.

Тогда я решил что раз webpack знает нужную информацию, то ее возможно получится достать с помощью плагинов. Готовых я не нашел и решил написать свой. И, только ради демонстрации этого плагина, я и пишу эту статью.

import * as webpack from "webpack";

export interface IEntrypointsPluginOptions {
    readonly filename: string;
    readonly replacer?: (key: string, value: any) => any;
    readonly space?: string | number;
    readonly filter?: (chunk: webpack.compilation.Chunk) => boolean;
}

export default class EntrypointsPlugin {
    private readonly options: IEntrypointsPluginOptions;

    public constructor(options: IEntrypointsPluginOptions) {
        this.options = Object.assign<IEntrypointsPluginOptions, IEntrypointsPluginOptions>({
            filename: "entrypoints.json",
            replacer: null,
            space: null,
            filter: null
        }, options);
    }

    public apply(compiler: webpack.Compiler): void {
        compiler.hooks.emit.tap("entrypoints", (compilation: webpack.compilation.Compilation) => {
            const data = {};
            const filter = this.options.filter;
            const publicPath = compilation.compiler.options.output.publicPath;

            for (let [key, value] of compilation.entrypoints.entries()) {
                const chunks: webpack.compilation.Chunk[] = value.chunks.filter((data: webpack.compilation.Chunk) => {
                    return filter == null || filter(data);
                });

                const files = ([] as string[]).concat(...chunks.map(c => c.files.map(f => publicPath + f)));
                const js = files.filter(f => /\.js/.test(f) && !/\.js\.map/.test(f));
                const css = files.filter(f => /\.css/.test(f) && !/\.css\.map/.test(f));

                let entrypoint = {};
                if (js.length) entrypoint["js"] = js;
                if (css.length) entrypoint["css"] = css;

                data[key] = entrypoint;
            }
            const json = JSON.stringify(data, this.options.replacer, this.options.space);
            compilation.assets[this.options.filename] = {
                source: () => json,
                size: () => json.length
            };
        });
    }
}

В файле webpack.config.(ts|js) добавим новый плагин:

plugins: [
  new EntrypointsPlugin({
    filename: "entrypoints.json",
    space: 2
  })
]

и дождемся результата. Результатом будет файл entrypoints.json с вот таким содержанием:

{
  "index": {
    "js": ["vendors~another~index.bundle.js", "index.bundle.js"]
  },
  "another": {
    "js": ["vendors~another~index.bundle.js", "another.bundle.js"]
  }
}

Если используется extract-css, то кроме секции js будет еще и css.

Последнее, что нам остается, при формировании HTML страницы, это прочитать файл entrypoints.json, найти нужную точку входа, подключить js и css файлы из соответствующих списков.

Проблема решена

Как-то так.