m.component



コンポーネントはMithrilのアプリケーションを作成するのに使うブロックです。コンポーネントの仕組みに従うってコーディングすると、再利用可能な、カプセル化された部品を作ることができます。


コンポーネントのレンダリング

Mithrilのコンポーネントは、view関数と、オプションのcontroller関数を持つ以外のルールはありません。

var MyComponent = {
    controller: function(data) {
        return {greeting: "Hello"}
    },
    view: function(ctrl) {
        return m("h1", ctrl.greeting)
    }
}

m.mount(document.body, MyComponent) // <body>に<h1>Hello</h1>と表示

オプションのcontroller関数は、次のように使われるオブジェクトを作成することが期待されています:

  • viewと呼ばれるメソッドを持つ。
  • オブジェクトから、モデルのメソッドを直接呼び出したり、あるいはオブジェクトのメソッド内部から呼び出すことができる。
  • requestが返すpromiseなどのモデルメソッドが返すデータを保持することができる。
  • ビューモデルへの参照を持つことができる。

viewは、コントローラが公開すると決めたメソッドやプロパティにアクセスします。これらのメソッドとプロパティを使い、モデルデータを利用したり、モデルを変化させるコントローラのメソッドを呼んだりするテンプレートを作成します。これがMithrilの推奨する、ビューとモデルのデータの交換方法です。

//シンプルなMVCモデル

//値を公開するサンプルのモデル
var model = {count: 0}

var MyComponent = {
    controller: function(data) {
        return {
            increment: function() {
                //これはサンプル用として単純化している
                //通常はモデルのメソッドを使って値を変更する
                //ここでは直接変更している
                model.count++
            }
        }
    },
    view: function(ctrl) {
        return m("a[href=javascript:;]", {
            onclick: ctrl.increment //ビューはクリック時にコントローラメソッドを呼ぶ
        }, "Count: " + model.count)
    }
}

m.mount(document.body, MyComponent)

//以下のようにレンダリングされる
//<a href="javascript:;">Count: 0</a>
//
//リンクがクリックされるたびにカウンタがインクリメントされる

このコードを組み立てるときに、コントローラとビューを密結合させる必要がない点がポイントです。コントローラとビューを分割して定義しても問題ありません。マウントするときに統合することも可能です。

//controller.js
var controller = function(data) {
    return {greeting: "Hello"}
}

//view.js
var view = function(ctrl) {
    return m("h1", ctrl.greeting)
}

//レンダリング
m.mount(document.body, {controller: controller, view: view}) // 表示: <h1>Hello</h1>

コンポーネントを表示する方法は3通りあります:

  • m.route (複数ページ持つシングルページアプリケーションを作りたい場合)
  • m.mount (ページを1つしか持たない場合)
  • m.render (Mithrilのレンダリングエンジンを他のシステムに統合し、レンダリングを自分で制御したい場合)

controller関数は、コンポーネントがレンダリングされる時に一度だけ呼ばれます。その後、view関数が再描画が必要になるたびに呼ばれます。controller関数の返り値は、view関数の最初の引数として渡されます。

オプショナルなコントローラ

controller関数はオプショナルです。デフォルト値は空の関数(controller: function() {})です。

//コントローラなしのコンポーネント
var MyComponent = {
    view: function() {
        return m("h1", "Hello")
    }
}

m.mount(document.body, MyComponent) // 表示: <h1>Hello</h1>

クラスコンストラクタ形式のコントローラ

controllerはクラスコンストラクタとしても使用できます。その時は値を返すのではなく、コンストラクタ内でthisオブジェクトに値を追加していきます。

var MyComponent = {
    controller: function(data) {
        this.greeting = "Hello"
    },
    view: function(ctrl) {
        return m("h1", ctrl.greeting)
    }
}

m.mount(document.body, MyComponent) // 表示: <h1>Hello</h1>

view関数に関するメモ

view関数は使用されたタイミングではDOMツリーを作成することはありません。ビュー関数の返り値はDOMを表現したJavaScriptのデータ構造です。内部的にMithrilはこのDOMのデータ構造を使用して、データの変更を検知し、必要なところだけDOMを更新します。このレンダリング技術は仮想DOMの差分検知と呼ばれています。

ユーザ入力イベントのハンドラが起動された時など、再描画が必要な時にview関数は何度も呼ばれます。この返り値は以前の仮想DOMのツリーに対する差分を検知するのに使われます。

