JavaScript
WebComponents
Elm
ElmDay 7

Elm と他のフレームワークを組み合わせる

はじめに

既存のプロジェクトがあり Elm を導入したいというとき,全体を置き換えることはできなくても,一部だけ Elm で置き換えるということができれば良いですよね.Web Components を使って Elm を React や Angular,Vue などの既存のプロジェクトと組み合わせて使えないかなと思い,実際に組み合わせてみました.

成果物は calmery/elm-advent-calendar-2018: Elm Advent Calendar 2018 にあります.

Web Components とは

Web Components は,再利用可能なカスタム要素を作成し,ブラウザ上で利用するための技術,HTML Templates や Custom Elements,Shadow DOM をまとめた総称です.これまで React や Angular,Vue などのフレームワークを使用して実現していたカスタムコンポーネントや Scoped CSS などの機能を,標準の機能のみで実現することができます.

現状,全ての主要ブラウザが対応している訳ではありません.Chrome や Firefox,Safari は対応済みですが Edge や IE は対応していません.これらブラウザの対応状況は webcomponents.org の Browser Support で確認することができます.

Custom Elements を使用する

ここでは Custom Elements と Elm を組み合わせて,新しい要素を作っていきます.これらを組み合わせることで,他のフレームワークなどからこの要素を使用するときに Elm であることを意識する必要がなくなります.

Custom Elements と組み合わせるために,webpack と elm-webpack-loader を使用して Elm をバンドルします.まずは必要な環境を作っていきます.

$ npm i webpack webpack-cli elm-webpack-loader -D
webpack.config.js
module.exports = {
  entry: `${__dirname}/src/index.js`,
  mode: "development",
  module: {
    rules: [
      {
        test: /\.elm$/,
        exclude: [/elm-stuff/, /node_modules/],
        loader: ["elm-webpack-loader"]
      }
    ]
  },
  output: {
    path: `${__dirname}/build/`,
    filename: "index.js"
  }
};

これで webpack の設定ができたので,実際に Custom Elements と組み合わせていきます.

src/Main.elm
module Main exposing (main)

main =
  text "Hello World"
src/index.js
const { Elm } = require("./Main.elm");

class HelloWorld extends HTMLElement {
  connectedCallback() {
    Elm.Main.init({ node: this });
  }
}

customElements.define("hello-world", HelloWorld);

最後にバンドルし,生成されたファイルを読み込むことで hello-world タグ,<hello-world></hello-world> が使用できるようになります.

Flags や Port を使用する

ここでは Custom Elements と組み合わせた Elm と,その要素の外側にある JavaScript の連携のために Flags や Port を使用する方法を紹介します.

Flags を使用する

Elm の初期化時,Flags で必要なデータを Elm 側に渡すことができます.Custom Elements と組み合わせた Elm では,内部で init を実行するタイミングで外側から取得したデータを Elm に Flags として渡します.ここでは data-message という要素に設定された属性の値を取得して Elm に渡すようにします.

index.js
connectedCallback() {
  Elm.Main.init({
    node,
    flags: this.getAttribute("data-message")
  })
}
index.html
<example-component data-message="Hello World"></example-component>

これで,data-message に指定されている Hello World という文字列を Elm に Flags として渡すことができます.

Port でデータのやりとりをする

Elm と JavaScript は Port を使用してデータのやりとりを行うことができます.Flags では data-message 属性の値を初期値として渡しました.ですが,このままではこの属性の値が変更されたとき,Elm はその変更を知ることができません.また,Elm から何かデータを受け取ったときに要素の外側,JavaScript 側ではそのイベントを知ることができません.そのため dispatchEventCustomEvent を使用し,この要素の外側と Elm とでデータのやりとりを行えるようにします.

Custom Elements には observedAttributesattributeChangedCallback というメソッドが用意されています.observedAttributes で属性値を監視し,attributeChangedCallback で属性値の変更を受け取ることができます.

static get observedAttributes() {
  return ["data-message"];
}

attributeChangedCallback(name, _, nextValue) {
  if (this.app !== undefined && name === "data-message") {
    this.app.ports.sendMessage.send(nextValue);
  }
}

connectedCallback() {
  // ...
  this.app = Elm.Main.init({ /* ... */ });
}

この例では data-message という属性の値が変更されたとき,Port で Elm 側にそのデータを送っています.

逆に Elm からデータを受け取る場合は Port を subscribe してデータを受け取り,dispatchEventCustomEvent を使用してイベントを外側に送信します.

