3

I have successfully got Webpack and the CommonsChunkPlugin to split my code in two - one bundle with my codebase, and one with everything imported from node_modules. That was the relatively easy bit.

The next thing I'm trying to achieve in my app is to dynamically import a "sub-app", which has quite different vendor package requirements (e.g. React - my main app doesn't use it, but my sub-app does), without all of those packages appearing in the main vendor file.

If I add in the import(), but leave my Webpack config relatively untouched, I end up with 4 bundles

  1. Webpack runtime
  2. The main app codebase
  3. Vendor bundle with everything imported in the main app codebase
  4. The dynamically imported bundle, also containing all of the node_modules imported in it too :(

This isn't desirable. I'd like the same benefit of 'my code vs vendor code' that I get from my main codebase for my sub-app too. Ideally I'd end up with 5 bundles, with #4 in my list above split into two. When the dynamic import occurs at runtime, it would somehow magically load in both my sub-app code bundle, AND the accompanying sub-app-vendor bundle too. Ideally that sub vendor bundle wouldn't contain anything that was present in the main vendor bundle.

After attempting lots of things I've found in various blog posts, I got one situation working where I was manually selecting the node_modules directories I'd like to include in a separate vendor bundle, but the problem was that it wouldn't include their dependencies automatically, so I'd still end up with lots of node_modules in my sub app bundle - ones that I haven't specifically imported.

If I can get this to work correctly, I'd then like to replicate it for more sub-apps of my main app.


UPDATE 1

My Webpack config is split into 3 files - common, dev and prod. Only common and dev are relevant to this, so I'll share them here.

webpack.common.js

const webpack = require('webpack');
const path = require('path');

const NameAllModulesPlugin = require('name-all-modules-plugin');

module.exports = {
    entry: {
        /**
         * main.js - our global platform JS
         */
        main: './src/app.js'
    },
    module: {
        loaders: [
            {
                test: /\.js$/,
                exclude: /node_modules/,
                loader: 'babel-loader',
                query: {
                    presets: [
                        [
                            'env',
                            {
                                'targets': {
                                    'browsers': ['last 3 versions', 'ie >= 11']
                                }
                            }
                        ],
                        'react'
                    ],
                    plugins: [
                        'transform-class-properties',
                        'transform-object-rest-spread',

                        // Followed instructions here to get dynamic imports working
                        // http://docs1.w3cub.com/webpack~2/guides/code-splitting-import/
                        'syntax-dynamic-import',
                        'transform-async-to-generator',
                        'transform-regenerator',
                        'transform-runtime'
                    ]
                }
            }
        ]
    },
    resolve: {
        alias: {
            src: path.resolve(__dirname, 'src/')
        }
    },
    plugins: [
        new webpack.ProvidePlugin({
            $: 'jquery',
            jQuery: 'jquery',
            CodeMirror: 'codemirror'
        }),

        new webpack.NamedModulesPlugin(),

        new webpack.NamedChunksPlugin(),

        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: (m) => /node_modules/.test(m.context)
        }),

        new webpack.optimize.CommonsChunkPlugin({
            name: 'runtime',
            minChunks: Infinity
        }),

        new NameAllModulesPlugin()
    ]
};

webpack.dev.js

const webpack = require('webpack');
const path = require('path');
const merge = require('webpack-merge');

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

const common = require('./webpack.common.js');

module.exports = merge(common, {
    devtool: 'inline-source-map',
    output: {
        filename: '[name].js',
        chunkFilename: '[name]-chunk.js', // used for async chunks (those loaded via import())
        path: path.resolve(__dirname, 'build'),
        publicPath: '/js/build/'
    },
    plugins: [
        // Uncomment and run build, to launch the bundle analyzer webpage
        new BundleAnalyzerPlugin(),

        new webpack.DefinePlugin({
            'process.env': { NODE_ENV: JSON.stringify('dev') }
        })
    ]
});

UPDATE 2

I've stumbled on a config that appears to work. It even automatically loads in the sub-app's vendor chunk at the same time as the actual import.

// For the main app's modules
new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: (m, count) => /node_modules/.test(m.context)
}),

