Help us understand the problem. What is going on with this article?

[Elm] inputタグにlazyを使うときの落とし穴と解決策

More than 1 year has passed since last update.

概要

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 をつけてみる

さて、このままだと Modelvalue の値と全く関係ない別のイベントが発生した際にも、毎度 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.toStringIString.fromString, IString.set で置き換えるだけです。
こういうときに、強い静的型の恩恵をめっちゃ得られて最高ですね!

IString で置き換えたコードビルドしたページで Web コンソールを開いて "s" "3" "a" を入力してみてください。
lazy を使わないときと同じ挙動をして、しかも "dummy event" の発火時には digitInput の再レンダリングは起きていないことが確認できると思います。

IString は何をやっているのか

IString の正体は

type IString =
    IString Int String

という Opaque Type です。

内部に保持されている String の値を変更する方法は setmap 以外に用意されておらず、
これらの関数で String の値を上書きすると、IString Int StringInt の値がインクリメントされます。
結果として、仮に以前と同じ文字列で上書きしても、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 をお見せする」っていうガチなムリゲーにチャレンジしようと思います。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away