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 allimport
/export
declarations andimport()
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 setstrict
totrue
. If you're usingtsc --init
to create yourtsconfig.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 useasync
/await
. To emit these helper functions only once per bundle rather than once per usage, we'll instruct the compiler to import them from thetslib
package. Check out TypeScript 2.1: External Helpers Library for more information on theimportHelpers
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, themain.ts
file. This is where webpack starts walking our application's dependency graph.output
: We want the compiled JavaScript bundles to be written to thedist
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 wroteimport("./widget")
without specifying a file extension). Theresolve
option tells webpack which extensions to resolve automatically.module
: We want to use thets-loader
package to compile all TypeScript files within thesrc
directory during the bundling process.ts-loader
uses our locally installedtypescript
package as well as ourtsconfig.json
file.devServer
: If we locally run thewebpack-dev-server
during development, we want it to serve files (e.g. anindex.html
file) from thedist
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!