NHN Cloud NHN Cloud Meetup!

Webコンポーネント(5) – lit-htmlのReactようにコーディングする

今回は、Webコンポーネントをreactのようにコーディングしてみよう。サンプルで使うコードは、Todo Web Componentsから参照できます。前回の記事で調べたカスタム要素、Shadow DOM、lit-HTMLを使ったWebコンポーネントアプリケーションの作成方法を、サンプルを使って確認してみよう。

WebコンポーネントTODO APP

まず、以下のリンクからサンプルページを開いてみよう。

Todo Web Componentsアプリストア
Todo Web Componentsアプリのデモ

今回使用するTodo Web Componentsのサンプルは、TodoMVCに沿って作成しました。ここで同じTODOアプリを各フレームワークでどのように実装できるか、サンプルを使って比較できます。TodoMVCのサンプルは、フレームワークの強みを見せるため、過度に簡略化したりはせず、ある程度、実際のアプリケーションの構成になっており、客観的に比較できるメリットがあります。この理由から、今回はサンプルコードが少し長くなっているので、コード全体ではなく、一部コードだけを切り離して説明します。

プロジェクトの構造

このプロジェクトは、先に述べたカスタム要素、Shadow DOM、lit-HTMLを使用しています。カスタム要素を使うにはES6 Class文法が必須になります。多くのブラウザに対応するため、BabelからES5文法的でsrcにあるソースファイルを変換し、そのツーリングをWebpackからdistに保存しています。プロジェクトのディレクトリ構造は以下の通りです。

  • src:ソースファイル
    • components:カスタム要素
      • todoApp.js:アプリケーションのメインカスタム要素
      • todoInput.js:上段TODOアイテムの入力ウィンドウ
      • todoItem.js:入力されたTODO
      • todoList.js:todoItemをリスト形式で表示
      • todoToolbar:下段ツールバー。残TODO数、ステータスに応じてTODOアイテム表示
    • libs:以外のソース
      • actions.js:redux-zeroアクション。TODOアプリでステータスを更新できるアクションを定義
      • litRender.jslit-HTMLコンポーネントヘルパー。カスタム要素からinvalidateを呼び出すと、画面更新をスケジュール
      • store.js:redux-zeroストア、デフォルト設定
    • index.js:index
  • index.html:index HTML
  • webpack.config.js:Webpack設定
  • package.json:パッケージの設定とスクリプト

プロジェクトの使用

ローカルでは必ずしもこのプロジェクトを実行する必要はなく、不要な場合はこのセクションをスキップしてもよいでしょう。上記のTodo Web Componentsアプリストアデモページを参照するだけでも十分です。
ローカルで確認したい場合は、まずgitコマンドでTodo Web Componentsアプリストアからプロジェクト全体を持ってこよう。

git clone git@github.com:kyuwoo-choi/todo-web-components.git

次にyarnコマンドで必要なディペンデンシーパッケージをインストールします。yarnがインストールされていない場合は、npmを使ってもよいでしょう。

yarn install
or
npm install

package.jsonに定義されたserveスクリプトを実行して、ブラウザで確認してみよう。http://localhost:8080/

yarn run serve
or
npm run serve

index.html:カスタム要素を使用

<html>

<head>
  ...
  <script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.20/custom-elements-es5-adapter.js" defer></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.20/webcomponents-sd-ce.js" defer></script>
  <script src="./dist/TodoApp.js" defer></script>
  ...
</head>

<head>には変換されたTodoApp.jsファイルと2つのWebコンポーネントポリフィル custom-elements-es5-adapter.js, webcomponents-sd-ce.jsスクリプトが含まれます。現在、ChromeとSafariブラウザでは、ポリフィルなしで確認ができ、2つのポリフィルを使うと、Firefox、Edge、IE11にも対応できます。
Webコンポーネントポリフィルのプロジェクトでは、Shadow DOMCustom Elementsを使うので、webcomponents-sd-ce.jsを選択しました。(ポリフィルファイル名に含まれるsdceShadow DOMCustom Elementsの略)またカスタム要素が必要とするES6文法をES5文法に変換しているため、custom-elements-es5-adapter.jsが必要です。

<body>
  <todo-app></todo-app>
  ...
</body>

</html>

<body>は簡単に<todo-app>タグを含めました。このタグは、TodoApp.jsに含まれるカスタム要素を定義します。このようにカスタム要素を使うと、JavaScriptのファイルとタグを1つ使用するだけで非常に便利です。

todoApp.js:アプリケーションのコンポーネント

todoApp.jsは、src/componentsで探すことができ、上記のindex.htmlで使った<todo-app>タグをカスタム要素に定義します。加えて、単一アプリケーションとして必要なAPIも提供しています。

imports

import { html } from 'lit-html';

import LitRender from '../libs/litRender';
import store from '../libs/store';
import {
  add,
  toggle,
  remove,
  toggleAll,
  clearCompleted,
  replace
} from '../libs/actions';

import './todoInput';
import './todoToolbar';
import './todoList';

