見出し画像

How to Use a React Component in Vue and Vice Versa

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" />

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 iframes 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 {}

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() {}
}

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,
	)
}

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;
};

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);
}

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" />

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

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

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),
				},
			},
		}),
	],
});

main.ts:

import { defineNostalgicDiva } from "@aigamo/nostalgic-diva";

defineNostalgicDiva();

// ...

App.vue:

<template>
	<nostalgic-diva src="https://www.youtube.com/watch?v=bGdtvUQ9OAs" />
</template>
# 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);
	}

	// ...
}

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>

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}
/>;

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 })),
};
<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>

References

この記事が気に入ったらサポートをしてみませんか?