コンポーネント



アプリケーションアーキテクチャとコンポーネント

コンポーネントはコードを組織化するのに使える多目的なツールで、さまざまな用途があります。

まずはシンプルなモデルのエンティティを作ってみます。これはさまざまなコンポーネントの使用方法を説明するアプリケーションで使用します:

var Contact = function(data) {
    data = data || {}
    this.id = m.prop(data.id || "")
    this.name = m.prop(data.name || "")
    this.email = m.prop(data.email || "")
}
Contact.list = function(data) {
    return m.request({method: "GET", url: "/api/contact", type: Contact})
}
Contact.save = function(data) {
    return m.request({method: "POST", url: "/api/contact", data: data})
}

Contact(連絡先)クラスを定義しました。この連絡先には、id、名前、eメールが含まれます。これらには、データのリストを取得するlistメソッドと、単体の連絡先を取得するsaveメソッドという2つの静的メソッドがあります。このlistメソッドは、AJAXのレスポンスとして、クラスと同じ名前のフィールドを含むJSON形式で連絡先情報を返してくることを想定しています。

責務の集約

コンポーネントの組み立て方の1つの例としては、コンポーネントのパラメータリストを使ってデータを下流に流すと共に、モデルレイヤとのインタフェースとなっている中心のモジュールまでバブリングしてデータを上流に戻すイベントを定義するというものです。

var ContactsWidget = {
    controller: function update() {
        this.contacts = Contact.list()
        this.save = function(contact) {
            Contact.save(contact).then(update.bind(this))
        }.bind(this)
    },
    view: function(ctrl) {
        return [
            m.component(ContactForm, {onsave: ctrl.save}),
            m.component(ContactList, {contacts: ctrl.contacts})
        ]
    }
}

var ContactForm = {
    controller: function(args) {
        this.contact = m.prop(args.contact || new Contact())
    },
    view: function(ctrl, args) {
        var contact = ctrl.contact()

        return m("form", [
            m("label", "名前"),
            m("input", {oninput: m.withAttr("value", contact.name), value: contact.name()}),

            m("label", "Eメール"),
            m("input", {oninput: m.withAttr("value", contact.email), value: contact.email()}),

            m("button[type=button]", {onclick: args.onsave.bind(this, contact)}, "保存")
        ])
    }
}

var ContactList = {
    view: function(ctrl, args) {
        return m("table", [
            args.contacts().map(function(contact) {
                return m("tr", [
                    m("td", contact.id()),
                    m("td", contact.name()),
                    m("td", contact.email())
                ])
            })
        ])
    }
}

m.mount(document.body, ContactsWidget)

上記のサンプルには3つのコンポーネントが含まれています。ContactsWidgetdocument.bodyにレンダリングされる、最上位のモジュールです。また、これは先に定義していたContactモデルのエンティティと話をする責務を持っています。

ContactFormコンポーネントは、名前から推測できる通りで、Contactエンティティのフィールドを編集するフォームを提供します。このモジュールは、フォーム上の保存ボタンが押された時に起動されるonsaveイベントを公開しています。それに加えて、未保存の連絡先情報をコンポーネント内に保管しています(this.contact = m.prop(args.contact || new Contact()))。

ContactListコンポーネントは、contacts引数で渡されたすべての連絡先のエンティティを表形式で表示します。

もっとも興味深いコンポーネントはContactsWidgetです。

  1. 初期化時に連絡先のリストを取得します(this.contacts = Contact.list())。

  2. saveが呼ばれると、連絡先を保存する(Contact.save(contact))。

  3. 保存が完了したらリストのリロードをする(.then(update.bind(this)))。

updateはコントローラの関数そのものです。それをPromiseのコールバックとして定義するとということは、非同期操作(Contact.save())が完了したあとにコントローラを再初期化しています。

責務とトップレベルのコンポーネントで統合するという方法は、複数のモデルエンティティを持つ場合の管理を楽にします。多くのコンポーネントがサーバ側のデータを必要としても、AJAXアクセスをまとめて通信をシンプルにしたり、データのリフレッシュが行い易くなります。

それに加えて、さまざまなコンテキストでコンポーネントを再利用しやすくなります。ContactListリストはargs.contactsがデータベース内のすべての連絡先を参照しているか、フィルタリング条件でヒットしたものだけが渡されているのかどうかは知りません。同様にContactFormも、新規の連絡先の作成と、既存の連絡先の編集の両方に利用できます。保存の操作については、親のコンポーネントに残してあります。

