batonコア
batonjsのコア部分であるbaton関数とその周辺について説明します。
baton
batonjsを起動します。起動したbatonjsは単一のページ状態を管理します。
例
const withState = baton({count:0}, show, document.getElementById('root'))
パラメーター
名前 | 型 | デフォルト | 説明 |
---|---|---|---|
state | any | 省略不可 | 初期のページ状態。null とundefined は指定できません。 |
show | 関数 | 省略不可 | ページ状態をUI宣言に変換する関数。後述 |
baseEl | DOM要素 | document.documentElement (※) |
UI宣言ではCSSセレクターを使ってDOM要素を抽出しますが、その抽出の起点とするDOM要素。省略した場合はHTML文書全体になります。 ※batonjsをNode.js上で動かす場合は省略できません。 |
返却値
名前 | 型 | 説明 |
---|---|---|
withState | 関数 | ページ状態を更新する関数を受け取り、その関数を呼び出してその結果であるページ状態をページに反映させます。後述 |
ページ状態とwithStateによる更新
ページ状態は1つのjavascriptの値で表現します。値は何でも構いませんが、null
とundefined
は使えません。
ページ状態を変更するときはwithState
を使ってください。
batonjsはページ状態を一切変更しません。
withState
は多くの場合、イベントハンドラーの中で使います。
ユーザーがUIを操作してイベントが発生し、そのハンドラーの中でwithState
を使ってページ状態を更新する、という流れです。
withState
に渡すコールバック関数は、ページ状態を更新して返却します。
このとき、ページ状態を更新するには、元の値とは別の値(===
の意味で)を返却してください。batonjsは===
でページ状態が変わったかどうかを判断します。つまり、state.count++
のような代入(破壊的更新)を使うことはできないということです。
また、返却値がnull
かundefined
だった場合も、ページ状態は変わっていないとbatonjsは判断します。
batonjsを1つのページ内で複数起動する場合、withState
は起動するごとに別の値です。
withState
はbatonjsを起動する関数baton
の返却値として取得できます。
例
myButton.addEventListener(ev => {
withState(state => ({count: state.count + 1}))
})
パラメーター
名前 | 型 | デフォルト | 説明 |
---|---|---|---|
update | 関数 | 省略不可 | ページ状態を受け取り、更新された新しいページ状態を返却する関数。この関数がnull かundefined を返却した場合、ページ状態に変化は無いものと解釈されます。 |
返却値
なし
showとUI宣言
batonjsは、ページ状態が更新されるとshow関数を呼び出します。
show関数はページ状態をUI宣言に変換する、ユーザー定義の関数です。
show関数は、起動パラメーターとしてbatonjsに渡します。
UI宣言とは、UIの状態を表現する単一の値です。主として、ページ状態に応じてどのUIがどのように変わるのかを表現しています。
UI宣言には基本2つの構文があります。
- 要素宣言
- DOM要素の宣言です。左辺がCSSセレクターで、右辺は他の宣言をまとめたブロック(オブジェクト)です。左辺のCSSセレクターで抽出されたDOM要素に対して右辺の宣言群を適用しましょう、という意味合いです。
- batonjsは内部で
querySelectorAll
を使っていますが、これの仕様はCSSのとは少し違うので注意してください。 - 先ほど「show関数はUI宣言を返却します」と述べましたが、実はこれは厳密ではありませんでした。厳密に言うと「show関数は要素宣言の右辺を返却します」となります。では左辺は何なのかと言うと、baton関数への第3引数として渡されるDOM要素です。
- プロパティ宣言
- DOM要素のプロパティや属性の宣言です。左辺がプロパティ名で、右辺はプロパティの値です。
DOM要素のどのプロパティや属性も書けて、data-my-attr
のようなdata-*属性も書けます。ただし、読み取り専用のプロパティを書くと、後にbatonjsが更新しようとしたときにエラーが発生するでしょう。
下の例では2~4行目が要素宣言、3行目がプロパティ宣言です。1行目から始まるブロックは要素宣言の右辺です。
例
const show = (state) => ({
"#counter": {
innerText: state.count
}
})
const withState = baton(state, show, document.body)
パラメーター
名前 | 型 | デフォルト | 説明 |
---|---|---|---|
state | any | 省略不可 | 最新のページ状態。 |
返却値
名前 | 型 | 説明 |
---|---|---|
uiDeclaration | object | UI宣言(厳密には要素宣言の右辺) |
要素宣言とプロパティ宣言の判別
batonjsは宣言の種類をその左辺だけで判断します。要素宣言の左辺はCSSセレクターで、プロパティ宣言の左辺はプロパティ名です。
では、左辺がoption
の場合はどう判別されるのでしょうか。正解はプロパティ宣言です。
batonjsは、左辺が英数字およびハイフン・アンダースコアの羅列の場合にはプロパティ宣言であると判断します。だから、英字だけで構成されているoption
はプロパティ宣言と判断されるのです。
では逆に、HTMLオプション要素に対してUI宣言を書きたい場合はどうすればいいでしょう。シンプルにoption
とCSSセレクターを書くとプロパティ宣言と解釈されてしまいますので、同じ意味を持つがプロパティ宣言と解釈されないCSSセレクターを書く必要があります。
この要件を満たすCSSセレクターは次のようなものがあります:"option:not(.phantom-class)"
、"option:defined"
、"*|option"
、"option "
筆者のオススメは、3番目の名前空間を使ったもの("*|option"
)です。これは短く不要な単語が出現せず、しかもCSSセレクターであることも分かりやすいのが気に入っています。
プロパティの更新監視
DOM要素のプロパティ値が変わったときにコールバック関数を呼んでもらうことができます。batonjsではこれを更新監視と呼んでいます。
更新監視はUI宣言の中に書きます。監視対象とするプロパティの名前の前に"&"を付けたものを左辺とし、右辺にはコールバック関数を書きます。
const handleChecked = (element, propertyName, newValue, oldValue, cleanup) => {...}
const show = (state) => ({
checked: state.checkboxChecked,
"&checked": handleChecked
})
パラメーター
名前 | 型 | デフォルト | 説明 |
---|---|---|---|
element | DOM要素 | 省略不可 | プロパティが変わったDOM要素 |
propertyName | 文字列 | 省略不可 | 変わったプロパティの名前 |
newValue | any | 省略不可 | 変更後のプロパティの値 |
oldValue | any | 省略不可 | 変更前のプロパティの値 |
cleanup | 関数|null | 省略不可 | コールバックの実行後にbatonjsがクリーンアップ処理を必要としている場合に、そのクリーンアップ処理が関数として渡されます。nullでない値を受け取った場合には、対象のDOM要素を使い終わった後に呼び出してください。cleanup関数にパラメーターや返却値はありません。 |
返却値
なし
「ページ状態を自分で変更してそれを自分でUIに反映したのに、その変更を教えてもらう?それに何の価値があるの?」と思った方もいるかもしれません。
実は、これには大きな価値があります。それは、プログラムコードの分割です。
更新監視はプロパティ値の変化をトリガーとして、アニメーションを起動したり、bootstrapなど外部ライブラリの部品を起動したり、といった処理に使えます。
こういった処理は、ページ状態を管理するという視点からみると、脇道のように余計なものです。
こういった脇道的な処理をページ状態の管理から分離することを可能にするのが、更新監視の存在意義です。
プロパティの強い更新監視
前章で説明した更新監視は、実は典型的な話にすぎません。典型的というのは「ページ状態が変わり、それに伴いUI宣言が変わって、DOM要素のプロパティ値に反映され、その変更を通知してもらう」というパターンのことです。
batonjsには、DOM要素のプロパティ値が変わる場面が他にも2つあります。
1つ目はbatonjsが起動したときです。
batonjsが起動するとすぐに、初期のページ状態を元にUI宣言が作られ、それが既にあるHTMLページに対して反映されます。
このとき、既存のHTMLに由来するプロパティとUI宣言に由来するプロパティの間に差異があった場合、プロパティ値が更新されることになります。
2つ目はbatonjsがDOM要素を作ったときです。
DOM要素の作成については後述しますが、batonjsはテンプレートを元にDOM要素を作ることがあります。
このとき、テンプレートに由来するプロパティとUI宣言に由来するプロパティの間に差異があった場合、やはりプロパティ値は変更されることになります。
上記2つは、ページ状態やUI宣言が変わったわけではないのにプロパティ値が変わるという意味でやや特殊です。batonjsが初めて出会うDOM要素なので、batonjsのルールに従ってない可能性があるわけです。
batonjsでは、このような場面でも更新監視ができるようになっています。
どうするかと言うと、UI宣言に更新監視を宣言するときに、"&"の代わりに"&&"を使います。
"&"の更新監視はページ状態の変化に由来するプロパティ値変化だけを監視します。一方、"&&"の更新監視はそれに加え、ページ状態の変化が直接関係していないプロパティ値変化も監視します。
batonjsでは、"&&"による更新監視を「強い更新監視」と呼んでいます。
強い更新監視を使う場面はそれほど多くないと筆者は考えています。ただ、たとえばページの読み込みと同時にアニメーションを開始したいような場面では役に立つでしょう。
下の例では、ページの読み込みと同時にポップアップがアニメーション付きで起動するように指定しています。
"#startup-popup": {
"class-is-open": true,
"&&class-is-open": myTransition
}
UI宣言のその他の機能
UI宣言にはbatonjsをより有用にするための様々な機能があります。このセクションではそれらの機能を紹介します。
イベントハンドラー
addEventListener
を使う代わりに、UI宣言の中でイベントハンドラを設定することができます。プロパティ名はonclick
、onchange
など、イベントタイプの前にonを添えてください。
そのプロパティ名にも関わらず、batonjsは内部的にはaddEventLister
、removeEventListener
を使います。
"#button": {
onclick: countUp
}
注意
UI宣言の中に関数式(function () {...}
など)を書くと、show関数が呼ばれる度に関数オブジェクトが作成されることになります。これは大抵の場合は非効率ですので、イベントハンドラ関数はなるべくshow関数の外で定義するのが良いです。
UI宣言を入れ子にする
UI宣言は入れ子にできます。
その場合、仮に外側の宣言を親、内側を子と呼ぶことにすると、親のCSSセレクターで抽出されたDOM要素を起点として、子のCSSセレクターが使われるようになります。
".parent": {
".child": {...},
"data-index": ...
}
上の例の2行目では、CSSセレクター.parent .child
と同じDOM要素が抽出されます。
また、入れ子になった要素の宣言(.child
)と、プロパティの宣言(data-index
)を並べて書ける点にも注目してください。
UI宣言の中でDOM要素を使う
要素宣言の右辺(左辺のCSSセレクターに対応するブロックの部分)を関数にすることができます。
ここを関数にすると、CSSセレクターで抽出された各DOM要素を引数としてその関数が呼び出されるようになります。
{
".option": (element, index) => ({
selected: state.selection === element.value
})
}
class-*プロパティ、style-*プロパティ
プロパティ宣言にはclass属性とstyle属性のための便利な記法があります。
プロパティ名の先頭がclass-の場合、これは単一のCSSクラスの指定と解釈されます。プロパティの値は真偽値です。
たとえば、"class-is-open": true
という宣言は、感覚的にはclassList.add("is-open")
と同じです。逆に、"class-is-open": false
とするとclassList.remove("is-open")
になります。
同様に、プロパティ名の先頭がstyle-の場合、これは単一のCSSスタイルの指定と解釈されます。プロパティの値は文字列です。
たとえば、"style-font-size": "16px"
という宣言は、感覚的にはstyle属性の中にfont-size: 16px;
と書くのと同じです。
注意
- style-*プロパティの値に数値を設定しないよう注意してください。悪い例:
"style-font-size": 16
。これは誤作動の原因になるかもしれません - UI宣言内でclass-*とclassを併用した場合、style-*とstyleを併用した場合の挙動は不明です。そのような使い方はしないでください。HTML上のclass属性やstyle属性と、UI宣言内のclass-*プロパティやstyle-*プロパティを併用するのは構いません。
CSSトランジションの起動
更新監視の仕組みを使ってCSSトランジションを起動することができます。詳細はCSSトランジションページを参照してください。
{
"class-is-open": state.isAccordionOpen,
"&class-is-open": cssTransition("transition", "size")
}
DOM要素の追加と削除
batonjsは明示的に指示された場合にはDOM要素の追加と削除を行うこともできます。そうするには、どのDOM要素の子要素を管理するかを、batonjsに指示します。
ここからの説明では、追加および削除されるDOM要素を「子」、その親のDOM要素を「親」と呼ぶことにします。
子要素の追加と削除は、親要素のUI宣言にbatonChildKeys
プロパティとbatonChildTemplate
プロパティを定義することで指示します。
batonChildKeys
は子要素のキーの配列で、子要素の存否と並び順を表します。batonChildTemplate
は子要素を新たに作る場合に使われるテンプレートとなる要素です。
キーは子を特定するための文字列で、不変でなければならず、兄弟の中で重複があってもなりません。batonjsは各キーを子要素のdata-baton-key属性に保存します。
{
batonChildKeys: state.todos.map(todo => todo.id),
batonChildTemplate: document.getElementById('todo-template')
}
batonjsはキーの配列から子要素の正しい順番を認識し、DOM上でも子がその通りに並ぶように追加・削除と並べ替えを行います。
注意
HTML上にあらかじめ子が記述されている場合は、data-baton-key
属性を必ず書くようにしてください
補足
batonChildTemplate
に関数を指定することもできます。その場合、その関数はキーとインデックス(何番目の子か)をパラメータとして受け取り、子となるDOM要素を返却するようにしてください。
mountedプロパティとライフサイクルの更新監視
前章で説明したDOM要素の追加と削除ですが、更新監視の仕組みを使って子要素のライフサイクルの変化を監視できます。
そうするには、子要素のmountedプロパティに強い更新監視を仕掛けてください。
下の例では、子要素の追加・削除のタイミングでCSSトランジションを起動しています。
{
"&&mounted": cssTransition("transition", "size")
}
mountedプロパティは、その子要素がDOMツリーに追加されてbatonjsの管理下に入ったときにtrue
になり、管理下から外れてDOMツリーから削除される直前にfalse
になります。
mountedプロパティと子要素の状態には気を使う必要があります。mountedプロパティがfalse
でbatonjsの管理下には無いが、DOMツリー上には存在するタイミングがあり得ますので注意してください。この半端な状態は、削除時にCSSトランジションを実行するためには必須です。
mountedプロパティを用いたライフサイクルの更新監視は、直接追加・削除された子要素だけでなく、その子孫でも使えます。
注意
mountedプロパティはbatonjsの中だけで通用する特殊なプロパティです。実際には存在しないので、そのプロパティにアクセスすることはできません。できるのは更新の検知だけです。