Skip to content

rintonwc for React

先日定義してみた x-link は、web components のみの対応で、実際に React で読もうとすると ref にアタッチしないといけないため、使用することができなかった。
そのため、wrap するような関数を定義し、React component として export して使用できるようにした。

具体的には、以下の形式で利用可能になった。
rintonwc/src/react で呼ぶことができるようになっている。バンドルを考えていないのでこの形式になっているが、そのうち対応予定。

# use yarn
yarn add rintonwc@latest

# use npm
npm i rintonwc@latest
1
2
3
4
5
// main.tsx
import { XLink } from "rintonwc/src/react";

const Component = () => {
  return <XLink link="https://piyopanman.com" />;
};
1
2
3
4
5
6

React で web components を使うには

React では、DOM ツリーと React ツリーが別物として定義されてるので、それを繋ぎ合わせる必要がある(一部の React のイベントが web components で利用できないのはこれが原因)
実際、React の公式ドキュメントの Web Components - React には、以下のようにある。

Web Component から発された Event は React のレンダーツリーを正しく伝わってこない可能性があります。 React コンポーネント内でイベントに適切に対応するにはそのためのイベントハンドラを与える必要があります。

ここに、React で web components を利用するためのコードも記述されていて、以下のように書かれている。
これが、React で web components を利用するための一例である。

class XSearch extends HTMLElement {
  connectedCallback() {
    const mountPoint = document.createElement("span");
    this.attachShadow({ mode: "open" }).appendChild(mountPoint);

    const name = this.getAttribute("name");
    const url = "https://www.google.com/search?q=" + encodeURIComponent(name);
    ReactDOM.render(<a href={url}>{name}</a>, mountPoint);
  }
}
customElements.define("x-search", XSearch);
1
2
3
4
5
6
7
8
9
10
11

React で wrap する方法を考える

では、自分で定義した web components を React で wrap するにはどうしたらいいか。
パッと思いつく方法は 3 つくらいある。
今回試したのは 3 番目の方法。

エントリポイントでラップ

一つ目は、エントリポイントでラップするパターン。
例えば、ReactWc という wrapper を提供して、エントリポイントとなるファイルで呼ばれることを想定する。

// main.tsx
ReactDOM.render(ReactWc(<App />), document.getElementById("root"));
1
2

このようにして、React のエントリポイント全体を wrap して、web components として build するようにする。
これも一つのやり方だと思う。ただ、これだとカバーする範囲が広くなりがちで、かつ React 全体を wrap するため、root が shadow root になってしまう。これでは web components の旨味がない。
ということで今回は見送った。ただ、lit-html のようなノリで render 関数まで定義するのは個人的にはとてもありだと思うし、便利でウケるとは思う。ただ、今回は native の web components をどう React で wrap するかに焦点を当てたいので無しにした。

コンポーネントごとでラップ

二つ目は、コンポーネントごとに wrap するパターン。
wc-react とかがそう。

上と同様、wrap 関数を ReactWc とすると、以下のようになる。
これはとても良さそう。今回の実装ともとても迷ったけど、今回はベースとして web components がいる状態で React で wrap したかったためスルー。React 用の web components を定義したかったらこれ一択だと思う。
ただ、やってることは上であげてる例と同じ。

const Component = () => {
  return ReactWc(<Hoge />);
};
1
2
3

ラップ済みを提供

三つ目は、wrap したコンポーネントを配布するパターン、今回はこれ。 多少汎用性に欠けるけど、問題なく使用することができる。

これは、使うとしたら以下のようになる。
もともと定義してあるコンポーネントを使うだけなので、import して使えばいい。ユーザーからすると楽だと思う。

import { XTakurinton } from "rintonwc/react";

const Component = () => {
  return <XTakurinton />;
};
1
2
3
4
5

どう実装したみたか

実装は core、webcomponents、react の 3 つに分けて実装した。 それぞれの役割は以下。

  • core
    • ロジックを持つ
    • 関数、型定義はここで扱っている
  • webcomponents
    • web components 用の見た目はここで定義してる
    • class を定義して core の関数を呼んでるだけ
  • react
    • React 用の見た目はここで定義している
    • function 関数で core の関数を呼んで ref にアタッチしてるだけ

例として、前回メモに書いた x-link について書く。
まず最初に、class をやめて、全て関数に分離した。つまり、private 関数で定義していた getData と、getMetaTags を分離した。

