タイダログ

もっと怠けますか? (y/n)

手探りで始める Fable (F#)

夏で開放的な気分になって、何でもできる気がしてきたので、部屋に篭って Fable を始めてみました。

fable.io

具体的には、以前 JavaScript で書いたものをわざわざ F# で書き直して、それを FableJavaScript にトランスパイルするという、最高にクールな取り組みです。

それがこちらです。

taidalog.html.xdomain.jp

なんとかできました!

taidalab Version 2.0.0

Thank you F# and Fable!

Languages on GitHub!

これの続きです。

taidalog.hatenablog.com

taidalog.hatenablog.com

※特に断りがない場合は、執筆当時の最新版 taidalab Version 2.0.0 で説明します。

作業環境

動機

  • F# で遊びたい。
  • 新しいことをしてみたい。
  • 型が欲しい。
  • パイプラインを使いたい。
  • F# で遊びたい。

つまずいた点

HTMLElement 型からの型変換

document.getElementById で取得したオブジェクトは、HTMLElement 型になります。

このままでも、HTMLElement インターフェイスもしくはその元になっている Element クラスが持っているプロパティやメソッドは使えます。例えば innerHTML プロパティは Element クラスのプロパティですので、型変換しなくても使えます。

<div id="pageTitle"></div>
let pageTitle = document.getElementById "pageTitle"
// -> HTMLElement 型 (Element を継承)

pageTitle.innerHTML <- "<h1>Welcome!</h1>"
// Ok

一方、例えば <input>value プロパティは HTMLInputElement インターフェイス独自のプロパティなので、HTMLElement 型のままでは使えません。困りますよねぇ。

<input type="text" id="inputBox">
let inputBox = document.getElementById "inputBox"
// -> HTMLElement 型

printfn "The user input: %s" inputBox.value
// Error

そこで登場するのが :?> 演算子! この何とも言えない見た目!

これをこうするだけで、

<input type="text" id="inputBox">
let inputBox = document.getElementById "inputBox" :?> Browser.Types.HTMLInputElement
// -> HTMLInputElement 型

printfn "The user input: %s" inputBox.value
// Ok

アッという間に型変換ができてしまうンです!

どの型に変換したらいいかは、MDN Web Docs に書いてあります。

<input> の場合、<input>: 入力欄(フォーム入力)要素 - HTML: HyperText Markup Language | MDN の「属性」に書いてある HTMLInputElement に変換したらいいです。

<p> の場合は、<p>: 段落要素 - HTML: HyperText Markup Language | MDN の「試してみましょう」のデモエリアの下に表があって、そこの「DOM インターフェイス」に書いてある HTMLParagraphElement に型変換すればいいです。

:?> は、下位の型への変換を行う演算子だそうです。

この型を気にしなければいけない感じ、落ち着きます。

関数の定義順序

ページの切り替えに関する関数をまとめた Switcher.fsSwitcher モジュール内で、以下のようなことになりました。

module Switcher =
    let setHomeButtons () =
        // pushPage (↓) を呼び出したい。
        // エラー

    let newInitObject pathname =
        // setHomeButtons (↑) か、
        // 別モジュールの関数を呼び出す。

    let initPage initial_object =
        // 他の関数は呼び出さない。

    let pushPage pathname =
        // newInitObject (↑) と
        // initPage (↑) を呼び出す。

モジュール内で自分より後に定義された関数を呼び出すことはできないため、setHomeButtons から pushPage を呼び出そうとするとエラーが発生します。JavaScript では問題ないため、適当に書いておりました。

とりあえず再帰モジュールで解決しましたが、他にいい方法があるような気がします。

// Before, Error
module Switcher =

// After, Ok
module rec Switcher =

モジュールの依存関係

以下のように複数のソースファイルがある場合、

  • a.fs
  • b.fs
  • c.fs

.fsproj ファイルに以下の順番で書くと、

<Project Sdk="Microsoft.NET.Sdk">
  ...
  <ItemGroup>
    <Compile Include="a.fs" />
    <Compile Include="b.fs" />
    <Compile Include="c.fs" />
  </ItemGroup>
  ...
</Project>

c.fs からは b.fsa.fs に書いた関数等を呼び出せるけれども、a.fs からは b.fsc.fs の中身は呼び出せない、つまり呼び出される側を、呼び出す側よりも先に記述しなければいけない……C# 等の経験がある方なら当然の話ですね。

今回、a.fsb.fs が相互に呼び出し合う場面があり、相思相愛じゃんと思って微笑ましかったのですが、コンパイラがそれを許しません。結局、a.fsb.fs をひとつにまとめてしまうことでコンパイラの許しを得ることができました。反対されるほどに二人の仲が深まる。これを「ロミオとジュリエット効果」と呼びます。

array[0] ではなく array.[0]

array[0] と書いたら怒られて、array.[0] にしたら動きました。単純に F# でリストの要素にインデックスでアクセスする時の書き方がそうなっていたっていう話です。普段インデックスでアクセスしないから知りませんでした。

'T list (fsharp-core-docs)

return false;

JavaScript で、ボタンをクリックした時に、関数を呼び出して処理した後、画面を更新させないための方法として return false; があると思います。

document.getElementById('myButton').onclick = function() {
    //do something;
    return false;
};

F# で同様のことをするにはどうすればいいかしばし悩みましたが、false を返せばいいのだからこうですよね。

(document.getElementById "myButton").onclick <- (fun _ ->
    // do something
    false)

<input> での Enter 押下で <button> クリックと同じことをしつつ、ページを更新しない方法

こちらのサイト様のコードを F# に書き換えさせていただきました。ありがとうございました。

shikiyura.com

Enter 押下でクリックするポイントは以下の2点のようでした。

  • <input type="text"><button type="submit"> を同一の <form> で囲む
  • <button type="submit">.onclick の最後で false を返す

ただし、<button type="submit"> とするか、type を省略した場合のみです(省略はよろしくないようです)。<button type="button"> だとページの更新が発生します。また、<form> の中に <button> が複数あった場合は、一番上の <button type="submit"> クリックしたことになります。

.onclick の最後で false を返す方法は「return false;」の通りです。

<button type="button"> にしたい場合は、<form>.onsubmit の最後で false を返せばいいようです(<button type="button">.onclick の最後でも false を返す)。

<form id="inputArea">
    <input type="text">
    <button type="button" id="myButton">確認</button>
</form>
(document.getElementById "inputArea").onsubmit <- (fun _ ->
    // do something
    false)
(document.getElementById "myButton").onclick <- (fun _ ->
    // do something
    false)

HTMLFormElement: submit イベント - Web API | MDN によると、

submit イベントは、ユーザーが送信ボタン (<button> または <input type="submit">) を押したり、 Enter キーをフォーム内のフィールド (例えば <input type="text">) の編集中に押したりしたときに発生します。

HTMLFormElement: submit イベント - Web API | MDN より

とのことです。

試してみた結果、こうなりました。

<button type="submit">.onclick のパターンだと、<form> での Enter 押下で <button type="submit">click イベントが発生し、.onclick の処理をした後 false を返しておしまい。<form>submit イベントは発生しない。

<form">
    <input type="text">
    <button type="submit" id="myButton">確認</button>
</form>
window.addEventListener("submit", (fun _ ->
    printfn ".onsubmit"))
(document.getElementById "myButton").onclick <- (fun _ ->
    printfn ".onclick"
    false)
// Result
.onclick

<form>.onsubmit + <button type="button">.onclick のパターンだと、<form> での Enter 押下で <form>submit イベントが発生し、.onsubmit の処理をした後 false を返しておしまい。<button type="button">click イベントは発生しない。

<form id="inputArea">
    <input type="text">
    <button type="submit" id="myButton">確認</button>
</form>
(document.getElementById "inputArea").onsubmit <- (fun _ ->
    printfn ".onsubmit"
    false)
(document.getElementById "myButton").onclick <- (fun _ ->
    printfn ".onclick"
    false)
// Result
.onsubmit

「HTML button ページ 更新しない」などで検索すると、「してほしくない」の意味なのに、「してほしいのにしない場合の対処法」ばかり出て困る。

NodeListList ではない

ページ内の <a> 要素に一括で .onclick を登録したかったのですが、以下のようにしたらエラーになりました。

// Error
document.getElementsByTagName "a"
|> List.map (fun x -> x :?> Browser.Types.HTMLAnchorElement)
|> List.map (fun x ->
    x.onclick <- (fun ev ->
        ev.preventDefault()
        pushPage x.pathname))
|> ignore

document.getElementsByTagName の戻り値は Browser.Types.NodeListOf<Browser.Types.Element> で、これは List とは互換性がないとのことでした。

Browser.Types.NodeListOf<Browser.Types.Element> というのは、 JavaScriptNodeList のことみたいですね……私はどちらも存じ上げませんが。

developer.mozilla.org

とりあえず NodeList.item() メソッドでインデックスを指定して要素を取得できたので、[0..要素数-1] のリストを作って以下のようにしました。

// Ok
let anchors = document.getElementsByTagName "a"

[0 ..(anchors.length - 1)]
|> List.map double
|> List.map (fun i -> anchors.item(i) :?> Browser.Types.HTMLAnchorElement)
|> List.map (fun x ->
    x.onclick <- (fun ev ->
        ev.preventDefault()
        pushPage x.pathname))
|> ignore

当然のことながら、HTML の知識がないと Web 開発(と言っていいのか?)は難しいですね。自分が分かっていないことが、HTML の領域の話なのか、JavaScript の話なのか、F# なのか、Fable なのか、それすらも分かりません。まあ調べてみると大体 HTML か JavaScript の領域なんですけどね。

DOMTokenListList ではない

要素に、あるクラスが指定してあるかどうかを調べたくて以下のようなことをしたらエラー。そんな気はしてましたよ。

// Error
List.contains "class-name" (document.getElementById "targetId").classList

Element.classList の戻り値は Browser.Types.DOMTokenList で、これは List とは互換性がないとのことでした。あーこれさっきと同じパターンだ。

developer.mozilla.org

幸い DOMTokenList.contains() メソッドで同じことができたので困りませんでした。

// Ok
(document.getElementById "targetId").classList.contains "no-display"

mutable を避ける

問題に正解した場合、次の問題を出します。この時、ランダムな数値を生成するわけですが、同じ問題が間を置かずに出てきたら面白くないので、「ランダムな数値を生成し、すでに出題した数値だったら取得し直す」という処理にしました。

はじめは while...do 式を使ってこのように書いていたのですが、

// System.Random の Next() のラッパー関数
let getRandomBetween min max =
    let rand = new Random()
    rand.Next(min, max + 1)
let mutable nextNumber = getRandomBetween 0 255

while List.contains nextNumber last_answers do
    nextNumber <- getRandomBetween 0 255

last_answers は、これまでに出題した数値を格納した int list

ループや再代入は避けたい。考えた結果、このようなコードになりました。みんな大好き再帰関数です。

let rec newNumber generator last_list =
    let (nextCand :int ) = generator ()
    if List.contains nextCand last_list = false then
        nextCand
    else
        newNumber generator last_list

// val newNumber: generator: (unit -> int) -> last_lit: int list -> int
let nextNumber = newNumber (fun _ -> getRandomBetween 0 255) last_answers

newNumber 関数に、「ランダムな数値を生成する関数」と「すでに出題した数値のリスト」を渡すと、「まだ出題していない数値」を返します。見たまんまですね。

関数を渡す必要がありますので、(fun _ -> getRandomBetween 0 255) というラムダ式を使っています。getRandomBetween 0 255 のままで渡すと、「getRandomBetween0255 を渡した結果 (int)」が渡ってしまいます。

string から char list への型変換

stringchar list に変換して処理する時は、Stack Overflow のこのページにある方法が使えました。

Seq.toList "Hello"

f# - How do I split a string into a List of chars in F sharp - Stack Overflow より

そういえば F# の string はシーケンスでしたね。

string 型は、Unicode 文字のシーケンスとして変更不可のテキストを表します。 string は .NET の System.String の別名です。

文字列 - F# | Microsoft Learn より

String モジュールにも、string を個々の char に分解して処理する関数があるのですが、

の3つは、個々の char を操作した結果を結合してひとつの string として返すし、

の2つは値を返しません。

今回は、個々の char に対して複数の処理を連続して行いたかったので、char list に変換できる Seq.toList が最適解でした。

あと、これは Array になりますが、.NET の String.ToCharArray() も使えましたね。

レコードのフィールドに関数を持たせる

ページの切り替え処理の中で、「HTML に追加する内容をまとめたオブジェクト」を扱う箇所がありました。たとえば、ページのタイトルや、ヘッダーに指定するクラス、問題の形式、ページ初期化用の関数などを含んでいます。詳しくは「手探りで始める SPA (JavaScript) - タイダログ」をご覧ください。

F# で書き換えるにあたって、このオブジェクトはレコードで作ることにしたのですが、この「ページ初期化用の関数」をどうやってレコードに持たせるかでつまずきました。レコードを定義する時には、それぞれのフィールドの型を指定しますが、「関数の型って何だっけ」と思ったのです。いろいろやっているうちに思い出しましたが、関数の型は「引数の型 -> 戻り値の型」でしたね。今回の関数は引数・戻り値ともになし (unit) なので、unit -> unit 型の関数ということになります。よって、レコードの定義は以下のようになりました。

type InitObject =
    { pathname : string
      title : string
      headerTitle : string
      ...
      initFunc : unit -> unit }

引数が unit のみの関数を定義する際は、引数として () を取ることを明示します。こうしないと関数ではなく実行式とみなされ、レコードに持たせる前に実行してしまいます。

// 関数
let setHomeButtons () =
    // do something
// 実行式
let setHomeButtons =
    // do something

この関数をレコードに持たせるには、引数として () を渡した状態でラムダ式の中に入れます。

let initialObject =
    {
        pathname = "/"
        title = "taidalab"
        headerTitle = "<h1>taidalab</h1>"
        ...
        initFunc = (fun _ -> setHomeButtons ())
    }

実際に使用する際はこのようにします。

initialObject.initFunc ()

こちらのサイト様を参考にさせていただきました。ありがとうございました。

www.sangikyo.co.jp

(いつかラズパイにも手を出そう)

JavaScript -> F# の変換

冒頭で述べた通り、一度 JavaScript で書いたものを F# に変換するのですが、面倒なので置換できる部分は置換しました。

普通の置換

  • == -> =
  • != -> <>
  • const -> let

正規表現を使った置換

  • 末尾の ; を取る
    ;$ -> ``
  • if 文を F# の形式にする
    if \((.+)\) \{ -> if $1 then
  • document.getElementById('numberInput'); を F# の形式にする
    (document\..+)\('(.+)'\);? -> $1 "$2"
  • console.log() を printfn にする
    console\.log\((.+)\) -> printfn "%A" $1

PowerShell で一括置換

スクリプトファイルが複数あって、VSCode の置換機能を使ったとはいえ手作業はしんどかったので、PowerShell の力を借りました。これでざっくり変換して、細かいところは手作業です。

$srcPath = "path to an original .js file"
$dstPath = "path to an output .fs file"

Get-Content -LiteralPath $srcPath -Encoding UTF8 |
    % { if ($_ -match "==") { $_ -replace "==", "=" } else { $_ } } | # == -> =
    % { if ($_ -match "!=") { $_ -replace "!=", "<>" } else { $_ } } | # != -> <>
    % { if ($_ -match "'") { $_ -replace "'", """" } else { $_ } } | # ' -> "
    % { if ($_ -match "const ") { $_ -replace "const ", "let " } else { $_ } } | # const -> let
    % { if ($_ -match "^(.+);$") { $Matches[1] } else { $_ } } | # ; -> ""
    % { if ($_ -match "( *)function ([^ ]+) *\((.+)\) *\{") { "{0}let {1} {2} =" -f $Matches[1], $Matches[2], ($Matches[3] -replace " ", "" -replace ",", " ") } else { $_ } } | # function (x, y, z) { -> let x y z =
    % { if ($_ -match "( *)function ([^ ]+) *\(\) *\{") { "{0}let {1} () =" -f $Matches[1], $Matches[2] } else { $_ } } | # function (x, y, z) { -> let x y z =
    % { if ($_ -match "( *)console\.log\((.+)\)") { "{0}printfn `"{1}: %A`" {1}" -f $Matches[1], $Matches[2] } else { $_ } } | # console.log -> printfn "%A"
    % { if ($_ -match "(.*)if \((.+)\) {") { "{0}if {1} then" -f $Matches[1], $Matches[2] } else { $_ } } | # if
    % { if ($_ -notmatch "document\..+" -and $_ -match "^( *[^\(]+)\((.+)\)(.*)") { "{0} {1}{2}" -f $Matches[1], ($Matches[2] -split "," -join " " -replace "  ", " "), $Matches[3] } else { $_ } } | # function call
    % { if ($_ -match "^( *[^\(]+)(document\.[^\(]+)\((.+)\)(.*)") { "{0}({1} {2}){3}" -f $Matches[1], $Matches[2], $Matches[3], $Matches[4] } else { $_ } } | # document.getElement.+
    % { if ($_ -match "( *\(document\.[^""'(]+ "".+""\)\.[^=]+) = (.+)") { "{0} <- {1}" -f $Matches[1], $Matches[2] } else { $_ } } | # document.getElement.+ = -> document.getElement.+ <-
    Set-Content -LiteralPath $dstPath -Encoding UTF8

結び

Twitter で教えていただいた The Elmish Book に「JavaScript と F# って似てるよね」という旨のことが書いてありました。確かに。

加えて、私はこうして F# に書き換えることを見越して、元から関数を中心とする書き方をしていたので、ロジックはほぼ変更せずにすみました。

Fable は、使い方が難しくなく、説明も英語ならきちんとあるため、問題なく使えました。「つまずいた点」の通り、つまずいたのは HTML か JavaScript か F# に関することでした。おそらく Fable に起因する苦労ポイントは、DOM の要素の型名が Fable 特有(?)のものになっていることくらいかなと思います。NodeListBrowser.Types.NodeListOf<Browser.Types.Element> になるやつです。でもググればわかるので大丈夫です。

一番の難関は、JavaScript で書いたものを F# に書きなおすところでした。今後あれをしなくて済むだけでだいぶ楽です。

何はともあれ、これで私もブラウザ上で動くプログラムの作成に対するやる気が出て参りましたので、今後とも F# と Fable で楽しくやっていこうと思います。

みなさんもやりましょうよ、F# × Fable

参考

更新履歴