🍋

Fresh内でWebComponentsを使う

2023/12/18に公開

Freshを使ってみる ではWebComponentsを使えませんでした。まぁ当然ながらフロント側でのみ使えるのでそれに対応する仕組みがないことが原因で preact では普通に使う方法がドキュメントに書いてあったはずです。

今回はFreshでWebComponentsを使えたのでその周りをまとめようと思います。

https://zenn.dev/azulitenet/articles/using_webcomponents_with_fresh_plugin

結論:エラーでも問題なく使える

まず deno run -A -r https://fresh.deno.dev でTailwindを使わないプロジェクトを作ります。
そして、routes/index.tsx に以下のようにWebComponentsを追加します。

        ~省略~
        <Counter count={count} />
        <sample-component style={{ color: "white" }}>
          WebComponents.
        </sample-component>
      </div>
      ~省略~

普通にVS Codeなどでは <sample-component> の部分に赤線が引かれエラーとなっています。実装がないので当然です。しかし、普通にこのまま実行すると動きます。


エラーはあるが普通に動く

後は routes/_app.tsx 辺りにWebComponentsが定義されたJSファイルを読み込むようにしてやれば問題なく動くでしょう。

        ~省略~
        <title>fresh-wc-test</title>
        <link rel="stylesheet" href="/styles.css" />
        <script src="/components/sample-component.js"></script>
      </head>
      ~省略~

これで正常に動くのでめでたしめでたし!という感じですが、エラーが出て気持ち悪いのでその辺りを解決していこうと思います。

今回の全差分は以下です。

https://github.com/azulamb/FreshWebComponentsTest/commit/e8816e095adc59ce1f1cdd1c72c3c9ecfaddf243

エラーをなくそう

現在発生しているエラーは以下です。

Property 'sample-component' does not exist on type 'JSX.IntrinsicElements'.
'sample-component' cannot be used as a JSX component. Its type '"sample-component"' is not a valid JSX element type.

これを解決して気持ちよく開発していきましょう。主に必要な工程は以下です。

  • WebComponentsを含むフロントコードのビルド
    • ここで型定義を出力しておく
  • サーバー側にWebComponentsの型情報だけを登録する
    • WebComponents部分は未定義でエラーになっても気にせず実行は可能なので、後は結びつけるだけ

順番にやっていきます。

WebComponentsの実装

まずWebComponentsの定義をやっておきます。
フォルダは既存の何処かではなく新規で web-components/src を作っておきます。

web-components/src/sample-component.ts
/* 今回は空だがJSXで属性付与の時などに使ったりするはずなので作っておく */
interface SampleComponent extends HTMLElement {
}

Promise.all([
  new Promise<string>((resolve, reject) => {
    const name = "sample-component";
    if (customElements.get(name)) {
      // 定義済み
      return reject(new Error(`Defined: ${name}`));
    }
    if (document.readyState !== "loading") {
      // DOMContentLoaded済みなので即初期化
      return resolve(name);
    }
    document.addEventListener("DOMContentLoaded", () => {
      // DOMContentLoaded時に初期化
      resolve(name);
    });
  }),
  Promise.resolve(<HTMLScriptElement> document.currentScript),
]).then((results) => {
  const [name] = results;
  customElements.define(
    name,
    // WebComponentsの実装では必ず implements しておく
    class extends HTMLElement implements SampleComponent {
      constructor() {
        super();

        const shadow = this.attachShadow({ mode: "open" });

        const style = document.createElement("style");
        style.innerHTML = [
          ":host { background: gray; }",
        ].join("");

        const contents = document.createElement("div");
        contents.appendChild(document.createElement("slot"));

        shadow.appendChild(style);
        shadow.appendChild(contents);
      }
    },
  );
});

ビルドに関しては色々やり方があると思います。今回は tsc を直で使ったものにします。今回は以下のようにします。

  • 入力
    • web-components/src/**/*.ts
      • 必ず入力は web-components/src/ の中にあるフォルダのコードを使う。
  • 出力
    • static/components/*.js
      • 実際にHTMLで取り込むJSファイルを静的ファイルとしてここに設置
    • web-components/types.d.ts
      • 型定義を一箇所に集める

これらを行うために web-components/tsconfig.json は以下のようにします。

{
  "compilerOptions": {
    "outDir": "../static/components/",
    "module": "ES2022",
    "lib": ["dom", "es2022"],
    "target": "ES2022",
    "removeComments": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "sourceMap": false
  },
  "include": [
    "./src/**/*.ts"
  ]
}

そして deno.json のタスクに以下を追加します。

{
  "tasks": {
    ~省略~
    "build:front": "tsc -p web-components --removeComments && tsc -p web-components -d --emitDeclarationOnly --outFile web-components/types.d.ts",
    ~省略~
  },
  ~省略~
}

ビルドするとJSと型定義を別々の場所に出力するように2回 tsc コマンドを実行しています。ビルドは色々な方法があるので今回は可能な限り生の環境で行っています。

これで deno task build:front することでWebComponentsのビルドは完了です。

型定義の追加

後はFreshのサーバー側にWebComponentsの型を伝えるだけです。しかし普通のコンポーネントと異なり sample-component のように - が入っています。これをどのように結びつけるのでしょうか?

これは次のようなコードを追加することでWebComponentsの登録ができます。

declare global {
  namespace preact.createElement.JSX {
    interface IntrinsicElements {
      ["sample-component"]: JSXInternal.HTMLAttributes<HTMLElement>;
    }
  }
}

preact.createElement.JSX にはデフォルトのHTMLのタグとその型定義が入っています。ここにWebComponentsを追加してあげれば良いわけです。

具体的には以下のように書いていきます。

web-components/web-components.ts
// WebComponentsのビルドでひとまとめにした型定義
// 今回は SampleComponent が必要。
/// <reference path="./types.d.ts" />

// Fresh内で有効なコンポーネントの型定義に必要なimport
import type {JSXInternal} from "preact/src/jsx.d.ts";

declare global {
  namespace preact.createElement.JSX {
    interface IntrinsicElements {
      // - が含まれるので ["sample-component"] というキーで追加
      // 値は JSXInternal.HTMLAttributes<HTMLElement> がデフォルトなので、SampleComponentに差し替えると独自の属性が使えるようになる
      ["sample-component"]: JSXInternal.HTMLAttributes<SampleComponent>;
    }
  }
}

これでFresh側でWebComponentsを使ってもエラーが出なくなりました。

まとめ

重要なのは以下です。

  • WebComponentsの型定義をしておく
  • 上で定義した型と名前を関連付けるコードを追加する

別途ビルドが必要なのは少し面倒ですが、未定義のWebComponentsは無視したままレンダリングされることがわかったので後はどうとでもなるでしょう。

今回はビルド環境をシンプルにするために少々ややこしい構成になっていますが、ビルドツールをうまく使えば諸々準備が楽にできるのではないかと思います。(型定義を集めておくとか自動的にファイルを作るとか。)
個人的にWebComponentsは最強の再利用性を持つ機能で非常に好きなのですが、Freshのようにサーバーサイドでコンポーネントを使う場合のみ再利用できないという弱点があります。(WebComponents自体がフロントでのみ使えるものなので。)
しかし今回のようにWebComponentsと思われるもののエラーを見逃して動いてくれる場合であれば十分共存可能であることがわかったので、今後は積極的にFreshを使っていこうかなと思います。

Discussion