Stencil でつくられた Custom Element を React/TypeScript 環境から呼び出したい

会社で Stencil で作られた Web Component の NPM パッケージが OSS として公開されていて、Vue.js のアプリケーションではこれまでも利用していたけれど、React でも利用したくなった。が、やってみるとドキュメント以上の工夫が必要だったのでメモ。

ちなみに使ったものは @uit/glitch-image というパッケージ。

一応免責として 2020/09/16 時点での公式ドキュメントにない範囲のハマりであり、連休にでもドキュメントに Pull Request でも送ろうかな。と思っているので、割とすぐに outdated な情報になるかもしれません。

Stencil の吐き出す Custom Elements と型定義

まず、Stencil はオフィシャルに Stencil 製の Custom Element と React の連携について、ドキュメントとして利用方法を紹介しています。

原文は https://stenciljs.com/docs/react にて閲覧できますが、ざっくりいうと index.js に以下のような定義をすると、そのまま利用できるということです。

// https://stenciljs.com/docs/react
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

// test-component is the name of our made up Web Component that we have
// published to npm:
import { applyPolyfills, defineCustomElements } from 'test-components/loader';

ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();

applyPolyfills().then(() => {
  defineCustomElements();
});

この紹介は index.js なので、 React/JavaScript として割り切ってるなら問題のないコードです。TypeScript に関しては自分たちでなんとかするスタイルなら納得感はあるのですが、実はすぐ下に以下のような any でなんとかしているタイプの TypeScript コードが出てきます。

import React, { useRef, useEffect } from 'react';
import { Forecast } from '../models';
import { iconPaths } from '../util';

const DailyForecast: React.FC<{ forecast: Forecast; scale: string }> = ({ forecast, scale }) => {
  const elementRef = useRef(null);

  useEffect(() => {
    (elementRef.current as any)!.iconPaths = iconPaths;
    (elementRef.current as any)!.forecasts = forecast;
  }, [forecast]);

  return <kws-daily-forecast scale={scale} ref={elementRef}></kws-daily-forecast>;
};

export default DailyForecast;

as any してるのになんで non-null assertion operator 使ってるんだみたいな細かなツッコミどころはこの際おいておくとして、まぁ React と Stencil 間の props について any で妥協してるなどありますが、まぁ利用はできそうです。

が、困るのはこれはそのまま動かないということ。というのも、実際に記述してみると、以下のように tsc レイヤーでのエラーとなります。

Image from Gyazo

これは TypeScript としては勿論妥当な挙動で、 JSX.IntrinsicElements に glitch-image なんてタグは存在しないので、しっかりと TypeScript が JSX を守っていると解釈できます。

ただ問題はここから。こういった根本的な拡張が入るものは、ユーザーランドで定義するか、あるいは ambient declarations として定義されたものを import することが求められる。

現実的なアプローチで言うと、 global の JSX に対しての型の拡張をライブラリが提供し、それを tsconfig に載せたりすることになることが多い。私が管理している @nuxtjs/dayjs なんかもそういったアプローチ。

ただ、Stencil はかなり特殊なアプローチをとっており、以下のように Stencil の型定義ファイルにおいて、 JSX の定義は @stencil/core の module 内に書かれてしまっており、 Stencil 同士での相互運用性しか担保されていない状態となっています。

// node_modules/@uit/glitch-image/dist/types/components.d.ts

declare namespace LocalJSX {
    interface GlitchImage {
        "src"?: string;
    }
    interface IntrinsicElements {
        "glitch-image": GlitchImage;
    }
}
export { LocalJSX as JSX };
declare module "@stencil/core" {
    export namespace JSX {
        interface IntrinsicElements {
            "glitch-image": LocalJSX.GlitchImage & JSXBase.HTMLAttributes<HTMLGlitchImageElement>;
        }
    }
}

そのため、現実的に React/TypeScript で正しく検知した上で props を渡すとなると、以下のようなアプローチが必要となります。

import { JSX as LocalJSX } from '@uit/glitch-image/dist/loader'

declare global {
  export namespace JSX {
    interface IntrinsicElements extends LocalJSX.IntrinsicElements {
    }
  }
}

ただこれだと src 以外は存在しないことになっており、例えば idclass などの汎用的な HTML attribute はエラーとなってしまう。

Image from Gyazo

なので、それまで含めて解決し、実用するなら以下のように変換を噛ませることで、両方の属性を担保することに。

import { JSX as LocalJSX } from '@uit/glitch-images/dist/loader'
import { HTMLAttributes } from 'react'

type ToReact<T> = {
  [P in keyof T]?: T[P] & Omit<HTMLAttributes<Element>, 'className'> & {
    class?: string;
  }
}

declare global {
  export namespace JSX {
    interface IntrinsicElements extends ToReact<LocalJSX.IntrinsicElements> {
    }
  }
}

これで OK。

Image from Gyazo

Stencil 自体のドキュメントがなんちゃって TypeScript で書かれていることと、後述する @stencil/react-output-target に寄せたい気持ちが見て取れて、微妙に実用性の欠ける利用方法が紹介されているのは少し残念。

Image from Gyazo

ところで、glitch-image で画像がグリッチするとかっこいいので、使い方を見ながらサイトのワンポイントで使ってみると面白いかもしれません。

Stencil と @stencil/react-output-target について

以下余談と、 https://www.npmjs.com/package/@stencil/react-output-target に対する気持ち。

ちなみに今回は glitch-image 側が純粋な Custom Element として提供しているためこういった形となったが、より詳細な調和や連携を求める場合、ライブラリ側で @stencil/react-output-target の導入を選択できる。

これは React 用の専用ビルドを用意するものであり、純粋な Custom Element とはそもそも出力を分けることで、バインディングなどに関する諸問題が解決できるものとのこと。

ただこれはフレームワークレスで作り、各フレームワークとの連携は利用者に委ねる Custom Element の良さが、かなりライブラリ製作者側の負担が大きいアプローチな気がする。

多分 Ionic チームが Ionic を作る分にはこのアプローチが妥当だと思うし、例えば Ant Design なんかはデザインシステムだけを共有し、React 実装を見て Vue 実装側が頑張る体制になっているので、それよりは内部的には負担が少ないことは誰も異議はないはず。

ただ、かなり Ionic のドメインに依存した社内ツールが OSS として公開されてる状態感というか、課題に対するアプローチが Ionic Ionic してるな〜という印象はうけた。

Custom Element のフレームワーク連携、個人的には Custom Element 開発者自身は負担を負わないで済む体制のほうが健全だと思っているけれど、どうなんだろうか……。