ジンジャー研究室

長めのつぶやき。難しいことは書きません。

DIV ⇒ SVG 移行して気付いたこと

マップ閲覧・編集システムで画面上に DIV で描いていた図形を SVG に移行した。内部に数百のオブジェクトを含む要素の動作が重く、ドラッグしたときにカクカクになる問題を解決したかった。ちなみに Virtual DOM は使っているがすでに最適化は済んでおり、毎回描画されるような事態はあらかじめ避けている。代替案として Canvas と迷ったが CreateJS のようなライブラリを使わないとイベントハンドリングがきついので、 SVG で行けるならそれが一番。以下、移行した結果と移行中に気付いたことのまとめ。

  • 描画パフォーマンスが上がった(Firefox: 20fps ⇒ 60fps、Chrome: 50fps ⇒ 60fps)。
  • Chrome の DevTools で見ると、DIV 版は UpdateLayout と Paint に時間がかかっているが、 SVG は Layout に時間がかかっている。
  • requestAnimationFrame で測るとデータが飛び飛びだったので平滑化したところ読みやすくなった。
  • Virtual DOM も特に問題なく使える(今回は Elm の SVG ライブラリを使用)。
  • createElementNSsetAttributeNS を使わないとSVG要素として動かないので注意が必要。
  • z-indexがない。あらかじめ並び替えておく必要がある。
  • 隣の要素とボーダーが重なる。普通の DIV だと 1px のボーダーが隣と合わせて 2px になることがあった。
  • <img> の代わりに <image>を使う。 title はあるが alt はない。 SEO 的には微妙かもしれない。
  • 相対位置は <g transform="translate(x,y)"> を使う。xyではない。
  • align: center の代わりに text-anchor: middle と 親要素の中央の X 座標を使う。
  • alignmentBaseline<g> などに設置すると Chrome は効くが Firefox では効かないので <text> に直接つける。
  • Twemoji のような HTML を書き換えて何かするライブラリが動かなくなる。仕方がないのでいったん仮の要素を作って適用してから、SVG に移し替えた。 SVG の中に HTML を埋め込むことはできる(foreignObject要素)ようだが積極活用は避けたい。
  • viewBoxでマップの平行移動、拡大・縮小などができるが、transitionができない(頑張ってanimateで対応しても動作が重い)ので普通に CSS で位置と大きさを指定した方が良い。
  • word-break のような便利なものはない。foreignObject要素でも出来るようだが、今回は<canvas>要素のmeasureTextを使って自力で計算した。

SVG の特性上、やはり文書よりも図形っぽいものに使うのが適していると思う。パフォーマンスは条件によると思うが、少なくとも試す価値はある。Canvas vs SVG は良く見るが、 DIV vs SVG はあまり見なかったので速くなるケースがあることを確認できてよかった。ちなみにデータは以下。計測方法が微妙で発表できる形にまとまっていないので、雑なメモと思って欲しい。元のデータが CSS in JS(Elm) だったので、本当は普通に CSS を使った時と比較した方が良いのだが、その余裕はなかった。

Rendering Performance · Issue #31 · WorksApplications/office-maker · GitHub

複雑な UI を持つ Web アプリの実装課題を洗い出す

Virtual DOM でとりあえずレンダリング周りの問題が解決していて、 Elm を使うと型まわりも解決するのだが、その先に課題が山積してきたので、いま考えていることをメモしておく。前提としては、一般的な GUI っぽいアプリをブラウザ上で動かすことを考えている。お役立ち情報ではないので悪しからず。これで 90 日以上ブログを更新していないとかいう広告も消えるはず。

ルーティング

GUI 延長のブラウザアプリという視点では、アドレスバーは付加機能に過ぎなくて、個人的には「そこは UI じゃないからいじらないで」と言いたいのだが、やっぱり「?q=hogehoge」とかを弄る人がいて面倒だなと思っている。このパラメータ部分は組み合わせ問題で「?a=foo&b=bar」というのがあった時にどうしても「aを指定したときはbは指定するな」等という暗黙の制約があり、その時に単に無視する戦略と URL を修正するのかという問題がある。

それから、 URL をモデルあるいは UI の状態と1対1に紐づけるような設計にしていると思わぬところでハマりまくる。シリアライズ時に、「?a=foo&b=bar」の「bar」の部分だけを更新したいが構わず全部更新してしまったりする。あるいは逆に全部を更新したい場合もある。例えば、何らかのキーをしていしてページを訪れた後に何かを検索したとして、検索は一時的なものだから最初のキーが保存されててほしい場合と、その検索が重要なので新しい URL になっててほしい場合と両方ある。アドレスバーを無視すると、「このページへのリンクをコピー」ボタンというのが良くある UI で、無難な選択肢だと思う。

