Mithril 1.1.0

コンポーネント

構造

コンポーネントはビューの部品をカプセル化することで、構造化しやすくしたり、再利用性を高めるための仕組みです。

viewメソッドを持つJavaScriptのオブジェクトは、Mithrilのコンポーネントとして扱うことができます。コンポーネントを利用するにはm()ユーティリティを使います:

var Example = {
    view: function() {
        return m("div", "Hello")
    }
}

m(Example)

// 下記のHTMLが生成されます
// <div>Hello</div>

コンポーネントにデータを渡す

コンポーネントのインスタンスには、attrsオブジェクトをhyperscript関数の第二引数に渡すことでデータを渡すことができます:

m(Example, {name: "Floyd"})

このデータは、コンポーネントのビュー、あるいはライフサイクルメソッドの引数のvnode.attrs属性を通じて触ることができます:

var Example = {
    view: function (vnode) {
        return m("div", "Hello, " + vnode.attrs.name)
    }
}

NOTE: ライフサイクルメソッドもまたattrsオブジェクトを通じて提供されるため、その引数に規定の名前を使わないようにしてください。Mithrilが意図せずに呼び出すこととなります。attrsに格納されたライフサイクルメソッドを使うのは、ライフサイクルメソッドにフックをかけるという特別な意図がある時のみにしましょう。


ライフサイクルメソッド

コンポーネントは仮想DOMと同じライフサイクルメソッドを持っています。oninit, oncreate, onupdate, onbeforeremove, onremove, onbeforeupdateの各メソッドがあります。

var ComponentWithHooks = {
    oninit: function(vnode) {
        console.log("初期化しました")
    },
    oncreate: function(vnode) {
        console.log("DOMが作成されました")
    },
    onupdate: function(vnode) {
        console.log("DOMが更新されました")
    },
    onbeforeremove: function(vnode) {
        console.log("終了アニメーションが開始できます")
        return new Promise(function(resolve) {
            // アニメーション完了後に呼ばれる
            resolve()
        })
    },
    onremove: function(vnode) {
        console.log("DOM要素を削除します")
    },
    onbeforeupdate: function(vnode, old) {
        return true
    },
    view: function(vnode) {
        return "hello"
    }
}

他の種類の仮想DOMノードと同じように、コンポーネントもビュー内のvnodeとして呼び出される追加のライフサイクルメソッドを持つことができます。

function initialize() {
    console.log("vnodeとして初期化されました")
}

m(ComponentWithHooks, {oninit: initialize})

vnode内のライフサイクルメソッドはコンポーネントのライフサイクルメソッドを上書きすることはありませんし、その逆もありません。今ポー0ネントのライフサイクルメソッドは、常にvnodeの同名のメソッドの後に呼ばれます。

vnode内に、ライフサイクルメソッドと同名の名前のコールバックを定義しないように気をつけましょう。

ライフサイクルメソッドについて詳しく知りたい方は、ライフサイクルメソッドのページを御覧ください。.


文法のバリエーション

ES6クラス

コンポーネントはES6クラス文法を使って作成することもできます:

class ES6ClassComponent {
    constructor(vnode) {
        // vnode.stateはこの時点では未定義
        this.kind = "ES6クラス"
    }
    view() {
        return m("div", `${this.kind} からこんにちわ`)
    }
    oncreate() {
        console.log(`${this.kind} コンポーネントが作成されました`)
    }
}

コンポーネントクラスはツリーをレンダリングできるように、.prototype.viewとしてアクセス形式でview()メソッドを定義する必要があります。

通常の方法で作ったコンポーネントと同じように使用できます。

// サンプル: m.renderから使用
m.render(document.body, m(ES6ClassComponent))

// サンプル: m.mountから使用
m.mount(document.body, ES6ClassComponent)

// サンプル: m.routeから使用
m.route(document.body, "/", {
    "/": ES6ClassComponent
})

// サンプル: コンポーネントの合成
class AnotherES6ClassComponent {
    view() {
        return m("main", [
            m(ES6ClassComponent)
        ])
    }
}

クロージャーコンポーネント

関数型が好きな開発者は「クロージャーコンポーネント」記法の方を気に入るでしょう:

function closureComponent(vnode) {
    // vnode.stateはこの時点では未定義
    var kind = "クロージャコンポーネント"

    return {
        view: function() {
            return m("div", kind + "からこんにちわ")
        },
        oncreate: function() {
            console.log(kind + "が作成されました")
        }
    }
}