connectedCallback() {
  // ...
  this.app = Elm.Main.init({ /* ... */ });

  this.app.port.getMessage.subscribe(message => {
    this.dispatchEvent(new CustomEvent("message", { detail: message }));
  });
}

要素の外側でこのイベントを捕捉するには addEventListener を使用します.

const element = document.querySelector("example-component");

element.addEventListener("message", event => {
  // ...
});

最後に,この要素が削除されたときに Port を unsubscribe するようにします.

disconnectedCallback() {
  Object.entries(this.app.ports).forEach(([, value]) => {
    if (value.hasOwnProperty("unsubscribe")) {
      value.unsubscribe();
    }
  });
}

これで Elm と Port を使用してデータのやりとりができるようになりました.

CSS をバンドルする

アプリケーションのレイアウトを整えるにあたって elm-csselm-ui などを使用した Elm のみのプロジェクトであれば問題ありませんが,既存の CSS を使用してレイアウトを調整したい場合もあります.アプリケーション全体に適用したいスタイルであれば単に css-loader などでバンドルしてしまえば良いのですが Custom Elements と組み合わせて他のアプリケーションでも使用したい場合,外部に影響を与えるような形でスタイルをバンドルする訳にはいきません.

Shadow DOM を使う

ここでは Shadow DOM を使って Scoped CSS を実現します.外部に影響が出ないよう Custom Elements で作成した要素内で Shadow DOM を使用し,その中で style タグを使用して CSS を追加します.これで Shadow DOM 内の要素にのみ指定されたスタイルが適用され,外部の要素に影響を与えることはありません.

CSS をテキストとして読み込みたいので webpack のローダーである raw-loader を使用します.その後,読み込んだ CSS を Custom Elements 内の Shadom DOM に追加します.Sass などを使用したい場合は sass-loader などで変換した結果を raw-loader で読み込むようにすると良いです.

webpack.config.js
module: {
  rules: [
    {
      test: /\.s?css$/,
      loader: ["raw-loader", "sass-loader"]
    }
  ]
}
index.js
connectedCallback() {
  const shadomDom = this.attachShadow({ mode: "open" });

  const style = document.createElement("style");
  style.innerHTML = require("./style.css");
  shadomDom.appendChild(style);

  const div = document.createElement("div");
  shadomDom.appendChild(div);

  Elm.Main.init({ node: div });
}

これで Shadow DOM を使用して Scoped CSS を実現することができました.

画像をバンドルする

Custom Elements 内で画像を使用したい場合,外部の画像の絶対パスを指定するか,画像を Base64 に変換し,ソースコード中に埋め込む必要があります.ここでは elm-assets-loader を使用した方法を紹介しますが,Elm の最新バージョンである 0.19 では使用できません.もし画像を埋め込みたいとなれば 0.18 を使い elm-assets-loader を使用するか,0.19 で画像を Base64 に変換して埋め込むような形になります.

elm-assets-loader と url-loader を組み合わせる

そもそも elm-assets-loader は何をするかというと,elm-webpack-loader が出力した JavaScript のソースコードから webpack の設定で指定したファイル,カスタム型のコンストラクタに対応する箇所を見つけ出し,コンストラクタに与えられた画像などのパスを require を使用した形へと変換するということをしています.これによって他のローダーで画像などのパスを require を通して検出できるようになります.

ここでは elm-assets-loader を使用して Elm のソースコード中の画像パスを require を使用したソースコードへと変換し,url-loader を使用して対応する画像ファイルを Base64 としてソースコード中に埋め込むようにします.

基本的には NoRedInk/elm-assets-loader の README を読みつつ進めていけば問題ないと思います.

Assets.elm
module Assets exposing (getPath, logo)


type AssetPath
    = AssetPath String


getPath : AssetPath -> String
getPath (AssetPath path) =
    path


logo =
    AssetPath "../assets/logo.svg"

ここでは画像を Base64 に変換するため webpack には elm-assets-loader の設定だけではなく url-loader の設定も追加します.

webpack.config.js
module: {
  rules: [
    {
      test: /\.elm$/,
      exclude: [/elm-stuff/, /node_modules/],
      use: [{
        loader: 'elm-assets-loader',
        options: {
          module: 'Assets',
          tagger: 'AssetPath'
        }
      }, "elm-webpack-loader"]
    },
    {
      test: /\.svg/,
      loader: 'url-loader'
    }
  ]
}

これで画像も Base64 としてバンドルできるようになりました.

elm-assets-loader を 0.19 でも使用する

elm-assets-loader のソースコードをちょこっと変更すると 0.19 でも使用できるようになります.おすすめはしません.

