サーバサイド開発者がシュッとTypeScriptでWebフロントエンドに入門する(1/4)
最近チームで検証用ツールのフロントエンド開発をする必要があったため、自分の理解を確かめるためにも、バックエンド開発者向けのフロントエンド導入記事を作ることにした。
TypeScript 関係のドキュメントについては探した限りだと
- プログラミング自体の経験が浅い人向け
- フロントエンド開発者がフロントエンド開発者向けに書いたドキュメント
が多く、バックエンド開発者が良い感じにフロントエンド開発に親しむことのできるドキュメントがあまり見つからなかったので、ないものは作る精神で書くことを決めた。 なお、筆者はバックエンド開発者なのでフロントエンド開発者からのツッコミは歓迎したい。
大まかな流れとしてはまず Deno で TypeScript 自体に慣れてもらった後に Node.js 環境に移行し、 Jest を利用したテストを書けるようにする。
その後に React を学び、最終的には Next.js でシュッとしたサイトをデプロイすることを目指す。
対象読者
- バックエンド開発には慣れているが、Webフロントエンド何もわからん人
- 何もわからんけど理解はしたい
- 基本的な CLI 操作については特に説明を要しない技術レベルを想定している
- JavaScript は触ったことくらいはある
var
で変数宣言してたなぁ、くらいで OK- 他のプログラミング言語には何か一つは習熟していることを想定している
今回のゴール
- TypeScript を少し書けるようにする
- async / await がなんとなく使える
使うもの
What is / Why Deno?
Deno は Node.js の作者 (Ryan Dahl) により開発された新しい JavaScript / TypeScript のランタイムである。 詳細は Wikipedia を参照のこと。
1.6.0 でシングルバイナリへのコンパイル機能が入ったことや、 複雑な設定なしに TypeScript が実行できることから、TypeScript に慣れるための実行環境として利用する。
思想は先進的ではあるものの、コミュニティやサードパーティがついてきている感じはまだなので入門以降では Node.js / npm を利用する。(とはいえ、 compile の unstable が外れたら用途次第ではプロダクション利用もできそうだなとは思っている)
Install Deno
公式ドキュメント を参照。
PATH を通すのを忘れないこと。
curl -fsSL https://deno.land/x/install/install.sh | sh
deno が動くことを確認しよう。
$ deno --version deno 1.9.2 (release, x86_64-unknown-linux-gnu) v8 9.1.269.5 TypeScript 4.2.2 $ deno eval "console.log('hello, deno')" hello, deno
実行環境ができたら、英語の得意な人は 公式のドキュメント 読めば文法は大丈夫。あとの部分は読み飛ばしてもらって構わない。
TypeScript is 何という話はこの辺りの記事を参照してほしい。
また、JavaScript は ECMA Script(ES) 2015以前とそれ以降では全く別物となるため、 jQuery での DOM 操作に神経をすり減らしていた時代で経験が止まっている方は 2015~2017年のECMAScript コンプリートガイド 辺りもあわせて読めば理解が深まるだろう。
First compile with deno
文法の細かい話をする前に、まずは FizzBuzz を書いて Deno の威力を体験してみよう。コマンドライン引数の第一引数で「どこまで数え上げるか」を指定するものとする。
// function {function_name}({argument_name}: {type}): {return_type} // のような形式で関数を定義する function fizzbuzz(n: number): string { if (n % 15 == 0) { return "fizzbuzz"; } if (n % 3 == 0) { return "fizz"; } if (n % 5 == 0) { return "buzz"; } return n.toString(); } // Deno でのコマンドライン引数の受け取り方 if (Deno.args.length > 0) { // Spread operator で配列を展開する // JS/TS には range 関数がないのでこういう書き方をする [...Array(Number(Deno.args[0])).keys()].map((i: number) => { // print 文が `console.{log_level}` となる console.log(fizzbuzz(i + 1)); }); } else { console.log("Needs some arguments!"); }
$ deno run fizzbuzz.ts Check file: が初回は出てくる Needs some arguments! $ deno run fizzbuzz.ts 15 1 2 fizz 4 buzz fizz 7 8 fizz buzz 11 fizz 13 14 fizzbuzz
折角なのでコンパイル。
# 2021-04-24 時点では --unstable オプションをつけないとコンパイルできない $ deno compile --unstable fizzbuzz.ts Check file:///PATH/TO/fizz.ts Bundle file:///PATH/TO/fizz.ts Compile file:///PATH/TO/fizz.ts Emit fizzbuzz $ file fizzbuzz fizzbuzz: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV)... $ ./fizzbuzz Needs some arguments! $ ./fizzbuzz 15 1 2 fizz 4 buzz fizz 7 8 fizz buzz 11 fizz 13 14 fizzbuzz
一瞬で実行可能ファイルが作れた。すごい。バイナリサイズはかなり富豪的ではあるがすごい。
$ du -h fizzbuzz 77M fizzbuzz
TypeScript の前に JavaScript をサラッと押さえる
TypeScript に入る前に、最近 (といっても async/await が2017年に正式採用されているので言うほど最近ではない) の JavaScript を軽く押さえておく。
const を使え(ES2015)
変数宣言では const
と let
を使うことができるが、基本的に const
を利用すること。 let
はできるだけ使わず、 var
は使わない。
JavaScript での const
は再代入不可能な変数宣言で、 だいたい Kotlin でいう val
, Swift でいうlet
と同じである。 (Swift と JavaScript で let
の意味が逆転するので毎回混乱するんですが良い覚え方はないでしょうか?)
「だいたい」としたのには理由がある。 const
で宣言したオブジェクトの中身は変更ができてしまうのだ。 割れ窓感……
const sample = { foo: "Foo", bar: "Bar", }; sample.bar = "ReDefined"; // コンパイルエラーにならない // sample.bar = 1; だとTSではコンパイルエラーになる console.log(sample.bar); // "ReDefined" が出力される
経験豊かなバックエンド開発者の皆様は、 const
は(実際にはそうではないけど) Kotlin の val
相当だと脳内で変換して実装してほしい。
とはいえ再代入はできないし、 sample.bar
の型は変えられないので、 var
しかない世界とは比較にならない素晴らしさである。
余談だが、サーバサイド Kotlin はとても開発体験が良いのでオススメしたい。Immutable な変数宣言が言語レベルでサポートされている世界は安心である。
アロー関数は便利(ES2015)
アロー関数は簡潔な関数シンタックスである。例を出そう。
() => {}
で関数が記述できる。ワンライナーなら {}
が省略できるため、非常に使い勝手が良い。
// function pow(x: number) { // return x * x; // } // と等価 const pow = (x: number) => x * x; // ワンライナーなら {} が省略できる // 関数を型として渡すことも容易である const decorate = (f: (n: number) => number, m: number) => { console.log("start function"); console.log(f(m)); console.log("end function"); }; console.log(pow(4)); decorate(pow, 3); /* 16 start function 9 end function */
モジュールインポート・エクスポートの共通化(ES2015)
ES2015 でモジュールのインポート・エクスポート書式が統一された。これも例を見れば理解しやすいだろう。
試しに軽減税率かどうかを判断し、税込み価格の計算をするモジュールを tax.ts
として書いてみよう。
// tax.ts export const TAX_RATE = 0.1; export const REDUCED_TAX_RATE = 0.08; export default function calcTaxInPrice(price: number, reduced: boolean) { const taxInPrice = reduced ? price * (1 + REDUCED_TAX_RATE) : price * (1 + TAX_RATE); return taxInPrice.toFixed(0); }
利用側のコードは以下のようになる。 tax.ts
と同じ階層に置こう。
import calcTaxIncludedPrice, { TAX_RATE, REDUCED_TAX_RATE } from "./tax.ts"; // `` は後に紹介するテンプレートリテラル console.log(`Tax: ${TAX_RATE} (Reduced: ${REDUCED_TAX_RATE})`); const originalPrice = 100; console.log(`if not reduced: ${calcTaxIncludedPrice(originalPrice, false)}`); console.log(`if reduced : ${calcTaxIncludedPrice(originalPrice, true)}`); /* Tax: 0.1 (Reduced: 0.08) if not reduced: 110 if reduced : 108 */
import Hoge
となっているのが default
で export されたもので、 default でないものは {}
で囲う必要がある。後々 React を書く時に時折引っかかるので覚えておいてほしい。
※ Node.js 環境では ./tax
で良いが、 Deno 環境では .ts
が必要である。
ジェネレータできたよ(ES2015)
逐次処理が大好きな方々にはなじみ深いジェネレータも使うことができる…が、 for-of 構文で利用するために宣言が少しややこしいので積極的には使うべきではないのかもしれない。
そもそもフロントエンド開発において、 list
でメモリ効率を著しく悪くするようなデータ量を扱うなという話でもあるので、バックエンド開発に比べあまり使わない印象が強い。
function* generatorSample(limit: number): Generator<number, void, unknown> { for (let index = 0; index < limit; index++) { yield index; } } for (const number of generatorSample(10)) { console.log(number ** 2); }
テンプレートリテラルできたよ(ES2015)
先ほどから注釈なく使っているが、バッククォートで囲うことで文字列に変数を埋め込むことができる。けっこう複雑な処理も可能だ。
console.log(`円周率は ${Math.PI}`); console.log(`円周率はおよそ ${Math.PI.toFixed(0)}`); /* 円周率は 3.141592653589793 円周率はおよそ 3 */
関数にデフォルト引数が書ける(ES2015)
ES2015以前はできなかったのがけっこう驚きであるが、今のJS/TSでは当たり前に関数にデフォルト引数が与えられる。
async/await(ES2017)
現代の JS/TS に馴染む上で最も理解が大事なのが async/await である。 async/await により、非同期処理の記述が劇的に簡単になった。
本題に入る前に少しだけ JS での非同期処理についておさらいしよう。
ES2015 で Promise による非同期処理が導入された。 Java プログラマならば Future による非同期処理とデザインが似ているため理解しやすいかもしれない。
では Promise 以前はどうだったかというと、 悪名高いコールバック関数が利用されていた。非同期処理を重ねたときに何が起こるかというと、コールバックがネストすることでカオスが発生する。Promise ではコールバック地獄をかなり防ぐことができる。
大雑把に言うと、 Promise は正常系(=resolve) と異常系(=reject) を定義し、受け取る側は resolve された時の処理を then
で、 reject された時の処理を catch
で書くことになる。非同期処理全般で利用するが、JS/TS の場合によく利用するのが HTTP 通信での利用だろう。まずは簡単な API で Promise の動作を確認してみよう。
試しに使うAPIとしては、猫の情報を教えてくれる無料API, cat-facts を利用する。まずは curl でアクセスしてみよう。
$ curl -s 'https://cat-fact.herokuapp.com/facts/random' | jq . { "status": { "verified": true, "sentCount": 1 }, "type": "cat", "deleted": false, "_id": "591f98783b90f7150a19c192", "__v": 0, "text": "When a cat drinks, its tongue - which has tiny barbs on it - scoops the liquid up backwards.", "source": "api", "updatedAt": "2020-08-23T20:20:01.611Z", "createdAt": "2018-05-09T20:24:01.979Z", "used": false, "user": "5a9ac18c7478810ea6c06381" }
猫、水を飲むときに舌の裏側ですくい取るんだ…知らなかった…
とにかく、この API にアクセスして、トリビアを取ってくる forchure
コマンドを作ってみよう。命名は Fortune コマンド) と CIAOちゅ~る をかけた洒落である。
まずは Promise の説明のために async/await を使わない書き方をする。
interface Fact { status: { verified: boolean; sentCount: number; feedback?: string; // 仕様書にはあるが返ってこない }; _id: string; _v: number; user: string; text: string; updateAt: string; createdAt: string; deleted: boolean; source: string; used: boolean; } function fetchFact(animalType = "cat"): Promise<Fact> { const endpoint = "https://cat-fact.herokuapp.com/facts/random"; const query = `animal_type=${animalType}&amount=1`; const url = `${endpoint}?${query}`; return fetch(url) // API アクセス成功時の処理 .then((rawResponse) => rawResponse.json()) // API アクセスか、json への変換が失敗した時の処理 .catch((reason) => { return new Promise((_, reject) => reject(reason)); }) // json への変換が成功した時の処理 .then((responseJson) => { return new Promise<Fact>((resolve) => resolve(responseJson as Fact)); } const animal = Deno.args.length == 0 ? "cat" : Deno.args[0]; // 利用側のコード fetchFact(animal) .then((fact) => { console.log(fact.text); }) .catch((reason) => { console.error(reason); });
interface
はオブジェクトに型をつけるときの定義となる。APIレスポンスに型をつけている。
fetchFact
関数は Fact
型を resolve するはずの Promise 型を返す。
最初は URL の組み立てだから良いとして、 return
以降の書き方が慣れないと読みにくいだろう。 Fetch API が非同期処理のためである。
fetch 自体が Promise なので、 then/catch で待つ。APIコールが失敗するか、jsonへの変換が失敗したら catch
の処理が実行され、失敗理由が送出される。
json への変換が成功したら Fact
として成功時の処理を送出する。ものは試しに実行してみよう。コマンドライン引数で動物タイプを設定できる。
$ deno compile --unstable --allow-net forchure.ts Emit forchure $ ./forchure Cats often overract to unexpected stimuli because of their extremely sensitive nervous system. $ ./forchure dog Dogs have over 200 million scent receptors in their noses (we have only 5 million) so it’s important that their food smells good and tastes good. $ ./forchure lion SyntaxError: Unexpected end of JSON input at JSON.parse (<anonymous>) at packageData (deno:op_crates/fetch/22_body.js:247:21) at Response.json (deno:op_crates/fetch/22_body.js:192:18)
ソースコードを見ても分かるかと思うが、処理の単純さのわりに記法がややこしいしわかりにくい。ES2017 以降の JS では Promise の syntastic sugar である async/await を使うことでこの辺りを分かりやすく書くことができる。
async function fetchFact(animalType = "cat"): Promise<Fact> { const endpoint = "https://cat-fact.herokuapp.com/facts/random"; const query = `animal_type=${animalType}&amount=${length}`; const url = `${endpoint}?${query}`; try { const rawResponse = await fetch(url); const responseJson = await rawResponse.json(); return responseJson as Fact; } catch (e) { if (e instanceof SyntaxError) { console.error("Cannot parse as Json."); } throw e; } } // 利用側 try { const animal = args.animal; const fact = await fetchFact(animal); console.log(fact.text); } catch (e) { console.error(e); }
async/await を利用する関数は async function
になる。最初のうちは時折忘れて首を捻る原因になりやすいが、定義を見るだけで非同期処理かどうかわかるので見やすい。
await で非同期処理を待つことができる。ここでは
が非同期処理となっており、直列に待っている。並列処理の場合は Promise.all を使えば良い。
async/await を使うと、バックエンド開発者には慣れた try-catch-finally 構文で書けることからも見やすいだろう。読者は forchure を拡張したり、あるいは好きなAPIを使って async/await に慣れてみてほしい。
まとめ
Deno を利用して TypeScript と仲良くなれただろうか。Deno は Argument Parser が公式で用意 されておりCLI ツールを爆速で作ることも可能なので、コンパイル機能の進化によっては Golang より遙かに少ない学習コストで CLI ツールが作れそうな気配がある。バイナリサイズの問題があったり、ツールチェインの成熟度がまだまだなので Golang の優位はしばらく変わらなさそうだが、個人的にはウォッチしておいて損はなさそうに感じた。
次回はデファクトスタンダードである Node.js 環境に移行し、単体テストを中心に学んでいく。GWで全編書こうかと思ったが他の勉強(AWSとかGolangとか)もしていたら存外記事が書けなかった。今月中には第二回を出したい。