Vue-CLI and Single File Webpack Bundles

As I spend more and more time in the Vue.js ecosystem, I’m developing an a much greater appreciation for the time the core team has spent developing tools that make managing the project configuration.

That said, a big part of managing a Vue.js project still requires a lot of Webpack knowledge… More than I have, unfortunately.

Today I was working on a sort of micro-application that would be embedded on various web pages that I really have no control over. More specifically, it’s a web app that enables consent management for the CCPA privacy regulations, and it handles conditionally popping open a small UI for California visitors and then providing the opt-out signals to upstream code like prebid.js.

This app needs to load on all sorts of third-party web pages, and ideally the functionality would be enabled by simply including a JavaScript in the header of the page before the rest of the site’s advertising technology stack would load. This means the Vue.js app doesn’t really own the page, and it really needed to load as a cleanly as possible in a single JavaScript file.

This took a little more wrangling that I’d expected, and much like my deeper dives into doing things like adding Snap.svg to Vue and Nuxt projects, it required a bit of a deeper dive into Webpack and the configuration chaining behavior of the newer vue-cli generated apps.

Injecting your own mount DIV

One of the things your Vue.js project always needs is a place to mount itself on the web page. Generally, your project will have some sort of HTML template that hosts it, and often your whole site may be the actual Vue.js app and that simple HTML file is all you need to deploy.

More often than not, however, I’m loading Vue.js apps onto existing pages, and that involves just adding a <div> element somewhere with the right ID in an existing layout.

For this project, I had zero control over the host pages so I had to dynamically inject a mount point. To do this, I had to modify the main.js file in my Vue project to do something like this…

import Vue from 'vue'
import App from './App.vue'

let gMyApp = undefined;

document.addEventListener("DOMContentLoaded", () => {
  
    // Inject a private mount point div to display UI...

    let body = document.getElementsByTagName('body')[0];
    body.innerHTML = body.innerHTML + "<div id=myApp_mount />";
    
    gMyApp = new Vue( { render: h => h(App), } ).$mount('#myApp_mount')

} );

Inspecting the generated webpack configuration redux

In my previous article, I talked about inspecting the vue-cli generated Webpack configuration by using ‘vue inspect’ to eject a copy of the configuration, and then modifying it using webpack-chain in the vue.config.js file.

One detail that I missed in this earlier exploration was the need to eject a production configuration. To do this, you use the following command and your shell…

vue inspect --mode production > ejected.js

Without the mode command line variable, what you’re seeing is your development configuration, which can run a whole different chain of loaders and perform other Webpack mischief you’re not expecting.

vendors.js must go!

But back to my original problem of getting a single bundle as output from the project. By default, the Vue.js CLI will kick out separate bundles for your code, vendor libraries and any extracted CSS. Trying to hand this off to a third party and explaining they needed to include a dog’s breakfast of separate files on all of their site pages was a deal breaker, so figuring out how to merge all of these files in the production build was important.

The easy first step was to just stop Webpack from splitting the JavaScript into separate chunks. That’s actually Webpack’s default behavior, so all we need to do is get rid of some of the configuration passed into the split chunks plugin by vue-cli’s defaults. Using chain-webpack in vue.config.js, that looks something like this…

module.exports = {

chainWebpack: config=> {

   // Disable splitChunks plugin, all the code goes into one bundle.

   config.optimization
     .splitChunks( ).clear();

} }

Combining CSS and JavaScript in one Webpack bundle

That got the project down to a single emitted JavaScript file and a single CSS bundle. And that’s where things got interesting.

To get the CSS to reside with the JavaScript code, it needed to be packaged and injected. That mean undoing the extraction of the CSS into its separate file and then separately using style-loader to sneak it into the DOM when the JavaScript was executed on the page.

The first step was disabling the existing extraction process handled by the extract-css-loader. Again, inside vue.config.js in the chainWebpack:config function…

config.module.rule('css').oneOf('vue').uses.delete('extract-css-loader');
Running the build with that command in place got rid of the separate CSS in the dist directory. But, it didn’t load on the page. The bundle size actually went up, so it was making it into the bundle, but we needed one more step to actually do the DOM injection. Inspecting the config again made it clear nobody was picking up the output from the chain of CSS loaders, and what we really needed was to add style-loader to the end of the list to get it to do that last step for us…
config.module
   .rule('css')
      .oneOf('vue')
         .use('style-loader')
            .before('css-loader')
            .loader('style-loader')
            .end();

With that in place, the project kicked out a single .js file that had everything in it… vendor and project code, plus the css. And, the css actually got injected when the script ran. Perfect!

Bonus Episode! Renaming the Webpack Output File

As a final bit of convenience, I modified the webpack generated filename to use a date format necessary for this project. While normally the cache-busting hashes are super useful for making sure everything deploys in sync, in this case we wanted the filename to reflect the build date.
// Get the filename into the format we use for different builds.

let today = newDate();
let mm = today.getMonth() +1; // getMonth() is zero-based
let dd = today.getDate();
let yy = today.getFullYear().toString().substr(-2);

let dateStr = [ yy, (mm>9?'':'0') + mm, (dd>9?'':'0') + dd ].join('');

config.output.filename( 'myapp-'+ dateStr +'.js' )

Perhaps a little wordy, but it gets the job done.

Putting it all together in one webpack chain configuration

Putting all the parts together…
module.exports = {

chainWebpack: config=> {

   // Get the filename into the format we use for different builds.

   let today = newDate();
   let mm = today.getMonth() +1; // getMonth() is zero-based
   let dd = today.getDate();
   let yy = today.getFullYear().toString().substr(-2);

   let dateStr = [ yy, (mm>9?'':'0') + mm, (dd>9?'':'0') + dd ].join('');

   config.output.filename( 'myapp-'+ dateStr +'.js' )


   // Disable splitChunks plugin, all the code goes into one bundle.

   config.optimization.splitChunks( ).clear();


   // Disable the CSS extraction into a separate file.

   config.module.rule('css').oneOf('vue').uses.delete('extract-css-loader');

   // Take the CSS from the bundle and inject it in the DOM when
   // the page loads...

   config.module
      .rule('css')
      .oneOf('vue')
         .use('style-loader')
            .before('css-loader')
            .loader('style-loader')
            .end();
} }


One further caveat with this solution… If you’re using various CSS preprocessors, you’ll see when you inspect the Webpack configuration various loader chains for each of the individual CSS preprocessors (less, sass, scss, stylus) and you’ll need similar sets of rules to delete extract-css-loader and insert style-loader for each of those scenarios. For my project, I only needed to update the loader chains in…
config.module.rule('css').oneOf('vue')...
…but looking at the configurations there are a variety of other loader chains for different module types and for the different CSS preprocessors, so depending on what your project uses you made need to repeat deleting the extract loader and inserting the style-loader for those using additional chain rules.

Leave a Reply

Your email address will not be published. Required fields are marked *