返すオブジェクトはツリーレンダリングするためにview関数を保持する必要があります。

通常の方法で作ったコンポーネントと同じように使用できます。

// サンプル: m.renderから使用
m.render(document.body, m(closureComponent))

// サンプル: m.mountから使用
m.mount(document.body, closureComponent)

// サンプル: m.routeから使用
m.route(document.body, "/", {
    "/": closureComponent
})

// サンプル: componentの合成
function anotherClosureComponent() {
    return {
        view: function() {
            return m("main", [
                m(closureComponent)
            ])
        }
    }
}

コンポーネントの種類を混ぜる

コンポーネントは自由に混ぜることができます。クラスコンポーネントはクロージャー、POJOコンポーネントを子供に持たせられますし、他の組み合わせもできます。


状態

他の仮想DOMノードと同じように、コンポーネントのvnodeも状態を持つことができます。コンポーネントの状態はオブジェクト指向アーキテクチャをサポートするのに便利です。関心を分離してカプセル化を行うことができます。

コンポーネントの状態にアクセスする方法は3つあります。初期化時にコンポーネントのオブジェクトの雛形経由、vnode.state経由、そして、コンポーネントのメソッドのthisキーワードです。

初期化時

POJOコンポーネントでは、コンポーネントオブジェクトはそれぞれのコンポーネントのインスタンスのプロトタイプとなります。そのため、コンポーネントオブジェクトで定義したプロパティは、vnode.stateのプロパティとしてアクセスできます。これにより、シンプルに状態を初期化できます。

次のサンプルのdataComponentWithInitialStateコンポーネントの状態オブジェクトのプロパティです。

var ComponentWithInitialState = {
    data: "初期データ",
    view: function(vnode) {
        return m("div", vnode.state.data)
    }
}

m(ComponentWithInitialState)

// 下記のHTMLが生成されます
// <div>初期データ</div>

クラスコンポーネントの場合は、状態はクラスのインスタンスになるため、コンストラクタが呼ばれた後に正しく設定する必要があります。

クロージャーコンポーネントは、状態はクロージャーが返すオブジェクトになるため、クロージャーが返された後に正しく設定する必要があります。状態オブジェクトは、クロージャスコープ内で定義された変数を代わりに使用できるので、クロージャコンポーネントとは重複しています。

Via vnode.state

vnode.stateプロパティ経由でアクセスすることもできます。このプロパティはすべてのライフサイクルメソッドおよび、コンポーネントのviewメソッドから利用できます。

var ComponentWithDynamicState = {
    oninit: function(vnode) {
        vnode.state.data = vnode.attrs.text
    },
    view: function(vnode) {
        return m("div", vnode.state.data)
    }
}

m(ComponentWithDynamicState, {text: "Hello"})

// 次のHTMLを生成します
// <div>Hello</div>

thisキーワード経由

thisキーワード経由でアクセスすることもできます。このプロパティはすべてのライフサイクルメソッドおよび、コンポーネントのviewメソッドから利用できます。

var ComponentUsingThis = {
    oninit: function(vnode) {
        this.data = vnode.attrs.text
    },
    view: function(vnode) {
        return m("div", this.data)
    }
}

m(ComponentUsingThis, {text: "Hello"})

// 次のHTMLを生成します
// <div>Hello</div>

ES5の関数を使う時は注意が必要です。ネストされた無名関数のthisはコンポーネントのインスタンスとは別のものを参照します。この問題を回避するための推奨される手法は2つあります。ひとつめはES6のアロー関数を使う方法です。もしES6が利用できないのであれば、vnode.stateを使ってください。


アンチパターンを避ける

Mithrilは制約が少なく柔軟ですが、いつくか非推奨のコードパターンがあります。

太ったコンポーネントを避ける

一般的に言えば、「太った」コンポーネントというのは、カスタムのインスタンスメソッドを持ったコンポーネントです。言い換えると、vnode.statethisにはメソッドを追加すべきではありません。論理的に考えると、とあるコンポーネントにはフィットするが、他のコンポーネントで再利用できないロジックというものは非常にまれです。いちど作ったロジックを別のコンポーネントでも利用したくなることはよくあります。

コンポーネントの状態に強く関連したロジックだとしても、それをデータのレイヤーにリファクタリングして移動することは難しくありません。

次の太ったコンポーネントについて考えてみましょう

