WebComponents

JSフレームワークの末端がWebComponentsになるのか、なれるのか、検証してみた

https://speakerdeck.com/mizchi/real-world-es201x-and-future で、「Reactやその他のフレームワークの末端はWebComponentsになるのではないか?」という話をした。とはいえ、実際に自分でそういうものを実装したわけではなかった。

じゃあ実際に、Reactから web components を呼ぶにはどうなるだろうか?実装してみた。

ゴールの設定

こういうコードが動いてほしいとする。

import React from 'react'

export default class Home extends React.Component {
  constructor() {
    super()
    this.state = {
      value: 0
    }
  }
  componentDidMount() {
    let cnt = 0
    setInterval(() => {
      this.setState({ value: cnt++ })
    }, 1000)
  }
  render() {
    return (
      <my-button
        text={this.state.value}

        onClick={_ev => {
          console.log('onClick', this.state.value)
        }}
      />
    )
  }
}

my-button には text と onClick のハンドラを渡す。text は毎秒変化する。

webcomponents 的にはこんな感じよな、と思ってlit-htmlを使ってコードを書き始めた。

import { html, render } from 'lit-html'

const template = props => html`
  <div>
    <button>${props.text}</button>
  </div>
`

export default class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['text', 'onClick']
  }

  connectedCallback() {
    this.render()
  }

  attributeChangedCallback(_attrName, _old, _new) {
    this.render()
  }

  render() {
    const text = this.getAttribute('text')
    render(template({ text, callback }), this)
  }
}

window.customElements.define('my-button', MyButton)

static get observedAttributes() で attributes を監視できるようになる。

ここまで実装して気づいた。 text は毎秒変化して、文字、というか数値として渡している。 しかしReactから custom elements へ関数参照をstringを経ずに渡す方法がない。HTMLを見る限りは、toString() されてしまっている。これではクロージャを引き継げないし、使う側も eval が必要になり論外だしで、このままでは使い物にならない。

考え方が間違ってるのでは

  1. onClick のような方法はやはり React の特例的な実装であって、element.addEventListener() でカスタムイベントを発行して、 componentDidMount でそれを購読するのが、古き良き実装なのでは。
  2. いや、そのような関数渡しの時代は Backbone.View の delegateEvents で終わったはずだ。そもそも Angular などでもこの辺はハンドラ渡しやObservable化がちゃんと行わているはずだ。ここはなんとしても関数参照をそのまま渡すべきだ

関数参照を渡す実装

関数の toString を書き換えて返す funcMap を用意した

funcMap.js
/* @flow */
import uuid from 'uuid'
const m = new Map()
export const createCallback = (func: Function) => {
  const id = uuid()
  m.set(id, func)
  func.toString = () => id
  return func
}

export const removeCallback = (id: string) => {
  m.delete(id)
}

export const getCallback = (id: string) => {
  return m.get(id)
}

React側では createCallback を通してハンドラを渡す。

import * as React from 'react'
import { createCallback } from '../../funcMap'

export default class Home extends React.Component {
  constructor() {
    super()
    this.state = {
      value: 0
    }
  }
  componentDidMount() {
    let cnt = 0
    setInterval(() => {
      console.log('interval', cnt)
      this.setState({ value: cnt++ })
    }, 1000)
  }
  render() {
    return (
      <my-button
        text={this.state.value}
        callback={createCallback(_ev => {
          console.log('onClick', this.state.value)
        })}
      />
    )
  }
}

toString された callback から、関数参照を引き直す。
connectedCallbakcで ハンドラを定義して、また値の変化を監視して、古いコールバックは捨ててやるようにする。

import { html, render } from 'lit-html'
import { getCallback, removeCallback } from '../funcMap'

const template = props => html`
  <div>
    <button>${props.text}: ${props.callback}</button>
  </div>
`

export default class MyButton extends HTMLElement {
  static get observedAttributes() {
    return ['text', 'callback']
  }

  connectedCallback() {
    this.render()

    this._onClick = ev => {
      const callback = getCallback(this.getAttribute('callback'))
      if (callback) {
        callback(ev)
      }
    }
    this.querySelector('button').addEventListener('click', this._onClick)
  }

  disconnectedCallback() {
    if (this._onClick) {
      this.querySelector('button').removeEventListener('click', this._onClick)
      removeCallback(this.getAttribute('callback'))
    }
  }

  attributeChangedCallback(attrName, old, _new) {
    if (attrName === 'callback') {
      removeCallback(old)
    }
    this.render()
  }

  render() {
    const text = this.getAttribute('text')
    const callback = this.getAttribute('callback')
    render(template({ text, callback }), this)
  }
}

window.customElements.define('my-button', MyButton)

問題

シングルトンのfuncMap、扱いをミスすると無限にハンドラが張り付いてしまって、盛大にメモリが漏れていきそう。まあこれは気合で何とかなる範囲ではあるが、完全に隠蔽した上できれいに動くセマンティクスが必要。

あと、イベントのデリゲート処理が貧弱すぎる。7年前のBackboneかと思った。

一瞬 lit-html の問題かと思ったが、よく考えなくてもシリアライズされたタイミングで既に関数のクロージャは消失するので、 preactなんかに変えても解決するわけではない。

解決方法

  • 最初に考えたようにカスタムイベントを addEventListener する
  • customElements で関数参照を引き渡すのを諦める
    • 扱うのは静的な値のみで我慢することになる
  • react側の受け口でいい感じの規約を発明する
    • この funcMap の部分を透過的に扱えるインターフェースを考えると勝てそう
    • WeakMap でいい感じに書けそうな気はするが…
  • コールバックを参照透過な関数に変換して安全に評価する
  • WebComponents を使わない

現時点で恩恵がない以上、やはりまだ webcoponents は使うべきではない。ハンドラの移譲の退化はどう見ても前時代的。

Reactで関数参照を扱えるようになって便利になっていたんだなと思う反面、うまく解決策見つけないと厳しい。

私見

自分の印象でしかないんだけど、GoogleはHTML中心主義だが、MozillaとFacebookはJS中心主義的な発想をしているように見えている。おそらくはクローラを抱えている側の評価コストみたいな問題だとも思うのだけど、イベントハンドラ周りをもう一歩進んで標準化してもらわないと、現実に複数のコンポーネントが協調するようなアプリで、フレームワークレスな世界はこないんじゃないかと思う。

AMPやその他のGoogleの資料を読む限り、HTMLに値をベタベタ書くとアプリが完成して楽勝、エンドユーザーはJSなんか書く必要ない、みたいな世界観がGoogleにありそうと思っていて、 とくに HTML Import の仕様とかに顕著なんだけど、それはそれで一つの選択肢だと思うんいつつ、実際にはそんな簡単な世界観で作れるのはごく簡単なものに過ぎず、それが理由で Mozilla 方面と標準化で話が噛み合ってないように見えている。

個人の感想です。関数渡し周りの評価戦略、よりよい提案や拾いこぼしがあったら教えてください。