LitElement を Webpack でバンドルして IE11 で動かすための最適解

WebComponents の対応状況がだいぶいい感じになってきました。

WebComponents のブラウザ対応状況 - Can I use

スマホ環境は iOS で一部 Scoped CSS に制限があるものの、ほぼネイティブに対応していますし、PC 環境は IE/Edge がネックですが、Edge は遠からず Chronium 版に移行する運命。これで Edge で対応が遅れていた shadow DOM も問題なく使える…!
そこで足を引っ張るのはおなじみの IE11。2025 年まで存命予定のブラウザを現時点で切るのは流石に難しいので、これは Polyfill で対応するしかないです。

WebComponents の Polyfill を使う

(主に)IE/Edge 向けに WebComponents Polyfills が提供されています。
これを導入すれば WebComponents がそのまま使えるようになるかと言うとそうでもなく、もう二手間ほど対応が必要になります。

手間その 1. ES5 へトランスパイルする

WebComponents は ES2016 のクラス構文が前提となっているので、そのままでは IE11 は動作しません。WebComponents のコードを ES5 にトランパイルする必要があります。

トランスパイルと言えば Babel ですが、実はここに落とし穴があります。
WebComponents Polyfills は未対応ブラウザに対して 自動的に Polyfill をあててくれるのですが、この処理と Babel の core-js の処理との相性が悪く、IE11 でスタックオーバーフローが発生してしまいます。
この不具合は Issue としてはあるものの、未だ解決には至らない様子。 https://github.com/webcomponents/polyfills/issues/43