// views/Login.js
// 避けるべきコード
var Login = {
    username: "",
    password: "",
    setUsername: function(value) {
        this.username = value
    },
    setPassword: function(value) {
        this.password = value
    },
    canSubmit: function() {
        return this.username !== "" && this.password !== ""
    },
    login: function() {/*...*/},
    view: function() {
        return m(".login", [
            m("input[type=text]", {oninput: m.withAttr("value", this.setUsername.bind(this)), value: this.username}),
            m("input[type=password]", {oninput: m.withAttr("value", this.setPassword.bind(this)), value: this.password}),
            m("button", {disabled: !this.canSubmit(), onclick: this.login}, "ログイン"),
        ])
    }
}

通常、大きなアプリケーション開発では上記のようなコンポーネントが、ユーザー登録およびパスワードのリカバリーなどのさまざまなところで使われます。Eメールのフィールドはログインスクリーンから登録および、パスワード復旧画面(など)では予め埋めておきたいでしょう。なるべくユーザーがアドレスをタイプしなくてもすむようにしたいでしょう。また、ログイン時に未登録のメールアドレスであれば、ユーザーをアドレス入りのユーザー登録フォームに遷移させたいですよね。

現在はこのusernamepasswordのフィールドを他のコンポーネントと共有するのはこんなんです。これは、太ったコンポーネントが状態をカプセル化してしまうため、基本的に外部から状態へのアクセスは難しくなります。

共有するためには、コンポーネントをリファクタリングして、状態に関わるコードをコンポーネントから分離して、データレイヤーに移動する方が理にかなっています。これは新しいモジュールを作成するのと同じぐらい簡単に行えます。

// models/Auth.js
// 望ましいコード
var Auth = {
    username: "",
    password: "",
    setUsername: function(value) {
        Auth.username = value
    },
    setPassword: function(value) {
        Auth.password = value
    },
    canSubmit: function() {
        return Auth.username !== "" && Auth.password !== ""
    },
    login: function() {/*...*/},
}

module.exports = Auth

これにより、コンポーネントをきれいに整理することができます:

// views/Login.js
// 望ましいコード
var Auth = require("../models/Auth")

var Login = {
    view: function() {
        return m(".login", [
            m("input[type=text]", {oninput: m.withAttr("value", Auth.setUsername), value: Auth.username}),
            m("input[type=password]", {oninput: m.withAttr("value", Auth.setPassword), value: Auth.password}),
            m("button", {disabled: !Auth.canSubmit(), onclick: Auth.login}, "ログイン"),
        ])
    }
}

このコードでは、Authモジュールは認証周りの状態の正しい情報を提供するモジュールとなりました。Registerコンポーネントからも簡単にデータにアクセスできますし、必要であればcanSubmitを呼び出す事ができます。これに加えて、必要であれば(例えばEメールのフィールドなどで)バリデーションが必要であれば、setEmailメソッドを修正して、コンポーネント側でEメールが入力されたときにメールアドレスのバリデーションを行わせるようにすることができます。

おまけの効果として、.bindを使ってコンポーネントのイベントハンドラーに状態の参照をわざわざ結びつけておく必要もなくなりました。

制限のあるインターフェースを避ける

コンポーネントのインタフェースの一般性を維持するために、もし、入力されたものに対して特別なロジックを実行するのでなければ、attrschildrenを直接使うようにしてください。

次のサンプルでは、buttonの設定が限られています。onclick以外のイベントハンドラが使えませんし、スタイルの設定もできません。また、子供の要素としてはテキストのみが利用できます。タグ要素、フラグメント、信頼されたHTMLは利用できません。

// 避けるべきコード
var RestrictiveComponent = {
    view: function(vnode) {
        return m("button", {onclick: vnode.attrs.onclick}, [
            "クリック:  " + vnode.attrs.text
        ])
    }
}

必要な属性が一般的なDOM属性とおなじであれば、コンポーネントのルートノードに直接パラメータを渡すのが推奨されるスタイルです。

// 望ましいコード
var FlexibleComponent = {
    view: function(vnode) {
        return m("button", vnode.attrs, [
            "クリック: ", vnode.children
        ])
    }
}

childrenを変更しない

コンポーネントが属性や子供の要素になにか手心を加える必要があれば、カスタム属性を代わりに使うべきです。

例えば、設定可能なタイトルと本体があるコンポーネントの場合に、子供のコンポーネントを複数セット受け取りたいことがあります。

この目的で使う場合にchildrenプロパティを分離して使用することは避けるべきです。

