夏で開放的な気分になって、何でもできる気がしてきたので、部屋に篭って Fable を始めてみました。
具体的には、以前 JavaScript で書いたものをわざわざ F# で書き直して、それを Fable で JavaScript にトランスパイルするという、最高にクールな取り組みです。
それがこちらです。
なんとかできました!
これの続きです。
※特に断りがない場合は、執筆当時の最新版 taidalab Version 2.0.0 で説明します。
作業環境
- Windows 10 Home 21H2 (OS Build 19044.1889)
- Fable 3.2.9
- F# 6.0
- F# インタラクティブ 12.0.0.0
- dotnet 6.0.108
- npm 6.14.6
- Visual Studio Code 1.70.1
- Ionide for F# v7.0.0
- Firefox 103.0.2
動機
- F# で遊びたい。
- 新しいことをしてみたい。
- 型が欲しい。
- パイプラインを使いたい。
- F# で遊びたい。
つまずいた点
HTMLElement
型からの型変換- 関数の定義順序
- モジュールの依存関係
- array[0] ではなく array.[0]
- return false;
- <input> での Enter 押下で <button> クリックと同じことをしつつ、ページを更新しない方法
NodeList
はList
ではないDOMTokenList
もList
ではないmutable
を避けるstring
からchar list
への型変換- レコードのフィールドに関数を持たせる
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.fs
の Switcher
モジュール内で、以下のようなことになりました。
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.fs
や a.fs
に書いた関数等を呼び出せるけれども、a.fs
からは b.fs
や c.fs
の中身は呼び出せない、つまり呼び出される側を、呼び出す側よりも先に記述しなければいけない……C# 等の経験がある方なら当然の話ですね。
今回、a.fs
と b.fs
が相互に呼び出し合う場面があり、相思相愛じゃんと思って微笑ましかったのですが、コンパイラがそれを許しません。結局、a.fs
と b.fs
をひとつにまとめてしまうことでコンパイラの許しを得ることができました。反対されるほどに二人の仲が深まる。これを「ロミオとジュリエット効果」と呼びます。
array[0]
ではなく array.[0]
array[0]
と書いたら怒られて、array.[0]
にしたら動きました。単純に F# でリストの要素にインデックスでアクセスする時の書き方がそうなっていたっていう話です。普段インデックスでアクセスしないから知りませんでした。
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# に書き換えさせていただきました。ありがとうございました。
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 ページ 更新しない」などで検索すると、「してほしくない」の意味なのに、「してほしいのにしない場合の対処法」ばかり出て困る。
NodeList
は List
ではない
ページ内の <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>
というのは、 JavaScript の NodeList
のことみたいですね……私はどちらも存じ上げませんが。
とりあえず 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 の領域なんですけどね。
DOMTokenList
も List
ではない
要素に、あるクラスが指定してあるかどうかを調べたくて以下のようなことをしたらエラー。そんな気はしてましたよ。
// Error List.contains "class-name" (document.getElementById "targetId").classList
Element.classList
の戻り値は Browser.Types.DOMTokenList
で、これは List
とは互換性がないとのことでした。あーこれさっきと同じパターンだ。
幸い 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
のままで渡すと、「getRandomBetween
に 0
と 255
を渡した結果 (int
)」が渡ってしまいます。
string
から char list
への型変換
string
を char 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 の別名です。
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 ()
こちらのサイト様を参考にさせていただきました。ありがとうございました。
(いつかラズパイにも手を出そう)
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 特有(?)のものになっていることくらいかなと思います。NodeList
が Browser.Types.NodeListOf<Browser.Types.Element>
になるやつです。でもググればわかるので大丈夫です。
一番の難関は、JavaScript で書いたものを F# に書きなおすところでした。今後あれをしなくて済むだけでだいぶ楽です。
何はともあれ、これで私もブラウザ上で動くプログラムの作成に対するやる気が出て参りましたので、今後とも F# と Fable で楽しくやっていこうと思います。
みなさんもやりましょうよ、F# × Fable。
参考
- The Elmish Book
- 小規模なSPAをつくってみてわかったFable(F# -> JS)の良いところ - DEV Community
- モジュール - F# | Microsoft Learn
- NodeList - Web API | MDN
- DOMTokenList - Web API | MDN
- [HTML]formをエンターで送信したい | しきゆらの備忘録
- HTML formのinputでEnterキーを押下するとsubmitボタンのclickイベントが発火する
- HTMLFormElement: submit イベント - Web API | MDN
- string in char list f# - Code Examples & Solutions
- f# - How do I split a string into a List of chars in F sharp - Stack Overflow
- F#でラズパイを動かす(4)|三技協 LED通信ブログ
更新履歴
- 「レコードのフィールドに関数を持たせる」を追加 (2022/08/15)
- 「こちらもどうぞ」の下の GitHub へのリンクを修正 (2022/08/15)
- 「こちらもどうぞ」を更新 (2023/04/24)