一見、変更を表示するたびに、このビュー全体に対する再計算を行うのは高コストな処理に見えるかもしれませんが、以前からあったフレームワークが使うレンダリングの方法論に比べると極めて高速です。Mithrilは差分検知アルゴリズムを利用することで、高コストなDOM操作を本当に必要な箇所だけ実行することができます。また、全体を再描画するという仕組み上、アプリケーションの状態がビューとモデルの2つに存在するのではなく、モデルの状態によってのみ管理されるため、トラブルシューティングが簡単になります。

短縮文法

もし、m()の最初の引数がコンポーネントであれば、m.component()の代わりに使うことができます。

var MyComponent = {
    controller: function() {
        return {greeting: "hello"}
    },
    view: function(ctrl, args) {
        return m("h1", ctrl.greeting + " " + args.data)
    }
}

m.render(document.body, [
    //以下の2行は等価です
    m(MyComponent, {data: "world"}),
    m.component(MyComponent, {data: "world"})
])

パラメータ化されたコンポーネント

コンポーネントは「事前にロードされた」引数を持つことができます。m.component(MyComponent, {foo: "bar"})という呼び出しをすると、MyComponentと同じコンポーネントを返しますが、{foo: "bar"}という引数がcontrollerview関数に束縛されます。

//コンポーネント宣言
var MyComponent = {
    controller: function(args, extras) {
        console.log(args.name, extras)
        return {greeting: "Hello"}
    },
    view: function(ctrl, args, extras) {
        return m("h1", ctrl.greeting + " " + args.name + " " + extras)
    }
}

//controllerとviewが同じ値を受け取るコンポーネントを作成する
var component = m.component(MyComponent, {name: "world"}, "this is a test")

var ctrl = new component.controller() // 表示: "world", "this is a test"

m.render(document.body, component.view(ctrl)) // 手動で仮想DOMツリーをレンダリング

//<body><h1>Hello world this is a test</h1></body>

コンポーネントオブジェクトの後の最初のパラメータは属性のマップで、{name: "world"}などのオブジェクトを指定する必要があります。それに続く引数("this is a test")には制限がありません。


コンポーネントのネスト

コンポーネントのビューは他のコンポーネントを持つことができます:

var App = {
    view: function() {
        return m(".app", [
            m("h1", "My App"),

            //ネストされたコンポーネント
            m.component(MyComponent, {message: "Hello"})
        ])
    }
}

var MyComponent = {
    controller: function(args) {
        return {greeting: args.message}
    },
    view: function(ctrl) {
        return m("h2", ctrl.greeting)
    }
}

m.mount(document.body, App)

// <div class="app">
//      <h1>My App</h1>
//      <h2>Hello</h2>
// </div>

コンポーネントは、通常のエレメントが置けるところであればどこにでも置くことができます。もしソート可能なリストの中にコンポーネントを置く場合には、key 属性をコンポーネントに追加して、単なる移動時にDOM要素が再生成されないようにしてください。キーは兄弟のDOM要素の中でユニークである必要があります。キーの値は文字列でも数値でも大丈夫です。

var App = {
    controller: function() {
        return {data: [1, 2, 3]}
    },
    view: function(ctrl) {
        return m(".app", [
            //ボタンを押すとリストの順序が逆転する
            m("button[type=button]", {onclick: function() {ctrl.data.reverse()}}, "My App"),

            ctrl.data.map(function(item) {
                //キーがあると、場所の移動時にDOMが再生成されるのを防ぐ
                return m.component(MyComponent, {message: "Hello " + item, key: item})
            })
        ])
    }
}

var MyComponent = {
    controller: function(args) {
        return {greeting: args.message}
    },
    view: function(ctrl) {
        return m("h2", ctrl.greeting)
    }
}

m.mount(document.body, App)

状態の取り扱い

ステートレスなコンポーネント

コンポーネントは、内部にデータを保持しなければステートレスになります。実際には、純粋関数の集合です。コンポーネントをステートレスにすると、挙動が予測しやすくなり、テストやトラブルシューティングがやりやすくなるため、良いプラクティスと言えます。

コントローラオブジェクトに引数を渡して、コントローラオブジェクトからビューに渡すという実装の場合はコンポーネントの内部状態ができてしまいます。初期化時に渡される引数にもとづいてビューを更新するほうが望ましい場面が多いです。