コードの上段から必要なディペンデンシーをインポートします。htmlレンダリングに必要なlit-HTML、これをカスタム要素で簡単に使えるように定義したLitRenderミックスのヘルパー。アプリケーションの状態とアクションを管理するRedux-Zerostore, add, toggleのようなアクション。最後にアプリケーションのコンポーネントを構成するtodoInputtodoToolbartodoListをインポートします。
import ‘./todoInput’の文法が面倒な方もいるでしょうが、これはインポートされたモジュールを保存せず、モジュールをロードするだけの方法です。import TodoInput from ‘./todoInput’も正しい使い方ですが、コードでTodoInputを使わない場合、WebpackがTree Shakingでディペンデンシーを除去してしまいます。これを避けるための文法であり、特にコンポーネントクラスを直接使用することもないため、現在の形になったと理解すればよいでしょう。

カスタム要素クラス

...
class TodoApp extends LitRender(HTMLElement) {
  constructor(name) {
    super();

    this.attachShadow({ mode: 'open' });

    this.invalidate();
  }
...

ES6 class構文でTodoAppカスタム要素を定義します。このクラスは、HTMLElementLitRender mixinを拡張します。constructorでは、Shadow DOMをopenモードでこのカスタム要素に生成します。最後にinvalidate()していますが、これはLitRenderに定義された関数で、このコンポーネントをレンダリングするようにしてくれます。LitRenderinvalidateについては後で詳しく調べるので、ここでは直感的にinvalidateの効用だけ考慮すれば十分です。

カスタム要素API

...
  add(title) {
    add(title);
  }
...
  get length() {
    const todoList = store.getState().todoList;

    return todoList.length;
  }
...

APIを定義します。デモページまたはローカルサーバーhttp://localhost:8080に接続してAPIを使用してみよう。document.querySelector(‘todo-app’).add(‘hello’), document.querySelector(‘todo-app’).lengthのコマンドとして使用できます。カスタム要素クラスに関数を定義することで、このように直感的なAPIを提供できます。

HTMLレンダリング

...
  render() {
    return html`
      <style>
        host: {
          display: block;
        }
        section {
          background: #fff;
          margin: 130px 0 40px 0;
          position: relative;
          box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
        }
      </style>
      <section>
        <todo-input></todo-input>
        <todo-list></todo-list>
        <todo-toolbar></todo-toolbar>
      </section>
    `;
  }
}
...

render関数は、上記のinvalidateと対をなす関数でLitRenderから呼び出されます。この関数が呼び出されると、lit-HTMLhtml Template Literal関数を使ってカスタム要素のサブエレメントをレンダリングします。

カスタム要素の登録

customElements.define('todo-app', TodoApp);

最後にカスタム要素を定義したクラスをtodo-appタグとして定義します。カスタム要素を作成する際、todo-appのようにタグ名には必ずを1つ以上つけよう。ブラウザはHTMLを解析できる-を含むタグを検出するためです。

litRender.js

litRender.jsはsrc/libs以下で確認でき、当該アプリケーションの各コンポーネントのレンダリングを助けます。各コンポーネントは、class SomeComponent extends LitRender(HTMLElement)形式でLitRenderのmixinに拡張して使用します。一度に何度も内容が更新される場合は、毎回レンダリングせずに、まとめてから一度にレンダリングすることで、性能向上に役立つコードです。これを拡張するコンポーネントでthis.invalidateを呼び出すと、コンポーネントに定義されたrender関数の呼び出しが予約されます。

import { render } from '../../node_modules/lit-html/lib/lit-extended';

export default base =>
  class extends base {
    render() {}

    async invalidate(instant) {
      if (!this.needsRender) {
        if (!instant) {
          this.needsRender = true;
          await 0;
          this.needsRender = false;
        }
        render(this.render(), this.shadowRoot);
      }
    }
  };

コンポーネント:todoList.js, todoItem.js, todoInput.js, todoToolbar.js

Todoアプリケーションを構成する個々のコンポーネントを定義します。todoApp.jsコードを見ると<todo-list>,<todo-toolbar>などの形態で使用しているのが確認できます。

connectedCallback / disconnectedCallback

import { toggle, remove, replace } from '../libs/actions';
...
class TodoItem extends LitRender(HTMLElement) {
...
  connectedCallback() {
    const root = this.shadowRoot;
...
    root.addEventListener('click', handlers.onClick);
...
  }

  disconnectedCallback() {
    const root = this.shadowRoot;
...
    root.removeEventListener('click', this._handlers.onClick);
...
  }
...
  _onClick(event) {
    const id = this.todo.id;
    const classList = event.path[0].classList;

    if (classList.contains('toggle')) {
      toggle(id);
    } else if (classList.contains('destroy')) {
      remove(id);
    }
  }
...

TodoAppで使用されなかったconnectedCallbackdisconnectedCallbackが見られます。この関数は、カスタム要素のコールバックでエレメントがDOMにattach, detachするときに呼び出されます。このコールバック関数がDOMイベントハンドラの割り当て/解除に最適な場所です。もし適切にハンドラが解除されなければ、メモリ漏れになるので注意しよう。
onClickハンドラは、条件に応じてtoggleremove Reduxアクションを実行しています。

render / html