// For my sub app's modules
new webpack.optimize.CommonsChunkPlugin({
    name: 'any-name-here', // doesn't appear to be used anywhere, but prevents 'main' from showing up in the chunk filename (?!)
    chunks: ['name-of-dynamic-import'], // this has to be the 'webpackChunkName' you've used within the import() statement
    async: 'name-of-dynamic-import-vendor', // name the chunk filename
    minChunks: (m, count) => /node_modules/.test(m.context)
}),

new webpack.optimize.CommonsChunkPlugin({
    name: 'runtime',
    minChunks: Infinity
}),
Gav
  • 431
  • 5
  • 12
  • Hey Gav, do you think that you can provide your webpack configuration? I have a feeling that there is a way to accomplish this but very much depends on how your entry points are setup in addition to how your CommonsChunkPlugin is configured as well. – Sean Larkin Jan 04 '18 at 17:36
  • @SeanLarkin I've updated my post with the config which produces the 4 bundles: main.js, runtime.js, vendor.js, and the dynamically imported bundle. – Gav Jan 04 '18 at 17:49

1 Answers1

0

So let's breakdown what CommonsChunkPlugin "instructions" you are providing:

Dynamic Vendor Chunk Creation

    new webpack.optimize.CommonsChunkPlugin({
        name: 'vendor',
        minChunks: (m) => /node_modules/.test(m.context)
    }),

Above you are telling webpack that it needs to includes "x" modules into a specific new bundle called "vendor" based on only one condition. And that condition is that this modules fully resolved path is within node_modules. However if you look at our documentation, there is a second argument: count which gets called and you can leverage in your app. This is important because right now, if you had 20 lazy loaded bundles, and only one used "react", that all bundles would require that you load that react dependency (aka count=1) from your vendor chunk. Instead, I would recommend that you append your predicate here to show:

minChunks: (m, count) => /node_modules/.test(m.context) && count >= 10

In conversational terms you can consider this telling webpack:

Sup, webpack! Whenever you come across a module (whos resolved path includes node_modules), that is referenced in 10 or more bundles [chunks], extract it into a separate bundle called vendor.

This way, if and only if x amount of lazy bundles needs this dep, that it will get moved to the vendor chunk. Now if you reach 10 lazy bundles that require this dep, they would get surfaced up. You can also add the async: true flag in the plugin options if you wanted this to not float up as a sync dependency, but instead a shared async bundle.

Force webpack Runtime into Separate Bundle

    new webpack.optimize.CommonsChunkPlugin({
        name: 'runtime',
        minChunks: Infinity
    })

This is specifically a feature for long term caching so that when you lazy load bundles, the manifest which could alter doesn't cause hash changes. This can stay the same.

Sean Larkin
  • 6,290
  • 1
  • 28
  • 43
  • Thanks for your help Sean, but unfortunately I've implemented your solution and it has only made it worse. Now the vendor.js bundle has disappeared altogether, and all of the node_modules have merged into main.js alongside the rest of my code. I'm trying to ensure node_modules are always separated out from my code, so if I push a code update I don't force users to download all of the node modules again. – Gav Jan 04 '18 at 18:55
  • It seems to be best practise to separate node modules from your actual code, so that when you push new code (but node modules haven't changed) that you're not forcing users to download things that haven't changed. So smaller files, improved cache performance. I'd like to make wide use of dynamic imports too, and those imported apps may have different node module requirements that the base doesn't have. – Gav Jan 04 '18 at 19:08
  • Totally makes sense. However improved cache performance can also have a cost. If 1/100 entries use lodash, the above plugin will still add it to the vendor bundle. However the trade-off is that each entrypoint now, has to load the vendor bundle now. (when its only needed 1% of the time). – Sean Larkin Jan 04 '18 at 21:44
  • Here is my recommendation: remove the current CommonsChunkPlugin you have for your vendor bundle, and instead just start optimizing you app with code splitting (lazy-loading with `import()`) first. Then one you have gotten your initial download to 180-220kb (uncompressed) total JavaScript for your page, then I'd start focusing on caching. The number one cost of slowness is always the _parse_, _eval_, and _execution_ of JavaScript, so even caching (for every browser) may not include this. – Sean Larkin Jan 04 '18 at 21:47
  • I understand your proposed solution and reasoning, but while I only have a very small number of async chunks (just one to begin with) it doesn't seem like the most suitable solution. But I have just stumbled on one that works, so I'll copy that as answer. I would appreciate if you could review it. – Gav Jan 05 '18 at 01:19