// 避けるべきコード
var Header = {
    view: function(vnode) {
        return m(".section", [
            m(".header", vnode.children[0]),
            m(".tagline", vnode.children[1]),
        ])
    }
}

m(Header, [
    m("h1", "タイトル"),
    m("h2", "Lorem ipsum"),
])

// 使いにくいコンポーネントの使用例
m(Header, [
    [
        m("h1", "タイトル"),
        m("small", "小ノート"),
    ],
    m("h2", "Lorem ipsum"),
])

このコンポーネントは、子供の要素は受け取ったのと同じ状態で出力されるはずだ、とする期待を裏切っています。実装を見ないでコンポーネントの挙動を理解するのは難しいでしょう。この場合は名前付きのパラメータを属性として使い、そのまま利用される子要素としてchildrenを使うべきです。

// 望ましいコード
var BetterHeader = {
    view: function(vnode) {
        return m(".section", [
            m(".header", vnode.attrs.title),
            m(".tagline", vnode.attrs.tagline),
        ])
    }
}

m(BetterHeader, {
    title: m("h1", "タイトル"),
    tagline: m("h2", "Lorem ipsum"),
})

// 分かりやすいコンポーネントの使用例
m(BetterHeader, {
    title: [
        m("h1", "タイトル"),
        m("small", "小ノート"),
    ],
    tagline: m("h2", "Lorem ipsum"),
})

静的にコンポーネントを定義して、動的に使う

コンポーネントをビューの内部で定義するのは避けるべきです。

もしコンポーネントをviewメソッドの中で作成している(直接生成するか、そこから呼び出している関数で生成しているかにかかわらず)場合、再描画のたびにコンポーネントの別のクローンが生成されることになります。コンポーネントのvnodeの差分検出時に、もしコンポーネントが新しいvnodeから参照されていて、それが古いvnodeから参照されているコンポーネントと違っていたら、同じコードで実行していたとしても、2つは異なるコンポーネントとみなされます。これは、コンポーネントが毎回スクラッチから動的に再生成されているという意味です。

これがコンポーネントの再作成を避ける理由です。慣用的なコンポーネントの利用例です。

// 避けるべきコード
var ComponentFactory = function(greeting) {
    // 新しいコンポーネントが呼び出しごとに作成される
    return {
        view: function() {
            return m("div", greeting)
        }
    }
}
m.render(document.body, m(ComponentFactory("hello")))
// 2回呼び出すと、divタグがスクラッチから作成される
m.render(document.body, m(ComponentFactory("hello")))

// 望ましいコード
var Component = {
    view: function(vnode) {
        return m("div", vnode.attrs.greeting)
    }
}
m.render(document.body, m(Component, {greeting: "hello"}))
// 2回呼び出してもDOMは変更されない
m.render(document.body, m(Component, {greeting: "hello"}))
コンポーネントのインスタンスをビューの外で作成するのを避ける

逆に、同様の理由で、コンポーネントのインスタンスがビューの外部で作成された場合、再描画時のビューの呼び出しでは何も変更されないため、ノードの等価かどうかのチェックで常に等価になるため、更新がスキップされます。そのため、コンポーネントのインスタンスは常にビューの中で作成されるべきです:

// 避けるべきコード
var Counter = {
    count: 0,
    view: function(vnode) {
        return m("div",
            m("p", "カウント: " + vnode.state.count ),

            m("button", {
                onclick: function() {
                    vnode.state.count++
                }
            }, "野鳥カウント")
        )
    }
}

var counter = m(Counter)

m.mount(document.body, {
    view: function(vnode) {
        return [
            m("h1", "アプリケーション"),
            counter
        ]
    }
})

上記のサンプルでは、カウンターコンポーネントのボタンをクリックすると、カウントの状態がインクリメントされますが、vnode表現の参照が同じ為、採苗は実行されません。そのため、描画プロセスはそれらの差分を取ることもしません。新しいvnodeが作成されるように、かならずビューの中でコンポーネントを呼び出しましょう。

// 望ましいコード
var Counter = {
    count: 0,
    view: function(vnode) {
        return m("div",
            m("p", "カウント: " + vnode.state.count ),

            m("button", {
                onclick: function() {
                    vnode.state.count++
                }
            }, "野鳥カウント")
        )
    }
}

m.mount(document.body, {
    view: function(vnode) {
        return [
            m("h1", "アプリケーション"),
            m(Counter)
        ]
    }
})

License: MIT. © Leo Horie.