Marius Schulz
Marius Schulz
Front End Engineer

Code-Splitting a TypeScript Application with import() and webpack

This post outlines how to set up code splitting in a client-side web application using dynamic import() expressions, TypeScript, and webpack.

tl;dr: Check out my typescript-webpack-dynamic-import repository on GitHub for the final application setup with all configuration in place.

#Setting Up Our Demo Application

In my previous post TypeScript 2.4: Dynamic import() Expressions, we used two modules (main.ts and widget.ts) to explain how import() expressions let us lazily import modules on demand. We'll use these two modules again in this post.

Within widget.ts, we've implemented a simple jQuery widget. The module imports the jquery package and exports a single render function:

import * as $ from "jquery";

export function render(container: HTMLElement) {
  $(container).text("Hello, World!");
}

Within main.ts, we first check whether we find the container into which we want to render the widget. We then dynamically import the widget module if we were able to find the container:

async function renderWidget() {
  const container = document.getElementById("widget");
  if (container !== null) {
    const widget = await import("./widget");
    widget.render(container);
  }
}

renderWidget();

We'll also need a bunch of npm packages to set up our build process:

yarn add es6-promise jquery tslib
yarn add @types/jquery ts-loader typescript webpack webpack-dev-server --dev

Alright, with these packages in place, let's start by configuring the TypeScript compiler.

#Configuring the TypeScript Compiler

Here's a first version of our tsconfig.json file:

{
  "compilerOptions": {
    "target": "es5",
    "moduleResolution": "node",
    "module": "esnext",
    "strict": true,
    "importHelpers": true
  }
}

Let's go through each of the options specified above:

  • target: To support older browsers, we want to target ES5 as a language level. You can bump this setting to "es2015" (or higher) if you don't need to support older browsers.
  • moduleResolution: We want the TypeScript compiler to mimic the module resolution mechanism that Node itself uses, e.g. to have it pick up types from npm packages automatically. Check out the Module Resolution chapter in the TypeScript documentation for more information.
  • module: We want the compiler to emit all import/export declarations and import() expressions unchanged. We'll let webpack bundle and split our code later.
  • strict: We opt into strict type checking mode to get the highest level of type safety for our application. I recommend you always set strict to true. If you're using tsc --init to create your tsconfig.json files, this setting is enabled by default.
  • importHelpers: Since we target ES5 as a language level, the TypeScript compiler emits a bunch of helper functions like __awaiter and __generator whenever we use async/await. To emit these helper functions only once per bundle rather than once per usage, we'll instruct the compiler to import them from the tslib package. Check out TypeScript 2.1: External Helpers Library for more information on the importHelpers compiler option.

Next up: polyfills!

#Adding a Promise Polyfill

If you're not in the luxurious position that your application only needs to run in the newest evergreen browsers, chances are you have to support an older browser like IE11. Unfortunately, IE11 and other older browsers don't have a native Promise implementation. Therefore, you'll need a Promise polyfill because async/await and import() expressions are built on top of promises.

Let's import the es6-promise package within our main.ts module:

import * as ES6Promise from "es6-promise";
ES6Promise.polyfill();

async function renderWidget() {
  const container = document.getElementById("widget");
  if (container !== null) {
    const widget = await import("./widget");
    widget.render(container);
  }
}

renderWidget();

Since we're targeting ES5 as a language level, TypeScript will error and let us know that there is no Promise in ES5 environments. We need to tell the compiler that it can assume to find a Promise implementation at runtime (either implemented natively or provided via our polyfill).

To do this, we'll have to provide the lib compiler option in our tsconfig.json file and specify the list of library files to be included in the compilation. Once we specify that option, the default libraries are no longer injected automatically, so we'll have to explicitly spell out all library files we need.

Our updated tsconfig.json file now looks like this:

{
  "compilerOptions": {
    "target": "es5",
    "moduleResolution": "node",
    "module": "esnext",
    "strict": true,
    "importHelpers": true,
    "lib": ["dom", "es5", "es2015.promise"]
  }
}

Alright, let's finally move on to the webpack configuration.

#Configuring webpack

Just like before, let's look at the entire webpack.config.js file first:

const path = require("path");

module.exports = {
  entry: "./src/main.ts",

  output: {
    path: path.join(__dirname, "dist"),
    filename: "[name].bundle.js",
    chunkFilename: "[name].chunk.js",
  },

  resolve: {
    extensions: [".js", ".ts"],
  },

  module: {
    loaders: [
      {
        test: /\.ts$/,
        include: path.join(__dirname, "src"),
        loader: "ts-loader",
      },
    ],
  },

  devServer: {
    contentBase: "./dist",
  },
};

Here's what all the individual settings do:

  • entry: Our entry module, the main.ts file. This is where webpack starts walking our application's dependency graph.
  • output: We want the compiled JavaScript bundles to be written to the dist folder. Here, we can also specify a file name pattern for bundles and chunks.
  • resolve: We want to be able to import modules without having to specify the file extension (recall that we wrote import("./widget") without specifying a file extension). The resolve option tells webpack which extensions to resolve automatically.
  • module: We want to use the ts-loader package to compile all TypeScript files within the src directory during the bundling process. ts-loader uses our locally installed typescript package as well as our tsconfig.json file.
  • devServer: If we locally run the webpack-dev-server during development, we want it to serve files (e.g. an index.html file) from the dist directory.

Let's add the following two scripts to our package.json to make it a little easier to trigger webpack builds or to start the webpack development server:

{
  // ...

  "scripts": {
    "build": "webpack",
    "start": "webpack-dev-server"
  }

  // ...
}

Note that we're doing a development build here. For a production build, you'd add the -p flag to both commands.

#Compiling and Bundling Our Application

Now that we've configured everything, it's time to compile and bundle our application. Run the following command in your favorite terminal to initiate a webpack build:

yarn build

You should now see the following two files in your dist folder:

  • 0.chunk.js
  • main.bundle.js

The main.bundle.js file includes our main.ts module as well as the es6-promise polyfill, while the 0.chunk.js module contains our widget and the jquery package. Very nice!

Let's make one final tweak before we wrap it up.

#Specifying webpack Chunk Names

Right now, webpack will use an incrementing counter for the chunk names, which is why our chunk file was named 0.chunk.js. We can provide an explicit chunk name by adding a special comment within the import() expression:

import * as ES6Promise from "es6-promise";
ES6Promise.polyfill();

async function renderWidget() {
  const container = document.getElementById("widget");
  if (container !== null) {
    const widget = await import(/* webpackChunkName: "widget" */ "./widget");
    widget.render(container);
  }
}

renderWidget();

If you now run the build script again, webpack will emit the following two JavaScript files:

  • main.bundle.js
  • widget.chunk.js

The benefit of named chunks is that they're easier to debug because you can recognize the chunk by its name right away. Also, you get stable file names which allow you to prefetch the chunks ahead of time for better application performance.

And that's it! If you want to play around with the demo application shown in this blog post, make sure to check out my typescript-webpack-dynamic-import repository on GitHub. Happy coding!