Creating web apps via TypeScript and webpack

[2020-04-19] dev, javascript, typescript
(Ad, please don’t block)

This blog post describes how to create web apps via TypeScript and webpack. We will only be using the DOM API, not a particular frontend framework. The repository ts-demo-webpack with the files can be downloaded from GitHub.

Required knowledge: It helps if you have a rough understanding of how TypeScript, webpack, and npm work.

Limitations  

Using ES modules via TypeScript and npm is still fragile. Therefore, we will stick with CommonJS modules, bundled as script files.

The repository ts-demo-webpack  

This is how the repository ts-demo-webpack is structured:

ts-demo-webpack/
  build/   (created on demand)
  html/
    index.html
  package.json
  ts/
    src/
      main.ts
  tsconfig.json
  webpack.config.js

In order to build the web app, we need to compile two sets of files into the directory build/:

  • TypeScript files are stored in ts/.
  • HTML files are stored in html/.

Both tasks are handled by webpack:

  • For TypeScript, webpack starts at main.ts, locates all TypeScript and JavaScript files that are used, and compiles them into the single script file build/main-bundle.js. This process is called bundling. For compiling TypeScript to JavaScript, webpack uses the loader (plugin) ts-loader.

  • Copying the files in html/ is done via the webpack plugin copy-webpack-plugin.

Installing, building and running the web app  

First we need to install all npm packages that our web app depends on:

npm install

Then we need to run webpack (which was also installed by the previous step) via a script in package.json:

npm run wpw

From now on, webpack watches the files in the repository for changes and rebuilds the web app whenever it detects any.

In a different command line, we can now start a web server that serves the contents of build/ on localhost:

npm run serve

If we go to the URL printed out by the web server, we can see the web app in action.

Note that simple reloading may not be enough to see the results after changes – due to caching. You may have to force-reload by pressing shift when reloading.

Building in Visual Studio Code  

Instead of building from a command line, we can also do that from within Visual Studio Code, via a so-called build task:

  • Execute “Configure Default Build Task...” from the “Terminal” menu.
  • Choose “npm: wpw”.
  • Optional – set up the proper problem matcher in .vscode/tasks.json:
    "problemMatcher": ["$tsc-watch"],
    

We can now execute “Run Build Task...” from the “Terminal” menu.

package.json  

package.json specifies our scripts and the npm packages that the project depends on:

{
  "private": true,
  "scripts": {
    "tsc": "tsc",
    "tscw": "tsc --watch",
    "wp": "webpack",
    "wpw": "webpack --watch",
    "serve": "http-server build"
  },
  "dependencies": {
    "@types/lodash": "···",
    "copy-webpack-plugin": "···",
    "http-server": "···",
    "lodash": "···",
    "ts-loader": "···",
    "typescript": "···",
    "webpack": "···",
    "webpack-cli": "···"
  }
}
  • "private": true means that npm doesn’t complain if we don’t provide a package name and a package version.
  • Scripts:
    • tsc, tscw: We probably won’t invoke the TypeScript compiler tsc directly if we use webpack with ts-loader.
    • wp: run webpack once, compile everything.
    • wpw: webpack watches the files and only compiles what changes, incrementally.
    • serve: run the server http-server and serve the contents of directory build/.
  • Dependencies:
    • Webpack incl. support for using it via a CLI (command line interface) and plugins: webpack, webpack-cli, ts-loader, copy-webpack-plugin
    • Needed by ts-loader: typescript
    • Webserver for the web app: http-server
    • Library plus type definitions that the TypeScript code uses: lodash, @types/lodash

webpack.config.js  

This is how we configure webpack:

const path = require('path');
const CopyWebpackPlugin = require('copy-webpack-plugin');

module.exports = {
  mode: "development",
  devtool: "inline-source-map",
  entry: {
    main: "./ts/src/main.ts",
  },
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: "[name]-bundle.js",
  },
  resolve: {
    // Add ".ts" and ".tsx" as resolvable extensions.
    extensions: [".ts", ".tsx", ".js"],
  },
  module: {
    rules: [
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      { test: /\.tsx?$/, loader: "ts-loader" },
    ],
  },
  plugins: [
    new CopyWebpackPlugin([
      {
        from: './html',
      }
    ]),
  ],
};

For more information on configuring webpack, see the webpack website.

tsconfig.json  

This file configures the TypeScript compiler:

{
  "compilerOptions": {
    "rootDir": "ts",
    "outDir": "dist",
    "target": "es2019",
    "lib": [
      "es2019",
      "dom"
    ],
    "module": "commonjs",
    "esModuleInterop": true,
    "strict": true,
    "sourceMap": true
  }
}

The option outDir is not needed if we use webpack with ts-loader. However, we’ll need it if we use webpack without a loader (as explained later in this post).

index.html  

This is the HTML page of the web app:

<!doctype html>
<html>
<head>
  <meta charset="UTF-8">
  <title>ts-demo-webpack</title>
</head>
<body>
  <div id="output"></div>
  <script src="main-bundle.js"></script>
</body>
</html>

The <div> with the id output is where the web app displays its output. main-bundle.js contains the bundled code.

main.ts  

This is the TypeScript code of the web app:

import template from 'lodash/template';

const outputElement = document.getElementById('output');
if (outputElement) {
  var compiled = template(`
    <h1><%- heading %></h1>
    Current date and time: <%- dateTimeString %>
  `.trim());
  outputElement.innerHTML = compiled({
    heading: 'ts-demo-webpack',
    dateTimeString: new Date().toISOString(),
  });
}

For more information on template(), see Lodash’s documentation.

Using webpack without a loader: webpack-no-loader.config.js  

Instead of depending on ts-loader, we can also first compile all TypeScript files to JavaScript files (via the TypeScript compiler) and then bundle those files via webpack. More information on how that works is provided in the blog post “Creating CommonJS-based npm packages via TypeScript”.

We now don’t have to configure ts-loader and our webpack configuration file is simpler:

const path = require('path');

module.exports = {
  entry: {
    main: "./dist/src/main.js",
  },
  output: {
    path: path.join(__dirname, 'build'),
    filename: '[name]-bundle.js',
  },
  plugins: [
    new CopyWebpackPlugin([
      {
        from: './html',
      }
    ]),
  ],
};

Why would we want to produce intermediate files before bundling them? One benefit is that we can use Node.js to run unit tests for some of the TypeScript code.