2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

WebComponent × Reactは実現可能なのか検証してみる

Last updated at Posted at 2019-03-31

Micro Frontend を実現するにあたり各ライブラリごとのWebComponentへの対応状況を調査していたのですが Reactが 71% (2019/03/31時点)と低いスコアを叩き出しており、WebComponentでReactを利用するにはリスクが高く採用できないと判断してしまう数値です。

ならVueでいいのでは...?という話ですが、
個人であれば問題はありませんが組織としてReactを中心に開発を行っている場合、

  • 過去の資産(独自のReact UIライブラリなど)が活用できない
  • Vueのに移行するための学習コスト

などを考えるとReactを利用できない場合、WebComponent導入を断念する可能性も出てきます。
そこで今回は 現段階でReactを利用するには どのような方法がとれるかを検討し、
なんとか採用出来る方法を模索してみようと思います。

Reactのイベントが発火しない問題

まずは現状の問題の確認です。
現段階でReactが抱える最も大きな課題はWebComponentでReactを利用した際に
React側で実装しているイベントが一切発火しないことです。

原因としてはReactのイベントはネイティブイベントではなく、
SyntheticEventと呼ばれるReact独自のラッパーイベントで実装されており、
そのためWebComponentとReactでイベントが伝達されず、イベントを発火することが出来ません。
( 詳しくは SyntheticEvent – React を見て下さい )

実際にコードを書きながら確認してみます。

まず、押された際にアラートを出すbutton要素をReact Componentで用意します。

export const Component = ({message}) => {
  return (
    <button
      type={'button'}
      onClick={() => {
        alert('call !!');
      }}
    > {message} </button>
  )
};

こちらを公式のサンプルを元にWebComponentに組み込んでみます。

import { render } from 'react-dom'
import { Component } from './component'

export class ConfirmButton extends HTMLElement {
  connectedCallback() {
    const parent = document.createElement('div');
    const message = this.getAttribute('message');
    this.attachShadow({ mode: 'open' }).appendChild(parent);

    render(<Component message={message} />, parent);
  }
}

customElements.define('confirm-button', ConfirmButton);

想定する動きとしてはボタンをクリック時、
アラートが表示されるはずですが、残念ながらこのコードは動作しません。

解決策

結論として完全な解決策は今の所存在しません。

React側でも この問題は認識しており何らかの解決策を論じている最中のようです。
Bypass synthetic event system for Web Component events · Issue #7901 · facebook/react · GitHub

とはいえ、公式の対応を待つ以外にも方法は無いか検証してみます。

React側でイベントリスナーを登録する

結局の所、SyntheticEventが発火しないことが原因なので
DOM要素に直接イベントを登録してしまえばSyntheticEventを回避することができます。

import { render } from 'react-dom'

class ConfirmButton extends Component {
  componentDidMount() {
  // refで直接イベントを定義
    this.refs.button.addEventListener('click', () => {
      alert('call !!')
    });
  }

  render() {
    return (
      <button ref={"button"} type={'button'}> {this.props.message} </button>
    )
  }
};

export class ConfirmButtonElement extends HTMLElement {
  connectedCallback() {
    const parent = document.createElement('div');
    const message = this.getAttribute('message');
    this.attachShadow({ mode: 'open' }).appendChild(parent);

    render(<ConfirmButton message={message} />, parent);
  }
}

この方法であれば発火するのはネイティブイベントのため、
特に問題なくWebComponentで利用することができます。

ただし、この実装はReact側がWebComponentに強く依存するため以下の問題があります。

  • npmで公開されているReactComponent系のモジュールは殆ど使えない。
  • 既にReactの資産がある場合、それらは再実装するか切り捨てる必要がある。
  • バインドさせるイベントをクロスブラウザに対応させる必要がある。

正直な所、ここまでになるとReactを無理して使う必要ないのでは...と思えてきます。

ネイティブイベントとSyntheticEventをバインドする

SyntheticEventとネイティブイベントを紐づけて強制的にイベントを発火させる方法です。
こちらの実装はかなり面倒なので自前で実装するより
react-web-componentというパッケージを利用するのが良さそうです
GitHub - spring-media/react-web-component: Create Web Components with React

import ReactWebComponent from 'react-web-component';

ReactWebComponent.create(<ConfirmButton message="宜しいですか?"/>, "confirm-button")

ただ残念なことに このライブラリはReactComponentをWebComponentとしてマウントさせるだけなので
本来 WebComponent側でやっていたAttribute属性から値を取得するといった機能が使えません。

そのためHTML側で値を渡すことができなくなるため自由度はかなり低くなります。

preactに変換して使う

Reactのサブセットであるpreactを使って そもそもの原因を回避する方法です。
preactでは合成イベントは利用されていないためイベントハンドラは問題なく発火します。

