Components as Bundle
The latest example defined another component which is transpiled,
together with the first component into a dist/ directory containing
an index.html ready to use the components.
There is nothing especially wrong with the proposed structure - one can build components using TypeScript starting from this foundation. However, the build layout contains quite a few errors and pitfalls making it more difficult than actually required:
importof other modules not possible without further ado,- components and other modules need to be included in
index.htmlto be usable, - paths to include scripts must match the location the transpiled files are put into
and there are definitely more which I have not found yet.
The sources to this web component and others can be found on GitHub: https://github.com/bvfnbk/example-web-components
An Excuse …
Originally, this whole project was intended to develop an environment to work with web components using small (if not minimal) increments:
- adding one small feature after the other without loosing any functionality and
- each step easy to understand
until I reach a sufficiently convenient setup or end up using React, deleting the posts/repos again.
Unfortunately, I stumbled across a problem for which I failed to spot the immediate (minimal) fix. Too early, I considered the creation of a bundle to be the only practical solution to the problem at hand.
Now, I do not believe this anymore.
Comments on the Sources
All .ts files have initially been put into the same src/ts/ directory. This works for a proof of concept but quickly
gets messy when other TypeScript modules are being added (especially when following a “one class/interface/enumeration
per file” rule).
Step 1: Add More Structure
Currently, I have only web components, thus, all .ts files are moved to src/ts/components/. Changing the src
paths in the corresponding <script> tags in the src/public/index.html file should be sufficient. However, the
TypeScript configuration causes the compiler to put all results directly into dist/js/ source - flat, without
matching the source directory structure.
This can be easily fixed: update tsconfig.json and add the property rootDir pointing to src/ts to the
compilerOptions:
{
"compilerOptions": {
"rootDir": "src/ts"
}
}
Rebuild dist/ and open dist/index.html.
Please note: Building the bundle fails with this setting recently. Why is not 100% clear - eventually TypeScript has been updated or the
ts-loader. However, this setting was only a quick fix on the way to the actual bundle.This is not required once the bundle has been created. So, skip it and jump to the bundle creation.
Step 2: Create Entry Point
The initialization code is currently distributed across several files:
- the
src/public/index.html…- … includes the components using
<script src="...">tags and - … registers the required event listeners which actually perform the update.
- … includes the components using
- the individual components register themselves using
customElements.define(...).
OK, not so many files, but this may change in the future. This change suggests the creation of a src/ts/index.ts file
which, in the end, should look like the following snippet:
import HelloMessage from './components/HelloMessage';
import MessageInput from './components/MessageInput';
customElements.define('hello-message', HelloMessage);
customElements.define('message-input', MessageInput);
const greeter = document.getElementById('greeter');
document.getElementById('msgInput')!.addEventListener(
'MessageUpdateEvent',
(event: Event) => {
const custom = event as CustomEvent;
greeter!.setAttribute('name', custom.detail);
});
A Short Overview of ES6 Modules
Recent web browser allow the usage of ES6 modules. This allows the definition of a module, e.g. the component defined in
dist/js/components/HelloMessage.js as an ES6 Module, i.e.
export default class HelloMessage extends HTMLElement {}
The module defines a default export which can be used in other ES6 modules, e.g. the entry point dist/js/index.js
like
import HelloMessage from './components/HelloMessage.js';
The module js/index.js can be included using a <script> tag
<script src="js/index.js" type="module"></script>
Please note:
- The attribute
type="module"is required for the web browser to handle the JavaScript file as module. - The
importinstructions injs/index.jscause the browser to load the named modules from the given path. - The page must be served from an HTTP service (web browsers will not load ES6 modules from
file://URLs). - The browser composes an HTTP request given the relative path to the module to be imported.
- The
.jsextension is required; the browser gets an404otherwise.
Quick Fix import Instructions
Update src/ts/index.ts and append the .js extension to the module import instructions, i.e.
import HelloMessage from './components/HelloMessage.js';
import MessageInput from './components/MessageInput.js';
Build dist/ and open dist/index.html and the page should work again. The Karma tests are not running though:
Karma fails to be able to import the components-under-test as these are ES6 modules which export themselves and
Karma seems to not understand (at least without further ado).
Implemented “Fix”
Creating a Bundle
Please note: There are several tools out there for bundle creation, e.g.
- Webpack
- Parcel
esbuild- Vite.
to name but a few from which I chose Webpack (seems to be the top dog; eventually about to get replaced with Vite or whatever). Install it with
npm install --save-dev webpack webpack-cli ts-loader
and create the following webpack.config.js in the working directory:
const path = require('path');
module.exports = {
mode: 'development',
entry: './src/ts/index.ts',
devtool: 'inline-source-map',
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve : {
extensions: [
'.ts',
'.js'
]
},
output: {
filename: 'bundle.js',
path : path.resolve(__dirname, 'dist', 'js')
}
}
Please note:
- This defines the entry point
src/ts/index.tsfrom which Webpack builds the tree of the modules to be included in the bundle. - It adds a rule to pipe all
.tsfiles through thets-loader. - It defines the desired output file, namely
dist/js/bundle.js.
Now, fix src/public/index.html to load the dist/js/bundle.js file and not the entry point directly. It should look
like
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Example / Web Components / Greeter</title>
</head>
<body>
<message-input id="msgInput"></message-input>
<br/>
<hello-message id="greeter" name="Friend"></hello-message>
<script src="js/bundle.js" type="module"></script>
</body>
</html>
The script section can be updated in the package.json:
{
"scripts" : {
"webpack" : "webpack",
"build" : "npm-run-all prepare webpack copy-assets"
}
}
The compile script (using tsc) is not required anymore for building: Webpack handles all TypeScript files
through its ts-loader. Thus, it can be replaced with a webpack script.
Now try building it and open dist/index.html.
Fixing the Tests
Webpack does not fix the tests though: Karma still cannot handle the export instructions. I tried out different
configurations, different packages and plugins to handle TypeScript, different preprocessors or transforms to
handle the tests for Karma and, unfortunately, to no avail.
Please note: Most certainly, the problem sits in front of the keyboard: I am sure, that I am not the only one trying to test TypeScript front-end code with Karma and Jasmine.
But never fear, I was not especially happy with the way Karma and Jasmine worked anyway. Karma sometimes failed to start the browsers and the reports were not as I expected (I tried different output formats).
This is the reason I did not want to put more efforts into getting Karma to work and why I actually switched to Jest.
First remove all Karma and Jasmine related packages and install
@types/jestjestjest-environment-jsdomandts-jest
Create a Jest configuration file jest.config.js with the following contents
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
};
and remove the karma script from the package.json. The test script may then simply call jest.
On the one hand, Jest uses the same test specification language (describe, it etc.) but on the other hand, there
are at least some differences:
Assertions:
toBeTrue()is actuallytoBeTruthy()toBeFalse()is actuallytoBeFalsy()
Mocking: No
spyOnin Jest but explicit patching of the object, e.g.const event = new Event('whatever'); event.stopPropagation = jest.fn(() => {});
There is still an issue that some tests fail: Jest uses a specific environment for front-end tests. This environment
is required to provide document and customElements to the tests and is rather picky when it comes to the asserted
property, e.g. a test checking innerText of a component which updates its innerHTML property will fail.
Karma uses the browsers to run the tests and those environments are more forgiving.
Please note: The tests are not watched anymore and must be explicitly run - but they are running faster (on the good side).
Summary
Bundling the TypeScript modules has been added to the Web Component and tests have been made to work (again) - albeit by switching the test framework. Some minor improvements have been implemented to prepare the grounds for future features (e.g. hot module reloading etc.).