index.js
33 -  const packageName = config['package'] || 'user/project';
34 -  const taggerName = '_' + [
33 +  const packageName = config['package'] || 'author/project';
34 +  const taggerName = [

Update · calmery/elm-assets-loader@b3114a5

古いブラウザにも対応する

Web Components に含まれる Custom Elements は最新の Chrome や Safari,Firefox であれば問題なく使用できます.ですが Edge は Custom Elements に対応しておらず,IE 11 は Class 構文に対応していないため,このままでは Web Components の機能を使用することができません.

こうした環境にも対応するため Web Components の Polyfill である @webcomponents/webcomponentsjs を使用します.この Browser Support を見るとわかるように,この Polyfill で主要な環境には対応することができます.

まずは必要となるパッケージをインストールします.

$ npm i @webcomponents/webcomponentsjs
$ npm i @babel/core @babel/preset-env babel-loader -D

次に Web Components を使用しているファイル上で @webcomponents/webcomponentsjs をインポートします.

src/index.js
require("@webcomponents/webcomponentsjs");

さらに Edge や IE 11 で動作させるため Babel と webpack を使用して変換を行います.Babel では @babel/preset-env を使用します.@babel/preset-env には基本的に対応するブラウザによってオプションを付けるべきですが,ここでは特に指定をしません.

Sidenote, if no targets are specified, @babel/preset-env behaves exactly the same as @babel/preset-es2015, @babel/preset-es2016 and @babel/preset-es2017 together (or the deprecated babel-preset-latest).
@babel/preset-env · Babel

.babelrc.js
module.exports = {
  presets: ["@babel/preset-env"]
};
webpack.config.js
module: {
  rules: [
    {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: "babel-loader"
    }
  ]
}

最後に webpack を用いてバンドルを行います.

動作の確認のために index.html を作成します.ここでバンドル後のファイルを読み込み,Edge や IE 11 での動作を確認すると問題なく動作していることがわかります.

index.html
<!DOCTYPE html>
<html>
  <body>
    <example-component></example-component>
    <script src="build/index.js"></script>
  </body>
</html>

これで Edge や IE 11 でも問題なく動作するようになりました.

ここまで触れませんでしたが Polyfill を使用していると出力されるファイルサイズが大きくなったり,そもそも実装されていない機能を使用するためブラウザにかかる負荷が大きくなったりとデメリットもあります.ファイルサイズについては Edge や IE 11 以外のブラウザに対しても影響があるので注意する必要があります.

他のフレームワークと組み合わせる

ここでは React や Angular,Vue などのフレームワークと,Custom Elements を使用した Elm を実際に組み合わせていきます.

それぞれのフレームワークで簡単なサンプルを実装しています.Elm 側は Qiita のユーザアイコン,ユーザ IDを与えられたユーザ ID から取得,表示するようになっています.各フレームワークではユーザ ID を入力し,ボタンをクリックすると入力したユーザ ID を Elm 側へと送信します.Elm 側でのユーザ情報の取得中は入力,ボタンのクリックができないようになっています.CSS を使用し,Flags や Port も使用しているので,どう組み合わせるのかの参考になると思います.

calmery/elm-advent-calendar-2018: Elm Advent Calendar 2018

React

elm-advent-calendar-2018/examples/react at master · calmery/elm-advent-calendar-2018

ここでは create-react-app を使用して生成されたプロジェクトを使用します.

$ npx create-react-app react-web-components

React では Custom Elements を定義したファイルをインポートするだけで使用できるようになります.

src/App.js
import React, { Component } from 'react';
import './App.css';
import 'example-component';

class App extends Component {
  render() {
    return (
      <div className="App">
        <example-component />
      </div>
    );
  }
}

export default App;

もし wmonk/create-react-app-typescript などで TypeScript を使用している場合は,型定義ファイルに次のように記述すると問題なく使用できます.型定義は各自,気の済むまでやってください.

index.d.ts
declare module JSX {
  interface IntrinsicElements {
    "example-component": any;
  }
}

Angular

elm-advent-calendar-2018/examples/angular at master · calmery/elm-advent-calendar-2018

Angular は Angular CLI で生成したプロジェクトを使用します.

$ npm i -g @angular/cli
$ ng new angular-web-components

まずは Custom Elements を定義したファイルをインポートします.

src/main.ts
import 'example-component';

次に Custom Elements を使用したいモジュールの NgModuleschemas を追加します.ここでは src/app/app.module.ts に追加しました.

src/app/app.module.ts
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  // ...
  schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
})
export class AppModule { }
src/app/app.component.html
<example-component></example-component>

