DEV Community

James Garbutt
James Garbutt

Posted on

How I created a vanilla web component

I recently published shiki-element, a simple web component used to apply syntax highlighting to text via the 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 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:

<shiki-highlight language="javascript">
function hello() {
  return 'hello world';
}
</shiki-highlight>
Enter fullscreen mode Exit fullscreen mode

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:

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:

{
  "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"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 in web-test-runner.config.mjs:

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()
  ]
};
Enter fullscreen mode Exit fullscreen mode

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.

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.

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 ๐Ÿ‘€

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 ๐Ÿ˜ฌ

A simple component

To begin with, remember our expected HTML usage:

<shiki-highlight language="javascript">
console.log(12345);
</shiki-highlight>
Enter fullscreen mode Exit fullscreen mode

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

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

customElements.define('shiki-highlight', ShikiHighlight);
Enter fullscreen mode Exit fullscreen mode

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.

// These are not equivalent
node.setAttribute('language', 'javascript');
node.language = 'javascript';
Enter fullscreen mode Exit fullscreen mode

This is solved by using the attributeChanged callback and observedAttributes:

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;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

public constructor() {
  super();
  this.attachShadow({mode: 'open'});
}
Enter fullscreen mode Exit fullscreen mode

This'll result in DOM like so:

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

Observing light DOM contents

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

<shiki-highlight>
console.log(12345); // This is text content of the element
</shiki-highlight>
Enter fullscreen mode Exit fullscreen mode

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:

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
}
Enter fullscreen mode Exit fullscreen mode

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:

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

protected _render(): void {
  // do some syntax highlighting here
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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();
}
Enter fullscreen mode Exit fullscreen mode

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:

protected _render(): void {
  const highlightedHTML = highlightText(this.textContent ?? '');
  this.shadowRoot.innerHTML = highlightedHTML;
}
Enter fullscreen mode Exit fullscreen mode

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:

<shiki-highlight language="javascript">
  # shadow-root (open)
    <pre class="shiki"> ... </pre>
  console.log(12345);
</shiki-highlight>
Enter fullscreen mode Exit fullscreen mode

Tests and what not

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

import {assert} from 'uvu/assert';

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

Which I ran via WTR:

npx web-test-runner
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)