あとは履歴に残すか残さないか問題。「ページが変わった」と認識されるタイミングで残すのが良いのだと思うが、個人的にはほとんど使わない。というのは、1ページ中に複雑な UI を持つアプリにフォーカスしているので、ページをまたぐなら普通にブラウザ機能で遷移すればいいんじゃないかと思っている。同じ理由で「/foo/bar」という普通の URL をクライアント側でルーティングする気があまりない。サーバーサイドを巻き込むのと、クライアントサイドで複数ページの状態を一括管理するのが面倒なので。

コピペなどの一般的な GUI 機能

コピペや Undo 機能などを持つアプリはブラウザと喧嘩する。テキストフィールドをアンドゥしたつもりが、別の個所も一緒に Undo されたりする。まだ試してないが、編集対象にフォーカスを持たせると干渉しないで済むと思う。

コピー状態は、アプリ内に保持せず何らかの方法でシリアライズしてクリップボードに貼るのが良いと思う。最初はページ内で完結するコピペでも、機能追加で Excel から貼りたいとか、逆に Excel に貼りたいというのが出てくるので、その時に2か所で状態を持っているとどっちか分からなくなり、やっかいなことになる。あとは、クリップボードからペーストする時にセキュリティの都合でただ Ctrl + V をハンドルするだけではダメなので、こちらもフォーカスを解決する必要がある。

Undo については、 Flux の延長みたいなシンプルな方法が高度なアプリでは全然通用しない。モデルを全保存する方法と、アクションを保存して差分適用する方法を両方試したが、どちらにせよ他に考えることがたくさんある。例えば、連続した同じ動作はまとめて戻るとか、ある時刻まで戻るとか。あとは、過去のデータにタイムスタンプを持っていると「戻した結果をセーブする」場合に非常に混乱したので、次回やるときは賢くやりたい。

通信の最適化

画面上でデータをあれこれ編集するようなアプリだと、編集状態をサーバ側に同期したり、他の人が編集した内容をリアルタイムに反映したりという要求が出てきて、通信が大量に発生するので最適化が必要になる。通信量的には、画面全体のデータではなく差分のみを送ると削減できる。通信頻度的には、いわゆるデバウンス(またはスロットル)と呼ばれる仕組みを使う。ただ、やっぱり複雑なケースになるとシンプルなデバウンスでは通用しなくなって、「複数のリクエストを上手くマージしてから送信する」ような仕組みが必要になり、非常に面倒だった。

リアルタイム同時編集については、今のところ楽観的ロックでやっていて、自分の見ているのがいつ時点のデータなのかというのをリクエストに含めて、その間に誰かが編集してたら「駄目だったよん」という風にしている。ダメだった場合にどうするのが適切なのか、悲観的ロックの方が良いのでは?については回答を持っていない。それから、他の人が編集した結果をどの頻度で画面に反映するかも悩みどころ。あまり頻繁でも煩そうだし、通信頻度も増える。

マウスイベント制御

画面上のオブジェクトをクリックしたりドラッグしたりという事を大量にやっていると、処理が干渉してくる。例えば、「クリックした場合は処理A、ドラッグした場合は処理B、ただしドラッグ終了時にはAの処理をしたくない」というのを、 onclick と onmousedown でやっていてぐちゃぐちゃになった。最終的に ClickEmulator のような実装を作る羽目になり、過去2回のイベントからクリックを判定するとか、過去4回のイベントからダブルクリックかどうかを判定するとかをやっていてすごく面倒だった。その辺の解決策が欲しい。

あとは、ブラウザのデフォルトの挙動なのか、なぜか背景にある画像をドラッグするような挙動になることがあり、再現性なく起こるので原因が分かっていない。preventDefault / stopPropagation の組み合わせと Virtual DOM の描画タイミング問題が複雑に絡まっていそうな予感がある。

10000行超のElmを書いて見つけたベストプラクティス

この記事はElm Advent Calendar 2016 の4日目です。

f:id:jinjor:20161204002055p:plain

会社で書かせてもらってるElm製アプリが10000行を超えたので、現時点で個人的にこれはと思うベストプラクティスを実際のソース付きで書いてみる。

github.com

(アプリについての情報は機会があれば)

1.必ずスタイルガイドに従う

行数が増える傾向にあるが、かなり読みやすくなるので絶対に従った方が良い。

Style Guide

関連コミットlet...in中に空行を挿入している)

2.データ構造にタプルを使わない

例えばマウスの位置などをタプルで(Int, Int)のように書きたくなる。しかし後悔するのでやめた方が良い。

-- 微妙
calculateX : Model -> Int
calculateX model =
  let
    (x, y) =
      model.position
  in
    max 0 x
-- 微妙
calculateX : Model -> Int
calculateX model =
  max 0 (Tuple.first model.position)

タプルから値を取り出すにはlet...in中で展開するか、Tuple.firstなどを使うしかない。単純に汚くなるし、firstは値の意味を表していない。代わりにtype alias Position = { x : Int, y : Int }を使う。

-- 良い
calculateX : Model -> Int
calculateX model =
  max 0 model.position.x

