React と Web Components の共存方法について

2022/12/01に公開

はじめに

UIコンポーネントをJavaScriptフレームワークに依存しない方法で一度定義し、他JavScriptフレームワーク間で再利用できるのが、Web Components です。

この記事は Web Components について深く掘り下げるものではありません。すべてのAPIとライフサイクルをカバーするわけではありませんし、shadowRootslotもカバーしません。

  • Web Components への興味を喚起するのに簡潔な紹介
  • React で実際に Webコンポーネント を使用するためのガイダンス

を紹介できたらと考えています。

要約

  • Web Components はクラスで実装する
  • React を使用してのデータバインディングや状態管理の機能はこれから
  • Web Components と JavaScriptフレームワークの共存について

Web Components とは?

Web Componentsは、カプセル化された単一の責任を持つコードブロックです。あらゆるページで再利用することができます。

基本的には、JavaScript のクラスを定義し、それを HTMLElement から継承して、Web コンポーネントが持つあらゆるプロパティ、属性、スタイル、そしてもちろん、最終的にユーザーに表示するマークアップを定義します。

https://developer.mozilla.org/ja/docs/Web/Web_Components

シンプルな Web Components を理解する

  1. Web Components を作るには、まずHTMLElementextendsしたJavaScriptのclassを定義
class Example extends HTMLElement {
  1. 新しい Web Components(カスタム要素) を定義
if (!customElements.get("example-webcomponent")) {
  customElements.define("example-webcomponent", Example);
}
  1. 描画する
 <example-webcomponent />

connectedCallback: Web コンポーネントが DOM に追加されたときに発火されるメソッド。
このメソッドを使用して、好きなコンテンツをレンダリングすることができます。

connectedCallback() {
  this.innerHTML = "<div style='color: green'>This is Web Components</div>";
}

https://developer.mozilla.org/ja/docs/Web/API/CustomElementRegistry

example.js
class Example extends HTMLElement {
  connectedCallback() {
    this.innerHTML = "<div style='color: green'>This is Web Components</div>";
  }
}
if (!customElements.get("example")) {
  customElements.define("example", Example);
}

React で Web Components を理解する

サンプルコードはこちらです。全体のコードを確認したい際には以下をご参照ください。
https://codesandbox.io/embed/webcomponents-with-react-op8km7?fontsize=14&hidenavigation=1&theme=dark

Web Components を理解するため、counterコンポーネントを実装します。
主な実装内容として

  • 状態変化を追加
    • 表示される色を制御するために、color属性を追加。
    • 表示される数値を制御するために、incrementプロパティを追加。
      • 2、3、4 と値を増やせるようにします。

color属性を set します。属性に更新があったら、それに対応するために監視する属性を返すobservedAttributesプロパティを追加。

static get observedAttributes() {
  return ["color"];
}

observedAttributesに set された属性が set, update されるたびに実行。

attributeChangedCallback(name) {
  if (name === "color") {
    this.update();
  }
}

this.update() を実装

update() {
  this.valSpan.innerText = this._currentValue;
  this.valSpan.style.color = this.getAttribute("color") || "black";
}

incrementプロパティを追加

increment = 1;

Web Components はできました。
では、React と組み合わせていきましょう

React と組み合わせる

まず、React が持つ Web Components の課題について

  • React は HTML属性 にプリミティブ型しか渡せない(配列やオブジェクトは渡せない)
    • どうしても配列として受け取りたい場合はlist="red, green, blue"this.list = this.getAttribute("list").split(",") ?? [];を処理する必要がありそうです。
  • React は Web Component のイベントを渡せないので、手動で独自のハンドラを利用する必要がある

さらに React と Web Components は基本的に相互運用をサポートしていません。ですが、 Web Components への進捗を追いかけることはこちらできます。
面白いことに React の実験的ブランチで Web Components への対応されたと思っていたのですが、何か理由があってバージョン18にマージされなかったらしいのです。

現状の React と組み合わせる

React と融合させるには ref を使用して、Web Component のインスタンスを取得し、値が変更されたときに手動でincrementを set (wcRef.current.something = something) することです。

import { useState, useRef, useEffect } from 'react';
import './counter';

export default function App() {
  const [increment, setIncrement] = useState(1);
  const [color, setColor] = useState('red');
  const wcRef = useRef(null);

  useEffect(() => {
    wcRef.current.increment = increment;
  }, [increment]);

  return (
    <div>
      <div className="increment-container">
        <button onClick={() => setIncrement(1)}>Increment by 1</button>
        <button onClick={() => setIncrement(2)}>Increment by 2</button>
      </div>

      <select value={color} onChange={(e) => setColor(e.target.value)}>
        <option value="red">Red</option>
        <option value="green">Green</option>
        <option value="blue">Blue</option>
      </select>

      <counter-webcomponent ref={wcRef} increment={increment} color={color}></counter-webcomponent>
    </div>
  );
}

下記のように Web Components と React API を各コンポーネントで対応させるよりも、

const wcRef = useRef(null);

useEffect(() => {
  wcRef.current.increment = increment;
  wcRef.current.decrease = decrease;
}, [increment, decrease]);

<counter-webcomponent ref={wcRef} increment={increment} decrease={decrease} color={color}></counter-webcomponent>

Web Components のタグ名(この場合はcounter-webcomponent)をすべての属性とプロパティと一緒に渡して、
WebComponentWrapperが Web Components(counter-webcomponent) をレンダリングして

App.jsx
export default function App() {
  const [increment, setIncrement] = useState(1);
  const [color, setColor] = useState("red");

  return (
    <WebComponentWrapper
     wcTag="counter-webcomponent"
     increment={increment}
     color={color}
    />
  );
}

refを追加し、何がプロパティで、何が属性かを判断してくれるとよいではないでしょうか。

WebComponentWrapper.js
import { createElement, useRef, useLayoutEffect, memo } from 'react';

const _WebComponentWrapper = (props) => {
  const { wcTag, children, ...restProps } = props;
  const wcRef = useRef(null);

  useLayoutEffect(() => {
    const wc = wcRef.current;

    for (const [key, value] of Object.entries(restProps)) {
      if (key in wc) {
        // プロパティであるか  
        if (wc[key] !== value) {
          wc[key] = value;
        }
      } else {
        // 属性(attribute)であるか  
        if (wc.getAttribute(key) !== value) {
          wc.setAttribute(key, value);
        }
      }
    }
  });

  return createElement(wcTag, { ref: wcRef });
};

export const WebComponentWrapper = memo(_WebComponentWrapper);

コンテンツがレンダリングされる前にこれらの更新を直ちに実行したいので、useLayoutEffectを使用しています。useLayoutEffect には依存関係の配列がないことにも注意してください。これは、頻繁に再レンダリングする傾向があるため、パフォーマンス観点でリスクがある場合があります。これを対応するために、React.memoでラップしています。実際のプロパティが変更された場合にのみ再レンダリングを行い、それが起こったかどうかは単純な等価検査されます。

Web Components の未来

もしも、UIライブラリと Web Components を組み合わせるなら、Web Components はフォーカスやホバーされたボタンの見た目に、JSライブラリ、フレームワークはユーザーがそのボタンをクリックしたときに起こる処理(ロジックやデータ取得、ルーティング)に責務を分離させるのではないでしょうか。
Web Components を支えるツールlit-ssrWebCも目立ち始めており、個人的にはワクワクしています。

GitHubで編集を提案

Discussion