Streamlining TypeScript development with webpack dynamic imports

HTTP/2 is upon us. Lazy-loading resources doesn't have the same performance disadvantages as in HTTP/1. This allows us to prefer loading many files on demand rather than loading a big blob of JavaScript on the initial load. Let's look at a pattern that simplifies the process of creating these modules.

This post is a successor to my earlier post Streamlining Modularized JS patterns using Browserify and Vue.js. Make sure to check it out!

If you're already using a popular web framework, like Vue or React, chances are that you're already lazy-loading through the router. If you're using a different kind of routing, typically non-SPAs, then this article might be useful for you.

Let's create a way to load modules whenever it shows up in the DOM. Whenever I define a module like this <div data-module="minesweeper" data-options="{ bombs: 15 }" /> in the DOM, I want all scripts for that module to load.

In this example, we'll use Svelte. Svelte is an upcoming web framework that isn't a web framework. It's a compiler. The parts of Svelte that our module will use will be cherrypicked and we're left with a real skinny codebase to load.

I wrote a small minesweeper game in Svelte and JavaScript a few weeks ago. The repo for minesweeper.svelte can be found on GitHub. Here's how that game looks.

A game of minesweeper

Load scripts based on folder name

Let's look at the following file structure.

modules/
├── minesweeper/
    ├── index.ts
    ├── game.svelte

Minesweeper is a feature folder. The folder name must match the data-module attribute name in the DOM. With Webpack dynamic import and Webpack magic strings, we can create a way to automatically bundle up our modules into separate chunks of JavaScript that can be loaded on demand.

All of our index.ts files need to have a similar interface. This is where typescript shines. It allows more strict boundaries in our modules. Let's define that interface!

export interface IModule {
    init: Function;
}

This interface is really thin and will allow for easier expansion in the future. And then we implement that interface in our minesweeper module.

import minesweeper from './minesweeper.svelte';
import { IModule } from '../../interfaces/module';
const app: IModule = {
    init: (el: HTMLElement, options: Object = {}) => new minesweeper({ target: el })
}
export default app;

The module contains an init function that takes two arguments. The logic inside this module is using Svelte, but the implementation allows other kinds of code as well. Now that we've established a way to write modules, let's create a way to load them!

const registerModule: (value: HTMLElement, key: number, parent: HTMLElement[]) => void = (el) => {
    const moduleName = el.dataset.module;
    const options = typeof(el.dataset.options) !== 'undefined' ? JSON.parse(el.dataset.state) : {};
    import(`./modules/${moduleName}/index` /* webpackMode: "lazy", webpackChunkName: "[request]" */).then(({ default: module }) => {
        if(typeof(module.init) === 'undefined') return;
        module.init(el, options);
    }).catch((reason) => console.error(reason)); 
};

const allModules: NodeListOf<HTMLElement> = document.querySelectorAll('[data-module]');
Array.from(allModules).forEach(registerModule);

This script will scan the DOM and find all elements that match data-module and do an import for each module it finds. After the module gets loaded, we call the init method in the module and inject the HTMLElement and our options.

You might have noticed the funny looking comment inside the import statement. This comment is used by Webpack as configuration for the import. "webpackMode: lazy" will load the script on demand and "webpackChunkName: [request]" will give reasonable names to our bundles based on the feature folder name. In the minesweeper, the bundle will be called minesweeper-index.js and some hash if you want to add that. Read more about magic comments in webpack's documentation.

The Webpack config must also be configured and should look something like this.

const path = require('path');

module.exports = {
  entry: './src/index.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/
      },
      {
        test: /\.svelte?$/,
        use: 'svelte-loader',
        exclude: /node_modules/
      }
    ],
  },
  resolve: {
    extensions: [ '.mjs', '.ts', '.js', '.svelte' ],
    mainFields: [ 'svelte', 'browser', 'module', 'main' ],
    alias: {
      svelte: path.resolve('node_modules', 'svelte')
    }
  },
  output: {
    filename: '[name].js',
    path: path.resolve(__dirname, 'demo'),
  },
};

Contains all of our loaders for TypeScript and Svelte. In our entry point (./src/index.ts) lies our boostrapper code and that will handle the automatic bundling of our feature folders.

Our app is now ready to be loaded from the DOM. Just add a script tag to the main.js bundle and insert <div data-module="minesweeper" data-options="{ bombs: 15 }" /> to the DOM. We should now see our dynamic loader in action.

Table from chrome showing performance data

That's a loader, a web framework and a game with styling loaded in at a total of 18.1 kilobytes in size. I think that's pretty good.

It's all about preventing bloat

While this approach is very slim, we should consider the dangers of adding big vendor scripts to our modules. We run into the risk of loading big scripts like Vue or React over and over. Even in some instances of using Svelte has the potential of scaling poorly. In this case, adding all vendor code into one bundle and load vendors before the modules would be beneficial.

This post doesn't show all the code, but no worries! You can find the source code right here.