Webpack 4 - create vendor chunk
Asked Answered
E

8

91

In a webpack 3 configuration I would use the code below to create separate vendor.js chunk:

entry: {
    client: ['./client.js'],
    vendor: ['babel-polyfill', 'react', 'react-dom', 'redux'],
},

output: {
  filename: '[name].[chunkhash].bundle.js',
  path: '../dist',
  chunkFilename: '[name].[chunkhash].bundle.js',
  publicPath: '/',
},

plugins: [
    new webpack.HashedModuleIdsPlugin(),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
    }),
    new webpack.optimize.CommonsChunkPlugin({
      name: 'runtime',
    }),
],

With all the changes I'm not sure how to do it with Webpack 4. I know that CommonChunksPlugin was removed, so there is a different way to achieve that. I've also read this tutorial but I'm still not sure about extracting runtime chunk and properly defining output property.

EDIT: Unfortunately, I was experiencing issues with the most popular answer here. Check out my answer.

Edroi answered 26/2, 2018 at 10:2 Comment(0)
D
86

In order to reduce the vendor JS bundle size. We can split the node module packages into different bundle files. I referred this blog for splitting the bulky vendor file generated by Webpack. Gist of that link which I used initially:

optimization: {
  runtimeChunk: 'single',
  splitChunks: {
    chunks: 'all',
    maxInitialRequests: Infinity,
    minSize: 0,
    cacheGroups: {
      vendor: {
        test: /[\\/]node_modules[\\/]/,
        name(module) {
          // get the name. E.g. node_modules/packageName/not/this/part.js
          // or node_modules/packageName
          const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];

          // npm package names are URL-safe, but some servers don't like @ symbols
          return `npm.${packageName.replace('@', '')}`;
        },
      },
    },
  },
}

If one wants to group multiple packages and chunk then into different bundles then refer following gist.

optimization: {
  runtimeChunk: 'single',
  splitChunks: {
    chunks: 'all',
    maxInitialRequests: Infinity,
    minSize: 0,
    cacheGroups: {
      reactVendor: {
        test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
        name: "reactvendor"
      },
      utilityVendor: {
        test: /[\\/]node_modules[\\/](lodash|moment|moment-timezone)[\\/]/,
        name: "utilityVendor"
      },
      bootstrapVendor: {
        test: /[\\/]node_modules[\\/](react-bootstrap)[\\/]/,
        name: "bootstrapVendor"
      },
      vendor: {
        test: /[\\/]node_modules[\\/](!react-bootstrap)(!lodash)(!moment)(!moment-timezone)[\\/]/,
        name: "vendor"
      },
    },
  },
}
Dardanelles answered 24/10, 2018 at 5:56 Comment(4)
Why don't you exclude react and react-dom from your vendor chunk?Jealous
Thanks, very usefull for analysing code coverage in running app !Deport
Why use exclusion patterns in the test? Try adding priority (defaults to 0) and each possible module will be captured by the cache group test with the highest priority.Scurrility
@RiteshJagga: Try remove the output.chunkFilename. Then the name in each cacheGroup will be inserted to the [name] of output.filename. Give me a reply if this help :)Topeka
E
30

In order to separate the vendors and the runtime you need to use the optimization option.

Possible Webpack 4 configuration:

// mode: 'development' | 'production' | 'none'

entry: {
    client: ['./client.js'],
    vendor: ['babel-polyfill', 'react', 'react-dom', 'redux'],
},

output: {
    filename: '[name].[chunkhash].bundle.js',
    path: '../dist',
    chunkFilename: '[name].[chunkhash].bundle.js',
    publicPath: '/',
},

optimization: {
    runtimeChunk: 'single',
    splitChunks: {
        cacheGroups: {
            vendor: {
                test: /[\\/]node_modules[\\/]/,
                name: 'vendors',
                enforce: true,
                chunks: 'all'
            }
        }
    }
}

More info related with W4 can be found in this Webpack-Demo.

Also, you can achieve the same changing the optimization.splitChunks.chunks property to "all". Read more here

