基本トピック
上級トピック
その他
コンポーネント
アプリケーションアーキテクチャとコンポーネント
コンポーネントはコードを組織化するのに使える多目的なツールで、さまざまな用途があります。
まずはシンプルなモデルのエンティティを作ってみます。これはさまざまなコンポーネントの使用方法を説明するアプリケーションで使用します:
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つのコンポーネントが含まれています。ContactsWidget
はdocument.body
にレンダリングされる、最上位のモジュールです。また、これは先に定義していたContact
モデルのエンティティと話をする責務を持っています。
ContactForm
コンポーネントは、名前から推測できる通りで、Contact
エンティティのフィールドを編集するフォームを提供します。このモジュールは、フォーム上の保存ボタンが押された時に起動されるonsave
イベントを公開しています。それに加えて、未保存の連絡先情報をコンポーネント内に保管しています(this.contact = m.prop(args.contact || new Contact())
)。
ContactList
コンポーネントは、contacts
引数で渡されたすべての連絡先のエンティティを表形式で表示します。
もっとも興味深いコンポーネントはContactsWidget
です。
初期化時に連絡先のリストを取得します(
this.contacts = Contact.list()
)。save
が呼ばれると、連絡先を保存する(Contact.save(contact)
)。保存が完了したらリストのリロードをする(
.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
はコントローラのエンティティを監視対象のエンティティとして登録します。trigger
はregister
で登録されたコントローラをリロードします。onunload
イベントが起動されるとコントローラの登録は解除されます。
ContactList
コンポーネントのコントローラが監視対象としてマークして、その後ContactForm
のsave
イベントハンドラの中で、保存後に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
オブジェクトをそのままコンポーネントとして使えるようにcontroller
とview
プロパティを提供しています。また、それに追加して基本的なアップロードの機能を提供する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}, "アップロード")
])
]
}
}