次のサンプルはこのパターンを説明しています:

var MyApp = {
    controller: function() {
        return {
            temp: m.prop(10) // ケルビン
        }
    },
    view: function(ctrl) {
        return m("div", [
            m("input", {oninput: m.withAttr("value", ctrl.temp), value: ctrl.temp()}), "K",
            m("br"),
            m.component(TemperatureConverter, {value: ctrl.temp()})
        ]);
    }
};
var TemperatureConverter = {
    controller: function() {
        //このコントローラは引数には触れない

        //ビューから呼ばれるヘルパー関数をいくつか定義する
        return {
            kelvinToCelsius: function(value) {
                return value - 273.15
            },
            kelvinToFahrenheit: function(value) {
                return (9 / 5 * (value - 273.15)) + 32
            }
        }
    },
    view: function(ctrl, args) {
        return m('div', [
            "celsius:", ctrl.kelvinToCelsius(args.value),
            m("br"),
            "fahrenheit:", ctrl.kelvinToFahrenheit(args.value),
        ]);
    }
};
m.mount(document.body, MyApp);

上記のサンプルは、テキスト入力と、temp getter-setterが双方向のバインディングで接続されています。入力フォームから温度を入力すると、温度値が変更されてTemperatureConverterに直接渡されます。ここから変換関数が呼ばれます。TemperatureConverterコントローラは値を保持することはありません。

コンポーネントのさまざまなパーツをテストするのは簡単です。

//コントローラのtransformation関数のテスト
var ctrl = new TemperatureConverter.controller();
assert(ctrl.kelvinToCelsius(273.15) == 0)

//テンプレートのテスト
var tpl = TemperatureConverter.view(ctrl, {value: 273.15})
assert(tpl.children[1] == 0)

//実際のDOMを使ったテスト
var testRoot = document.createElement("div")
m.render(testRoot, TemperatureConverter.view(ctrl, {value: 273.15}))
assert(testRoot.innerHTML.indexOf("celsius:0") > -1)

上記のサンプルは実際には役に立ちません。理想的には温度変換処理や、データの領域で行われるその他の処理はコンポーネントのコントローラではなく、モデルレイヤーに移動すべきです。


ステートフルなコンポーネント

通常は、アプリケーションの状態は、ビューモデルか、ネストされたコンポーネントの場合は最上位のコンポーネント以外のコンポーネントに持たせるべきではありません。コンポーネントはステートフルにすることもできますが、コンポーネントに状態を持たせる目的は、モデルレイヤーが、コンポーネント内の情報で汚染されるのを防ぐことになります。例えば、自動補完コンポーネントはドロップダウンが表示されているかどうかのフラグを持っています。しかしこの種の状態はアプリケーションのビジネスロジックは関係ありません。

コンポーネント外で管理する意味がない場合は、コンポーネントの状態を維持することを選ぶことになるでしょう。例えば、大きなページ内に、他の関係ないコンポーネントと一緒にUserFormコンポーネントがあったとします。UserFormコンポーネント内に未保存のデータがあった場合は、親ページに知らせる必要があるでしょう。


パラメータの初期状態

コントローラ内で引数を持てる機能は、コンポーネントの初期値のセットアップに便利です:

var MyComponent = {
    controller: function(args) {
        //このコードは一度しか呼ばれない
        return {
            things: m.request({method: "GET", url: "/api/things/", data: args}) //何らかのルールでデータの一部を切り出し
        }
    },
    view: function(ctrl) {
        return m("ul", [
            ctrl.things().map(function(thing) {
                return m("li", thing.name)
            })
        ]);
    }
};

しかし、複数のコンポーネントに散らばっている複数のリクエストは一箇所にあつめるべきです。リクエストをトップレベルのコンポーネントに集約することは、リスト内のデータを変更した後などに再度リクエストを行うのが簡単になります。ネストされたコンポーネントをたどっていく前に必要なデータセットが確実に存在することを保証できますし、兄弟コンポーネントで同じデータを何度もリクエストするのを防げます。コンポーネント化されたコードの構成については、アプリケーションアーキテクチャのセクションをお読みください。


データ駆動コンポーネントの同一性

コンポーネントを再初期化したい場合は、key属性を書き換えると行われます。これは違うモデルエンティティに対してAJAX呼び出しを再実行した場合に便利です。

