画面
HTML はデザイナが書いたものを複製する
画面のプログラムに HTML は書きません。デザイナが書いた HTML から必要な部分を複製し、値を入れます。
Screen >> rowOf: o =
(Html clone: '#lines tr' fill: {
'.c-id' -> o id.
'.c-customer' -> o customer.
'.c-total' -> (self yen: o total) })
key: o id.
#lines tr は、デザイナが表の見た目を確かめるために HTML に置いた見本の行です。プログラムはそれを一件ごとに複製し、.c-id などの要素に値を入れます。
デザイナが列を追加しても、プログラムの変更は不要です。class 名が変わったときはコンパイルエラーになります。モジュールの先頭に page: で使う HTML ファイルを指定してあり、コンパイラがその HTML を読んで、プログラムに書いたセレクタが存在するかを検査するためです。
module Orders page: 'index.html'.項目が多い画面
項目ごとに一行ずつ書く必要はありません。JSON のキーと HTML の class 名を規則で対応づけると、まとめて入れられます。
Screen >> view = self reply: (
data keys inject: (Html clone: '#card') into: [:node :k |
node at: ('.f-' , k) put: ((data at: k) asStr orElse: [(data at: k) asText]) ]).
項目数に関わらず、この三行です。
編集中のレコードについても同じです。self with: { qty: 120 } のように、with: では項目名をコードに直接書くので、綴りの間違いはコンパイルエラーになります。しかし入力欄からの値は、どの項目に入力されたかがイベントの文字列として届くので、この書き方ができません。そのために、項目名を文字列で指定して読み書きする fieldAt: と fieldAt:put: があります。クラスは項目名の一覧を fieldNames で返します。値の受け渡しは Json です。
Screen >> typed: text at: field =
self with: { vm: (vm fieldAt: field put: (Json str: text)) }.
項目の型は初期値から決まります。qty: 0 と書いてあれば qty は Int で、fieldAt: 'qty' put: に "120" を渡せば Int の 120 として入ります。項目が三百ある画面でも、入力を受けるハンドラは一つです。受注入力がこの書き方です。
再描画の範囲は指定しない
多くのフレームワークでは、「この値が変わったときだけ、この部分を描き直す」という条件をプログラマが書きます。LPScript にはその指定がありません。
ハンドラは次の状態を返します。ランタイムは、返ってきた状態が今の状態と同じオブジェクトかどうかを比べます。値は変更できないので、with: で作った状態は必ず別のオブジェクト、self は必ず同じオブジェクトです。
Screen >> search: text = self with: { query: text }. "別のオブジェクト。描き直す"
Screen >> ping = self. "同じオブジェクト。描き直さない"
同じオブジェクトかどうかの比較は、描き直しの範囲も決めます。clone:fill: はセレクタで指定した部分だけを作り直すので、変更の無い行は前回のノードと同じオブジェクトです。そこで比較が止まり、属性やノードの比較は行われません。1,000 行の表で一つのセルだけを変え、四十回描いて測ったところ、一描画は 4.9ms から 3.1ms になりました。
プログラムを書くうえで覚えることは三つです。
- 状態を変えないときは
selfを返す。描き直しは起きない - 子アクタは自分の領域を持つ。親を描き直しても、子への参照が変わっていなければ子は描き直されない。一覧の行を子アクタにすれば、親の再描画は行に及ばない。
Html child: rowは子をdivの中に描き、Html child: row as: 'tr'は子を入れる要素のタグを指定する - 一覧の行には
key:を付ける。ノードが作り直されずに移動するので、行の中の入力欄の内容が保たれる
計測例です。ゲームの点数ラベルに毎フレーム同じ文字列を送っていたとき、ラベルは毎回新しい状態を返していたので、毎秒 60 回、同じ内容が DOM に書かれていました。最悪のフレーム間隔は 48.2ms でした。ラベルを「文字列が同じなら self を返す」ように直すと、18.8ms になりました。変更は一行です。
他のフレームワークとの違い
React では React.memo、useMemo、useCallback と依存配列を書きます。書き忘れると、無駄な再描画か、古い値の表示になります。どちらも実行するまで分かりません。Elm の Html.Lazy も同じ目的の指定です。LPScript にはこれらに相当する指定がありません。
イベントはハンドラ名で指定する
クリックされたときの処理は、ハンドラ名で指定します。ブロックは渡しません。
(row on: #click send: #select: with: o id)
自分のクラスに #select: というハンドラが無ければコンパイルエラーです。イベントが運ぶ値にも型が付きます。値が必要なときは on:sendValue:、イベントそのものが必要なときは on:sendEvent: を使い、e key、e value、e scrollTop などで読み出します。
自分の領域の外にある要素のイベントも受け取れます。
me listen: '#back' on: #click send: #goBack.
ブロックで書けない理由は、VNode が作ったアクタとは別のアクタで使われるためです。子アクタが作った画面を親が組み込む場合や、共通部品が画面を作る場合がそれにあたります。ブロックは作られた場所の変数を参照しているので、別のアクタへコピーして渡せません。ハンドラ名は文字列なので渡せます。イベントが起きると、ランタイムはその領域を持つアクタに、その名前のメッセージを送ります。
key
行を作るときに、その行を一意に決める値(受注番号など)を key: で指定します。
(Html clone: '#lines tr' fill: { ... }) key: o id
指定すると、並べ替えや絞り込みで行の位置が変わっても、DOM のノードは作り直されずに移動します。行の中に入力欄があれば、入力中の文字とフォーカスが保たれます。
指定しなくても表示は正しくなります。位置で対応づけて中身を上書きするだけです。行の中に入力欄が無ければ、それで問題ありません。
同じ key を二つ指定するとエラーになります。黙って修正すると行が一つ消え、しかもエラーが出ないためです。
仮想スクロール
一描画にかかる時間は、画面に出ている行数に比例します。数千行を超えると、見えている行だけを描きます。これは言語の機能ではなく、画面の書き方です。
スクロールのイベントを受け取ると、scrollTop と height が届きます。行の高さは CSS で決めた定数なので、割り算で何行目から何行が必要かが出ます。描かない分の高さは、上下のスペーサーの行に持たせます。スクロールバーの長さはそれで正しくなります。
一万行の一覧がこの書き方です。1 万行で、実際に描くのは二十数行、一描画は 4.9ms です。
ファイル
ファイルを選ぶのもイベントです。他のイベントと違い、イベントを受けた場所ですぐにメッセージを送りません。
(node at: '.csv' on: #change sendFileText: #loaded:text:)
ファイルの読み込みはこの場所で始まり、名前と内容が揃ったときにハンドラへ届きます。読み込みに時間がかかるので、読み終わる時点ではイベントは終わっているためです。文字列として読んではいけないファイル(帳票や表計算)には sendFileBytes: を使います。
ファイル選択、貼り付け、ドラッグ&ドロップは同じ扱いです。on: #paste も on: #drop も同じハンドラに届きます。
ファイルを渡す側は App save: 'orders.csv' type: 'text/csv' text: s です。ブラウザ本来の方法(リンク要素を作り、クリックさせ、捨て、オブジェクト URL を解放する)は四手順で、一つ忘れるとメモリが漏れます。それを一つのメッセージにしてあります。
アニメーション
毎フレーム描き直す場合も、書き方は同じです。requestAnimationFrame を Extern で宣言し、コールバックの中で自分にメッセージを送ります。
Screen >> tick = (
Win requestAnimationFrame: [:t | me ref tell tick].
...次の状態を作る... ).
他のハンドラと同じで、次の状態を返すと描かれます。計測では毎秒 60 フレームで、そのすべてでアクタが描き直していました。
canvas に描く場合は view を使いません。canvas は描いた内容が残るので、毎フレーム自分で消してから描き直します。ゲームを参照してください。