The joke I’ve heard goes like this:

I went to an all night JavaScript hackathon and by morning we finally had the build process configured!

Like most jokes there is an element of truth to the matter.

I’ve been working on an application that is mostly server rendered and requires minimal amounts of JavaScript. However, there are “pockets” in the application that require a more sophisticated user experience, and thus a heavy dose of JavaScript. These pockets all map to a specific application feature, like “the accounting dashboard” or “the user profile management page”.

These facts led me to the following requirements:

1. All third party code should build into a single .js file.

2. Each application feature should build into a distinct .js file.

Requirement #1 requires the “vendor bundle”. This bundle contains all the frameworks and libraries each application feature depends on. By building all this code into a single bundle, the client can effectively cache the bundle, and we only need to rebuild the bundle when a framework updates.

Requirement #2 requires multiple “feature bundles”. Feature bundles are smaller than the vendor bundle, so feature bundles can re-build each time a file inside changes. In my project, an ASP.NET Core application using feature folders, the scripts for features are scattered inside the feature folders. I want to build feature bundles into an output folder and retain the same feature folder structure (example below).

I tinkered with various JavaScript bundlers and task runners until I settled on webpack. With webpack I found a solution that would support the above requirements and provide a decently fast development experience.

The Vendor Bundle

Here is a webpack configuration file for building the vendor bundle. In this case we will build a vendor bundle that includes React and ReactDOM, but webpack will examine any JS module name you add to the vendor array of the configuration file. webpack will place the named module and all of its dependencies into the output bundle named vendor.js. For example, Angular 2 applications would include “@angular/common” in the list. Since this is an ASP.NET Core application, I’m building the bundle into a subfolder of the wwwroot folder.

const webpack = require("webpack"); const path = require("path"); const assets = path.join(__dirname, "wwwroot", "assets"); module.exports = { resolve: { extensions: ["", ".js"] }, entry: { vendor: [ "react", "react-dom" ... and so on ... ] }, output: { path: assets, filename: "[name].js", library: "[name]_dll" }, plugins: [ new webpack.DllPlugin({ path: path.join(assets, "[name]-manifest.json"), name: '[name]_dll' }), new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }) ] };

webpack offers a number of different plugins to deal with common code, like the CommonsChunk plugin. After some experimentation, I’ve come to prefer the DllPlugin for this job. For Windows developers, the DllPlugin name is confusing, but the idea is to share common code using “dynamically linked libraries”, so the name borrows from Windows.

DllPlugin will keep track of all the JS modules webpack includes in a bundle and will write these module names into a manifest file. In this configuration, the manifest name is vendor-manifest.json. When we build the individual feature bundles, we can use the manifest file to know which modules do not need to appear in those feature bundles.

Important note: make sure the output.library property and the DllPlugin name property match. It is this match that allows a library to dynamically “link” at runtime.

I typically place this vendor configuration into a file named webpack.vendor.config.js. A simple npm script entry of “webpack --config webpack.vendor.config.js” will build the bundle on an as-needed basis.

Feature Bundles

Feature bundles are a bit trickier, because now we need webpack to find multiple entry modules scattered throughout the feature folders of an application. In the following configuration, we’ll dynamically build the entry property for webpack by searching for all .tsx files inside the feature folders (tsx being the extension for the TypeScript flavor of JSX).

const webpack = require("webpack"); const path = require("path"); const assets = path.join(__dirname, "wwwroot", "assets"); const glob = require("glob"); const entries = {}; const files = glob.sync("./Features/**/*.tsx"); files.forEach(file => { var name = file.match("./Features(.+/[^/]+)\.tsx$")[1]; entries[name] = file; }); module.exports = { resolve: { extensions: ["", ".ts", ".tsx", ".js"], modulesDirectories: [ "./client/script/", "./node_modules" ] }, entry: entries, output: { path: assets, filename: "[name].js" }, module: { loaders: [ { test: /\.tsx?$/, loader: 'ts-loader' } ] }, plugins: [ new webpack.DllReferencePlugin({ context: ".", manifest: require("./wwwroot/assets/vendor-manifest.json") }) ] };

A couple notes on this particular configuration file.

First, you might have .tsx files inside a feature folder that are not entry points for an application feature but are supporting modules for a particular feature. In this scenario, you might want to identify entry points using a naming convention (like dashboard.main.tsx). With the above config file, you can place supporting modules or common application code into the client/script directory. webpack’s resolve.modulesDirectories property controls this directory name, and once you enter in a specific directory name you’ll also need to explicitly include node_modules in the list if you still want webpack to search node_modules for a piece of code. Both webpack and the TypeScript compiler need to know about the custom location for modules, so you’ll also need to add a compilerOptions.path setting in the tsconfig.json config file for TypeScript (this is a fantastic new feature in TypeScript 2.*).

{ "compilerOptions": { "noImplicitAny": true, "noEmitOnError": true, "removeComments": false, "sourceMap": true, "module": "commonjs", "target": "es5", "jsx": "react", "baseUrl": ".", "moduleResolution": "node", "paths": { "*": [ "*", "Client/script/*" ] } }, "compileOnSave": false, "exclude": [ "node_modules", "wwwroot" ] }

Secondly, the output property of webpack’s configuration used to confuse me until I realized you can parameterize output.filename with [name] and [hash] parameters (hash being something you probably want to add to the configuration to help with cache busting). It looks like output.filename will only create a single file from all of the entries. But, if you have multiple keys in the entry property, webpack will build multiple output files and even create sub-directories.

For example, given the following entry:

entry: { '/Home/Home': './Features/Home/Home.tsx', '/Admin/Users/ManageProfile': './Features/Admin/Users/ManageProfile.tsx' }

webpack will create /home/home.js and /admin/users/manageprofile.js in the wwwroot/assets directory.

Finally, notice the use of the DllReferencePlugin in the webpack configuration file. Give this plugin the manifest file created during the vendor build and all of the framework code is excluded from the feature bundle. Now when building the page for a particular feature, include the vendor.js bundle first with a script tag, and the bundle specific to the given feature second.

Summary

As easy as it may sound, arriving at this particular solution was not an easy journey. The first time I attempted such a feat was roughly a year ago, and I gave up and went in a different direction. Tools at that time were not flexible enough to work with the combination of everything I wanted, like custom module folders, fast builds, and multiple bundles. Even when part of the toolchain worked, editors could fall apart and show false positive errors.

It is good to see tools, editors, and frameworks evolve to the point where the solution is possible. Still, there are many frustrating moments in understanding how the different pieces work together and knowing the mental model required to work with each tool, since different minds build different pieces. Two things I’ve learned are that documentation is still lacking in this ecosystem, and GitHub issues can never replace StackOverflow as a good place to look for answers.