このように、明らかにすっきりする。他にも領域を表すのに(Int, Int, Int, Int)などを使っていると、(left, top, right, bottom)なのか(left, top, width, height)なのか分からなくてとても混乱する。代わりに、先ほどのPositiontype alias Size = { width : Int, height : Int }を使うと分かりやすくなる。

タプルは文字通り、複数の結果を「組」にして返したい用途で一時的に使うのが良い。データ構造を表すのに使うと失敗する。

  • 関連コミット (ありとあらゆる場所にあった(Int, Int)の大部分をPositionに置き換えた)

3.型の別名(type alias)を使って可読性を上げる

型注釈がStringだらけになると何を表しているのか分からなって混乱する。

-- 微妙
setPerson : String -> String -> Model -> Model

これを次のようにすると、何をしているのか分かるようになる。

-- 良い
type alias ObjectId = String
type alias PersonId = String

setPerson : ObjectId -> PersonId -> Model -> Model

これに合わせて変数名もただのidからxxxIdに変更したところ、とても読みやすくなった。

4.import時にexposingするのは型のみにする

次のようにimportすると関数がどのモジュール由来だかすぐに分からなくなる。

-- 悪い
import Foo.Bar exposing (..)

exposingするのは型のみにして、関数はモジュール名から書いた方が良い。

-- 良い
import Foo.Bar as Bar exposing (Bar)
  • 関連コミットexposing (..)をやめてモジュール名から関数を書くようにした)

5.一つのモジュールから沢山の型を公開しない

これも同じで、型がどのモジュール由来だかすぐに分からなくなる。Javaほどガチガチにする必要はないが、基本的にモジュール名と同じ名前の型をひとつだけ公開するのが良い。

-- 微妙
import Foo.Bar exposing (Bar, Baz, Pen, Pineapple, Apple)
  • 関連コミットObjectsOperationモジュールにあったDirection型をDirectionモジュールに分離した)

6.パイプを使う

MaybeListを使っていると、どんどんcase...ofが増えて読みづらくなる。

-- 微妙
message =
  case List.head (List.reverse (getPeople model)) of
    Just person ->
      person.name ++ "was the first person."

    Nothing ->
      "No one found."

そこで、パイプが使える。メソッドチェーンのようなものだ。

-- 良い
message =
  getPeople model
    |> List.reverse
    |> List.head
    |> Maybe.map (\person -> person.name ++ "was the first person.")
    |> Maybe.withDefault "No one found."

視線もあっちにいったりこっちに行ったりしなくて良くなる。ただし慣れるまでには少し時間がかかる。早めに慣れよう。

7.CmdよりもTaskベースのAPIを提供する

CmdはTaskより一段抽象度が高く、連鎖させることを許可していない。

Taskであれば、次のように色々連鎖させたリクエストを投げることが出来る。

getUser config
  |> Task.andThen(\user -> getOrganization config user.orgId
  |> Task.map(\org -> { user = user, org = org }))
  |> Task.onError(\err -> Task.succeed (Error err))
  |> Task.perform GotInfo
  • 関連モジュールAPIモジュールはサーバとの通信をすべてTask Http.Error a型で定義している)

8.Subscriptionをケチる

Virtual DOMという富豪的な仕組みを採用している以上、出来ることならMouse.movesのような頻繁に起こるイベントは避けたい。

subscriptionsの型はModel -> Sub msgとなっているので、モデルの状態に応じて蛇口を緩めるようにすればいい。次の例は公式のドラッグ&ドロップの例より。

subscriptions : Model -> Sub Msg
subscriptions model =
  case model.drag of
    Nothing ->
      Sub.none

    Just _ ->
      Sub.batch [ Mouse.moves DragAt, Mouse.ups DragEnd ]
  • 関連コミット (問答無用で垂れ流していたMouse.movesをドラッグ中に限定した)

9.データ構造を真剣に考える

正しいデータ構造(型)を定義することで、堅牢性を上げることが出来る。単純な例だと「必ず1つ以上の値を持つリスト」をList a型で表現することもできるが、これだと何かの拍子に空のリストになる可能性がある。そこで次のようなデータを作る。

type alias NonEmptyList a =
  { head : a
  , tail : List a
  }

これによって、必ずhead(最初の要素)が存在することは保証できる。「何らかの操作をした時にリストの長さが0であってはならない」というテストも不要になる。

もっと具体的・現実的な例は、Richard Feldman氏のトーク「Making Impossible State Impossible(不可能な状態を不可能にする)」で沢山紹介されているので、ぜひ参照してほしい。

  • 関連コミット (レコードをユニオンに直してありえないパターンを撲滅した)
  • 関連コミット (ユニオンをレコードに直してありうるパターンを復元した)

10.カプセル化する

堅牢性に貢献する要素として、データ構造の正しさと同じくらい重要なのがカプセル化だ(これも上のトークで触れられている)。 例えば、先ほどの例では要素が空にならないことは保証できたが、新しい要素を間違えてtailに突っ込んでしまったら全て台無しになってしまう。

そうならないように、Elmではデータへの操作をモジュール内の関数に限定するテクニックがある。

