概要
input
タグを含む関数に対して Html.Lazy.lazy
(および lazy2
, lazy3
, ...) を適用すると、特定の条件で lazy
なしの場合と異なる挙動をしてしまいます。
この記事では、その特定の条件と原因、および解決策についてまとめます。
lazy なしの場合
この記事で紹介するコードは、すべてgithub上で確認できます。
まずは、lazy
を用いない例を考えましょう。
type alias Model =
{ ...
, value : String
}
type Msg
= ...
| UpdateValue String
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
...
UpdateValue str ->
( { model
| value = normalize str
}
, Cmd.none
)
view : Model -> Html Msg
view model =
div
[]
[ ...
, digitInput model.value
]
digitInput : String -> Html Msg
digitInput str =
Debug.log "digitInput was called." <|
input
[ onInput UpdateValue
, value str
]
[]
input
タグに文字を入力して、入力値を Model
に反映させて管理するよくあるプログラムです。
着目してほしいのは、入力内容を update
関数で Model
に反映させる際に、事前に正規化的な処理を行っていることです。
{-| Filter only digit characters.
normalize "s"
--> ""
normalize "9"
--> "9"
normalize "asl20la2"
--> "202"
-}
normalize : String -> String
normalize =
String.fromList << List.filter Char.isDigit << String.toList
今回の例では入力の正規化用に normalize
関数を定義しており、
ここに示したとおり単に半角数字以外の入力を無視するだけの関数です。
update
処理で Model
に入力値を反映させる前に、この normalize
関数で半角数字以外を除去しています。
結果として、ユーザーが入力欄に半角数字以外を打ち込んでも反映されず、半角数字のみを受け付ける入力欄が実現されます。
このような例は電話番号の入力やクレジットカード番号の入力など、似たような実例は珍しくはないと思います。
では、このサンプルプログラムで "s" "3" "a" と順番にキーを入力したどうなるでしょうか?
サンプルページを用意したので、よければ試してみてください。
まず、"s" が入力されると、update
関数が呼び出され、normalize
を "s"
に適用した値で model.value
を上書きします。
normalize "s"
は ""
として評価されるので、実際には model.value
の値は初期値 ""
のままです。
digitInput
によって、input
タグの value
の値が ""
にセットされるので、結果として "s" の入力は入力欄には反映されず、無視された形になります。
次に "3" が入力されると、normalize "3"
の結果である "3"
が入力欄に反映されます。
最後に "a" を入力すると、同様に normalize "3a"
の評価結果である "3"
が入力欄に反映され、結果として "a" の入力が無視されたことになります。
lazy をつけてみる
さて、このままだと Model
の value
の値と全く関係ない別のイベントが発生した際にも、毎度 digitInput
が再評価されて都度レンダリングが生じてしまいます。
実際に先ほどのサンプルページで、Webコンソールを開いた状態で "dummy event" と書かれたボタンを押してみましょう。
特に入力欄を操作していないにも関わらず、Webコンソールに
digitInput was called.: ......
というログが表示され、digitInput
が無駄に再評価されていることがわかります。
このような不要な再評価を抑制するために、Html.Lazy.lazy
などの関数が用意されています。
Html.Lazy
の使い方と、一般的な注意点は @jinjor さんがわかりやすくまとめてくださっています。
では、実際に lazy
を使ってみましょう。
view : Model -> Html Msg
view model =
div
[]
[ ...
, lazy digitInput model.value
]
単に lazy
をつけただけですが、これで model.value
が変化したときにだけ、digitInput
が評価されるようになりました。
では、ためしに lazy
をつけたものをビルドしたページで Web コンソールを開き、"s" "3" "a" を順番に入力してみましょう。
たしかに digitInput was called.: ......
は "s" や "a" を入力した際には表示されません。
でも、なんだか挙動がおかしくなりました。
"s" を入力したら、そのまま入力欄に "s" が表示されてしまいます。
そのあと "3" を入力すると無事に "s" は消えて "3" だけが表示されるのですが、
"a" を入力すると "3a" と表示されて今度は "a" が残ってしまいます。
lazy
を使うと入力欄の挙動が変わるというおかしな現象に遭遇してしまいました。。。
挙動が変わる原因
この挙動は lazy
のバグなどではなく、仕様通りの動作です。
諸悪の根源は input
タグが一種の「内部状態」をもっていることにあります。
まず、ページ読み込み時には model.value
には ""
が入っています。
そこで "s" を入力することで、まずは input
タグが内部状態として "s" を保持し、その "s" の値を表示します。
このとき同時に normalize "s"
の結果である ""
によって model.value
の値が上書きされるのですが、
実際には上書き前と同じ文字列のため、lazy
は 「model.value
は変化していないんだから、digitInput
を再評価する必要はないよね」と判断します。
結果として、input
タグが内部に保持している value
の値が ""
に書き換えられることはなく、"s"
という input
タグの「内部状態」がそのまま表示されてしまうのです。
"3" が入力された際には、normalize "s3" => "3"
が以前の値 "s" とは異なるため digitInput
が再評価され、
無事に入力欄は "3" のみが表示されるようになります。
しかし、"a" が入力されると normalize "3a" => "3"
がそれ以前の値 "3" と変わらないため digitInput
が再評価されず、input
タグに "3a"
がゴーストとして残ってしまうのです。
どんなに Elm コードを Single Source of Truth な設計にしても、input
タグが mutable になっているせいで台無しです。
対策
こんな時のために elm-istring
という便利なパッケージを作成しておきました。
使い方は簡単。model.value
の型を String
から IString
に変えるだけです。
あとは試しにコンパイルしてみれば「ここのところ、IString
のはずなのに String
としてあつかわれているよ」といくつかエラーがでるので、その部分を IString.toString
や IString.fromString
, IString.set
で置き換えるだけです。
こういうときに、強い静的型の恩恵をめっちゃ得られて最高ですね!
IString
で置き換えたコードをビルドしたページで Web コンソールを開いて "s" "3" "a" を入力してみてください。
lazy
を使わないときと同じ挙動をして、しかも "dummy event" の発火時には digitInput
の再レンダリングは起きていないことが確認できると思います。
IString
は何をやっているのか
IString
の正体は
type IString =
IString Int String
という Opaque Type です。
内部に保持されている String
の値を変更する方法は set
と map
以外に用意されておらず、
これらの関数で String
の値を上書きすると、IString Int String
の Int
の値がインクリメントされます。
結果として、仮に以前と同じ文字列で上書きしても、Int
の部分の値が異なることになり、lazy
は「お、前とは別の値に変わっとるやんか」と判断し、input
タグを含む関数が再評価され、input
タグの value
値が Model
と正しく同期されます。
追記
その後 @jinjor さんから「インクリメントしなくても、型コンストラクタでくるんだだけで lazy
は『"referentially equal" じゃない』って判断すると思うよ〜」的なことを教えていただきました。
試したらうまくいったので、最新の IString
パッケージでは
type IString =
IString String
と内部で定義しています。
もちろん、 Opaque Type の内部構造が変わっただけなので、IString
を使う側としては特に気にすることはありません。
まとめ
以上、input
タグがうんこなせいで Single Source of Truth が破壊される落とし穴と、それをさくっと解決してくれる IString
型についてご紹介しました。
Elm Tokyo Meetup #4の発表ネタをこれに変えるっていうのも考えたんですが、elm-css-modules-loader
に関する発表で、「初心者からエキスパートまでみんなが感動する本物の Elm をお見せする」っていうガチなムリゲーにチャレンジしようと思います。