Note: You can configure it via optimization.splitChunks. The examples say something about chunks, by default it only works for async chunks, but with optimization.splitChunks.chunks: "all" the same would be true for initial chunks.

Educable answered 22/3, 2018 at 13:5 Comment(4)
Could you tell me what is "initial" here ?Oralee
How can I get the vendors cacheGroup to get compiled with Babel? @EducableRuggles
@Oralee initial is the stuff needed at first load. You also have async which is the opposite of initial as I understand, and all which gives webpack more freedom to move things around. Docs: v4.webpack.js.org/plugins/split-chunks-pluginScurrility
I'm not able to use chunkFilename and cacheGroups.vendor.name together. chunkFilename overwrites the names given to the vendor chunks.Spadiceous
L
27

There are a few examples located here: https://github.com/webpack/webpack/tree/master/examples

Based on your example i believe this translate to:

// mode: "development || "production",
entry: {
  client: './client.js',
},
output: {
  path: path.join(__dirname, '../dist'),
  filename: '[name].chunkhash.bundle.js',
  chunkFilename: '[name].chunkhash.bundle.js',
  publicPath: '/',
},
optimization: {
  splitChunks: {
    cacheGroups: {
      vendor: {
        chunks: 'initial',
        name: 'vendor',
        test: 'vendor',
        enforce: true
      },
    }
  },
  runtimeChunk: true
}
Latoyalatoye answered 27/2, 2018 at 9:18 Comment(4)
It gives me some weird results. client.js doesn't get any smaller and vendor.js is almost as big as client.js containing some dynamically imported packages.Edroi
interesting though, now the whole size of chunks is bigger making me wonder if it was worth it.Edroi
Having a vendor: in entry: seems to contradict the documentation. "Do not create a entry for vendors or other stuff which is not the starting point of execution.". webpack.js.org/concepts/entry-points/…Anoint
@TomaszMularczyk: The reason is that you didn't specify the dependOn field for your first entry client. (I hope this will help new readers in the future.)Topeka
H
25

You could remove vendor out of the entry property and set the optimization property like so...

entry: {
 client: './client.js'
},

output: {
 path: path.join(__dirname, '../dist'),
 filename: '[name].chunkhash.bundle.js',
 chunkFilename: '[name].chunkhash.bundle.js',
 publicPath: '/',
},

optimization: {
  splitChunks: {
   cacheGroups: {
    vendor: {
     test: /node_modules/,
     chunks: 'initial',
     name: 'vendor',
     enforce: true
    },
   }
  } 
 }

Check this source webpack examples

Holmes answered 10/3, 2018 at 2:37 Comment(7)
but, how do I specify which packages I want in a vendor chunk?Edroi
@Tomasz webpack will check which packages you are using in your project via your import statements, then it will chunk them out to vendor automagically.Holmes
It works! However... it bundles every package from node_modules which is not ideal. 1. vendor bundle gets big. 2. if you upgrade even one little package, the whole bundle will get different hash on next build - which will beat the idea of having vendor chunk for long-term caching.Edroi
really? every package in node_modules. 🤔 I can't reproduce that. have you come up with a solution?Holmes
Accepted answer is the solution to that. Anyway I couldnt imagine webpack deciding for me which package should be included in vendor bundle, because its best to only include packages that rarely change.Edroi
@jhamPac How can I get the vendors cacheGroup to get compiled with Babel?Ruggles
@jousi, how do you mean? Is your question regarding how to get it compiled using the babel-loader. You would have to set that in the rules property.Holmes
E
19

After some time I found out that this configuration:

entry: {
  vendor: ['@babel/polyfill', 'react', 'react-dom', 'redux'],
  client: './client.js',
},
optimization: {
  splitChunks: {
    cacheGroups: {
      vendor: {
        chunks: 'initial',
        name: 'vendor',
        test: 'vendor',
        enforce: true
      },
    }
  },
  runtimeChunk: true
}

was failing to somehow to load @babel/polyfill which was causing browser incompatibility errors... So recently I looked up to the updated webpack documentation and found a way to create explicit vendor chunk that was properly loading @babel/polyfill:

const moduleList = ["@babel/polyfill", "react", "react-dom"];
...

  entry: {
    client: ["@babel/polyfill", "../src/client.js"]
  }
  optimization: {
    runtimeChunk: "single",
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: new RegExp(
            `[\\/]node_modules[\\/](${moduleList.join("|")})[\\/]`
          ),
          chunks: "initial",
          name: "vendors",
          enforce: true
        }
      }
    }
  }

Notice that I create one entry with all of the code included and then I specify with splitChunks.cacheGroups.vendor.test which modules should be split out to the vendor chunk.

Still, I'm not sure if this is 100% correct or if it could be improved as this is literally one of the most confusing things ever. However, this seems to be closest to the documentation, seems to produce correct chunks when I inspect them with webpack-bundle-analyzer (only updates the chunks that were changed and rest of them stays the same across builds) and fixes the issue with polyfill.

Edroi answered 26/2, 2018 at 10:40 Comment(5)
"this is literally one of the most confusing things ever" webpack generallyTebet
I understand that webpack is designed to be very flexible and configurable so this makes it more complex to configure... but building an app bundle/vendor bundle looks like a pretty basic/standard requirement. It is crazy that there is no clear description about how to achieve this :(Cilla
I don't find this much confusing at all. I see a readabe, logical and proper solution here. (But of course... I'm already used to webpack "madness" :D)Interlaken
Can you elaborate on how you used webpack-bundle-analyzer to determine which chunks were changing as a result of code changes? Do you just mean you manually checked the output tree display before and after?Adipocere
@Adipocere Maybe easier, just yarn build; npx serve -p 1337 -s ./build/; npx bundle-wizard localhost:1337 --port=1338 and you get a nice explorer view in your browser.Scurrility
A
10

I found a much shorter way to do this:

optimization: {
  splitChunks: { name: 'vendor', chunks: 'all' }
}

When splitChunks.name is given as a string, the documentation says: "Specifying either a string or a function that always returns the same string will merge all common modules and vendors into a single chunk." In combination with splitChunks.chunks, it will extract all dependencies.

Aeneus answered 12/3, 2020 at 8:48 Comment(0)
E
8

I think if you do this:

optimization: {
    splitChunks: {
        chunks: 'all',
    },
    runtimeChunk: true,
}

It will create a vendors~ and runtime~ chunk for you. Sokra said the default for splitChunks is this:

splitChunks: {
    chunks: "async",
    minSize: 30000,
    minChunks: 1,
    maxAsyncRequests: 5,
    maxInitialRequests: 3,
    name: true,
    cacheGroups: {
        default: {
            minChunks: 2,
            priority: -20
            reuseExistingChunk: true,
        },
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            priority: -10
        }
    }
}

Which already includes a vendors and default bundle. In testing, I haven't seen a default bundle appear.

I don't know what the expected workflow for including these files is, but I wrote this helper function in PHP:

public static function webpack_asset($chunkName, $extensions=null, $media=false) {
    static $stats;
    if($stats === null) {
        $stats = WxJson::loadFile(WX::$path.'/webpack.stats.json');
    }
    $paths = WXU::array_get($stats,['assetsByChunkName',$chunkName],false);
    if($paths === false) {
        throw new \Exception("webpack asset not found: $chunkName");
    }
    foreach($stats['assetsByChunkName'] as $cn => $files) {
        if(self::EndsWith($cn, '~' . $chunkName)) {
            // prepend additional supporting chunks
            $paths = array_merge($files, $paths);
        }
    }
    $html = [];
    foreach((array)$paths as $p) {
        $ext = WXU::GetFileExt($p);
        if($extensions) {
            if(is_array($extensions)) {
                if(!in_array($ext,$extensions)) {
                    continue;
                }
            } elseif(is_string($extensions)) {
                if($ext !== $extensions) {
                    continue;
                }
            } else {
                throw new \Exception("Unexpected type for \$extensions: ".WXU::get_type($extensions));
            }
        }
        switch($ext) {
            case 'js':
                $html[] = WXU::html_tag('script',['src'=>$stats['publicPath'].$p,'charset'=>'utf-8'],'');
                break;
            case 'css':
                $html[] = WXU::html_tag('link',['href'=>$stats['publicPath'].$p,'rel'=>'stylesheet','type'=>'text/css','media'=>$media],null); // "charset=utf-8" doesn't work in IE8
                break;
        }
    }
    return implode(PHP_EOL, $html);
}