-- 良い
type NonEmptyList a =
  NonEmptyList
    { head : a
    , tail : List a
    }


add : a -> NonEmptyList a -> NonEmptyList a
add a (NonEmptyList list) =
  NonEmptyList
    { head = a
    , tail = list.head :: list.tail
    }

新しいバージョンのNonEmptyListは、モジュール外で直接headtailを触ることを禁止している。代わりに、公開APIとしてadd関数を提供する。 こうすることで、「新しい要素をheadに追加し、それまでheadにあったものをtailの先頭に移動させる」という一連の操作が安全であることを保証できる。

詳細はこちらの記事にも書いている。

11.コンポーネントにすべきかを真剣に考える

Elmにおいてコンポーネントとは「Model / Update / View を一緒に提供する」モジュールを指す。

よくある失敗パターンは、再利用可能で疎結合コンポーネントを作ろうとして本来ひとつであるアプリケーションロジックを分業してしまう事だ。「検索欄はこっちが提供する。ボタンが押されたら具体的な検索ロジックはそっちでやってくれ。結果をコールバックしたらこっちでキャッシュしてあげる。キャッシュをクリアしたいときは命令してくれ。結果の表示はこっちでやる。」もっとシンプルに、入力欄・検索ロジック・結果表示のビューに分ければ良くて、コンポーネントである必要がない。

続いてよくある失敗パターンは、画面を縦横に分割していって、そのままコンポーネントに見立ててしまうことだ。例えば、大抵どんなサイトにもヘッダがある。だから「ヘッダ」コンポーネントを作りたくなる。ところが、ヘッダには色んな機能(ログアウト、設定など)があるから、それらのロジックをどんどん吸い込んでしまい、ページによって内容の過不足が出ておかしなことになる。ヘッダに求められるのは「メニューを渡したらそれを横に並べる」機能で、ヘッダ自体がそれらの機能を持っている必要はない。

すべてはコンポーネント「ではない」。不要な複雑さを持ち込む前に「普通のビューじゃ駄目なのか」をまず検討したい。

  • 関連コミット (「隣にある」という理由だけでフォームと一体になっていたファイル読み込み用のモジュールを切り出した)

Context Menu のデザイン

ブラウザ上に Context Menu を実装するときに何を参考にすればいいのかわからなかったので、自分用にメモ。

見た目

Win10 - Chrome

f:id:jinjor:20161105202640p:plain

Win10 - Firefox

f:id:jinjor:20161105200406p:plain

Win10 - Edge

f:id:jinjor:20161105200412p:plain

Win10 - Desktop

f:id:jinjor:20161105200348p:plain

Mac - Browsers/Desktop

f:id:jinjor:20161105200422p:plain

f:id:jinjor:20161105202508p:plain

Google Spreadsheet

f:id:jinjor:20161105200428p:plain

位置

クリックした位置からメニューが出る方向と、はみ出したときに画面内に収める方法。

環境 デフォルト方向 左右はみ出し 上下はみ出し
Win10 Desktop 左下 シフト 反転
Win10 Chrome 右下 シフト 反転
Win10 Firefox 右下 シフト 反転
Win10 Edge 右下 反転 反転
Mac Desktop/Browsers 右下 反転 シフト
Google Spreadsheet 右下 反転 反転

elm-conf 2016に行ってきたメモ

elm-conf 2016

アメリカ、セントルイスにて。 以下、終わった後に記憶を頼りに書き起こした雑極まりないメモ。

雰囲気

写真は休憩中だから人少ないけど。

f:id:jinjor:20160915122715j:plain

トーク

Keynote: Code is the easy part (by Evan Czaplicki)

コードを書くのは一番簡単な部分。言語設計として何を書くかが問題。 Pythonは最初の5年ほぼプライベートで、ドキュメントが整うのには10年かかった。Elmはまだ4年。 要求を一つずつコードで実装して解決することはしない。数ある要求を集めてパターンを見つける。そのためにたくさんのフィードバックが欲しい。 0.18に向けて今取り組んでるのがデバッガー。モデルの状態を全部記憶できるし、状態を保存してリロードできたりする。あとサーバーサイドレンダリングとか。細々したタスクは今はあえて無視してる。

Beyond Hello World and Todo Lists (by Ossi Hanhinen)

リアルワールド体験談。割と苦労した話とか。 すべてをコンポーネントにしてはいけない。問題を一般化せずに体当たりしていけ。 パッケージの切り分け方とかルーティングとかデバッグとかのTips紹介。

Compilers as Therapists, or Why Elm is Good for ADHD (by Luke Westby)

会場沸いてたけど、ほとんど聞き取れなかったごめん。 ADHDは注意力(集中力)に難のある障害なんだけど、Elmだと大丈夫だった。という話だったと思う。

LT: Rich Animation (by Matthew Griffith)

同時にアニメーションさせるときに割り込むかキューに積むかとか、真面目に考えると結構難しい。 elm-style-animationだとエレガントにできる。