このスタックオーバーフローは Symbol の Polyfill が原因のようで、別途 Symbol の Polyfill (例えば https://polyfill.io/v3/polyfill.min.js?features=Symbol など)を当てることでこのエラーは一応は回避できますが、polyfill.io、WebComponents Polyfills、Babel(core-js) と 3 つの Polyfill を重ね掛けする形になります。

そこで、今回の WebComponents を IE11 で動かす目的としては TypeScript を使うことを強くオススメします。 polymer-cli の build コマンドも tsc を使っていることもあり、TypeScript の ES5 トランスパイルではこの問題に遭遇することなく動作します。

手間その 2. ShadyCSS で CSS のスコープを指定する

通常の WebComponents では以下のような記述になります。

class MyButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <style>
        button {
          border-radius: 5px;
        }
      </style>
      <button><slot></slot></button>
    `;
    this.addEventListener("click", e => {
      alert("click!!!");
    });
  }
}
customElements.define("my-button", MyButton);

ですが、IE/Edge ではこの記述のままだと style の定義がグローバルに撒け出てきてしまいます。これを回避する方法として公式の使い方 にもあるように、ShadyCSS を使って CSS のスコープが shadowRoot にあることを明示してあげる必要があります。 修正後のコードは以下の通りです。

const html = document.createElement("template");
html.innerHTML = `
  <style>
    button {
      border-radius: 5px;
    }
  </style>
  <button><slot></slot></button>
`;

window.ShadyCSS && ShadyCSS.prepareTemplate(html, "my-button");

class MyButton extends HTMLElement {
  constructor() {
    super();
    this.addEventListener("click", e => {
      alert("click!!!");
    });
  }
  connectedCallback() {
    window.ShadyCSS && ShadyCSS.styleElement(this);
    if (!this.shadowRoot) {
      this.attachShadow({ mode: "open" });
      this.shadowRoot.appendChild(html.content.cloneNode(true));
    }
  }
}
customElements.define("my-button", MyButton);

バニラ WebComponents はつらいのでライブラリを使いたい

WebComponents で HTML 周りの処理を書くと DOM API ベースで書くことになり、今のご時世では結構なつらみがあります。
React.js や Vue.js の Virtual DOM による差分更新のように、レンダリングパフォーマンス最適化の恩恵にも与りたい…
ということで、IE11 にも対応した WebComponents ライブラリを使うことにしましょう。

WebComponents を生成するライブラリといえば Polymer Project。その中に WebComponents を生成する軽量クラスライブラリの LitElement があります。
lit-html で書けるので DOM API で書くよりパフォーマンスが良いですし、先の ShadyCSS の適用などもライブラリ側がやってくれるので、こちらの手間が減ります。
LitElement で書くと以下のような記述でスッキリ書けます。

import { LitElement, html, css } from "lit-element";

class MyButton extends LitElement {
  static get styles() {
    return css`
      button {
        border-radius: 5px;
      }
    `;
  }
  render() {
    return html`
      <button @click=${() => alert("click!!!")}><slot></slot></button>
    `;
  }
}

customElements.define("my-button", MyButton);

公式のブラウザ互換表示にはちゃんと IE11 のアイコンもいるので安心して使えます。
…と思いきや、LitElement の公式手順に則ってもすぐにちゃんと動いてくれなくて、実働させるまでに色々と試行錯誤がありました。
自分がいくつかハマったポイントをここに記して行きます。

ハマりポイント 1. WebComponents を呼ぶ script に type="module" は付けない

LitElement の使い方 では、

<script type="module" src="./js/my-button.js"></script>

と書いてあって、最初はその通りに書いてみたものの、JS ファイルが読み込まれません。 当たり前といえば当たり前なんですが、 type="module" は ES Modules 向けの記述で、IE11 は解釈できずに処理をスキップします。

そもそも ES5 にトランスパイルしているファイルなので、以下のように通常の JavaScript と同様の形で呼び出してあげる必要があります。

<script src="./js/my-button.js"></script>

ハマりポイント 2. loader のオプションで LitElement, lit-html をトランスパイルの対象に指定する

npm に登録されている LitElement, lit-html は ES2017 で書かれており、ES5 での提供がありません。 そのため IE11 で動かすためにはこれらを import 時にトランスパイルする必要があります。

しかし、Webpack の loader は通例的に exclude オプションで /node_modules/ はトランパイルの対象外としていることが多く、そのままだと バンドルファイルに ES2017 のコードが混じってしまいます。 loader のオプションで、 LitElement, lit-html は例外的にトランスパイルの対象として通知する必要があります。

module: {
  rules: [
    {
      test: /\.(js|ts)$/,
      use: 'ts-loader',
      exclude: /node_modules\/(?!(lit-html|lit-element))\//,
    },
  ],
},
module: {
  rules: [
    {
      test: /\.js$/,
      use: 'babel-loader',
      exclude: /node_modules\/(?!(lit-html|lit-element))\//,
    },
  ],
},

ハマりポイント 3. (Babel を使う場合) @babel/preset-env で useBuiltIns: ‘entry’ を指定する

Babel の v7.4 以降から、 @babel/polyfill が deprecated となり、 core-js を使うことが推奨されるようになりました。

Babel7.4 で非推奨になった babel/polyfill の代替手段と設定方法

いくつかの代替方法がありますが、useBuiltIns: 'usage' ではうまく動かなかったので、今回は useBuiltIns: 'entry' を使います。

以下は babel の設定例です。

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: "ie 11",
        modules: false,
        useBuiltIns: "entry",
        corejs: 3
      }
    ]
  ]
};

エントリーポイントの JS ファイルの先頭に以下を呼んでおきます。

import "core-js/stable";
import "regenerator-runtime/runtime";

サンプルコード&動作デモ

Webpack & LitElement でどうにか IE11 に対応できないか、試行錯誤の結果のコードを以下にまとめました。

https://github.com/okamoai/lit-element-es5

動作デモは以下で確認できます。

https://rano-raraku.net/lit-element-es5/

まとめ:IE11 向けの WebComponents は LitElement & TypeScript で書け

最初はいつもの構成で Webpack & babel-loader でがんばってましたが、IE11 で WebComponents する場合、相当な理由がない限りは Babel を使わずに TypeScript で書いた方が謎の不具合に遭遇せず、圧倒的に楽できます。TypeScript で書きましょう。

動作デモに添付した Webpack Bundle Analyzer のレポートを見ても分かりますが、生成されたコードも圧倒的に TypeScript の方が軽いです。

  • Babel: 146KB (Gzip: 42.92KB)
  • TypeScript: 28.8KB (Gzip: 8.05KB)

これは Babel の オプションが useBuiltIns: 'usage' で、Polyfill 全乗っけ状態なので当然といえば当然ですが。

ビルドに polymer-cli を使った方が早いという話もありますが、Webpack の splitChunks や Tree Sharking はやっぱり有用ですし、画像ファイルも WebComponents にバンドルしたい、とかなると、やっぱり Webpack で WebComponents をビルドできるメリットは大きいですね。