  render() {
    const todo = this.todo;
    const classCompleted = todo.completed ? ' completed' : '';
    const inputToggle = todo.completed
      ? html`<input class="toggle" type="checkbox" checked>`
      : html`<input class="toggle" type="checkbox">`;

    const classEditing = this._editing ? ' editing' : '';

    return html`
      ${style}
      <div data-id$="${todo.id}" class$="${'item' +
      classCompleted +
      classEditing}">
        <div class="view">
          ${inputToggle}
          <label>${todo.title}</label>
          <button class="destroy"></button>
        </div>
        <input class="edit" type="text" />
      </div>
    `;
  }
}
...
const style = html`
  <style>
    host: {
      display: block;
    }
    .item {
      position: relative;
      font-size: 24px;
      border-bottom: 1px solid #ededed;
    }
...
  </style>
`;
...

render関数が少し複雑になりました。lit-HTML htmlテンプレートリテラル関数は、テンプレートリテラルを引数として受け取り、HTMLTemplateElementを含むオブジェクトTemplateResultを返します。${something}には変数や定数表現以外にもTemplateResult, Promise,  Array, Iterablesなどをサポートします。さまざまな方法を組み合わせて自由にテンプレートを構成すればよいでしょう。上記のコードでも、テンプレートが複雑に見えないように<style><input>などを分離した後、htmlテンプレートリテラルを重複して使用しています。

オブジェクトの配信

  // todoList.js
  render() {
...
    const todoItems = todoList
      .filter(todo => {
        return (
          route === '' ||
          (route === 'completed' && todo.completed) ||
          (route === 'active' && !todo.completed)
        );
      })
      .map(todo => html`<todo-item todo=${todo}></todo-item>`);

    return html`
      ${style}
      <div class="todo">
        ${btnToggleAll}
        <div class="todo-list">
          ${todoItems}
        </div>
      </div>
    `;
  }
...
  // todoItem.js
  set todo(todo) {
    this._todo = todo;
    this.invalidate();
  }

lit-HTMLの拡張機能を使用すると、htmlのように他のカスタム要素にobjectを配信できます。Attributeはhtml<todo-item name$=${someText}></todo-item>で名前の後ろに$をつけます。objectを使用するtodoItems.jsはthis.todoでアクセスすればよいでしょう。このコードでは、getterを作って値が割り当てられると、自動でinvalidateを呼び出して、カスタム要素が更新されるようにしました。ここで1つ指摘したいのは、lit-HTMLの動作が十分に性能を考慮して作られているということです。テンプレートリテラルに渡された値を記憶して、渡された値が異なる場合にのみ、コンポーネントを更新します。デモでこのコードが動作することを確認すれば、追加/削除/変更されたアイテムのみ更新されます。

store.js, actions.js

Redux-Zeroのstore, actionを定義します。

import createStore from 'redux-zero';

const initialState = { route: '', todoList: [] };
const store = createStore(initialState);

export default store;
import store from './store';

function actionCreator(action) {
  return function() {
    let state = store.getState();
    state = action(state, ...arguments);
    store.setState(state);
  };
}
...
export const remove = actionCreator((state, id) => {
  state.todoList = state.todoList.filter(todo => todo.id !== id);

  return state;
});
...

#UseThePlatformフレームワークがなくてもReactようにコーディング

ここまで、カスタム要素、Shadow DOM、lit-HTMLを使って、TODOアプリケーションを、私たちが使い慣れているReactのように作成するコードを簡単に説明しました。この方法は単にReactをまねることが目的ではありません。自分たちが使い慣れた方法でアクセスしながらも、フレームワークを必要とせず、2KBに満たないlit-HTMLライブラリだけを使用しています。メリットは明らかです。

  1. フレームワークのダウンロード時間がないので、ページが軽くて速い。
  2. 直感的なDOM Integration. document.querySelector(‘todo-app’).add(‘hello’)のような直感的な方法を提供してくれるフレームワークはない。
  3. 完全な標準ECMAScriptコードで、フレームワークの流行に左右されず、長く保守可能なコードを作れる。
  4. フレームワークとは無縁で、むしろいくつかのフレームワークと同じように使うことができる。
  5. 参入障壁が低く、どのフロントエンド開発者も理解できるコードである。

もちろんフレームワークが提供する利便性や、ブラウザ対応などを考慮すると、残念な点もあります。lit-HTMLも、もう少し洗練させる必要があります。

おわりに

Chrome Dev Summit 2017 – lit-HTMLを見た後、lit-HTML、Webコンポーネントがどの程度の高速パフォーマンスを見せるのか気になりました。せっかくなのでtodo preact benchmarkに追加して、パフォーマンスを比較した結果、あまりにも速く驚きました。ただ、想像以上の速さのため疑問が生じ、いろいろ検索したところ、Vue.js TodoMVC Benchmarkを見つけました。この意見のように、フレームワークのベンチマークは意味がないと感じたので、ベンチマークの結果は別途作成せず、このプロジェクトだけをTodoMVCに提出する予定です。

参考文献

NHN Cloud Meetup 編集部

NHN Cloudの技術ナレッジやお得なイベント情報を発信していきます
pagetop