LT: Functional Data Structures (by Tessa Kelly)

バイナリツリーを4種類の方法で実装して比較してみた。 インデックスとかよりもポインタ(union typeとかで)実装した方が明らかにエレガント。

LT: 0-60 in 15 Minutes: Building a Realtime App With Elm and Horizon (by Abadi Kurniawan)

とにかくサーバサイド書きたくない。なら書かなきゃいいじゃん。 Elmを使ってHorizon.js(RethinkDB)のインターフェイスを実装したら、Elmだけでチャットアプリが作れた。

Rolling Random Romans (by Joël Quenneville)

ローマ人の名前の付け方の法則をモデル化してランダム生成してみた。 文脈によって制約が変わったりしてとても複雑。 純粋な関数型でランダム値を生成するにはSeedが必要で面倒だが、0.17でCmdによる生成が可能になった。 ランダムのパターンを自由に組み立てられるのが便利。ボトムアップに組み立てると良い。

Building an Interactive Storytelling Framework in Elm (by Jeff Schomay)

ユーザ選択によって変わるストーリーを生成する。 会場のリクエストで選択肢を決定してストーリーを進めるデモ。最高の盛り上がり。 3パターンくらい試行錯誤した。union type使うと網羅できるけど、書き方が冗長になるのとフレームワークの場合は具体的な内容を知ることができないので万能ではなかった。最終的にリストを使ったDSLに。

The Clockwork Gardener: Growing an Elm App With Templates (by Jessica Kerr)

AtomエディタでElmの良くあるボイラープレートを生成する。(Atomist) 「〜〜を〜〜で〜〜しろ」みたいな一行のコマンドでどんどんElmコードを生成するテンプレート芸。 だいぶ頭おかしい(良い意味で)。 コードの自動生成が怖い?Elmコンパイラがついてるから全然怖くないよ。

Nightingale.space - Elm and Crowd-Source Music-Making (by Murphy Randle)

Twitter上に楽譜を置いたらキューに積んで片っ端から再生していくアプリ。 Elixir(Phoenix) + Elm + JavaScript。 パースに使ってるelm-combine最高(とても同意)。 会場でリアルタイムにツイートされた楽譜を再生していくデモ。楽しい。

Making Impossible States Impossible (by Richard Feldman)

データ構造がしっかりしていればinvalidな状態を作ることは原理的に不可能。 たとえばCSSの@は順番がある。ユーザにリストで書かせるとバリデーションとかテストが必要。 テストはいいけど、しなくて済むならその方がいいよね。 その他の例としてはHistoryとか。 あとは、シングルコンストラクタのパターンを使ってデータをプライベート化した上で、公開したい操作だけを関数で提供する。

トーク全体の感想

とにかく全員トークのクオリティが高くて、どうやったら面白くなるかが真剣に練られてるのがすごいと思った。見習わないと。 英語はほとんど聞き取れなくてスライドの文字情報と雰囲気で補完した。無理。全然無理。

最後のMaking Impossible States Impossibleは、基本的だけどなかなかはっきり言及されてない部分なので、何気に名トークかも。いつも関数型の人が雑に「型があるから安全」って言ってるけど、実は構造体の定義の話もあって「じゃあJavaも型あるじゃん」とか言われてなかなか伝わらない部分。

Q/Aセッション

スピーカー全員が回答者。言語の開発に関してはEvanだったけど。

Q: プロダクションで客にどう説明してる?
A: リスクは確かにあるけど、そこで議論がストップするの良くない。 それに見合うだけあるいはそれを上回るメリットがあるならリスクは取っていい。

Q: Haskell使えない人がコントリビュートするには?
A: Haskellコードいきなりいじる前にコミュニティのコンセンサスとって。あとドキュメントを直接直してくる人も居るけど、いや待ってこれ君の本じゃないから。

Q: デザイナーと協業するには?
A: 前提としてデザイナーは頭がいい。CSSが使えるんだから。 Elmを理解してもらおう。(ツールについての言及もあったけど聞き取れず)

Q: Effect Managerどうなるの?
A: 現時点でほとんど完成してる(Stablish)とは思うけど、今後の展開によっては変えないといけない部分があるかもしれないから安易に公開OKにできない事情がある。

他にもあったけど忘れた。

Evanに話しかけてみた

アイコン見せたらお前かー!みたいな感じだった。elm-time-travelの存在は認知してたけど見てはいなかったみたいで、デモ見せてみたら「これ今まさに作ってるやつじゃん」って言ってた。モデルの出力どうやってるのか聞かれたので、toStringしたやつをパースしてると白状したら爆笑してた。

Evanめっちゃいい人。 唯一知ってる日本語は「仕事がありません」らしい。

ElmでHTMLパーサを作って公開するまでの手順

ElmでHTMLパーサを作った。

github.com

