How I created a vanilla web component
Tags: webcomponents, web, typescript, javascript
Published: 2021-02-20 12:56:36

I recently published [shiki-element](https://github.com/43081j/shiki-element), a simple web component used to apply syntax highlighting to text via the [shiki](https://github.com/shikijs/shiki) library.

It was a fun experience writing a vanilla web component for this using only modern solutions, so here's a ~~brief~~ (it didn't end up so brief after all) write up in case anyone else wants to try the same. Also to show you don't always need a framework.

**NOTE:** I understand there's a fair amount of boilerplate to follow, in real world cases I'd normally suggest choosing focused libraries to fill the gaps and something like [lit-element](https://github.com/polymer/lit-element) for the rendering/propagation layer. This is just to demonstrate how you can make a vanilla component and my particular experience.

# The objective

The objective I had was to create a web component which wraps the shiki library and has the following interface/consumption:

```html
<shiki-highlight language="javascript">
function hello() {
  return 'hello world';
}
</shiki-highlight>
```

I didn't want to use any frameworks or libraries, zero-dependencies other than the `shiki` dependency if possible.

I also wanted to go ESM-only, i.e. no CommonJS support and no CommonJS dependencies.

# Initial project setup

My immediate thought was to throw together the basic tooling stack I wanted:

* TypeScript
* mocha
* [web-test-runner](https://github.com/modernweb-dev/web/tree/master/packages/test-runner)
* [prettier](https://github.com/prettier/prettier)
* [eslint](https://github.com/eslint/eslint)
* [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint)
* [eslint-config-google](https://github.com/google/eslint-config-google)

With all of my sources in a `src/` directory and my tests in `src/test/`.

## TypeScript

Seeing as I wanted to write ESM and output ESM, my `tsconfig.json` was fairly straight forward:

```json
{
  "compilerOptions": {
    "target": "es2017",
    "module": "esnext",
    "moduleResolution": "node",
    "declaration": true,
    "outDir": "./lib",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [
    "src/**/*.ts"
  ]
}
```

## ESLint

To keep things simple, I chose to use Google's lint config and tweak a couple of rules for my own preference in `.eslintrc.json`:

```json
{
  "extends": [
    "eslint:recommended",
    "google",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "plugins": ["@typescript-eslint"],
  "rules": {
    "indent": "off",
    "comma-dangle": ["error", "never"],
    "spaced-comment": "off",
    "@typescript-eslint/no-unused-vars": "off",
    "@typescript-eslint/no-inferrable-types": "off"
  }
}
```

I disabled the `no-unused-vars` rule as the TypeScript compiler does such a check already, and better than the one ESLint does (via `noUnusedLocals` and `noUnusedParameters`).

I also disabled `no-inferrable-types` as I prefer declaring my types than relying on inference, for consistency.

## Prettier

I also chose to add my own `.prettierrc.json` to configure a few Prettier options to my preference, but the defaults are probably fine for most people.

## web-test-runner

I configured web-test-runner to use my transpiled tests via [puppeteer](https://pptr.dev/) in `web-test-runner.config.mjs`:

```ts
import {puppeteerLauncher} from '@web/test-runner-puppeteer';

export default {
  nodeResolve: true,
  files: 'lib/test/**/*_test.js',
  testFramework: {
    config: {
      ui: 'bdd'
    }
  },
  coverage: true,
  coverageConfig: {
    include: ['lib/**/*.js'],
    exclude: ['lib/test/**/*.js']
  },
  browsers: [
    puppeteerLauncher()
  ]
};
```

Again, fairly simple, I want to use mocha's BDD interface with test coverage enabled, launched via puppeteer.

Keep in mind, I chose to run WTR against my transpiled sources as they're almost equal to my actual sources. It is possible, however, to have WTR run against your TypeScript sources by using the [esbuild plugin](https://modern-web.dev/docs/dev-server/plugins/esbuild/).

## Assertions

The last missing piece of my setup was what I'll use for assertions in my tests.

I'd usually opt for chai, but it is increasingly becoming outdated (or has already, to be honest). It provides no official ESM entrypoint, which means I would be forced to support CommonJS in my stack in order to use it. This would mean introducing a bundle in my build process, unacceptable!

So I happily threw chai away, and pestered people for suggestions of alternatives which support ESM. This is where I came across [uvu](https://github.com/lukeed/uvu).

`uvu` is very small, supports TypeScript and is published as ESM! Great.

It does come with its own mocha-alternative but I'm not sure I'm a fan of the design, so I chose to use only the `uvu/assert` module it contains and stick with mocha.

# Finally, some code :eyes:

I suspect its unusual to throw together a whole project setup before even writing a line of code, so feel free to skip most of the above :grimacing:

## A simple component

To begin with, remember our expected HTML usage:

```html
<shiki-highlight language="javascript">
console.log(12345);
</shiki-highlight>
```

So we know from this, our component needs to roughly look like so:

```ts
class ShikiHighlight extends HTMLElement {
  public language?: string;
}

customElements.define('shiki-highlight', ShikiHighlight);
```

Right now, this'll render nothing but has the right interface.

## Attributes and properties aren't the same

We have a `language` property, but a property is _not_ the same as a HTML attribute. So our `language` attribute will do nothing right now, and won't sync with the property.

```ts
// These are not equivalent
node.setAttribute('language', 'javascript');
node.language = 'javascript';
```

This is solved by using the `attributeChanged` callback and `observedAttributes`:

```ts
class ShikiHighlight extends HTMLElement {
  public language?: string;

  public static get observedAttributes(): string[] {
    return ['language'];
  }

  public attributeChangedCallback(
    name: string,
    oldValue: string,
    newValue: string
  ): void {
    if (name === 'language') {
      this.language = newValue;
    }
  }
}
```

The `observedAttributes` static is used by the browser to determine which attributes to trigger the change callback for. The change callback (`attributeChangedCallback`) is fired each time one of the observed attributes changes value.

This means any time `language` changes on the element, our property will also be set to the same value.

NOTE: for now, synchronisation won't happen the other way, i.e. the property being set won't set the attribute.

## Creating a shadow root

Ultimately, we want to render our syntax highlighted nodes in a shadow root so we don't affect the consumer's DOM tree (the "light DOM").

So we need a root:

```ts
public constructor() {
  super();
  this.attachShadow({mode: 'open'});
}
```

This'll result in DOM like so:

```html
<shiki-highlight>
  #shadow-root (open)
    <!-- syntax highlight result will live here -->
  function hello() {
    return 'hello world';
  }
</shiki-highlight>
```

## Observing light DOM contents

We need something to syntax highlight... the contents of the element if you remember from our previous example:

```html
<shiki-highlight>
console.log(12345); // This is text content of the element
</shiki-highlight>
```

We need to observe changes to this text content and trigger a new syntax highlight each time, outputting the resulting HTML to the shadow root we created earlier.

This can be done by a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver):

```ts
public constructor() {
  super();
  this.attachShadow({mode: 'open'});

  this._observer = new MutationObserver(() =>
    this._domChanged());
}

public connectedCallback(): void {
  this._observer.observe(this, {
    characterData: true,
    subtree: true,
    childList: true
  });
}

public disconnectedCallback(): void {
  this._observer.disconnect();
}

protected _domChanged(): void {
  // Fired any time the dom changes
}
```

`connectedCallback` is called by the browser when the element is added to the DOM tree, `disconnectedCallback` is called when it is removed from the DOM tree.

In our case, we want to observe the light DOM (`this`) when connected, and stop observing when disconnected.

We are observing changes to the text (`characterData`) and child nodes (`childList`).

**NOTE**: A bit of a TIL, setting `textContent` does not mutate `characterData`, in fact it mutates the `childList` as it results in setting a new text node as a child.

Our `_domChanged` can be implemented like so:

```ts
protected _domChanged(): void {
  this._render();
}

protected _render(): void {
  // do some syntax highlighting here
}
```

## Observing property changes

Remember our `language` property? We need to re-render each time that changes as the syntax highlighting will differ per language.

We can implement this kind of observer by getters and setters:

```ts
// Change our language property to be protected
protected _language?: string;

// Replace the original property with a getter
public get language(): string|undefined {
  return this._language;
}

// and a setter which triggers a re-render
public set language(v: string) {
  this._language = v;
  this._render();
}
```

Now any time we set the `language` property, we will re-render.

Remember we also need to ensure the previous attribute changed callback sets `language` (and not `_language`), so it triggers a re-render too.

## Implementing the render method

Finally, we need to do the work for syntax highlighting:

```ts
protected _render(): void {
  const highlightedHTML = highlightText(this.textContent ?? '');
  this.shadowRoot.innerHTML = highlightedHTML;
}
```

Pretty basic, we pass the light DOM text content to our highlighting library, which then returns HTML we append into our shadow root.

Our DOM will then look like this:

```html
<shiki-highlight language="javascript">
  # shadow-root (open)
    <pre class="shiki"> ... </pre>
  console.log(12345);
</shiki-highlight>
```

## Tests and what not

After this, I wrote a bunch of unit tests using uvu and mocha:

```ts
import {assert} from 'uvu/assert';

describe('shiki-highlight', () => {
  it('should work', () => {
    assert.is(actual, expected);
  });
});
```

Which I ran via WTR:

```bash
npx web-test-runner
```

I also setup a github workflow and some package scripts (lint, format, etc.).

# Wrap-up

Again, I'm writing this really because I enjoyed being able to produce a component using vanilla JS and modern tooling.

I've given no consideration to older browsers and have relied on a fair amount of features only the latest support.

There is nothing wrong with bundling, either, I just wanted to avoid it in this case as it'd be nice to publish _and write_ ESM as-is.

You can view the finished component here:
https://github.com/43081j/shiki-element

You can see a demo here (may load a little slow initially since shiki is a bit on the chunky side):
https://webcomponents.dev/preview/cQHUW8WJDlFAKqWpKibc

Not everything needs a framework, or a base library. The point at which you'll likely need to consider one is when you need state or more complex data propagation/observation.