React + TypeScript環境でもWebComponentsを使いたい!

この記事はWeb Components Advent Calendar 2018 3日目の記事です。

TL;DR

TSX(TypeScript + JSX)環境でWebComponentsを使うときは型定義を拡張してあげる必要がある。

WebComponentsとJSフレームワーク

今年はFirefoxが正式サポートされるなどブラウザの対応も進み、そろそろプロダクションでの採用も増えてきた感があります。

とはいえ、WebComponents単体では

  • プロパティが文字列渡しになる
  • 再描画の面倒を開発者側が見る必要がある

など辛さがあります。

それを改善すべく、Polymer ProjectからLitElementというベースクラスが提供されていて、かなり便利にCustom Elementsが作れるようになっています。

とはいえ、WebComponentsは「再利用可能なコンポーネントを作る」ことを目的にした仕様群なので、SPAなどの開発に必要になるルーティングや状態管理などはスコープに入っていません。

Polymer Projectが提供しているPWA Starter KitはこのあたりをReduxと組み合わせたり、自前で開発したりして解決していますが、現実的にはまだ既存のJSフレームワークと組み合わせて使うことになるでしょう。

Reactと組み合わせる

JSの場合

WebComponentsとReactを組み合わせて使うコードは、次のようになります。

import React from 'react';
import ReactDOM from 'react-dom';
import { LitElement, html } from '@polymer/lit-element';

class MyElement extends LitElement {
    static get properties() {
        return {
            name: {
                type: String
            }
        };
    }

    render() {
        return html`
            <h1>Hello, ${this.name} !!</h1>
        `;
    }
}
customElements.define('my-element', MyElement);

const App = () => (<my-element name="WebComponents" />);

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

customElements.defineに登録したカスタム要素も問題なく使えています。

TypeScriptの場合

このままでも良いのですが、TypeScriptでも使いたいところです。

そこで、コードを次のように書き換えてみます。(デコレータを使ったコードに直していますが、やっていることは同じです。)

import React from 'react';
import ReactDOM from 'react-dom';
import { LitElement, html, property, customElement } from '@polymer/lit-element';

@customElement('my-element' as any)
class MyElement extends LitElement {

  @property({ type: String })
  name: string = 'no name';

  render() {
      return html`
          <h1>Hello, ${this.name} !!</h1>
      `;
  }
}

const App = () => (<my-element name="WebComponents" />);

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

、このままでは上手く動きません。

[ts] プロパティ 'my-element' は型 'JSX.IntrinsicElements' に存在しません。

コードはほぼそのままなのですが、なぜ動かないのでしょうか?

TypeScriptにおけるJSX(TSX

先の現象はReactというよりTSXの仕組み上の問題です。TypeScriptではJSXで使用される要素(Reactコンポーネントなどを除く、HTML標準のタグなど)について、JSX.IntrinsicElementsというインターフェースに該当の要素名(今回の例ではmy-element)が定義されることになっています。

上記のコードだけではJSX.IntrinsicElementsの中にmy-elementが定義されていないのでエラーが発生してしまう、というわけです。

JSX.IntrinsicElementsを拡張する

JSX.IntrinsicElementsに要素名が定義されていないのが問題なら、JSX.IntrinsicElementsを拡張して要素名を追加してあげれば良い、ということになります。

declare namespace JSX {
  interface IntrinsicElements {
    'my-element': { name: string }
  }
}

TypeScriptの型定義において、interfaceはopen-endedなのでわざわざオリジナルのinterface定義を拡張するような宣言をせずとも自動的にオリジナルの定義を拡張する形でマージされます。

型定義を記述してあげることで、TSX上でも補完付きでカスタム要素が使えるようになります。

雑な書き方

とはいえ、「タグ1つ1つに対してこのように型定義を記述するのは面倒だ、とりあえず動くようにしたい!」という場合もあるでしょう。その場合、次のように記述します。

declare namespace JSX {
  interface IntrinsicElements {
    [tagName: string]: any
  }
}

このように記述することで、TSX上でいかなる名前の要素でも利用できるようになります。

ただし、当然ながら要素名が誤っている場合のエラー表示や、属性の補完などは一切行ってくれなくなるので、あくまで一時しのぎとして使う程度にとどめたほうが良いでしょう。

まとめ

理屈は分かるのですが、「面倒だなー」というのが正直なところです。とはいえ、ReactとWebComponentsを組み合わせる事例が増えるにつれて同じ面倒さにぶつかる人も増えてくるはずなので、上手いこと解決できる仕組みが登場することに期待しています。