せっかくなので、ライブラリ制作に着手してから公開するまでのプロセスを書いてみる。Elm 開発の雰囲気を伝えるのが目的なので、特定のトピックが知りたい方はQiitaへどうぞ。(コードが沢山あるけど試してないので動かないかも。あと、途中でテストライブラリをアップデートしたりして実際に踏んだプロセスと違うし、コードも所々違うんだけど、それは無視して最短・最適のパスを踏んだことにする。)

経緯

Excel(とか他の表計算ソフト)からクリップボードにコピーしてWebアプリに貼り付けようとしたところ、フォーマットがHTMLだったのでパースしてデータを取り出したかった。ここで問題発生。

別にJSでパースしてElm側に送り込んでもいいんだけど、それだとなんとなく負けた気がするのでHTMLパーサを書くことにした。

プロジェクトを作る

プロジェクト用のディレクトリと簡単なElmコードを用意する。

elm-html-parser/
  - src/
    - HtmlParser.elm
src/HtmlParser.elm
module HtmlParser exposing (..)

parse : String -> ()
parse s = ()

まだ何も決まってないのでこれでいい。早速コンパイル

$ elm-make src/HtmlParser.elm

初回コンパイル時にelm-package.jsonやらelm-stuffやら色々出来る。

テストを書く

elm-community/elm-testを使う。と言っても、実際にはこのパッケージを手動でインストールする必要はなく、代わりにそのランナーであるnpmパッケージの elm-test をインストールして使う。

$ sudo npm install -g elm-test
$ elm-test init

elm-test initすると、テストに必要なひな形を作ってくれる。

tests/
  - .gitignore
  - elm-package.json
  - Main.elm
  - Tests.elm

Tests.elm を編集。

Tests.elm
module Tests exposing (..)

import Test exposing (..)
import Expect
import HtmlParser

all : Test
all =
  describe "HtmlParser"
    [ test "basic" (\_ -> Expect.equal () (HtmlParser.parse ""))
    ]

ラムダ式になっている(\_ ->)のは、ランダム値テストのため。fuzz関数を使うと与えた範囲でランダムな値を生成できる(CIの時は再現性が欲しいので、seedを固定値で指定する)。今回は使っていない。

elm-testコマンドでテスト実行。

$ elm-test

GitHubとTravisCIのための設定

.gitignore
elm-stuff
documentation.json

elm-stuff フォルダはライブラリの置き場所なので、必ず.gitignoreに入れておく。

続いて Travis CI にもelm-testを叩いてもらうように設定する。

.travis.yml
language: node_js
node_js:
  - "4.2"
before_script:
  - npm install -g elm
  - npm install -g elm-test
  - elm-package install -y
script: elm-test

README.md にバッジを設置。

README.md
# elm-html-parser