Which works with my assets plugin (updated for WP4):

{
    apply: function(compiler) {
        //let compilerOpts = this._compiler.options;
        compiler.plugin('done', function(stats, done) {
            let assets = {};
            stats.compilation.namedChunks.forEach((chunk, name) => {
                assets[name] = chunk.files;
            });

            fs.writeFile('webpack.stats.json', JSON.stringify({
                assetsByChunkName: assets,
                publicPath: stats.compilation.outputOptions.publicPath
            }), done);
        });
    }
},

All of this spits out something like:

<script src="/assets/runtime~main.a23dfea309e23d13bfcb.js" charset="utf-8"></script>
<link href="/assets/chunk.81da97be08338e4f2807.css" rel="stylesheet" type="text/css"/>
<script src="/assets/chunk.81da97be08338e4f2807.js" charset="utf-8"></script>
<link href="/assets/chunk.b0b8758057b023f28d41.css" rel="stylesheet" type="text/css"/>
<script src="/assets/chunk.b0b8758057b023f28d41.js" charset="utf-8"></script>
<link href="/assets/chunk.00ae08b2c535eb95bb2e.css" rel="stylesheet" type="text/css" media="print"/>

Now when I modify one of my custom JS files, only one of those JS chunks changes. Neither the runtime nor the vendors bundle needs to be updated.

If I add a new JS file and require it, the runtime still isn't updated. I think because the new file will just be compiled into the main bundle -- it doesn't need to be in the mapping because it's not dynamically imported. If I import() it, which causes code-splitting, then the runtime gets updated. The vendors bundle also appears to have changed -- I'm not sure why. I thought that was supposed to be avoided.

I also haven't figured out how to do per-file hashes. If you modify a .js file which is the same chunk as a .css file, both their filenames will change with [chunkhash].


I updated the assets plugin above. I think the order in which you include the <script> tags might matter... this will maintain that order AFAICT:

const fs = require('fs');

class EntryChunksPlugin {

    constructor(options) {
        this.filename = options.filename;
    }

    apply(compiler) {
        compiler.plugin('done', (stats, done) => {
            let assets = {};

            // do we need to use the chunkGraph instead to determine order??? https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693#gistcomment-2381967
            for(let chunkGroup of stats.compilation.chunkGroups) {
                if(chunkGroup.name) {
                    let files = [];
                    for(let chunk of chunkGroup.chunks) {
                        files.push(...chunk.files);
                    }
                    assets[chunkGroup.name] = files;
                }
            }

            fs.writeFile(this.filename, JSON.stringify({
                assetsByChunkName: assets,
                publicPath: stats.compilation.outputOptions.publicPath
            }), done);
        });
    }
}

module.exports = EntryChunksPlugin;
Extrorse answered 16/3, 2018 at 19:16 Comment(1)
How can I get the vendors cacheGroup to get compiled with Babel?Ruggles
U
2

It seems the order of entry files also matter. Since you have client.js before vendor, the bundling doesn't happen of vendor before your main app.

entry: {
 vendor: ['react', 'react-dom', 'react-router'],
 app: paths.appIndexJs
},

Now with the SplitChunks optimisation you can specify the output file name and refer to the entry name vendor as:

optimization: {
 splitChunks: {
  cacheGroups: {
    // match the entry point and spit out the file named here
    vendor: {
      chunks: 'initial',
      name: 'vendor',
      test: 'vendor',
      filename: 'vendor.js',
      enforce: true,
    },
  },
 },
},
Unkempt answered 17/12, 2018 at 10:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.