このアーキテクチャにすることで、高い柔軟性と再利用性の高いコードが得られますが、柔軟性が高いということはシステムを理解する負荷が高まるということです。どのようにデータが表示されるかを知るには、トップレベルのモジュールと、ContactListの2つを見る必要があります。場合によってはどのようにフィルタされるかを知る必要もあります。それに加えて、ツリーのネストが深くなると、コンポーネント間の引数のパススルーや、イベントハンドラが増えることになります。


具体的な責務の分担

他のコードの構築方法としては、具体的な責務をモジュール間に分配する方法があります。

以下のコードは上記のコードをリファクタリングしたものです:

var ContactForm = {
    controller: function() {
        this.contact = m.prop(new Contact())
        this.save = function(contact) {
            Contact.save(contact)
        }
    },
    view: function(ctrl) {
        var contact = ctrl.contact()

        return m("form", [
            m("label", "名前"),
            m("input", {oninput: m.withAttr("value", contact.name), value: contact.name()}),

            m("label", "Eメール"),
            m("input", {oninput: m.withAttr("value", contact.email), value: contact.email()}),

            m("button[type=button]", {onclick: ctrl.save.bind(this, contact)}, "保存")
        ])
    }
}

var ContactList = {
    controller: function() {
        this.contacts = Contact.list()
    },
    view: function(ctrl) {
        return m("table", [
            ctrl.contacts().map(function(contact) {
                return m("tr", [
                    m("td", contact.id()),
                    m("td", contact.name()),
                    m("td", contact.email())
                ])
            })
        ])
    }
}

m.route(document.body, "/", {
    "/list": ContactList,
    "/create": ContactForm
})

これらのコンポーネントは機能別にまとまっています。それぞれ異なったラウターを持ち、それぞれのコンポーネントは1つのタスクだけを行います。これらのコンポーネントはお互いのコンポーネントとやりとりをするようには設計されていません。それぞれのコンポーネントは個別の目的のために提供されているため、動作を理解するのは簡単ですが、前述のサンプルのような柔軟性はありません。ContactListは全連絡先のリストを表示するのにしか使えず、サブセットの表示はできません。

また、それぞれのコンポーネントの動作はカプセル化されているため、他のモジュールを外部から操作することは簡単にはできません。実際、ContactsWidget内にコンポーネントが2つあるとすると、何もコードを追加しなければリストが更新されることはありません。

単一目的のコンポーネント間のコミュニケーション

以下のコードは、上記の単一機能を提供するコンポーネント間のコミュニケーションを提供する方法です:

var Observable = function() {
    var controllers = []
    return {
        register: function(controller) {
            return function() {
                var ctrl = new controller
                ctrl.onunload = function() {
                    controllers.splice(controllers.indexOf(ctrl), 1)
                }
                controllers.push({instance: ctrl, controller: controller})
                return ctrl
            }
        },
        trigger: function() {
            controllers.map(function(c) {
                ctrl = new c.controller
                for (var i in ctrl) c.instance[i] = ctrl[i]
            })
        }
    }
}.call()


var ContactsWidget = {
    view: function(ctrl) {
        return [
            ContactForm,
            ContactList
        ]
    }
}

var ContactForm = {
    controller: function() {
        this.contact = m.prop(new Contact())
        this.save = function(contact) {
            Contact.save(contact).then(Observable.trigger)
        }
    },
    view: function(ctrl) {
        var contact = ctrl.contact()

        return m("form", [
            m("label", "名前"),
            m("input", {oninput: m.withAttr("value", contact.name), value: contact.name()}),

            m("label", "Eメール"),
            m("input", {oninput: m.withAttr("value", contact.email), value: contact.email()}),

            m("button[type=button]", {onclick: ctrl.save.bind(this, contact)}, "保存")
        ])
    }
}

var ContactList = {
    controller: Observable.register(function() {
        this.contacts = Contact.list()
    }),
    view: function(ctrl) {
        return m("table", [
            ctrl.contacts().map(function(contact) {
                return m("tr", [
                    m("td", contact.id()),
                    m("td", contact.name()),
                    m("td", contact.email())
                ])
            })
        ])
    }
}

m.mount(document.body, ContactsWidget)

ここでは、ContactFormコンポーネントとContactListコンポーネントはContactsWidgetコンポーネントの子供になっていて、どちらも同じページ内で表示されています。

Observableオブジェクトは2つのメソッドを提供しています。registerはコントローラのエンティティを監視対象のエンティティとして登録します。triggerregisterで登録されたコントローラをリロードします。onunloadイベントが起動されるとコントローラの登録は解除されます。

ContactListコンポーネントのコントローラが監視対象としてマークして、その後ContactFormsaveイベントハンドラの中で、保存後にObservable.trigger呼びます。

この仕組みを使うと、状態を変更させるような操作に対して複数のコンポーネントをリロードさせることができます。

このアーキテクチャの注意点としては、コンポーネントが内部状態をカプセル化しているため、AJAXアクセスが冗長になってしまうのを防ぐのが難しいということがあります。異なるコンポーネントがまったく同じAJAXアクセスを行うのを防ぐ方法はありません。

オブザーバパターン

Observable(監視可能)オブジェクトはtriggerを使ってコントローラが購読している"channels"にブロードキャストするという方式にリファクタリングすることができます。これはオブザーバパターンと呼ばれています。

var Observable = function() {
    var channels = {}
    return {
        register: function(subscriptions, controller) {
            return function self() {
                var ctrl = new controller
                var reload = controller.bind(ctrl)
                Observable.on(subscriptions, reload)
                ctrl.onunload = function() {
                    Observable.off(reload)
                }
                return ctrl
            }
        },
        on: function(subscriptions, callback) {
            subscriptions.forEach(function(subscription) {
                if (!channels[subscription]) channels[subscription] = []
                channels[subscription].push(callback)
            })
        },
        off: function(callback) {
            for (var channel in channels) {
                var index = channels[channel].indexOf(callback)
                if (index > -1) channels[channel].splice(index, 1)
            }
        },
        trigger: function(channel, args) {
            console.log("triggered: " + channel)
            channels[channel].map(function(callback) {
                callback(args)
            })
        }
    }
}.call()

このパターンは依存関係の鎖を分離するのに役立ちます。しかし、数多くの内部依存関係を持つことによって、イベントの連鎖を追いかけるのが難しくなる「地獄からやってくる」ケースに陥らないように注意する必要があります。

ハイブリッドアーキテクチャ

もちろん、責務の統合と、オブザーバパターンを同時に使うことができます。

次のサンプルは、連絡先アプリの別バージョンで、ContactFormが保存の責務を担っているバージョンです。

var ContactsWidget = {
    controller: Observable.register(["updateContact"], function() {
        this.contacts = Contact.list()
    }),
    view: function(ctrl) {
        return [
            m.component(ContactForm),
            m.component(ContactList, {contacts: ctrl.contacts})
        ]
    }
}

var ContactForm = {
    controller: function(args) {
        this.contact = m.prop(new Contact())
        this.save = function(contact) {
            Contact.save(contact).then(Observable.trigger("updateContact"))
        }
    },
    view: function(ctrl, args) {
        var contact = ctrl.contact()

        return m("form", [
            m("label", "名前"),
            m("input", {oninput: m.withAttr("value", contact.name), value: contact.name()}),

            m("label", "Eメール"),
            m("input", {oninput: m.withAttr("value", contact.email), value: contact.email()}),

            m("button[type=button]", {onclick: ctrl.save.bind(this, contact)}, "保存")
        ])
    }
}

var ContactList = {
    view: function(ctrl, args) {
        return m("table", [
            args.contacts().map(function(contact) {
                return m("tr", [
                    m("td", contact.id()),
                    m("td", contact.name()),
                    m("td", contact.email())
                ])
            })
        ])
    }
}

m.mount(document.body, ContactsWidget)

この場合データの取得コードはトップレーベルモジュールに集約されています。これによりAJAXリクエストを何度も行うことを避けられるようになっています。

また、保存の責務をContactFormコンポーネントに移動したことで、コンポーネントツリーをさかのぼってデータを渡す必要は軽減されています。 引数を使ってパススルーするノイズも減らしています。


クラシックなMVC

次は最後のパターンです。上記で説明したものの変形バージョンです。

//モデルレイヤのオブザーバ
Observable.on(["saveContact"], function(data) {
    Contact.save(data.contact).then(Observable.trigger("updateContact"))
})

//ContactsWidgetは以前と同じ
var ContactsWidget = {
    controller: Observable.register(["updateContact"], function() {
        this.contacts = Contact.list()
    }),
    view: function(ctrl) {
        return [
            m.component(ContactForm),
            ctrl.contacts() === undefined
              ?m("div", "連絡先をロード中...") //Promiseが解決されるのを待つ
              : m.component(ContactList, {contacts: ctrl.contacts})
        ]
    }
}

//ContactForm no longer calls `Contact.save`
var ContactForm = {
    controller: function(args) {
            var ctrl = this
        ctrl.contact = m.prop(new Contact())
        ctrl.save = function(contact) {
            Observable.trigger("saveContact", {contact: contact})
            ctrl.contact = m.prop(new Contact()) //連絡先を空にリセットする
        }
        return ctrl
    },
    view: function(ctrl, args) {
        var contact = ctrl.contact()

        return m("form", [
            m("label", "名前"),
            m("input", {oninput: m.withAttr("value", contact.name), value: contact.name()}),

            m("label", "Eメール"),
            m("input", {oninput: m.withAttr("value", contact.email), value: contact.email()}),

            m("button[type=button]", {onclick: ctrl.save.bind(this, contact)}, "保存")
        ])
    }
}

//ContactListは前の実装と同じ
var ContactList = {
    view: function(ctrl, args) {
        return m("table", [
            args.contacts().map(function(contact) {
                return m("tr", [
                    m("td", contact.id()),
                    m("td", contact.name()),
                    m("td", contact.email())
                ])
            })
        ])
    }
}

m.mount(document.body, ContactsWidget)

このコードでは、Contact.save(contact).then(Observable.trigger("updateContact"))ContactFormコンポーネントの中から、モデルレイヤの中に移動しています。ContactFormでは単に、アクションを送信しています。これはモデルレイヤーのオブザーバが受け取って処理をします。

これにより、ContactFormコンポーネントを変更することなく、saveContactハンドラの実装を変更することができます。


サンプル: HTML5ドラッグ・アンド・ドロップでファイルアップロードするコンポーネント

1つ面白いサンプルを紹介します。ドラッグ・アンド・ドロップでファイルをアップロードするコンポーネントです。Uploaderオブジェクトをそのままコンポーネントとして使えるようにcontrollerviewプロパティを提供しています。また、それに追加して基本的なアップロードの機能を提供するupload関数もモデルのメソッドとして提供しています。また、通常はJSONでシリアライズされるサーバリクエストで、application/x-www-form-urlencodedエンコードでファイル送信ができるようにするserialize関数も提供しています。

これらの2つの関数は、コンポーネント利用者にAPIを公開することで、コンポーネントのインタフェースを補完できることを示しています。コンポーネントにモデルメソッドをバンドルするときは、どのようにファイルを取り扱うかをハードコーディングするのを避けます。アプリケーションの需要に応じて柔軟に使用できる関数ライブラリを提供します。

var Uploader = {
    upload: function(options) {
        var formData = new FormData
        for (var key in options.data) {
            for (var i = 0; i < options.data[key].length; i++) {
                formData.append(key, options.data[key][i])
            }
        }

        //JSON.stringifyをしないで、FormDataをそのままバックエンドのXMLHttpRequestに渡す
        options.serialize = function(value) {return value}
        options.data = formData

        return m.request(options)
    },
    serialize: function(files) {
        var promises = files.map(function(file) {
            var deferred = m.deferred()

            var reader = new FileReader
            reader.readAsDataURL()
            reader.onloadend = function(e) {
                deferred.resolve(e.result)
            }
            reader.onerror = deferred.reject
            return deferred.promise
        })
        return m.sync(promises)
    },
    controller: function(args) {
        this.noop = function(e) {
            e.preventDefault()
        }
        this.update = function(e) {
            e.preventDefault()
            if (typeof args.onchange == "function") {
                args.onchange([].slice.call((e.dataTransfer || e.target).files))
            }
        }
    },
    view: function(ctrl, args) {
        return m(".uploader", {ondragover: ctrl.noop, ondrop: ctrl.update})
    }
}

次のコードはUploaderコンポーネントの使用例です:

//デモ1: コンポーネントにファイルがドロップされた時にmultipart/form-dataでアップロード
var Demo1 = {
    controller: function() {
        return {
            upload: function(files) {
                Uploader.upload({method: "POST", url: "/api/files", data: {files: files}}).then(function() {
                    alert("uploaded!")
                })
            }
        }
    },
    view: function(ctrl) {
        return [
            m("h1", "アップローダデモ"),
            m.component(Uploader, {onchange: ctrl.upload})
        ]
    }
}

デモ

//デモ2: base64のデータURLとしてデータをアップロード
var Demo2 = {
    Asset: {
        save: function(data) {
            return m.request({method: "POST", url: "/api/assets", data: data})
        }
    },

    controller: function() {
        var files = m.prop([])
        return {
            files: files,
            save: function() {
                Uploader.serialize(files()).then(function(files) {
                    Demo2.Asset.save({files: files}).then(function() {
                        alert("アップロード完了!")
                    })
                })
            }
        }
    },
    view: function(ctrl) {
        return [
            m("h1", "アップローダデモ"),
            m("p", "ファイルをここにドロップしてください。アップロードが完了するとアラートボックスが表示されます。"),
            m("form", [
                m.component(Uploader, {onchange: ctrl.files}),
                ctrl.files().map(function(file) {
                    return file.name
                }).join(),
                m("button[type=button]", {onclick: ctrl.save}, "アップロード")
            ])
        ]
    }
}

デモ