[![Build Status](https://travis-ci.org/jinjor/elm-html-parser.svg)]
(https://travis-ci.org/jinjor/elm-html-parser)

あとは Travis CI で該当リポジトリをテストするように設定する。これで、プッシュする度にテストが回る環境が整った。

LISENCEは特に理由がなければBSD3が適当。

パーサを実装する

$ elm-package install Bogdanp/elm-combine

Bogdanp/elm-combine はパーサコンビネータのライブラリ。別のもあるけどこれが一番速い。elm-packageは--saveとか書かなくてもデフォルトでelm-package.jsonに追記してくれる。

まずは、AST(抽象構文木)の定義。

src/HtmlParser.elm
type Node
  = Text String
  | Element String Attributes (List Node)
  | Comment String


parse : String -> List Node
parse s = [] -- TODO 実装する

適当にテストを書いて失敗させる。

tests/Tests.elm
testParse : String -> List Node -> (() -> Expectation)
testParse s ast = _ ->
  Expect.equal ast (HtmlParser.parse s)


all : Test
all =
  describe "HtmlParser"
    [ test "basic" (testParse "1" [Text "1"])
    , test "basic" (testParse "<a></a>" [Element "a" [] []])
    ]

次に、テストが通るまで頑張って実装。

src/HtmlParser.elm
parse : String -> List Node
parse s =
  case fst (Combine.parse node s) of
    Ok x -> [x]
    Err _ -> []


node : Parser Node
node =
  element `or` text


text : Parser Node
text =
  (\s -> Text s)
  `map` regex "[^<]*"


element : Parser Node
element =
  (\name _ -> Element name [] [])
  `map` startTag
  `andMap` endTag


tagName : Parser String
tagName =
  regex "[a-z][a-z0-9\\-]*"


startTag : Parser String
startTag =
  between (string "<") (string ">") tagName


endTag : Parser String
endTag =
  between (string "</") (string ">") tagName

これでテストが通る。あとはテスト増やす⇨実装する、の繰り返し。elm-combineの作者に教えてもらったトリビアとしては、Char型をなるべく使わずにString型とregexを使うと速くなる。

ドキュメントを書く

ライブラリが完成したらすぐに公開したいところだけど、公開するすべての型と関数にドキュメントを書くまで公にできない。

src/HtmlParser.elm
{-| Parse HTML.

`` `elm
parse "text" == [ Text "text" ]
`` `
-}
parse : String -> List Node
parse s = ...

見た目をプレビューするには、以下のコマンドを打って出てきたJSONファイルをここで読み込む。

$ elm-make --docs=documentation.json

上のサイトはちょっとバグってるが気にしない。

パッケージを公開する

elm-package.json をいい感じに書き直す。公開前に確認するのはだいたい以下。

elm-package.json
    "repository": "https://github.com/jinjor/elm-html-parser.git",
    "source-directories": [
        "src"
    ],
    "exposed-modules": [
        "HtmlParser",
        "HtmlParser.Util"
    ],

exposed-modules以外のモジュールは公開されないので、もし内部でのみ使うモジュールがあればHtmlParser.Internalのようにしておくと良い。こうするとテストのためだけに関数を公開できたりして便利。

Gitのタグをつけて公開(公開されたパッケージ)。リンクするURLは/latestにしておかないと古いドキュメントを参照してしまうという罠があるので気をつける。

$ git add -A
$ git commit -a -m "implement something"
$ git tag -a 1.0.0 -m "first release"
$ git push origin master
$ git push origin --tags
$ elm-package publish

パッケージの公開に関して詳しくはuehajさんの記事に良くまとまってます。

デモページ

GitHubリポジトリdocsフォルダを使って公開する。docsフォルダに色々突っ込んでもリポジトリの言語のバーには反映されないっぽくて助かる。生成されたJavaScriptを入れると真っ黄色になるので。

ElmにまともなEditorライブラリがないのでace.jsを使う。Elmの port 機能を使うと外界のJavaScriptと会話できるので、aceエディタの文字を送り込んでパースさせる。

<script src="./script.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.2.0/ace.js"></script>
<script type="text/javascript">
  var app = Elm.Demo.fullscreen();
  setTimeout(function() {
    var editor = ace.edit('editor');
    editor.setTheme("ace/theme/monokai");
    editor.getSession().setMode("ace/mode/html");
    app.ports.init.send(editor.getValue());
    window.addEventListener('keydown', function(e) {
      // Ctrl + S
      if(e.ctrlKey && e.keyCode == 83) {
        e.preventDefault();
        app.ports.parse.send(editor.getValue());
      }
    }, true);
  });
  </script>

バージョンアップして再公開

デモページで色んなHTMLを突っ込んでみたらたまに失敗してたので、パッチバージョンを当てることにした。

まず失敗するテストケースを追加。

tests/Tests.elm
+    , test "basic" (testParse """<input data-foo2="a">""" [Element "input" [("data-foo2", "a")] []])

実装を修正する。

src/HtmlParser.elm
-  map String.toLower (regex "[a-zA-Z][a-zA-Z:\\-]*")
+  map String.toLower (regex "[a-zA-Z][a-zA-Z0-9:\\-]*")

次のコマンドを打つとバージョンを上げてくれて、elm-package.json も更新される。

$ elm-package bump

バージョンアップの種類(MAJOR/MINOR/PATCH)は型を見て勝手に判定してくれる。今回はAPIを破壊していないし機能追加もしていないので、PATCH。いわゆるセマンティックバージョニングというやつ。

この時点では公開されていないので、先ほどと同じステップで公開する。

$ git add -A
$ git commit -a -m "fix something"
$ git tag -a 1.0.1 -m "second release"
$ git push origin master
$ git push origin --tags
$ elm-package publish

宣伝する

Slack とか Twitter とか elm-discuss を使う。Slackは初心者の質問も常に受け付けているので、詰まったら聞いてみると答えが得られたりする。

まとめ

Elmでテスト駆動開発しつつパッケージを公開するまでの流れを紹介してみた。そんなにハマりどころはないと思う。

再利用可能なコンポーネントはアンチパターン

言いたいこと

Webフロントエンド界隈で「コンポーネント」という言葉が蔓延していて、「再利用可能になるように設計すべきだ」という論調があるが、実際には本当に再利用可能である必要性があるまで、極力考えないほうが良い。YAGNIとも言う。

以下、現時点での考え。

ビューの階層化自体はOK

ここはReactの恩恵と言っても良い気がしていて、それまであんまり明言されて来なかった「ビューの階層化」について公式で説明している点がとても良くて、開発者全員がビューはツリーになってるよねというマインドで統一できた功績は大きいと思う。

再利用可能なコンポーネント

ビューはツリーでいいんだけど、それをコンポーネントと呼んでいるのでなんとなくDatePickerとかTextEditorみたいな汎用的なものを想像して、「アプリケーションの事情を知っていてはいけない」という気持ちになって疎結合に作りたくなってしまう。そうすると、次の2つの点で引数の数が増大する。

  • グローバルな「Model」を知っていてはいけないので、それまでmodelを渡せば良かったところが、model.isEditMode, model.saveEnabled, model.flags.colors, model.users, ... のようにどんどん増えていく。

  • グローバルな「Action」を知っていてはいけないので、onInputChange, onSubmit, onUserSelect, ... のような引数がどんどん増えて行く。

React界隈では以前からこれを「バケツリレー」と呼んでいて、辛いので避けるべきものだとされてきたが、とはいえやっぱり疎結合にした方が良いのではというジレンマが生まれる。しかし、この記事ではそれに真っ向から反対したい。

アプリケーションを知る・知らない

コンポーネントは粒度によって大まかに「アプリケーションを知っている(=再利用不可能)」「アプリケーションを知らない(=再利用可能)」に分けられる。個人的な感覚では、普通にアプリケーションを作ると前者が圧倒的に多い。YAGNIの一言で済むんだけど、再利用は幻想なのでやめるべき。

状態を持つ・持たない

「アプリケーションを知らない(再利用可能)」となると、副次的に導入されるのが「コンポーネントの状態」だ。例えば、DatePickerだと「いま何月何日が選択されているか」などという状態をアプリケーションに知られることなく管理して、必要に応じてイベントなりなんなりで引っ張り出して使ってもらうという設計になる。これもまた結果的に複雑化の原因になる。

疎結合のようで密結合

仮に理想的で疎結合なDatePickerが完成したとして、じゃあアプリケーションからそれを使いましょうとなった時に、問題が生じてくる。実は「権限に応じて選択できる部分とできない部分を変えましょう」とか、「ユーザーに応じて表示するカレンダーを変えましょう」という話になって、isSelectableとかformatDateみたいな引数が増える。ここまでは「バケツリレー」でお馴染みの辛さなのだが、もっと進むと「クリックした時にその日の予定一覧を取得してポップアップしましょう」とかいう話になる。いや、もちろんそれは汎用的に型付けして設計することは出来るので見た目は疎結合なんだけど、もうほとんど特定のアプリケーションのために存在しているだろう、という話になる。

重複するデータ

先ほどの例で、DatePickerがユーザの予定をキャッシュしていたとすると、他でもその予定を使いたいとなった時にデータが重複する。「ユーザの予定はDatePickerに保存されているはずだから、そこから取ってきて渡す」というのは明らかにおかしいし、グローバルに持っておくべきデータのはずだ。逆に、DatePickerが状態を持っていなかったとすると、今度は「DatePickerコンポーネントの使い方として、onFetchイベントが呼ばれたら外部で必ずキャッシュを保持してください」みたいな約束事を作ることになって、それも苦しい。

なぜ「コンポーネント」を作りたくなるのか

部分的なモデルとビューが「ほとんど」同じ関心を持っていることが多いのが原因だと思う。UIデザインとして、似たようなデータは似たような場所に表示するのがユーザとしてもわかりやすい。おそらく8割くらいモデルとビューが一致する。だから一緒にしてコンポーネントにするのだが、残り2割のために疎結合という前提が崩壊して辛い結果を生む。

共通化したいのは「見た目」

ほとんどの場合、共通化のモチベーションとしては「見た目を統一したい」だと思うので、振る舞いは二の次に考えよう。そうすると、与えたデータを表示するだけの「シンプルな関数」としてのビューが良いということになる。例えば、デフォルトボタンとプライマリボタンを共通化しておくとか、カレンダーも2画面以上で使うなら、ただカレンダーを描画する関数だけを用意しておく。

モデルは分割・アクションはグローバル

ただし、やはり「関心の分離」はしたい。そこで、もう少し具体的に考えると「引数に渡すのはある程度絞ったモデルで、アクションはグローバルなものを発行して良い」というルールが、今のところ一番バランス良く収まる気がしている。最初はアクションをツリー状にしてFooAction Foo.Clickみたいな感じにしていたのだが、結局その結果実行されるロジックはフラットだったので、本質的にはあまり意味がなかった。だからもう、アクションが30個並ぼうが50個並ぼうが気にしなくていい。本来そういう性質のアプリケーションなんだから、そういうものだと思えということ。「大きくなったから」という理由だけでアクションを分割したりモデルを分割したりすると、その部分の見た目は綺麗になるけど別の部分にしわ寄せが来るぞと。

型に任せてあとは禅の精神で書く

そもそもこれを書こうと思ったきっかけが、以下の記事。

medium.com

「デカいけどだから何?落ち着けよ」とのこと。自分も最近巨大なモデルとアクションに嫌気がさしてリファクタリングを試みたんだけど、上に挙げたような諸々の理由で失敗していた矢先にこの記事に出会ったので「ですよね」としか言えなかった。特にElmの場合は強力な型がついているので、見た目がおぞましくても意外と何とかなるし、後でリファクタリングしてもほぼバグらないので安心していて大丈夫。Elm Architectureがモジュラリティに優れていて素晴らしいという話と矛盾するようだけど、あれは本当にモジュラリティが必要な時のために取っておけばOK。

まとめ

再利用可能なコンポーネントは幻想であり、多くの場合アンチパターン。巨大なモデルやアクションはそれ自体悪いことではないので、恐れずそのまま突き進もう。あんまり賢くやることを考えすぎず、アプリケーションの本質を考えることに時間を費やそう。

以上。