下記のデータを持つ、ProjectListと呼ばれるコンポーネントがあったとします:


var people = [
    {id: 1, name: "ジョン"},
    {id: 2, name: "Mary"}
]

//AJAXでデータを取得し、Johnのプロジェクト一覧を表示
m.render(document.body, ProjectList({key: people[0].id, value: people[0]})

//AJAXでデータを取得し、Maryのプロジェクト一覧を表示
m.render(document.body, ProjectList({key: people[1].id, value: people[1]})

上記の例では、キーが異なるため、ProjectListのコンポーネントは一度破棄されて再生成されます。これにより、コントローラが再実行され、DOMが再生成されます。また、サードパーティのプラグインがconfigで設定されていた場合には、これも最初期化されます。

キーの適用のルールは通常のエレメントと同じです。同じ親の子供が同じキーを持つことはできません。またキーは文字列か数字、もしくは.toString()メソッドを持っていてローカルスコープ内でユニークなキーを生成できる必要があります。キーについてはここで学ぶことができます。


コンポーネントのアンロード

コンポーネントのコントローラがonunload関数を持っていた場合は、以下の状況のどれかに当てはまった時に呼ばれます:

  • m.mount関数が当たらたに呼ばれ、指定されたコンポーネントのルートのDOMエレメントが更新された時
  • m.routeを使ってラウトが変更された場合

他のコンポーネントをロードせずにコンポーネントをアンロード/アンマウントする場合は、コンポーネントの引数としてnullm.mountに渡すと行えます:

m.mount(rootElement, null);

コンポーネントをアンロードする前にタイマーをクリアしたり、イベントハンドラを削除したり、何か仕事をしたくなることがあります:

var MyComponent = {
    controller: function() {
        return {
            onunload: function() {
                console.log("MyComponentコンポーネントを削除しています");
            }
        }
    },
    view: function() {
        return m("div", "test")
    }
};

m.mount(document.body, MyComponent);

//...

var AnotherComponent = {
    view: function() {
        return m("div", "another")
    }
};

// 同じDOMエレメントに対してマウントし、MyComponentを置き換え
m.mount(document.body, AnotherComponent); // ログ: "MyComponentコンポーネントを削除しています"

ラウターの変更のコンテキスト内でonunload関数を使うことで、モジュールがアンロードされるのを中断することができます。これはページ移動前にデータ変更することを警告する時などに使えます。

var component = {
    controller: function() {
        var unsaved = m.prop(false)
        return {
            unsaved: unsaved,

            onunload: function(e) {
                if (unsaved()) {
                    e.preventDefault()
                }
            }
        }
    },
    //...
}

通常、m.mount呼び出しはコントローラのインスタンスを返します。しかし、コントローラのonunload内で、e.preventDefault()が呼ばれると、m.mountは新しいコントローラのインスタンスの生成をやめ、undefinedが返されます。

Mithrilはブラウザのonbeforeunloadイベントはフックしません。他のページに遷移しようとしたときに、アンロードが中断されたかどうかを知るには、m.mountの返り値をチェックしてください。

window.onbeforeunload = function() {
    if (!m.mount(rootElement, null)) {
        //onunloadのpreventDefaultが呼ばれた
        return "本当に移動しますか?"
    }
}

トップレベルのコンポーネントと同様に、他のコンポーネント内にネストされたコンポーネントのonunloadを呼び出し、その中のe.preventDefault()を呼ぶことができます。インスタンス化されたコンポーネントが仮想エレメントツリーから削除されるときにonunloadイベントが呼ばれます。

次のサンプルはボタンを押すとコンポーネントのonunloadイベントが呼ばれ、"アンロード!"とログに出力されます。

var MyApp = {
    controller: function() {
        return {loaded: true}
    },
    view: function(ctrl) {
        return [
            m("button[type=button]", {onclick: function() {ctrl.loaded = false}}),
            ctrl.loaded ?MyComponent : ""
        ]
    }
}

var MyComponent = {
    controller: function() {
        return {
            onunload: function() {
                console.log("アンロード!")
            }
        }
    },
    view: function() {
        return m("h1", "My component")
    }
}

m.mount(document.body, MyApp)

コンポーネントのonunload関数からe.preventDefault()を呼ぶと、ラウターの変更を中断しますが、ロールバックしたり、現在の再描画への変更を辞めることはしません。


非同期のネストされたコンポーネント

コントローラはモデルのメソッドを呼ぶことができますが、非同期の動作をカプセル化するのにネストされたコンポーネントを使うことができます。コンポーネントがネストされていないとMithrilはすべての非同期タスクが完了するのを待ちますが、ネストされていると非同期タスクの完了前に親コンポーネントが再描画することがあります。差分検知エンジンは、テンプレートをレンダリングする時にのみ、コンポーネントの存在を検知します。

もしコンポーネントが非同期のロードを行っていて、再描画システムのカウンター操作を行っている場合は、非同期操作が完了するまでは再描画が行われることはありません。コンポーネントの非同期操作が完了すると、他の再描画が起動されて、テンプレートのツリー全体を再び再評価します。これによって非同期のネストされたコンポーネントの数次第で、完全なレンダリングが行われるまでに、何度か仮想DOMツリーの再描画処理が走る可能性があります。

再描画が何度か走るのを避けるコンポーネントの組み立て方もあります。特に気にしなければ、backgroundオプションとinitialValueオプションを付けてm.requestを呼び出すか、手動でm.redraw()を呼び出せば、何度か強制的に再描画させることもできます。

もし、コンポーネントAが、非同期処理を行う他のコンポーネントBを含んでいて、Bの非同期タスクが完了する前であれば、描画時に<placeholder>タグがレンダリングされます。非同期処理が完了すると、プレースホルダは実際のコンポーネントBのビューに置き換えられます。


制限と制約

一番重要な制限は、テンプレートの中からMithrilの再描画メソッド(m.startComputation / m.endComputation / m.redraw)を呼び出すことができないことです。

それに加えて、テンプレート内でm.requestは使用できません。再描画処理の中で再描画を行ってしまうと、無限ループになってしまいます。

コンポーネントのネスト時には、いくつか技術的な制約があります:

  1. ネストされたコンポーネントのビューは、仮想エレメントか他のコンポーネントを返さなければならない。配列、文字列、数値、ブーリアン、負になる値などを返すとエラーになる。

  2. ネストされたコンポーネントはコントローラのコンストラクタからm.redraw.strategyを変更することはできません。ただしイベントハンドラからなら行えます。再描画の戦略を変更する代わりに、ctx.retainフラグを使うのを推奨します。

  3. コンポーネントのビューのルートのDOMエレメントはコンポーネントのライフサイクル内で変更してはいけません。未定義の動作をします。別の説明をすると、以下のようなことをしてはならないということです。

    var MyComponent = {
      view: function() {
          return someCondition ?m("a") : m("b")
      }
    }
    
  4. 初回レンダリング時に、コンポーネントのルートのエレメントがサブツリーディレクティブを返した場合は未定義の動作になります。


自動再描画システムの対象から外す

コンポーネントは自動再描画システムを有効にせずに描画を行わせることができます。m.renderを使用します:

var MyComponent = {
    controller: function() {
        return {greeting: "Hello"}
    },
    view: function(ctrl) {
        return m("h1", ctrl.greeting)
    }
}

m.render(document.body, MyComponent)

m.renderを使用するのは、他のフレームワークを利用していて、描画の管理はそのシステムに従う場合にのみにしてください。ほとんどの場合は、代わりにm.mountを使うべきです。


シグニチャ

シグニチャの読み方

Component component(Component component [, Object attributes [, any... args]])

where:
    Component :: Object { Controller, View }
    Controller :: SimpleController | UnloadableController
    SimpleController :: void controller([Object attributes [, any... args]])
    UnloadableController :: void controller([Object attributes [, any... args]]) { prototype: void unload(UnloadEvent e) }
    UnloadEvent :: Object {void preventDefault()}
    View :: void view(Object controllerInstance [, Object attributes [, any... args]])
  • Component component

    コンポーネントは、controllerviewのキーを持つオブジェクトです。それぞれ、JavaScriptの関数を持ちます。もしコントローラが指定されなければ、Mithrilは自動的に空のコントローラ関数を作成します。

  • Object attributes

    コンポーネントのcontrollerviewの両方の関数に束縛される属性のキー/バリューマップです。

  • any... args

    controllerview関数に束縛されるそれ以外の引数です。

  • returns Component parameterizedComponent

    引数が束縛されたコンポーネント。