11/17の夜くらいにいくつかのPRが3.2のマイルストーンに設定されました
そしてそれらが11/29の深夜にリリースされました。
含められてる変更は4つと数は多くないですが思い入れの深い機能なので紹介していきたいと思います
Outlets API
背景
このAPIの議論の真の発端はコントローラ間の連携をどのように行うかというissueの中です。
基本的にはイベントで連携をとるべきだとしながら、使役的な実装がしたいケースもあるよねというニーズに対して当時のメンテナのsstephensonがcocoaのoutletのようなものがあるといいねとレスしたところから始まりました
その後、hey(37signalsの運営するメールサービス)の中でひっそりと導入され、それが公式に入ることを心待ちにする声とPRが立ち上がり、今回マージされたという流れになります。
どういうものか
AコントローラがBコントローラのメソッドを直接呼び出すことができるというものです。
基本的な使い方
まずHTML側に使役されるコントローラ名とそのコントローラの割り当てられてる要素のセットを指定します。
構文は以下のようになります。
data-[identifier]-[outlet]-outlet="[selector]"
<button type="button"
data-controller="dialog-launcher"
{#
dialog-launcherコントローラがid="dialogA"の要素に割り当てられたdialogコントローラに
アクセスできるようにしている記述
#}
data-dialog-launcher-dialog-outlet="#dialogA"
data-action="dialog-launcher#open"
>dialogを開く</button>
<dialog
id="dialogA"
data-controller="dialog"
>
</dialog>
app.register('dialog-launcher', class extends Controller {
static outlets = ['dialog'];
open() {
this.dialogOutlet.show();
}
})
app.register('dialog', class extends Controller {
show() {
// https://developer.mozilla.org/ja/docs/Web/API/HTMLDialogElement/show
this.element.show();
}
})
このような関連付けをすることで#dialogA要素に割り当てられてるdialogコントローラをlauncher側から呼び出すことができます。
ありがちなユースケース
ありがちなユースケースとしては上述のダイアログみたいなケースでしょうか。
dialogのトリガーとなるボタンと実際のダイアログコンテンツが以下のように遠い場所にあったとしましょう。
body
...
...
button(type="button") dialogのトリガー
...
dialog(hidden="hidden")
h2 ....
こういったケースを一つのdialogコントローラで実現しようとすると双方の共通の親要素であるbodyにコントローラ設定をすることになります。
しかしその場合、ページに複数ダイアログがあってそれも同じような状況に置かれている時に、一つの要素に同じコントローラを複数設置できないという問題と遭遇してしまいます。
それを回避する際にlauncherとdialogでわけてコントローラを管理しようとするわけですが、分けた場合、二つのコントローラの連携手段が必要になります。
Stimulusの場合、この連携に関して多くの場合、カスタムイベントを発行することで解決します。
しかし、本件のような明らかな使役の関係性の時にイベントでの連携はやや冗長に思えます。 このような時、Outletsがあると楽だよということになるわけです。
サンプルコード
See the Pen Untitled by nazomikan (@nazomikan) on CodePen.
| 記述 | 型 | 効果 |
|---|---|---|
| [name]Outlet | Controller | アウトレットコントローラ(最初のもの)が返却される 存在しない場合エラーとなる |
| [name]Outlets | Array | アウトレットコントローラが配列で返却される |
| has[Name]Outlet | Boolean | アウトレットコントローラが存在するかどうか |
outletのhtml側には値部分にセレクタがかけます。
ここでクラス(例えば.bar)を指定した場合、outletは複数取得できることが想定できます。 このようなケースのために[name]Outletsゲッターが存在しています。
Outletコントローラに対応する要素にアクセスする
すでに紹介しているthis.xxxOutletはコントローラの実体にアクセスするためのゲッターになります。
コントローラに対応する要素にアクセスするときthis.xxxOutletElementとしてアクセスすることができます。
| 記述 | 型 | 効果 |
|---|---|---|
| [name]OutletElement | Element | アウトレットコントローラ(最初のもの)に対応する要素が返却される 存在しない場合エラーとなる |
| [name]OutletElements | Array | アウトレットコントローラに対応する要素が配列で返却される |
| has[Name]OutletElement | Boolean | アウトレットコントローラに対応する要素が存在するかどうか |
Outletコントローラの接続コールバック
アウトレットで参照される要素の削除や追加にともなって何か処理をさせたいというケースが稀にあります。
Targetsの場合、[name]TargetConnectedといったコールバックが用意されてますが、Outletも同様にコールバックが用意されています。
| 記述 | 引数 | 効果 |
|---|---|---|
| itemOutletConnected | outlet: 接続されたコントローラ element: 接続されたコンローラに対応する要素 |
outletで指定した要素がDOM中に出現した際に発火します。 |
| itemOutletDisconnected | outlet: 接続されたコントローラ element: 接続されたコンローラに対応する要素 |
outletで指定した要素がDOM中から消失した際に発火します。 |
マニアックな話
コントローラ名にディレクトリ表現(--)がある場合
Stimulusはstimulus-railsなどでオートローダー越しに利用されるケースがしばしばあります。
この際、ファイルパスとコントローラ名には規約が存在しており以下のようにディレクトリ(/)表現は--に変換されます。
| ファイルパス | コントローラ名 |
|---|---|
| foo/bar_controller.js | foo--bar |
このような--を持つコントローラをoutletゲッターでアクセスする際、単純に-をキャメルに変換して利用すると考えるとfoo-BarOutletとなってしまいます。
これを避けるために--がある場合は単一のハイフン(-)と同様の扱いをするようにになっています。
| ファイルパス | コントローラ名 | outletゲッターで参照する際 |
|---|---|---|
| foo/bar_controller.js | foo--bar | fooBarOutlet |
これはゲッターだけみるとfoo-barと区別がないのですが、まぁそれで問題が起きることは非常に低いのでAPIとしてのシンプルさを優先してそうなっています。
もし衝突した場合、identifierで区別ができるので以下のようにしてフィルタリングするのが良いでしょう
this.fooBarOutlets.filter(outlet => outlet.identifier === 'foo--bar');
コントローラ名のケース
コントローラ側(js)のoutletsプロパティの記述はコントローラ名に-を含む場合、3.2.0時点ではkebabケースとなっていますが、これはもしかすると近い将来targets等とあわせてcamelケースになるかもしれません。 (早ければv3.2.2で)
破壊的変更になってしまうため、しばらくは両方で動作し、kebabケースを利用してる場合、将来的になくなる旨のログを出すという感じの提案がなされています。
一方で--を単純なキャメルに変換して扱うという前述の仕様をどうするかという懸念も残っているのでしばらく注視したいところです。
Outletにアクセスできないタイミング
Outletは指定されたコントローラにアクセスするための構文ですが、その対象のコントローラがまだ作られてないタイミングでアクセスした場合は期待した結果が得られません。
主にconnectやinitializeハンドラのようなインスタンス化の順序関係がシビアに影響を与えるケースの時に注意しましょう。
キーボードイベントのフィルタリング構文
拙作ながら私のPRです。
背景
StimulusはHTMLを見ただけでおおよその振る舞いがわかるように(宣言的に)コードを記述することを推奨しています。
例をみてみましょう。
Don't
<button data-action="click->profile#click">Don't</button>
Recommend
<button data-action="click->profile#showDialog">Do</button>
#clickのようなクリックされた時に何かをするといった漠然とした意味合いの名前でなく#showDialogといった明確な名前をつけましょうといった感じです。
確かにこの教えに従うとHTMLをみただけで結構な説明力がうまれますが、しかしながらKeyboardイベント(keydown, keyup, (keypress))のようなイベントの場合はそれだけではやや説明力に欠ける部分があります。
以下の例をみてみましょう。
<div data-controller="dialog"
data-action="keydown->dialog#close"
>
app.register('dialog', class extends Controller {
...
close(evt) {
if (evt.key !== 'Escape') { return; }
...// 閉じる処理
}
});
これはエスケープキーを押された時ダイアログ要素を閉じるという目的をもったコードですが、HTML側だけみても、どのキーを押した時にcloseが実行されるのかが不明瞭です。
このキーボードイベントのフィルタ構文では以下のようにかけるようになります。
<div data-controller="dialog"
data-action="keydown.esc->dialog#close"
>
以前のコードに比べてより情報がくっきりしたかと思います。
簡単な使い方
構文は以下です。
data-action="[keyup|keydown|keypress].[modifier]->[controller]#[action]">
// example
<div role="dialog" data-action="keydown.esc->dialog#close" ...>
指定されたイベントがkeyboard系のイベントだった時のみ、ドットをつなげて押されたキーがmodifierと一致する時だけ発火させるという動作になります。
サンプルコード
さきほどoutletsの例で紹介したコードを改修して、ダイアログが開いた時にエスケープを押すとダイアログが閉じるという機能を実現してみましょう。
See the Pen Untitled by nazomikan (@nazomikan) on CodePen.
修飾キーとのコンビネーション
実現したいキーボードショートカットの中には修飾キー(ctrlとかshiftとか...)とあわせて実現したいものもあるでしょう。
全ての項目を選択するといったショートカットはたいていctrl+aが割り当てられます。
今回のリリースにはこのコンビネーションもサポートされています。
<div data-action="keydown.ctrl+a->listbox#selectAll" role="option" tabindex="0">...</div>
サポートされてる修飾キーは以下の四つです。
- meta
- alt
- ctrl
- shift
キーの拡張
このキーボードイベントのフィルタ機能で利用できるキーは今の所以下の通りです。
| フィルタ名 | event.keyの値 |
|---|---|
| enter | Enter |
| tab | Tab |
| esc | Escape |
| space | " " |
| up | ArrowUp |
| down | ArrowDown |
| left | ArrowLeft |
| right | ArrowRight |
| home | Home |
| end | End |
| [a-z] | [a-z] |
| [0-9] | [0-9] |
開発にでてくる主要なキーは押さえてあると思いますがこれでは足りない場合はApplication.startに拡張したschema渡すことで追加することができます。
import { Application, defaultSchema } from "@hotwired/stimulus"
const customSchema = {
...defaultSchema,
keyMappings: { ...defaultSchema.keyMappings, at: "@" }, // ここに追加していく
}
const app = Application.start(document.documentElement, customSchema)
マニアックな話
この変更は後方互換を守ってるようで完全に守れてはいません。
それはどこかというともともとのイベント名がドット(.)を含んでいた場合、フィルタと認識されてしまうところにあります。
実際3.2.0を出荷した際、jQueryのイベント名前空間でドットを使うケースが多々あり、関連issueが二件ほど立ち上がりました。
そのため、この構文が適応されるのをkeyup|keydown|(keypress)の三つのイベントの時だけにする変更を3.2.1に含めました。
もし3.2.0を利用していて同じ問題に当たってしまってる方がいらっしゃればお手数ですが3.2.1に上げてもいただけると幸いです。
afterLoad
コントローラがapp.registerで登録されたタイミングで実行させたい何らかの処理があった場合、それらを記述するためのコールバックが用意されました。
例えばページのデザインをダークモードとライトモードに切り替える機能があったとします。
そして一度変えればその情報はlocalStorageに保存され、次回接続された際にはそのモードが即時反映されるという仕様を考えましょう。
これを実現するコントローラを作るとすぐに一つの問題に直面します。
それは画面のちらつきです。
対象となるHTMLが出現し、MutationObserverによってコントローラがアタッチされるまでのタイムラグの間は指定のモードが適応されないため、一瞬のちらつきが発生します。
afterLoadはこういった場合に役に立ちます。
class DarkModeToggleButton extends Controller {
static afterLoad(identifier, application) {
// localstorageを見てサイトのデザインを切り替える処理をここにかける
}
}
インスタンスが生成されるよりはるか前に発火されるコールバックになるため、当然メソッドはstaticになることに注意してください。
以前shouldLoadという静的ゲッターがリリースされており、そのゲッターもコールバックスタイルで記述すれば同じことができますが、まぁ用途が違うよねということで両方存在しています。
Applicationクラスが継承可能に
Applicationクラスを継承することで何らかの便利なメソッドを加えようとする際、CustomApplication.start()が返却するインスタンスが継承されたものではないという問題がありました。
これはstart関数が内部的に決め打ちでnew Application(...)とされていたからです。
これが3.2からnew this(...)に置き換わり、継承後のクラスのインスタンスとして扱えるようになりました。