What is a Web Component
Recently, my coworkers were working on converting a Vue component to a Web Component. I was interested in what a Web Component is and how it works. In this blog post, we'll take a deep dive into Web Components by creating a custom element. The full source code is available here.
I'll take nostalgic-diva
, an open-source React component for playing a variety of URLs, as an example. Note that this project uses React version 17.
For example, the following React component will render a YouTube player:
<NostalgicDiva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
<NostalgicDiva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
where src
is the URL of the video to play.
Of course you can reuse this component inside React, but what if you want to use this component outside of React? This is where Web Components come in. We will convert this React component into a Web Component, and then use it in a Vue project. This is also achievable with iframe
s but this way will make the interface of the component easier to use through appropriate attributes and event listeners.
Converting a React component to a Web Component
I tried to convert my React component into a Web Component by using react-to-web-component, an open-source npm package, at the first attempt, but it turned out that there were some limitations for my use case, so I decided to write a Web Component manually instead.
A custom element is implemented as a class which extends HTMLElement
. Let's create a React component file named NostalgicDivaElement.tsx
and define the NostalgicDivaElement
custom element. I used the filename extension .tsx
instead of .ts
so that I can use JSX in that file as explained later.
class NostalgicDivaElement extends HTMLElement {}
class NostalgicDivaElement extends HTMLElement {}
We define three lifecycle callbacks in the NostalgicDivaElement
class, connectedCallback
, disconnectedCallback
and attributeChangedCallback
, which needs to be defined along with observedAttributes
. The connectedCallback
and disconnectedCallback
callbacks are called each time the element is added to the document, and removed from the document, respectively. The attributeChangedCallback
callback is called when attributes are changed, added, removed, or replaced.
class NostalgicDivaElement extends HTMLElement {
/**
* Called each time the element is added to the document.
*/
connectedCallback() {}
/**
* Called each time the element is removed from the document.
*/
disconnectedCallback() {}
/**
* This must be an array containing the names of all attributes for which the element needs change notifications.
*/
static readonly observedAttributes = ["src"];
/**
* Called when attributes are changed, added, removed, or replaced.
*/
attributeChangedCallback() {}
}
class NostalgicDivaElement extends HTMLElement {
/**
* Called each time the element is added to the document.
*/
connectedCallback() {}
/**
* Called each time the element is removed from the document.
*/
disconnectedCallback() {}
/**
* This must be an array containing the names of all attributes for which the element needs change notifications.
*/
static readonly observedAttributes = ["src"];
/**
* Called when attributes are changed, added, removed, or replaced.
*/
attributeChangedCallback() {}
}
Let's define a method responsible for rendering our React component. We need to render our React component when the custom element is added to the element, or when attributes are changed, so we call this method both in connectedCallback
and attributeChangedCallback
callbacks.
In JavaScript, private properties are declared by prefixing the property name with a hash
#
.
#render(): void {
ReactDOM.render(
<NostalgicDiva
src={this.src}
options={this.#options}
onControllerChange={this.#handleControllerChange}
/>,
this.container,
)
}
#render(): void {
ReactDOM.render(
<NostalgicDiva
src={this.src}
options={this.#options}
onControllerChange={this.#handleControllerChange}
/>,
this.container,
)
}
The first time you call render
, React will clear all the existing HTML content inside the domNode
before rendering the React component into it. If you call render
on the same domNode
more than once, React will update the DOM as necessary to reflect the latest JSX you passed. For more information, see the official documentation of React.
#handleControllerChange = (value: IPlayerController | undefined): void => {
this.controller = value;
};
#handleControllerChange = (value: IPlayerController | undefined): void => {
this.controller = value;
};
To make a custom element available in a page, we need to call the define()
method of Window.customElements
. We make a helper function named defineNostalgicDiva
to define the custom element.
function defineNostalgicDiva(): void {
customElements.define("nostalgic-diva", NostalgicDivaElement);
}
function defineNostalgicDiva(): void {
customElements.define("nostalgic-diva", NostalgicDivaElement);
}
Once you've defined and registered a custom element, you can use it in your code like this:
<nostalgic-diva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
<nostalgic-diva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
Now we can use the custom element that wraps the NostalgicDiva
React component.
Creating a Vue project
To demonstrate Web Components in Vue, we will use Vite to easily create a Vue application. To create our app, we run one of the following commands:
# npm
npm create vite@latest my-vue-app -- --template vue-ts
# yarn
yarn create vite my-vue-app --template vue-ts
# pnpm
pnpm create vite my-vue-app --template vue-ts
# npm
npm create vite@latest my-vue-app -- --template vue-ts
# yarn
yarn create vite my-vue-app --template vue-ts
# pnpm
pnpm create vite my-vue-app --template vue-ts
Using the custom element in Vue
I've already published the React component and the custom element as an npm package, so that you can install and import it. Let's install it by running one of the following commands:
# npm
npm i @aigamo/nostalgic-diva
# yarn
yarn add @aigamo/nostalgic-diva
# pnpm
pnpm i @aigamo/nostalgic-diva
# npm
npm i @aigamo/nostalgic-diva
# yarn
yarn add @aigamo/nostalgic-diva
# pnpm
pnpm i @aigamo/nostalgic-diva
Now you can import and call the defineNostalgicDiva
helper function to register the <nostalgic-diva />
custom element. By default, the <nostalgic-diva />
tag will cause Vue to emit a [Vue warn]: Failed to resolve component: nostalgic-diva
warning during development. To resolve this, we need to specify the compilerOptions.isCustomElement
option like this:
// vite.config.ts
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => ["nostalgic-diva"].includes(tag),
},
},
}),
],
});
// vite.config.ts
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => ["nostalgic-diva"].includes(tag),
},
},
}),
],
});
main.ts:
import { defineNostalgicDiva } from "@aigamo/nostalgic-diva";
defineNostalgicDiva();
// ...
import { defineNostalgicDiva } from "@aigamo/nostalgic-diva";
defineNostalgicDiva();
// ...
App.vue:
<template>
<nostalgic-diva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
</template>
<template>
<nostalgic-diva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
</template>
# npm
npm run dev
# yarn
yarn dev
# pnpm
pnpm dev
# npm
npm run dev
# yarn
yarn dev
# pnpm
pnpm dev
Defining methods to interact with the player
Once the development server is up and running, you can see that a player, which is a React component wrapped in a Web Component, can be rendered in Vue, but how can we control (e.g. play, pause and etc.) the player from the Vue side? To achieve this, we can add some methods to interact with the player to the custom element.
class NostalgicDivaElement extends HTMLElement {
// ...
async play(): Promise<void> {
await this.controller?.play();
}
async pause(): Promise<void> {
await this.controller?.pause();
}
// ...
async setMuted(muted: boolean): Promise<void> {
await this.controller?.setMuted(muted);
}
// ...
}
class NostalgicDivaElement extends HTMLElement {
// ...
async play(): Promise<void> {
await this.controller?.play();
}
async pause(): Promise<void> {
await this.controller?.pause();
}
// ...
async setMuted(muted: boolean): Promise<void> {
await this.controller?.setMuted(muted);
}
// ...
}
To retrieve an instance of the NostalgicDivaElement
class from the custom element, we can use a template ref. We declare a diva
ref (which will be set when the <nostalgic-diva />
custom element will be rendered for the first time), pass it to the <nostalgic-diva />
custom element as a ref
, and then we can use the methods of the NostalgicDivaElement
class through this template ref.
<script setup lang="ts">
import { NostalgicDivaElement } from "@aigamo/nostalgic-diva";
import { ref } from "vue";
// declare a ref to hold the element reference
// the name must match template ref value
const diva = ref<NostalgicDivaElement>(undefined!);
</script>
<template>
<nostalgic-diva
src="https://www.youtube.com/watch?v=bGdtvUQ9OAs"
ref="diva"
/>
<p>
<button @click="diva.play()">Play</button>
<button @click="diva.pause()">Pause</button>
<button @click="diva.setMuted(true)">Mute</button>
<button @click="diva.setMuted(false)">Unmute</button>
</p>
</template>
<script setup lang="ts">
import { NostalgicDivaElement } from "@aigamo/nostalgic-diva";
import { ref } from "vue";
// declare a ref to hold the element reference
// the name must match template ref value
const diva = ref<NostalgicDivaElement>(undefined!);
</script>
<template>
<nostalgic-diva
src="https://www.youtube.com/watch?v=bGdtvUQ9OAs"
ref="diva"
/>
<p>
<button @click="diva.play()">Play</button>
<button @click="diva.pause()">Pause</button>
<button @click="diva.setMuted(true)">Mute</button>
<button @click="diva.setMuted(false)">Unmute</button>
</p>
</template>
Listening to events that are dispatched by the React component
In React, you can handle playback events by passing callbacks as the options
prop. For example, the handlePlay
callback will be called when the playback has begun, the handlePause
callback when the playback has been paused, and so on.
const options = React.useMemo(
(): PlayerOptions => ({
onError: handleError,
onPlay: handlePlay,
onPause: handlePause,
onEnded: handleEnded,
onTimeUpdate: handleTimeUpdate,
}),
[handleError, handlePlay, handlePause, handleEnded, handleTimeUpdate]
);
<NostalgicDiva
src="https://www.youtube.com/watch?v=bGdtvUQ9OAs"
options={options}
/>;
const options = React.useMemo(
(): PlayerOptions => ({
onError: handleError,
onPlay: handlePlay,
onPause: handlePause,
onEnded: handleEnded,
onTimeUpdate: handleTimeUpdate,
}),
[handleError, handlePlay, handlePause, handleEnded, handleTimeUpdate]
);
<NostalgicDiva
src="https://www.youtube.com/watch?v=bGdtvUQ9OAs"
options={options}
/>;
How can we achieve that for a React component wrapped in a Web Component? The easiest way would be to use the dispatchEvent
method.
readonly #options: PlayerOptions = {
onError: (e) =>
this.dispatchEvent(new CustomEvent('error', { detail: e })),
onLoaded: (e) =>
this.dispatchEvent(new CustomEvent('loaded', { detail: e })),
onPlay: () => this.dispatchEvent(new CustomEvent('play')),
onPause: () => this.dispatchEvent(new CustomEvent('pause')),
onEnded: () => this.dispatchEvent(new CustomEvent('ended')),
onTimeUpdate: (e) =>
this.dispatchEvent(new CustomEvent('timeupdate', { detail: e })),
};
readonly #options: PlayerOptions = {
onError: (e) =>
this.dispatchEvent(new CustomEvent('error', { detail: e })),
onLoaded: (e) =>
this.dispatchEvent(new CustomEvent('loaded', { detail: e })),
onPlay: () => this.dispatchEvent(new CustomEvent('play')),
onPause: () => this.dispatchEvent(new CustomEvent('pause')),
onEnded: () => this.dispatchEvent(new CustomEvent('ended')),
onTimeUpdate: (e) =>
this.dispatchEvent(new CustomEvent('timeupdate', { detail: e })),
};
<script setup lang="ts">
onMounted(() => {
diva.value.addEventListener("error", handleError);
diva.value.addEventListener("loaded", handleLoaded);
diva.value.addEventListener("play", handlePlay);
diva.value.addEventListener("pause", handlePause);
diva.value.addEventListener("ended", handleEnded);
diva.value.addEventListener("timeupdate", handleTimeUpdate);
});
onUnmounted(() => {
diva.value.removeListener("error", handleError);
diva.value.removeListener("loaded", handleLoaded);
diva.value.removeListener("play", handlePlay);
diva.value.removeListener("pause", handlePause);
diva.value.removeListener("ended", handleEnded);
diva.value.removeListener("timeupdate", handleTimeUpdate);
});
</script>
<script setup lang="ts">
onMounted(() => {
diva.value.addEventListener("error", handleError);
diva.value.addEventListener("loaded", handleLoaded);
diva.value.addEventListener("play", handlePlay);
diva.value.addEventListener("pause", handlePause);
diva.value.addEventListener("ended", handleEnded);
diva.value.addEventListener("timeupdate", handleTimeUpdate);
});
onUnmounted(() => {
diva.value.removeListener("error", handleError);
diva.value.removeListener("loaded", handleLoaded);
diva.value.removeListener("play", handlePlay);
diva.value.removeListener("pause", handlePause);
diva.value.removeListener("ended", handleEnded);
diva.value.removeListener("timeupdate", handleTimeUpdate);
});
</script>