// /src/core/Link/getData.ts
export const getData = async (link: string) => {
  if (typeof window !== "undefined") {
    return await fetch(`https://api.takurinton.com/og?url=${link}`)
      .then((res) => {
        if (res.ok) return res.text();
        else console.log(res);
      })
      .then((text) => {
        if (text !== undefined)
          return new DOMParser().parseFromString(text, "text/html");
      });
  }
  return undefined;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// /src/core/Link/getMetaTags.ts
export const getMetaTags = (html: Document, link: string) => {
  const description = html.getElementsByName("description")[0];
  const favicon =
    html.querySelector('link[rel="icon"]') ??
    html.querySelector('link[rel="shortcut icon"]');

  // @ts-ignore
  const domain = link.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/)[1];
  let image;
  if (favicon === undefined) {
    image = "";
    // @ts-ignore
  } else if (
    favicon.href.slice(0, 5) === "https" &&
    favicon.href.slice(0, 16) !== "https://rintonwc"
  ) {
    // when favicon.href with origin + path

    // @ts-ignore
    const file = favicon.href;
    const fileLink = file.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/);

    if (fileLink === null) image = `https://${domain}${file.slice(7)}`;
    else if (fileLink[1] !== domain) {
      const filePathSplit = file.split("/")[3];
      image = `https://${fileLink[1]}/${filePathSplit}`;
    }
  } else {
    // when favicon.href with only absolute path

    // @ts-ignore
    const file = favicon.href;
    const fileLink = file.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/);
    if (fileLink === null) image = `https://${domain}${file.slice(7)}`;
    else {
      // Only the format 'https://{domain}/favicon.ico' can be supported.
      const filePathSplit = file.split("/").slice(3).join("/");
      image = `https://${domain}/${filePathSplit}`;
    }
  }

  return {
    title: html.title,
    // @ts-ignore
    description: description === undefined ? "" : description.content,
    image: image ?? "",
  };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

そして次に、与えられた引数を埋め込んで shadow root に innerHTML としてセットするような関数も分離した。
これは、React でも同じものを使いたかったからである。

// /src/core/Link/_html.ts
export const _html = ({ link, image, title, description }: LinkProps) => `
<style>
a {
    border: 1px gray solid;
    border-radius: 5px;
    width: 80%;
    padding: 10px;
    display: flex;
    text-decoration: none;
    color: #222222;
}
.left {
    height: 100px;
    width: 100px;
    text-align: center;
    padding-right: 40px;
}
.left > img {
    height: 100px;
    width: 100px;
}
.right {
    display: block;
    overflow: hidden;
}
.right > h1,
.right > p,
.right > a {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    text-overflow: ellipsis;
}
.right > h1 {
    height: 50px;
    margin: 0;
}
.right > p {
    margin: 0;
}
.link { 
    color: gray;
}
</style>
<a href="${link}" target="_blank">
<div class="left">
    <img src="${image}" alt="${title}" />
</div>
<div class="right">
    <h1>${title}</h1>
    <p class="description">${description}</p>
    <p class="link">${link}</p>
</div>
</a>
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

その他、型定義ファイルなども分離しているが、ここでは割愛。

次に、web components 用の定義を以下のように変更した。
class 内で定義して呼んでいたものを、先ほど定義した関数を呼ぶだけの状態にしている。

// /src/webcomponents/Link/index.ts
import { _html, getData, getMetaTags } from "../../core/Link";

export class Link extends HTMLElement {
  constructor() {
    super();
    const shadow = document.createElement("span");
    const link = this.getAttribute("link") as string;

    (async () => {
      const html = (await getData(link)) as Document;
      if (html === undefined) return;

      const { title, description, image } = getMetaTags(html, link);

      shadow.innerHTML = _html({ link, image, title, description });
      this.attachShadow({ mode: "open" }).appendChild(shadow);
    })();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

また、React の方では以下のように定義した。
ref をアタッチしてることなどの違いはあるものの、web components と非常に似た形で定義することができた。
これで、web components 側の export と React の export の両方を管理することができ、変更点はロジックのみで対応することができるようになった。

// /src/react/Link/index.tsx
import React, { useRef, useEffect } from "react";
import { _html, getData, getMetaTags } from "../../../core/Link";

export const XLink = ({ link }: { link: string }) => {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const shadow = document.createElement("span");

    (async () => {
      const html = (await getData(link)) as Document;
      if (html === undefined) return;

      const { title, description, image } = getMetaTags(html, link);

      shadow.innerHTML = _html({ link, image, title, description });
      ref.current?.attachShadow({ mode: "open" }).appendChild(shadow);
    })();
  }, []);

  return <div ref={ref}></div>;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

締め

上記で示した方法以外にも、例えば react-shadow-dom-retarget-events とか使えば差分吸収は可能になる。 ただ、個人的に、react と esbuild 以外を使わずに実装したかったというのがあり、一番手取り早い方法を選んでみた。 また、これは WIP かつライブラリ自体も private リポジトリで試してる程度のベータ版なので、そこまで深く考えていない。将来的に変えるかもしれないし、このままいくかもしれない。 学生のうちには public にしたいと思ってるので、頑張る。