Help us understand the problem. What is going on with this article?

フレームワークなしでSPAを作るために、WebComponentsを用いてFluxを実装する

SPAを作るために必要となる設計思想Flux。今回はWebComponentsを用いてVanillaJSでFluxを実装してみたいと思います。

なお今回のコードを書くにあたって10分で実装するFluxを参考にさせていただいています。

まず、Fluxのデータの流れをおさらいしましょう。かなり簡単に書くと以下のような感じだと思います。
View -> Action -> Dispatcher -> Store -> View -> ...
まずユーザーがWebサイトにアクセスすると何らかのViewが表示されます。そしてユーザーが何らかの行動を起こしActionが発火します。Actionはアクション名と必要なデータをDispatcherを通してStoreに伝えます。StoreはActionに応じて状態(state)を更新します。そして、更新したことをViewに伝え、Viewは新しい状態に基づいて再描画します。

ActionはDispatcherを使ってStoreへ、StoreはDispatcherを使って任意のActionを監視します。そのため、DispatcherはEventEmitterで実装できそうです。なお、ブラウザにEventEmitterは標準で実装されていないので、今回は代わりにEventTargetを使います。
Storeも状態を更新したことをViewに伝え、Viewは任意のStoreを監視します。つまり、StoreもEventTargetを使って実装します。
Viewは今回は、WebComponentsのCustomElementsを用いて実装してみたいと思います。

なお今回実装するのは、ボタンを押したらカウントが増えるアプリです。
ren.gif

まず、Dispatcherを見てましょう。DispatcherはEventTargetそのものです。

dispatcher.mjs
export default new EventTarget();

では次にActionを見てましょう。

actions.mjs
import dispatcher from 'dispatcher.mjs';

export default {
    countUp() {
        dispatcher.dispatchEvent(new CustomEvent('countUp'));
    }
}

Dispatcherを利用してActionをStoreに伝えるにはこのようにnew CustomEvent()を使います(new Event()でもよい)。CustomEventを使うとデータも渡せます。データは
eventTarget.dispatchEvent(new CustomEvent('eventName', { detail: data }));
と書くことによって、受け取り側は、
eventTarget.addEventListener((e) => { const data = e.detail });
と書くことによってデータを受け取れます。
では次にStoreを見てみましょう。

store.mjs
import dispatcher from 'dispatcher.mjs';

const initialState = {
    count: 0
};

class Store extends EventTarget {
    constructor() {
        super();

        Object.assign(this, initialState);

        dispatcher.addEventListener('countUp', () => {
            this.count++;
            this.dispatchEvent(new Event('CHANGE'));
            //stateを変更したことをcomponentに伝える
        });
    }
}

export default new Store();

'countUp'アクションをリッスンして、内部のthis.countというstateを更新していますね。そして、'CHANGE'イベントを発火させています。こうすることによって、Viewにstateが更新したことを伝えることができます。
では次に、View(Component)を見てみましょう。

Component.mjs
import actions from 'actions.mjs';
import store from 'store.mjs';

const html = `
<p>Count: <span>${store.count}</span></p>
<button type="button">Count Up</button>
`;

export default class Component extends HTMLElement {
    constructor() {
        super();

        const shadowRoot = this.attachShadow({mode: 'open'});
        shadowRoot.innerHTML = html;

        this.span = shadowRoot.querySelector('span');
        shadowRoot.querySelector('button').onclick = () => {
            actions.countUp();
        }

        this.handleStoreChange = this.handleStoreChange.bind(this);
    }

    connectedCallback() {
        //storeのstateの変更を監視する;
        store.addEventListener('CHANGE', this.handleStoreChange);
        //最初の描画;
        this.handleStoreChange();
    }

    disconnectedCallback() {
        store.removeEventListener('CHANGE', this.handleStoreChange);
    }

    handleStoreChange() {
        //storeのstateが変更されたらcomponentのstateへ渡す;
        this.count = store.count;
    }

    set count(value) {
        //componentのstateが新しい値に置き換わった時のみ描画する
        if(value === this._count) return;
        this._count = value;

        this.span.innerHTML = value;
    }
}

少し長いですね、順を追って見ていきましょう。

まず、CustomElementsを作るときはHTMLElementを継承したクラスを作ります。そして、constructor内でshadowRootをCustomElementsに追加し、そのshadowRootのinnerHTMLにベースとなるhtmlを代入します。これはお決まりのパターンでshadowRootを追加しないでconstructor内でhtmlを直接CustomElementsに代入すると、document.createElement()時にエラーになるので気を付けましょう。

また、constructor内でbutton要素のonclickを登録しています。ボタンがクリックされたらActionが呼ばれるようにしています。

constructorの下にconnectedCallbackとdisconnectedCallbackというメソッドがあります。これはCustomElementsのライフサイクルの一つで、このCustomElementsがdocumentに追加された時connectedCallback()が、切断された時はdisconnectedCallback()が呼ばれます。

ここでは、connectedCallbackでStoreの監視を始めています。監視しているStore内で'CHANGE'イベントが発火されると、handleStoreChangeメソッドが呼ばれます。なお、Viewがdocumentに追加されたタイミングで一回handleStoreChangeメソッドを呼び、Storeからstateを取得しています。
そして、disconnectedCallbackではStoreの監視を止めています。メモリリーク防止のためです。

では、handleStoreChangeメソッドでは何を行っているのでしょう。Componentのsetterであるcountにstore.countを代入しています。実はComponentは内部にstateを持つようにしています。stateを持つことによって、stateが変更された部分のみが差分で描画されるようになっています。

set count()では、Storeから渡されたstateが新しい値に置き換わっているか調べていて、新しい値が渡された時のみ、DOMを更新します。
このように、ViewにCustomElementsを使った場合、setter内部で描画を更新する処理を書きます。

以上Viewを見てきました。では、このComponentをdocumentに追加しましょう。以下のように書きます。

index.mjs
import Component from 'Component.mjs';

customElements.define('x-component', Component);

document.body.innerHTML = '<x-component></x-component>';

まず作ったCustomElementsを定義します。customElements.define(要素名, Component)で定義できます。要素名には必ず一つの-(ハイフン)が必要です。

これを実行した結果が以下です。以下のコードをブラウザのコンソールに直接入力してもカウントアップアプリができます。(SafariはEventTargetのpolyfillが必要です。)


See the Pen
CustomElements-Flux-sample
by shigure (@webkatu)
on CodePen.


以上、FluxをVanillaJSで実装してみました。以下のブログ記事にこのFlux実装を基にSPAを作る方法を簡単なサンプルを交えながら説明しています。
フレームワークなしで作るSPA

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした