画面

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 を読んで、プログラムに書いたセレクタが存在するかを検査するためです。

page: 'index.html'.

項目が多い画面

項目ごとに一行ずつ書く必要はありません。JSON のキーと HTML の class 名を規則で対応づけると、まとめて入れられます。

Screen >> view =
  data keys inject: (Html clone: '#card') into: [:node :k |
    node at: ('.f-' , k) put: ((data at: k) asStr orElse: [(data at: k) asText]) ].

項目数に関わらず、この三行です。

編集中のレコードについても同じです。self copyWith: { qty: 120 } のように、copyWith: では項目名をコードに直接書くので、綴りの間違いはコンパイルエラーになります。しかし入力欄からの値は、どの項目に入力されたかがイベントの文字列として届くので、この書き方ができません。そのために、項目名を文字列で指定して読み書きする atField: と atField:putJson: があります。クラスは項目名の一覧を fieldNames で返します。

Object subclass: #Typed fields: (text : Str, field : Str).
Typed >> text  = text.
Typed >> field = field.

Screen >> Typed m =
  self copyWith: { vm: (vm atField: m field putJson: (Json str: m text)) }.

項目の型は初期値から決まります。qty: 0 と書いてあれば qty は Int で、atField: 'qty' putJson: に JSON の "120" を渡せば Int の 120 として入ります。項目が三百ある画面でも、入力を受けるハンドラは一つです。受注入力がこの書き方です。

再描画の範囲は指定しない

多くのフレームワークでは、「この値が変わったときだけ、この部分を描き直す」という条件をプログラマが書きます。LPScript にはその指定がありません。

ハンドラは次の状態を返します。ランタイムは、返ってきた状態が今の状態と同じオブジェクトかどうかを比べます。値は変更できないので、copyWith: で作った状態は必ず別のオブジェクト、self は必ず同じオブジェクトです。

Screen >> Search m = self copyWith: { query: m text }.   "別のオブジェクト。描き直す"
Screen >> Ping     = self.                               "同じオブジェクト。描き直さない"

同じオブジェクトかどうかの比較は、描き直しの範囲も決めます。clone:fill: はセレクタで指定した部分だけを作り直すので、変更の無い行は前回のノードと同じオブジェクトです。そこで比較が止まり、属性やノードの比較は行われません。1,000 行の表で一つのセルだけを変え、四十回描いて測ったところ、一描画は 4.9ms から 3.1ms になりました。

プログラムを書くうえで覚えることは三つです。

計測例です。ゲームの点数ラベルに毎フレーム同じ文字列を送っていたとき、ラベルは毎回新しい状態を返していたので、毎秒 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 はメッセージクラスで、with: がそのフィールドの値を渡します。そのメッセージのハンドラが無ければコンパイルエラーで、渡す値はメッセージクラスのフィールドの型と照合されます。入力欄の値を送るときは on:sendValue:、イベントそのものを送るときは on:sendEvent: を使い、e key、e value、e scrollTop などで読み出します。

最初の引数はイベントで、こちらは Symbol です。send の後に書くのはクラスなので、ここに Symbol を書くとコンパイルエラーになります。押したときに落ちるのではありません。

#yes is a Symbol, and what goes here is the class of the message to send

自分の領域の外にある要素のイベントも受け取れます。

me listen: '#back' on: #click send: GoBack.

ブロックで書けない理由は、VNode が作ったアクタとは別のアクタで使われるためです。子アクタが作った画面を親が組み込む場合や、共通部品が画面を作る場合がそれにあたります。ブロックは作られた場所の変数を参照しているので、別のアクタへコピーして渡せません。メッセージクラスなら渡せます。イベントが起きると、ランタイムはそのメッセージを作り、その領域を持つアクタへ送ります。

メッセージクラスの型には宛先が入らないので、共通部品はメッセージクラスを引数で受け取り、知らないクラスのアクタへ送れます。

Pager total: Store total perPage: Screen size page: page send: GoPage

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)

Loaded はフィールドを三つ持つメッセージクラスで、ファイル名、type、中身です。ファイルの読み込みはこの場所で始まり、読み終わったときにメッセージが送られます。読み込みに時間がかかるので、読み終わる時点ではイベントは終わっているためです。文字列として読んではいけないファイル(帳票や表計算)には sendFileBytes: を使います。

type はブラウザの File.type で、ブラウザが知らなければ空です。名前だけでは判断できないことがあります。画面に貼り付けたスクリーンショットは image.png という名前か、名前が無いことも多いためです。

ファイル選択、貼り付け、ドラッグ&ドロップは同じ扱いです。on: #paste も on: #drop も同じハンドラに届きます。

ファイルがあったのに読めなかったことは、受け取ると書いておいたときだけ届きます。sendFileText:ifUnreadable: は二つ目のメッセージクラスを取り、読み込みが失敗したときにそれを送ります。フィールドは理由の Str 一つです。付けていない場合、失敗はコンソールに出ます。

ファイルを渡す側は App save: 'orders.csv' type: 'text/csv' text: s です。ブラウザ本来の方法(リンク要素を作り、クリックさせ、捨て、オブジェクト URL を解放する)は四手順で、一つ忘れるとメモリが漏れます。それを一つのメッセージにしてあります。

サーバで描いて、ブラウザで引き取る

画面をサーバで描き、ブラウザで引き取れます。同じランタイムが、ブラウザなしで Node の上で動きます。

サーバ側では、App page: でデザイナの HTML を読み、App mount:at: で画面を描き、App document がページ全体を返します。ブラウザに届いた時点で表は HTML に入っているので、ページのソースに出ますし、JavaScript を無効にしても表示されます。一回の描画は約 1 ミリ秒です。

Server class >> render: html data: json = (
  App page: html.
  screen := App mount: (Screen fromJson: ((Json parse: json) orElse: [Json null])) at: '#app'.
  page := App document replaceAll: '</body>'
    with: '<script id="lps-data" type="application/json">' , json , '</script></body>'.
  screen ! (Done fields: {}).
  page ).

App document は、デザイナの見本の要素を <template id="lps-original"> に残します。ブラウザ側の Html clone: はそこから複製します。

ブラウザ側では、App hydrate:at: が既にある領域を描き直さずに引き取ります。view が返した VNode と既存のマークアップを並べて辿り、タグが一致するノードはそのまま使ってイベントリスナを付けます。一致しないノードだけ作り直します。属性は書きません。同じ view から生成したマークアップなので既に一致しており、value を書くと、スクリプトの読み込み中に利用者が入力した文字が消えるためです。

seed := ((Doc getElementById: 'lps-data') map: [:e | e textContent]) orElse: ['{}'].
screen := App hydrate: (Screen fromJson: ((Json parse: seed) orElse: [Json null])) at: '#app'.

画面は一つのモジュールで、サーバとブラウザの両方が import します。状態は、サーバがページに埋め込んだ JSON としてブラウザへ渡します。ブラウザのアクタがサーバと同じ状態から始まらないと、view が食い違って引き取れません。

アニメーション

毎フレーム描き直す場合も、書き方は同じです。requestAnimationFrame を Extern で宣言し、コールバックの中で自分にメッセージを送ります。

Screen >> Tick = (
  Win requestAnimationFrame: [:t | me ref ! (Tick fields: {})].
  ...次の状態を作る... ).

他のハンドラと同じで、次の状態を返すと描かれます。計測では毎秒 60 フレームで、そのすべてでアクタが描き直していました。

canvas に描く場合は view を使いません。canvas は描いた内容が残るので、毎フレーム自分で消してから描き直します。ゲームを参照してください。