コンポーネント
構造
コンポーネントは、ビューの一部をカプセル化し、コードの整理や再利用を容易にするための仕組みです。
view
メソッドを持つ JavaScript オブジェクトは、Mithril.js のコンポーネントとみなされます。コンポーネントは、m()
ユーティリティ関数を通じて利用できます。
// コンポーネントの定義
var Example = {
view: function (vnode) {
return m('div', 'Hello');
},
};
// コンポーネントの利用
m(Example);
// 上記と同等の HTML
// <div>Hello</div>
ライフサイクルメソッド
コンポーネントは、仮想 DOM ノードと同様のライフサイクルメソッドを持つことができます。各ライフサイクルメソッドと view
には、引数として vnode
が渡されることに注意してください(onbeforeupdate
には、以前の vnode も追加で渡されます)。
var ComponentWithHooks = {
oninit: function (vnode) {
console.log('initialized');
},
oncreate: function (vnode) {
console.log('DOM created');
},
onbeforeupdate: function (newVnode, oldVnode) {
return true;
},
onupdate: function (vnode) {
console.log('DOM updated');
},
onbeforeremove: function (vnode) {
console.log('exit animation can start');
return new Promise(function (resolve) {
// アニメーション完了後に resolve を呼び出す
resolve();
});
},
onremove: function (vnode) {
console.log('removing DOM element');
},
view: function (vnode) {
return 'hello';
},
};
他の種類の仮想 DOM ノードと同様に、コンポーネントは vnode タイプとして使用される際に、追加のライフサイクルメソッドを定義できます。
function initialize(vnode) {
console.log('initialized as vnode');
}
m(ComponentWithHooks, { oninit: initialize });
vnode のライフサイクルメソッドは、コンポーネントのメソッドをオーバーライドしません。また、その逆もありません。コンポーネントのライフサイクルメソッドは、常に vnode の対応するメソッドの後に実行されます。
vnode で独自のコールバック関数名にライフサイクルメソッド名を使用しないように注意してください。
ライフサイクルメソッドの詳細については、ライフサイクルメソッドのページを参照してください。
コンポーネントへのデータの受け渡し
データは、ハイパースクリプト関数で attrs
オブジェクトを 2 番目のパラメータとして渡すことによって、コンポーネントインスタンスに渡すことができます。
m(Example, { name: 'Floyd' });
このデータは、コンポーネントの view またはライフサイクルメソッドで vnode.attrs
を介してアクセスできます。
var Example = {
view: function (vnode) {
return m('div', 'Hello, ' + vnode.attrs.name);
},
};
注意: ライフサイクルメソッドは attrs
オブジェクトでも定義できるため、独自のコールバックにそれらの名前を使用することは避ける必要があります。Mithril.js 自体もそれらを呼び出すためです。それらをライフサイクルメソッドとして使用したい場合にのみ、attrs
で使用してください。
ステート
すべての仮想 DOM ノードと同様に、コンポーネント vnode はステートを持つことができます。コンポーネントのステートは、オブジェクト指向アーキテクチャのサポートやカプセル化、関心の分離に役立ちます。
他の多くのフレームワークとは異なり、Mithril.js ではコンポーネントのステートを変更しても、自動的に再描画や DOM の更新がトリガーされるわけではないことに注意してください。代わりに、再描画は、イベントハンドラが起動したとき、m.request によって行われた HTTP リクエストが完了したとき、またはブラウザが別のルートに移動したときに実行されます。Mithril.js のコンポーネントステートメカニズムは、あくまでアプリケーションの便宜のために提供されています。
上記の条件によらないステートの変更(例:setTimeout
の後)が発生した場合は、m.redraw()
を使用して手動で再描画をトリガーできます。
クロージャコンポーネントステート
上記の例では、各コンポーネントは POJO(Plain Old JavaScript Object)として定義されています。これは、Mithril.js がそのコンポーネントのインスタンスのプロトタイプとして内部的に使用します。POJO でコンポーネントステートを使用することは可能ですが(後述)、最もクリーンで簡単なアプローチではありません。代わりに、クロージャコンポーネント** を使用することを推奨します。これは、POJO コンポーネントインスタンスを 返す ラッパー関数であり、それ自体がクローズされたスコープを持ちます。
クロージャコンポーネントを使用すると、ステートは外側の関数内で宣言された変数によって簡単に管理できます。
function ComponentWithState(initialVnode) {
// コンポーネントステート変数。各インスタンスに固有
var count = 0;
// POJO コンポーネントインスタンス:vnode を返す view 関数を持つオブジェクト
return {
oninit: function (vnode) {
console.log('init a closure component');
},
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + count),
m(
'button',
{
onclick: function () {
count += 1;
},
},
'Increment count'
)
);
},
};
}
クロージャ内で宣言された関数も、そのステート変数にアクセスできます。
function ComponentWithState(initialVnode) {
var count = 0;
function increment() {
count += 1;
}
function decrement() {
count -= 1;
}
return {
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + count),
m(
'button',
{
onclick: increment,
},
'Increment'
),
m(
'button',
{
onclick: decrement,
},
'Decrement'
)
);
},
};
}
クロージャコンポーネントは、POJO と同じように使用されます。例:m(ComponentWithState, { passedData: ... })
。
クロージャコンポーネントの大きな利点は、イベントハンドラコールバックをアタッチするときに this
をバインドすることを心配する必要がないことです。実際、this
はまったく使用されず、this
コンテキストのあいまいさについて考慮する必要はありません。
POJO コンポーネントステート
一般的に、コンポーネントステートの管理にはクロージャを使用することをお勧めします。ただし、POJO でステートを管理する理由がある場合は、コンポーネントのステートは、初期化時のブループリントとして、vnode.state
を介して、およびコンポーネントメソッドの this
キーワードを介して、次の 3 つの方法でアクセスできます。
初期化時
POJO コンポーネントの場合、コンポーネントオブジェクトは各コンポーネントインスタンスのプロトタイプであるため、コンポーネントオブジェクトで定義されたプロパティは vnode.state
のプロパティとしてアクセスできます。これにより、シンプルな「ブループリント」状態の初期化が可能になります。
以下の例では、data
は ComponentWithInitialState
コンポーネントの vnode.state
オブジェクトのプロパティになります。
var ComponentWithInitialState = {
data: 'Initial content',
view: function (vnode) {
return m('div', vnode.state.data);
},
};
m(ComponentWithInitialState);
// 上記と同等の HTML
// <div>Initial content</div>
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
の値はコンポーネントインスタンスではないことに注意してください。この JavaScript の制限を回避するには、アロー関数を使用するか、サポートされていない場合は vnode.state
を使用することをお勧めします。
クラス
ニーズに合致する場合は(オブジェクト指向プロジェクトなど)、クラスを使用してコンポーネントを作成することもできます。
class ClassComponent {
constructor(vnode) {
this.kind = 'class component';
}
view() {
return m('div', `Hello from a ${this.kind}`);
}
oncreate() {
console.log(`A ${this.kind} was created`);
}
}
クラスコンポーネントは、ツリーをレンダリングするために、.prototype.view
を介して検出される view()
メソッドを定義する必要があります。
これらは、通常のコンポーネントと同じように使用できます。
// 例:m.render 経由
m.render(document.body, m(ClassComponent));
// 例:m.mount 経由
m.mount(document.body, ClassComponent);
// 例:m.route 経由
m.route(document.body, '/', {
'/': ClassComponent,
});
// 例:コンポーネントの構成
class AnotherClassComponent {
view() {
return m('main', [m(ClassComponent)]);
}
}
クラスコンポーネントステート
クラスを使用すると、ステートはクラスインスタンスのプロパティとメソッドによって管理でき、this
を介してアクセスできます。
class ComponentWithState {
constructor(vnode) {
this.count = 0;
}
increment() {
this.count += 1;
}
decrement() {
this.count -= 1;
}
view() {
return m(
'div',
m('p', 'Count: ', this.count),
m(
'button',
{
onclick: () => {
this.increment();
},
},
'Increment'
),
m(
'button',
{
onclick: () => {
this.decrement();
},
},
'Decrement'
)
);
}
}
this
コンテキストを正しく参照できるように、イベントハンドラコールバックにはアロー関数を使用する必要があることに注意してください。
コンポーネントの種類の混合
コンポーネントの種類は自由に組み合わせることができます。クラスコンポーネントは、クロージャまたは POJO コンポーネントを子として持つことができます。
特殊な属性
Mithril.js では、特定のプロパティ key に特別な意味を持たせているため、通常のコンポーネント属性ではそれらの使用を避けてください。
- ライフサイクルメソッド:
oninit
、oncreate
、onbeforeupdate
、onupdate
、onbeforeremove
、およびonremove
key
:key 付きフラグメントで ID を追跡するために使用されますtag
:vnode を通常の属性オブジェクトや vnode 以外のオブジェクトと区別するために使用されます。
アンチパターンを避ける
Mithril.js は柔軟ですが、いくつかのコードパターンは推奨されません。
肥大化したコンポーネントを避ける
一般的に、「太った」コンポーネントとは、カスタムインスタンスメソッドを持つコンポーネントのことです。つまり、関数を vnode.state
または this
にアタッチすることは避けるべきです。コンポーネントインスタンスメソッドに論理的に適合し、他のコンポーネントで再利用できないロジックを持つことは非常にまれです。そのロジックが将来別のコンポーネントで必要になる可能性は比較的高いです。
そのロジックがコンポーネントステートに強く結びついている場合を除き、データレイヤーに配置されている方がコードをリファクタリングするのが簡単です。
このような太ったコンポーネントを考えてみてください。
// 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: function (e) {
this.setUsername(e.target.value);
},
value: this.username,
}),
m('input[type=password]', {
oninput: function (e) {
this.setPassword(e.target.value);
},
value: this.password,
}),
m(
'button',
{ disabled: !this.canSubmit(), onclick: this.login },
'Login'
),
]);
},
};
通常、大規模なアプリケーションのコンテキストでは、上記のログインコンポーネントは、ユーザー登録およびパスワード回復用のコンポーネントと並んで存在します。ログイン画面から登録画面またはパスワード回復画面に移動するときに、メールフィールドに事前に入力できるようにしたいと想像してください(またはその逆)。ユーザーが間違ったページに入力した場合にメールを再入力する必要がないようにするためです(または、ユーザー名が見つからない場合は、ユーザーを登録フォームに誘導したい場合があります)。
すぐに、このコンポーネントから別のコンポーネントに username
および password
フィールドを共有することが難しいことがわかります。これは肥大化したコンポーネントがステートをカプセル化しているため、定義上、外部からのアクセスが難しくなるためです。
このコンポーネントをリファクタリングし、ステートコードをコンポーネントからアプリケーションのデータレイヤーに分離する方が理にかなっています。これは、新しいモジュールを作成するのと同じくらい簡単です。
// 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: function (e) {
Auth.setUsername(e.target.value);
},
value: Auth.username,
}),
m('input[type=password]', {
oninput: function (e) {
Auth.setPassword(e.target.value);
},
value: Auth.password,
}),
m(
'button',
{
disabled: !Auth.canSubmit(),
onclick: Auth.login,
},
'Login'
),
]);
},
};
このようにして、Auth
モジュールは認証関連のステートの信頼できる唯一の情報源になり、Register
コンポーネントはこのデータに簡単にアクセスでき、必要に応じて canSubmit
などのメソッドを再利用することもできます。さらに、検証コードが必要な場合(たとえば、メールフィールドの場合)、setEmail
を変更するだけで済み、その変更により、メールフィールドを変更するすべてのコンポーネントでメールの検証が行われます。
おまけとして、コンポーネントのイベントハンドラのステートへの参照を保持するために .bind
を使用する必要がなくなったことに注意してください。
vnode.attrs
自体を他の vnode に転送しないでください
インターフェイスを柔軟に保ち、実装を簡単にするために、属性を特定の child コンポーネントまたは要素に転送したい場合があります。たとえば、Bootstrap のモーダルなどです。次のように vnode の属性を転送したくなるかもしれません。
// 避けるべきパターン
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs, [
// ここで `vnode.attrs` を転送 ^
// ...
]);
},
};
上記のように行うと、使用時に問題が生じる可能性があります。
var MyModal = {
view: function () {
return m(
Modal,
{
// これでは2回トグルされるため、正しく表示されません
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle');
},
},
[
// ...
]
);
},
};
代わりに、個々の 属性を vnode に転送する必要があります。
// 推奨パターン
var Modal = {
// ...
view: function (vnode) {
return m('.modal[tabindex=-1][role=dialog]', vnode.attrs.attrs, [
// ここで `attrs:` を転送 ^
// ...
]);
},
};
// 例
var MyModal = {
view: function () {
return m(Modal, {
attrs: {
// これにより 1 回切り替わります
onupdate: function (vnode) {
if (toggle) $(vnode.dom).modal('toggle');
},
},
// ...
});
},
};
children
を操作しないでください
コンポーネントが属性または 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', 'My title'), m('h2', 'Lorem ipsum')]);
// 不自然な使用例
m(Header, [
[m('h1', 'My title'), m('small', 'A small note')],
m('h2', 'Lorem ipsum'),
]);
上記のコンポーネントは、children が受信したのと同じ連続した形式で出力されるという前提を破ります。実装を読まないとコンポーネントを理解するのが困難です。代わりに、属性を名前付きパラメータとして使用し、均一な child コンテンツのために children
を予約します。
// 推奨パターン
var BetterHeader = {
view: function (vnode) {
return m('.section', [
m('.header', vnode.attrs.title),
m('.tagline', vnode.attrs.tagline),
]);
},
};
m(BetterHeader, {
title: m('h1', 'My title'),
tagline: m('h2', 'Lorem ipsum'),
});
// より明確な使用例
m(BetterHeader, {
title: [m('h1', 'My title'), m('small', 'A small note')],
tagline: m('h2', 'Lorem ipsum'),
});
コンポーネントを静的に定義し、動的に呼び出す
ビュー内でコンポーネント定義を作成することを避ける
view
メソッド内からコンポーネントを作成する場合(直接インラインで、またはそれを行う関数を呼び出すことによって)、再描画ごとにコンポーネントの異なるクローンが作成されます。コンポーネント vnode を diff する際、新しい vnode が参照するコンポーネントと古い vnode が参照するコンポーネントが厳密に等しくない場合、たとえ最終的に同じコードを実行するとしても、それらは異なるコンポーネントとみなされます。これは、ファクトリを介して動的に作成されたコンポーネントは常に最初から再作成されることを意味します。
そのため、コンポーネントを再作成することは避けるべきです。代わりに、コンポーネントを慣用的に使用してください。
// 避けるべきパターン
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', 'Count: ' + vnode.state.count),
m(
'button',
{
onclick: function () {
vnode.state.count++;
},
},
'Increase count'
)
);
},
};
var counter = m(Counter);
m.mount(document.body, {
view: function (vnode) {
return [m('h1', 'My app'), counter];
},
});
上記の例では、カウンタコンポーネントボタンをクリックすると、そのステートカウントが増加しますが、コンポーネントを表す vnode が同じ参照を共有するため、そのビューはトリガーされません。したがって、レンダリングプロセスではそれらが diff されません。新しい vnode が作成されるように、常にビューでコンポーネントを呼び出す必要があります。
// 推奨パターン
var Counter = {
count: 0,
view: function (vnode) {
return m(
'div',
m('p', 'Count: ' + vnode.state.count),
m(
'button',
{
onclick: function () {
vnode.state.count++;
},
},
'Increase count'
)
);
},
};
m.mount(document.body, {
view: function (vnode) {
return [m('h1', 'My app'), m(Counter)];
},
});