これで AppComponent 内で Custom Elements が使用できるようになります.

Vue

elm-advent-calendar-2018/examples/vue at master · calmery/elm-advent-calendar-2018

ここでは Vue CLI を使用します.

$ npm i -g @vue/cli @vue/cli-init
$ vue init webpack vue-web-components

まずは Custom Elements を定義したファイルをインポートします.

src/main.js
import 'example-component'

あとは表示させたい場所にタグを配置するだけです.

src/App.vue
<template>
  <example-component />
</template>

まとめ

ここでは Web Components の要素である Custom Elements や Shadow DOM を使用して Elm のプロジェクトを既存のプロジェクトへ埋め込む方法を紹介しました.

実際に埋め込むとなるとファイルサイズなど,気になる点が多いというのが正直なところです.この辺りはプロジェクトの内容,サポートするブラウザなどによっても変わってきそうだなと.もし Elm を使ってみたいというときに,こういう方法もあるということで見てもらえたらなと思います.

おまけ

create-elm-app を使用して生成したプロジェクトを Custom Elements と組み合わせて使えるようにします.この方法は,そもそも create-elm-app の用途として合っておらず,また elm-app eject が必要なのでおすすめしません.

elm-app eject する

create-elm-app が内部的に使用している webpack のローダーなどの設定を変更する必要があるので elm-app eject を実行します.また,registerServiceWorker.js は不要なので削除します.

CSS をテキストとして読み込むように変更する

次に webpack の CSS に関する設定を変更します.これまで行ってきたように CSS をテキストとして読み込んで Shadow DOM 内に配置したいので css-loader と style-loader,mini-css-extract-plugin を削除して raw-loader を追加します.

config/webpack.config.dev.js
194 - require.resolve('style-loader'),
195 - {
196 -   loader: require.resolve('css-loader'),
197 -     options: {
198 -       importLoaders: 1
199 -     }
200 - },
194 + require.resolve('raw-loader'),
config/webpack.config.prod.js
8 - const MiniCssExtractPlugin = require('mini-css-extract-plugin');

// ...

229 - MiniCssExtractPlugin.loader,
230 - {
231 -   loader: require.resolve('css-loader'),
232 -   options: {
233 -     importLoaders: 1,
234 -     minimize: true,
235 -     sourceMap: shouldUseSourceMap
236 -   }
237 - },
228 + require.resolve('raw-loader'),

// ...

302 - // Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
303 - new MiniCssExtractPlugin({
304 -   // Options similar to the same options in webpackOptions.output
305 -   // both options are optional
306 -   filename: 'static/css/[name].[contenthash:8].css',
307 -   chunkFilename: 'static/css/[name].[contenthash:8].chunk.css'
308 - }),

Custom Elements と組み合わせる

Elm の初期化を行なっている箇所を Custom Elements として使用できるように書き換えます.

public/index.html
<body>
  <example-components></example-components>
</body>
src/index.js
import css from './main.css';
import { Elm } from './Main.elm';

class ExampleComponent extends HTMLElement {
  connectedCallback() {
    const shadowDom = this.attachShadow({ mode: 'open' });

    // CSS

    const style = document.createElement('style');
    style.innerHTML = css;
    shadowDom.appendChild(style);

    // Elm

    const div = document.createElement('div');
    shadowDom.appendChild(div);

    Elm.Main.init({
      node: div
    });
  }
}

customElements.define('example-component', ExampleComponent);

これで <example-component></example-component> が使えるようになりました.

Chunk を生成しないようにする

create-elm-app では webpack の設定ファイル上で Chunk を生成するよう設定されていて webpack が出力するファイルが複数に分けられているので単純化のために Chunk の設定を削除します.これは実際にビルドする際,npm run build の際に使用される webpack.config.prod.js のものだけで良いです.

config/webpack.config.prod.js
46 - filename: 'static/js/[name].[chunkhash:8].js',
47 - chunkFilename: 'static/js/[name].[chunkhash:8].chunk.js',
46 + filename: 'index.js',

// ...

110 - ],
111 - // Automatically split vendor and commons
112 - // https://twitter.com/wSokra/status/969633336732905474
113 - splitChunks: {
114 -   chunks: 'all'
115 - },
116 - // Keep the runtime chunk seperated to enable long term caching
117 - // https://twitter.com/wSokra/status/969679223278505985
118 - runtimeChunk: true
109 + ]

package.json の main を設定する

package.json の mainbuild/index.js とします.これで外部からビルド済みのファイルを参照することができるようになります.