既存のreactのコードをpreactに移管するのは非常に簡単で、
webpack.config.jsに以下の設定を追加するだけです。

webpack.config.js
{
  "alias": {
    "react": "preact-compat",
    "react-dom": "preact-compat"
  }
}

もし、NextなどでHMRを実現している場合はpackage.jsonにも追記が必要です。

package.json
{
  "aliasify": {
    "aliases": {
      "react": "preact-compat",
      "react-dom": "preact-compat"
    }
  }
}

これでpreactへの移管は完了です。

Preact: Fast 3kB alternative to React with the same modern API. Components & Virtual DOM.

preactを使うことで先程は動かなかった下記のコードは問題なく動作するようになります。

export const Component = ({message}) => {
  return (
    <button
      type={'button'}
      onClick={() => {
        alert('call !!');
      }}
    > {message} </button>
  )
};

結論: preactを利用する

  • 実装側がwebComponentを意識することなくcomponentを実装できる。
  • 既にある資産を利用できる
  • 移行が容易
  • Reactに戻るのが容易

以上の理由で現段階ではpreactを利用するのが良さそうです。

Redux依存の問題

ReactではComponent同士の連携を行う際にはReduxを利用しますが
WebComponentではこれを利用することは お薦めできません。

Reduxを利用することにした時点でWebComponentは全てReactで実装する制約が生まれ、
WebComponentのメリットを完全に捨てることになるからです。

(これに関してはVueなども同じです。)

ブラウザイベントを利用する

この問題に対する1つの答えとしてブラウザイベントを利用する方法があります。
ブラウザイベントであればネイティブ機能のためライブラリに依存することなく
Component間の連携を実現することが出来ます。
( 具体的にはCustomEventを定義してイベントリスナーで値を受け渡すといった実装になります。 )

TODOアプリの実装

実際にTodoアプリを作ってブラウザイベントを使ったComponent間の連携を試してみます。

まず前提としてCustomEventを登録するための処理が必要になるわけですが、
今回は手っ取り早くbodyタグに登録してしまいます。

単純なlistenerでイベントを登録してdispatcherで発火させる関数を実装します。

export function dispatcher(key, value) {
  const event = new CustomEvent(key, {detail: value});
  document.getElementsByTagName('body')[0].dispatchEvent(event);
}

export function listener(key, callback) {
  document.getElementsByTagName('body')[0].addEventListener(key, callback);
}

後はよくある TODOのフォームとリストを実装していきます。
※ 今回はpreactを利用しています

Formでは追加ボタンクリック時にADD-TASKイベントを発火させ、
引数に入力されたテキスト値を渡します。

export class TaskForm extends Component {
  constructor(props) {
    super(props)

    this.state = {
      value: ''
    }
  }

  render() {
    return (
      <div>
        <input onChange={(e) => {
          this.setState({value: e.target.value})
        }}/>
        <button
          onClick={() => {
            dispatcher('ADD-TASK', this.state);
          }
        }>追加</button>
      </div>
    )
  }
}

List側ではADD-TASKが発火を検知し、引数で受け取った値をリストに追加します。

export class TaskList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      taskList: []
    }
  }

  componentDidMount() {
    listener('ADD-TASK', (e) => {
      const taskList = this.state.taskList
      taskList.push(e.detail.value)
      this.setState({taskList})
    })
  }

  render() {
    const elements = this.state.taskList.map((element, i) => {
      return (<li key={i}>{element}</li>)
    })

    return <ul> {elements} </ul>
  }
}

FormとListをComponentとして纏めてWebComponentで取り込みます。

export const TodoList = () => (
  <div>
    <TaskList />
    <TaskForm />
  </div>
);

class TodoListElement extends HTMLElement {
  connectedCallback() {
    const parent = document.createElement('div');
    this.attachShadow({ mode: 'open' }).appendChild(parent);

    render(<TodoList/>, parent);
  }
}

customElements.define('todo-list', TodoListElement);

最後に htmlでtodo-listタグを宣言すればTODOアプリが表示されるはずです。

<body>
    <todo-list />
</body>

これでTODOアプリは完成です。
DOMイベントを使ったComponent間の連携は特に問題なく実装することが可能でした。
※ 実際に実装する場合はイベント名のコンフリクトを避けるために規約を持たせる必要があることに注意してください。

WebComponentでReactは採用可能か

検討はしてみましたが やはり現段階でのReactの採用は難しく、
SyntheticEventの問題が解決するのを待つ必要がありそうです。

また、preactによる回避方法も紹介しましたが
こちらもpreact自体 WebComponentに完全に対応されていません。
(それでもReactよりかは全然マシなのですが...)

とはいえ幾つかの注意点を考慮すれば実装することも出来なくはないため
もし、導入を考えているのであれば
マイクロで影響範囲の少ないサービスから試験的に導入していくのが良さそうです。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?