イメージで伝われ!図解JavaScriptの非同期処理
ものすごい久しぶりのブログ更新になってしまいましたが、今回はJavaScriptの非同期処理について書いてみたいと思います。
このテーマはうまく説明できない部分が毎回ちょいちょいあるのですが、こうしてまとめることで頭の中が整理されていくということもあるので、最後まで頑張りたいと思います。
前提としてブラウザの実装の詳細や仕様については分からない部分も多いため、間違っている部分もあるかもしれません。
もし、お気づきの点などありましたら、教えていただけると助かります。
目次
- 目次
- 同期と非同期の比較その1
- 同期と非同期の比較その2
- 非同期API
- 非同期処理のユースケース
- コールバック
- Promise
- async/await
- 余談であり本題のRxJS
- 余談その2 React Fiber
- 余談その3
同期と非同期の比較その1
以下の2つの例はどちらも10msごとに1/100づつ背景の色を黒に近づけていくという処理が書かれています。
同期的な実行では背景は白から黒に段々と変化するのではなく、一気に黒に変化します。
それに対し、非同期的な実行では背景は白から黒へ段々と変化していき、なめらかにアニメーションします。
同期的実行
See the Pen 同期的 by Keita Okamoto (@all-user) on CodePen.
上の「実行」を押すと1秒ほど後に背景が突然黒くなることが確認できます。
それに対し、下の「実行」を押すと背景が白から黒へとなめらかに変化することが確認できます。
非同期的実行
See the Pen 非同期的 by Keita Okamoto (@all-user) on CodePen.
この2つの挙動の違いは以下のようになります。
同期的の例では、whileでループを回しつつ、その内部でDate.now()を実行し一定時間が過ぎていたら、処理を進める、というような書き方をしています。 この場合whileループを抜けるまでこの関数の実行は完了しないことになります。
同期的実行
JavaScriptはその関数が実行されている間、他の関数が並列で実行されることは絶対にありません。1
関数の実行に紐付いて画面の更新(再描画)もブロックされるため、whileループ完了後にはじめて再描画が走り、突然背景が黒くなったように見えます。
これはJavaScriptがシングルスレッド2であることと、コールスタック、実行キューの仕組みを考えると説明できます。
ある関数が呼ばれると図中右方向に伸びる実行キューに関数が追加されます。
実行キューは左から右に順に関数を実行していき、関数の処理に入るとその関数を抜けるまで他の処理が全てブロックされます。
シングルスレッドなので実行キューの関数を一つづつしか処理できないわけです。
最初に呼び出した関数の中でwhileループを回しているため、背景の色を黒に近づけていくという処理は全部で101回実行されていますが、その間ブラウザの再描画はブロックされ、関数の実行が終わったタイミングで再描画が走ります。
非同期的実行
それに対し、非同期の例ではsetIntervalによって関数が図中右方向にスケジューリングされています。
ここで言うスケジューリングとは、実行キューに関数を追加するタイミングを時間により指定しタイマーに登録することを指します。3
一つ一つの処理が別の関数として実行キューに追加されているため、全体の処理がブラウザの再描画をブロックすることなく、背景の色を黒に近づけるという処理が一つ終わるたびに関数を抜け、ブラウザの再描画を走らせることができます。4
このようにコールスタックに積まれるのではなく、図中右方向に伸びる実行キューへの関数の追加が行われ、一つの関数から別の関数へと処理がまたがって行われることを非同期処理あるいは非同期的実行という風に呼びます。
JavaScriptの非同期処理とはまさしくこの実行キュー内の関数をまたぐような処理のことを言います。
対して、最初のwhileを使った例のように、一つの関数の中で完結するような処理を同期処理あるいは同期的実行という風に呼びます。
この記事の中でも同期処理と非同期処理、同期的実行と非同期的実行という呼び方をします。
同期と非同期の比較その2
同期と非同期の動作をより詳しく見ていくために、別の例を見てみましょう。
同期的実行
function a(i) { if (i === 0) return; b(i) } function b(i) { c(i) } function c(i) { d(i) } function d(i) { e(i) } function e(i) { a(i - 1) } a(2);
上のコードを実行した様子を図にしたものが下の図になります。
関数の中で別の関数が呼ばれると、図中下方向に伸びるコールスタックにその関数が積まれます。
積まれた関数の中で更に別の関数が呼ばれると、その関数が更にコールスタックに積まれ、と続いていきます。
もうそれ以上呼ばれる関数が無くなると、最後に積まれた関数から順に関数の実行が完了していき、最終的に一番最初に呼ばれた関数に処理が戻り、そこを抜けると、はじめてブラウザの再描画が走ります。
このように、図中下方向に積まれていく処理は全て同期処理です。
コールスタック内で行われる処理は、どれだけ多くの関数呼び出しが行われたとしても5、それによって時間がかかったとしても同期的に実行されます。
非同期的実行
function a(i) { if (i === 0) return; b(i); } function b(i) { c(i); } function c(i) { d(i); } function d(i) { e(i); } function e(i) { requestAnimationFrame(() => a(i - 1)); } a(2);
同期的のコードとほぼ同じですが、function e
の内容だけ違います。
他の関数と違い、function e
の内部では直接関数を呼び出すのではなく、非同期APIであるrequestAnimationFrame
へのコールバックの登録という形でfunction a
を呼び出しています。
このコードを実行した様子を図にしたものが下の図になります。
function e
からfunction a
に処理が移る際、図中下方向のコールスタックに積まれるのではなく、図中右方向の実行キューに登録されていることが分かります。
このように、図中右方向の実行キューへと登録される処理は全て非同期処理です。
全ての非同期処理は非同期APIを介して処理を実行キューへ送り込むことによって実現しています。
クリックイベントのハンドリング、APIリクエストのレスポンス待ちなども全て非同期APIを介してコールバックが実行キューに送り込まれることで非同期処理が行われます。
クリックイベントであれば、クリックされたタイミングで実行キューにコールバックが登録され、APIリクエストのレスポンスであれば、レスポンスが返ってきたタイミングで実行キューにコールバックが登録されます。
並行処理と並列処理
こうしてみると、非同期と言いつつも実際には本当の意味で別々の処理が同時に実行されているわけではないという事が分かります。
あくまで、メインスレッドは実行キューに登録された関数を一つづつ実行することに徹し、同時に2つ以上の関数が実行されることはありません。
JavaScriptはシングルスレッドなので並行処理と並列処理で言うところの、並行処理しかできません6。
非同期API
setIntervalのようにスケジューリングを行ったり、実行キューへ関数を追加するような非同期処理を行うAPIのことを、非同期APIや非同期関数7と呼んだりします。
代表的な非同期APIには次のようなものがあります。
- window.setTimeout
- window.setInterval
- window.requestAnimationFrame
- EventTarget.addEventListener
- XMLHttpRequest
- window.fetch
- Promise
非同期APIが使われている場合、その処理は非同期処理8となります。
逆に言うと、非同期APIが使われない限り、どれだけ複雑で重い処理をしていたとしても、それは同期処理と言えます。
非同期APIの特徴
非同期APIによく見られる特徴として以下のようなものが挙げられます。
- コールバックを受け取る
- Promiseを返す
それぞれ、後に続く処理がコールスタックではなく、実行キューに追加される挙動を連想させるものです。
あくまでよく見られる特徴なので、コールバックを受け取ったり、Promiseを返したからといって、必ずしも非同期処理が行われるとは限りません。
その他の非同期API
少し脱線しますが、Zone.jsの非同期API一覧ページを見ると他にも色々なAPIが非同期処理を行っていることが分かります。
Zone.jsはAngularの内部で使用されているライブラリで、非同期処理の内部でコンテキストを形成するためのものです。
Zone.jsは非同期API全てに対しパッチを当てることで、この機能を実現しているそうです。
addEventListenerに渡したコールバックの同期的実行
addEventListenerは使い方によっては一つの関数のコールスタックから抜けることなく、同期処理となる場合があります。
const el = document.createElement('div'); console.log(1); el.addEventListener('click', ev => console.log(2)); el.click(); console.log(3); // 1 // 2 // 3
このコードのように、イベントの発火を同期的に行うと、コールバックも同期的に呼び出されます。
このことから、イベントハンドラの登録は同期的に行われており、非同期処理になるかどうかはイベントの発火タイミングが同期的実行か非同期的実行かの違いだということが分かります。
非同期処理のユースケース
ここまで非同期処理の仕組みについて見てきましたが、今度は非同期処理を伴う具体的なユースケースを見ていきたいと思います。
非同期処理が必要なシーンにはどんなものがあるでしょうか?以下に代表的なものを挙げてみます。
こうしてみると、何を作るにも何かしらの非同期処理が発生するということが分かります。
ユーザーがキー入力をするまで、処理がすべてブロックされて画面も固まってしまう、というようなことがあっては困るわけです。
これらの非同期処理を扱うための様々な方法が、これまでに発明され発展してきました。
コールバック
非同期で実行してほしい処理を関数として引数に渡す方法です。
// DOM構築が完了したらcallbackの部分が実行される document.body.addEventListener('DOMContentLoaded', () => { /* callback */ }); // someButtonがクリックされたらcallbackの部分が実行される someButton.addEventListener('click', () => { /* callback */ });
一番スタンダードな方法ですが、処理が複雑になってくると管理が難しくなるという問題があります。
特にNode.jsでは多くのAPIが非同期APIなため、少し長めの処理を書いただけでコールバックがどんどんネストしていき、コールバックヘルと呼ばれる状態になってしまったりすることがあります。
callback hellで検索するとこんな感じの画像がたくさん出てきます🔥
Promise
Promiseの詳細な説明はここでは省略しますが、イメージで言うと、その時点では扱えないような値を、さも扱える状態であるかのように(またはその時点では完了していない待機中の処理が完了しているかのように)抽象化したオブジェクト、という感じでしょうか。
コールバックとの違いについて考えてみます。
Promiseも引数に処理を渡しているという点ではコールバックと言えます。
const p = new Promise((resolve, reject) => { setTimeout(resolve, 10000); }); p.then(() => console.log('10秒経ちました'));
一番の違いは、Promiseは「さも扱える状態であるかのように抽象化したオブジェクト」であるという点です。
コールバックと違い、Promiseはその時点で扱えるか分からない値(または待機中の処理が完了したという状態)をオブジェクトとして扱うことができるため、配列に突っ込んだり、オブジェクトの値にしたり、イベントのペイロードとして送ったり、自由に扱うことができます。
コールバックの場合、あくまでそのコールバックの中でしか、その値が扱える(または待機中の処理が完了している)ことを保証できません。
const p1 = new Promise(resolve => setTimeout(resolve, 10000) // 10秒経ったら ); const p2 = new Promise(resolve => document.body.addEventListener('click', resolve) // document.bodyがクリックされたら ); const p3 = new Promise(resolve => window.addEventListener('load', resolve) // windowのloadが完了したら ); Promise.all([p1, p2, p3]).then(() => console.log('10秒経っていてかつ、document.bodyがクリックされていてかつ、windowのloadが完了した状態') );
Promiseの登場により複雑な非同期処理をコントロールすることができるようになりました。
また、複雑な非同期処理を扱うための標準的なAPIが提供されたことで、非同期処理へのアプローチが統一されたという点も大きいと思います。
Promiseの詳細についてはJavaScript Promiseの本がおすすめです。
async/await
Promiseの登場でいくつかの問題は解消されましたが、PromiseのAPIはコールバックを渡すという点ではそれまでと同じように実行順序が分かりにくかったり、処理を追いにくかったりという部分がありました。
async/awaitはJavaScriptの構文レベルでPromiseをサポートしてしまおう、というものです。
その内容は、Async Functionという必ずPromiseを返す関数の導入と、Promiseが解決されるまで処理を中断し、解決されたら処理を再開することができるawait演算子の導入です。
この「処理を中断する」というのはこれまで説明してきた同期的実行のブロッキングとは異なり、中断した時点で関数を抜け実行キューを先に進めつつ、解決された時点で続きの処理が実行キューに登録されるといった形になります。
function a() {} function b() { return new Promise(resolve => setTimeout(resolve, 1000)) } function c() {} const fn = async () => { a(); await b(); c(); } ; fn();
上のコードを実行した様子を図にしたものが下の図です。
async/await はPromise APIのシンタックスシュガーと言っても良いかもしれません。
上記のコードをPromiseで書くと下のようになります。
function a() {} function b() { return new Promise(resolve => setTimeout(resolve, 1000)) } function c() {} const fn = () => { a(); b().then(() => { c(); }); } ; fn();
Promiseは様々な非同期処理を行うための汎用的なインターフェイスとなっていますが、実際には多くのケースで、単純にその処理が終わるのを待ってから次に進みたい、逐次的に書き下せれば十分という側面があります。
async/awaitはこのように非同期処理を逐次的に書くのに打って付けのシンタックスで、最近では様々なライブラリでasync/awaitを想定した利用方法が解説されています。
余談であり本題のRxJS
はい!ここまで長かった、ここからが余談であり本題です。
JavaScriptの非同期処理についていろいろ書いてきましたが、このRxJSのことを書きたかったからなんですね。
自分はプロダクションでの運用経験は無いのですが、数年前に存在を知って以来9注目していて、どんな用途で使うと効果的だろう、みたいなイメージを膨らませていたりします。
そんな状態ではありますが、RxJSについても触れておきたいと思います。
RxJSのObservableの考え方1
まずはざっくりとRxJSのイメージを掴みたいと思います。
下の表はRxJSの公式ページに載っているものです。
https://rxjs-dev.firebaseapp.com/guide/observable
Single | Multiple | |
---|---|---|
Pull | Function | Iterator |
Push | Promise | Observable |
Observableはこの表にあるように、複数の値をPushすることができるPromiseと言えます。
Promiseは一度値が解決(Resolved)されると、それ以降同じコールバックが呼ばれることはありません。Observableは複数回値を流すことができます。
このことはよくイベントストリームであるとか、値が次々に流れてくる様子が川のようである、という風に例えられたりします。
Promiseのことを「その時点では扱えないような値を、さも扱える状態であるかのように(またはその時点では完了していない待機中の処理が完了しているかのように)抽象化したオブジェクト」と紹介しましたが、Observableはそれに似ている部分があります。
Observableは上述したように、値が次々に流れてくる川の流れのような時間軸を、そのまま抽象化したオブジェクトと言えるかもしれません。
RxJSを使うと、その川の流れのようなオブジェクト同士を、つなげたり、束ねたり、分岐したり、フィルターしたりできます。
RxJS の Observable の考え方2
この表も以前RxJSの公式ページに載っていたものです(記憶をもとに書いているので実際と少し違うかもしれません)。
この表現もObservableのイメージを掴む上でとても役に立つと思います。
Single | Multiple | |
---|---|---|
Sync | value | Array |
Async | Promise | Observable |
厳密にはObservableは同期的に値を流すこともできます。そのためおそらく表現が変わったのだと思います。
面白いのはArrayとObservableの比較で、同期がArray、非同期がObservableとなっています。
実際、ObservableにはArrayにあるメソッドと同名のメソッドがいくつかありますし、挙動も似ています。
- map
- filter
- reduce
この3つのメソッドはObservableにも存在します。
非同期なイベントの連続を、配列を操作するかのように扱える、というイメージです。
ダブルクリックのデモ
https://codepen.io/all-user/live/ppXrbd
このデモは、RxJSを使ってダブルクリックを判定する様子を可視化したものです。
上から縦に4つのObservableが並んでいて、Observableを生成するコードとそのObservableに値が流れる様子を可視化しています。
このRxJSのエディターを使っています。
RxJSの使い所
じゃあこのRxJSは一体何に使うべきなのか、という話になると思うのですが、正直本格的なアプリケーションをRxJSを使って書いた経験がないので分かりません。
分からないのですが、もし自分がGoogle Mapのようなアプリケーションを実装するとしたら、RxJSを採用すると思います。
ユーザーのインタラクション、画像やデータのロード、拡大縮小、座標の移動など、色々な変数が複雑に絡み合い、それぞれが非同期で発生するため、それらをハンドリングするのにRxJSが役立ちそうです。
他にもゲームやエディタなどの複雑なユーザーインタラクションをハンドリングするのにも向いていると思います。
また、AngularはこのRxJSがフレームワーク自体に組み込まれています。 Angularは通常の値、Promiseに加えこのObservableがサポートされているAPIが多くあります。
余談その2 React Fiber
Reactがバージョン16に上がる際、Reactの内部実装がガッツリ書き直されるということがありました。
具体的には仮想DOMを更新してUIに反映するアルゴリズムが変更されました。それがReact Fiberです。
その前後の様子を比較した分かりやすい例が下のツイートにあります。
The Fiber Triangle demo now lets you toggle time-slicing on and off. Makes it much easier to see the effect. Thanks @giamir for the PR! 🎉 pic.twitter.com/qhsWUIyXPf
— Andrew Clark (@acdlite) March 27, 2017
動画を再生すると、初めはReact Fiberでレンダリングされた様子が映り、その後React Fiberをoffにすると画面の更新がカクつく様子が見て取れます。
React Fiberが入る前は、仮想DOMの更新を全て同期的に行っていたため、この記事でも紹介したように、その間レンダリングがブロックされてしまい、画面がカクつくようなことが起きていました。(けっこう重めのアプリケーションでないと体感的には分からないレベルだったかもしれません)
下の2つの画像はReact Fiberが入る前後でレンダリング時のコールスタックの様子を比較したものです。
React Fiber前
React Fiber後
この記事で紹介した、同期と非同期の図に似ていると思いませんか?
実はこれは上の画像に似せて描いたからです。似ていると思っていただけたら幸いです。
同期的実行
非同期的実行
Reactの内部実装でも重い同期処理によるブロッキング問題を抱えていて、その処理を分割して非同期で処理するようにしたら改善したよ、ということがあったのです。
JavaScriptの非同期処理を理解する上での一つの良い例かと思います。
React Fiberの記事
React Fiberについての詳細は下の記事が分かりやすかったです。
余談その3
2017年11月からkurashiruを運営するdelyでフロントエンドエンジニアとして頑張らせていただいております。
そのdelyで開発部LT大会というものが開催されたことがありました(もう一年以上前だった)。
各々自由なテーマで話すという趣旨で、それぞれトークテーマを持ち寄りワイワイやるという感じで、とても楽しかったです。
そのとき自分はJavaScriptの非同期処理とRxJSについて話したのですが、この記事はその時の資料をベースに書き足していったものです。
LTの持ち時間は10分だったので、当然深ぼった話は出来ず、めちゃくちゃ駆け足かつフィーリングでこうっ!みたいな感じになってしまいました。
その時話せなかったことをガッツリ書いてみようと思い立って書いてみた次第でございました。
久しぶりにCODEPENのデモを動かしてみるとRxJSのimportが壊れていて動かなくなっていたので、修正を兼ねてversion 6へのアップデートを行いましたが、APIのインターフェイスが大きく変更されているのに驚きました。
pipeを使った書き方は、きっとパイプライン演算子が来ることを見越した設計なんだろうなと思います。
パイプライン演算子も楽しみですね!
-
正確にはシングルスレッドで動作している範囲においては並列で実行されることはありません。Web Workers APIのような例外は除きます。https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API↩
-
ブラウザのメインスレッドはUIスレッドとも呼ばれ、DOMの更新や画面の再描画なども行っているため、例で示したようなブロッキングが発生します。↩
-
関数を実行するタイミングではなく、実行キューに関数を追加するタイミングなので注意が必要な場合があります。指定された時間に必ず関数が実行されるとは限りません。↩
-
実際にはコールスタックに積める数には限りがあり、それを超えるとエラーになります。それがいわゆるStack Overflowであり、おなじみのエラー文"Maximum call stack size exceeded.“です↩
-
正確にはシングルスレッドで動作している範囲においては並列で実行されることはありません。Web Workers APIのような例外は除きます。https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API↩
-
非同期関数という呼び方はES2017で入ったAync Functionのことを指して使用されている例もみるので、個人的には非同期APIという呼び方を使っています。https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function↩
-
TargetElement.addEventListenerの場合は同期処理となる場合もあります。詳細は後項を参照のこと。↩
-
このブログへいただいたコメントがきっかけでした。http://b.hatena.ne.jp/entry/190576326/comment/masaru_b_cl↩
Flowtype 導入メモ
結構な数のつまづきがあったのでメモしておきます。
なお、Flow は現在も活発に開発中のため、そこそこカジュアルにBreaking Changeする可能性があります。
この記事はあくまでつまづきやすかったポイントを残すにとどめているので、基本は公式のドキュメントを参考にすることをお勧めします。
つまづきポイント
- Nuclide の設定がややこしい
- Flow サーバーがそこそこ不安定
- Flowのコードは2種類ある
- FlowのエラーとESLintのエラーがある
- class-fields っぽい書き方とオブジェクトリテラルっぽい書き方がある
- ドキュメントやエラーメッセージに出て来る英語が難しい
- ドキュメントの擬似コード
- 正確な型を表現することの難しさ
一つ一つの難易度はそれほど高くなくても、二つ以上のつまづきが同時に起こる事で対処が難しくなる。
問題を切り分けるのが大事。
Nuclide の設定がややこしい
とにかく設定項目が多いので必要ない機能は全部切る。
この記事を参考に設定しました。
Nuclide というのは Atom のプラグインで、Facebook スタックの開発環境をカバーする全部入りの IDE 的なもの(だと思っている)。
Flow 以外にも Hack、Swift などのサポートもある。1
config.cson
を置いときます、Swift 関連も有効にしてる。
その他のエディタサポートについてはこんなものがあるようです。
Flow サーバーがそこそこ不安定
Flow がコードを検証する時、毎回全てのコードを読み込むわけじゃなくて、裏でサーバーを立ててキャッシュし、二回目以降のチェックではそれを利用するような仕組みになっているらしい。
注意点として、Flowは型情報の収集にバックグラウンドでサーバを走らせるのだが、.flowconfig をファイルを書き換えた際の検知がそこそこの確率で失敗している。困ったら flow stop; flow と叩いていることが多い。 flow restart もうまく動いていないことが多い。経験上。
とくに、次の型定義ファイルをいれたときに、更新が設定ファイルのリロードに依存していて、結構頻繁にこれを叩くことになる。
これホントその通りで、感じが掴めるまではサーバーが変更後のコードを検証してくれてるかどうか注意したほうがいい。
やってるうちにだんだんサーバーの気持ちが分かるようになってくるという謎の技術でカバーするのがまた良くない。
一番確実なのは flow check
を実行すること。 経験上。
Flowのコードは2種類ある
通常の JS コードに型注釈を付けていく、いわゆる普通のシンタックスの他に、declare
キーワードを使ったライブラリの型定義のためのシンタックスがある。
少しややこしいのは、この declare
キーワードを使ったシンタックスは、通常のコードに混ぜて使うことも出来る、という点。
しかもこの機能、ドキュメントからいつの間にか消えているので、なぜエラーにならないのかもはや知る術がない。2
何かの拍子に書いた際にエラーにならなくて、エディタの動作を疑ったりした事があったので、とりあえずこういうシンタックスがあるということを知っておく。慌てない。
FlowのエラーとESLintのエラーがある
自分は Atom の Nuculide と linter-eslint を使ってそれぞれエラーがあればエディタ内に表示されるようにして使っている。
ここで気を付けないといけないのはエラーを出しているのが Flow なのか ESLint なのかを意識するということ。
具体的には ESLint のこのエラーの時に Flow のエラーだと勘違いしてハマった。
そんなところでつまづかねえよと思われるかもしれないが、Flow のエラーを修正してる途中でポッと ESLint のエラーを吐かれたりすると、頭が Flow の世界に入ってしまっているので、なぜエラーになるのか分からずしばらく悩んでしまった。
もちろん、エラーメッセージをちゃんと読めっていう話なんだけども。
これに加えて ESLint に Flow 向けのプラグインとルールを設定しておくと良い。
- flowtype + eslint 環境で eslint の no-undef で declare された型の型アノテーションの検出を無視する
- eslint と flow で import と import type で no-duplicate-imports の警告が出るのを消す
最近本体にも Lint の機能が入ったのでこれも設定するとなお良い。
Class Fields っぽい書き方とオブジェクトリテラルっぽい書き方がある
Class Fileds は class ブロック直下でプロパティを初期化できるというもの。
Object Types と Interface Types の定義の時に、各プロパティーのデリミタを ,
にするか ;
にするかという問題。
結論から言うとどちらも同じだそうです。ただし Object Types については、セミコロンは後方互換性のためでカンマ推奨。
Interface Types についてはよく分かんないっす。class の interface なのか、単純な map の interface なのか文脈で使い分ければ良いのかな。
Note: Previously object types used semicolons ; for splitting name-value pairs. While the syntax is still valid, you should use commas ,.
以下のように4つの型全てに互換性がある。
type SemicolonObject = { foo: number; bar: boolean; baz: string; }; type CommaObject = { foo: number, bar: boolean, baz: string, }; interface SemicolonInterface { foo: number; bar: boolean; baz: string; } interface CommaInterface { foo: number, bar: boolean, baz: string, } const so: SemicolonObject = { foo: 1, bar: true, baz: 'three', }; const si: SemicolonInterface = so; // OK const co: CommaObject = si; // OK const ci: CommaInterface = co; // OK
じゃあ Interface と Object の違いは何かという話なんだけど、ドキュメントを見るとまあいろいろ違う。
自分は以下のように使い分けてる。
- Interface Types
- class の interface を定義したい
- extends したい
- static field 使いたい
- Object Types
- それ以外の時
ドキュメントやエラーメッセージに出て来る英語が難しい
前提として自分は英語が得意じゃないです。平易な英語なら読めるけどちょっと難しいのだと解釈が合ってるか自信ない。
あとは、型についての知識とか専門用語についても Flow を触り始めてから学んだことが多い。
そのせいも大いにあるが、ドキュメントやエラーメッセージに出てくる単語や言い回しは見慣れないものが多く、独特の雰囲気を感じる。
たとえば Lint Rule の中の一つで sketchy-null
というのがあるんだけど、どういうルールなのかパッと連想できるだろうか。
これは if (value) { /* ... */ }
のような甘い null チェックを指摘してくれるルールなんだけど、sketchy
っていう表現は最初なんだか分からなかった。
英語分かる人にとってはむしろ直感的だったり、気の利いた言い回しだったりするんだろうなとは思う。
それから、専門用語。
静的型付き言語の世界では一般的な言葉なのかもしれないけど、variance、variant、covariant、invariant、contravariant みたいな言葉がドキュメントとかエラーメッセージでガンガン出てくる。
英語の壁と型の壁、二重の壁が立ちはだかる。というと大げさかもしれないが、チームで導入するならそういう心理的障壁は考慮する必要があるかもしれない。
ドキュメントの擬似コード
Flow のドキュメントは Flow が型をどう扱うか、ということの説明に結構多くの部分を割いている。
それ自体はすごい助かるし勉強になるんだけど、時々説明のために擬似コードを使うことがあって、前後の文脈を読まずにサンプルコードだけを追うと訳が分からなくなる。
以下の場所で使われている。
正確な型を表現することの難しさ
単純なコードの場合はいい。
コードが少しでも複雑になると、自分の頭の中では型が解決できていても Flow がエラーを吐く、という場面がけっこう出てくる。
このエラーを取り除くには、自分の頭の中の型情報を正確に Flow に伝えてあげる必要がある。
型情報を正確に Flow に伝えるには、Flow を用いた型の表現をしっかりと身に着けないといけない。
Flow 自体には高い表現力があるが、それだけにそれを正確に使いこなすのが非常に難しい。
複雑な表現はミスが入り込む余地も大きくなるので、慣れるまでは正確な型情報を一発で書けるということは中々無い。3
中途半端な理解で無理して型を表現しようとすると、型情報が間違っていたりするだけでなく、間違っているかどうかを判断することも難しくなるので、どんどん本筋と関係ないところで時間を奪われることになる。
段階的な導入は可能?
Flowtype の一つのメリットとして、段階的に導入できるという部分が挙げられることが多いが、果たして本当にそうだろうか?
自分が思うに、それはある意味では正しく、ある部分では誤解を与えているような気がする。これはコマーシャル的な意味でおそらく意図的にそうしているのかもしれない。
ここで言う段階的というのは、あくまで Flow を適用する範囲の話であって、学習曲線がなだらかで覚えやすいから導入しやすい、という話では無い。
ダイジョブ、ダイジョブ、使いながらおぼえればいいからではないのだ。
上でも書いたとおり、Flow で思った通りに型を表現出来るようになるまでには結構時間がかかる。
そんな状態で既存コードに型を付けていこうとしても、結構なんもできない。ある程度 Flow に精通して型の表現力を身に付けていないと、ただただ any
まみれで何が嬉しいのかさっぱり分からないコードになってしまう。
初めから Flow で書くほうが、知っていることしか書けない分むしろ楽。
導入は段階的でも、学習コストは初めにしっかり払う必要がある。
で Flow どうなの?
上で挙げたようなつまづきを乗り越え、晴れて型の恩恵に預かれるようになった暁には、たしかに Flow の型推論は賢いと感じたし、個人的にはこの先も触っていきたいと感じた。
ただ Breaking Change がまだまだあるかもしれないし、今直ぐギョウミーなコードに導入するのはリスキーかもしれない。
チームで導入するなら、ある程度 Flow の習熟度や型の知識などを揃えられないと現実的じゃないと思ってる。
勉強したり共有したりする時間とメンバーが確保できるならやってみたい気持ちはある。
最近 Angular を触り始めた影響で TypeScript にも入門したが、めちゃくちゃ簡単に導入できたような印象がある。
これはもちろん Flow で先につまづいておいたポイントを回避できた部分も大きいが、VSCodeや圧倒的な型定義ファイルの量(と質?)が物を言ったという感じがする。
TypeScript と Flow は共通点も多いので、片方で得られたノウハウはもう片方でもある程度使えると思う。
好きな方を勉強しておけばいざという時にはそれほど困らないような気がしてる。
それにバージョンアップを重ねるごとに違いも無くなっていってる気がするし。
そんなわけで Flow はそういうことも引っくるめて面白いので、これからも適当に watch していくと思う。
【おまけ】ドキュメントから消えた機能
Mixins
今ではもう使えなくなっている。うそ。まだ使えた。4
// You can mixin more than one class declare class MyClass extends Child mixins MixinA, MixinB {} declare class MixinA { a: number; b: number; } // Mixing in MixinB will NOT mix in MixinBase declare class MixinB extends MixinBase {} declare class MixinBase { c: number; } declare class Child extends Base { a: string; c: string; } declare class Base { b: string; } var c = new MyClass(); (c.a: number); // Both Child and MixinA provide `a`, so MixinA wins (c.b: number); // The same principle holds for `b`, which Child inherits (c.c: string); // mixins does not copy inherited properties, // so `c` comes from Child
Inline declarations in regular code
Qiita の記事でサンプルコードに使われてたりして、なるほどこういう時には便利だなと思ったことがあった。
declare class List<T> { map<U>(f: (x: T) => U): List<U>; } declare function foo(n: number): string; function fooList(ns: List<number>): List<string> { return ns.map(foo); }
Stylus JavaScript API とプラグインの仕組みについて
先日 foovar という Stylus のライブラリを公開しました。
Stylus の変数を JS ファイルにエクスポートするためのライブラリです。
その時に調べた Stylus JavaScript API の詳細や、プラグイン作成の方法です。
仕様の変更などにより記事の内容が古くなったらすぐ分かるように、記事のテストを書いてみました。
Stylus プラグインの仕組み
プラグインの実体は関数を返す関数(高階関数)です。
これを module.exports
するだけでプラグインの体を成します(役に立たないということを除けば)。
npm で公開すればすぐにプラグインとして利用可能です。
module.exports = function() { return function() {}; };
外側の関数は use
(後述)した際に呼ばれ、オプションを引数に受取ります。
内側の関数は Renderer
のインスタンスを引数に受け取ります(コード中stylus
)。
module.exports = function(...options) { return function(stylus) {}; };
内側の関数内の this
はこの Renderer
インスタンスに束縛されて呼び出されます。
require('stylus')()
で得られるインスタンスも同じく Renderer
のインスタンスです。
module.exports = function(...options) { return function(stylus) { this.condtructor.name // 'Renderer' this === stylus; // true this.constructor === require('stylus')().constructor; // true }; };
基本的にはこの Renderer
インスタンスを介して Stylus の世界とやり取りをすることになります。
Stylus から呼び出せる関数を JS で定義したり、あらかじめ用意した .styl
ファイルへのパスを通したりといった感じです。
この Renderer
インスタンスの API については公式のドキュメントに詳しい情報があります。
プラグインの読み込みとオプションの渡し方
プラグインを読み込む方法は3つあります。
- ビルトイン関数
use(path)
を使う - JavaScript API の
.use(fn)
を使う - CLI の
--use name
オプションを使う
いずれも use
という言葉が使われており、このキーワードがプラグイン読み込みを指すと思われる。
ビルトイン関数 use(path)
JS ファイルのパスを指定して呼び出す。
プラグイン名での呼び出しは出来ない。
第2引数にはハッシュをオプションとして渡せる。
渡せるのはハッシュのみで、それ以外はエラーになる。
また第3引数以降は無視される。
use('./my-plugin.js') use('../../node_modules/plugin/index.js') // 名前での呼び出しは出来ない。 node_modules 配下のエントリポイントを直接指定することは出来る use('../../node_modules/plugin/index.js', { foo: 20px }) // オプションに渡せるのはハッシュのみ
JavaScript API の .use(fn)
前述の高階関数の内側の関数のみを渡す。
require
で読み込んだモジュールを実行して内側の関数を取り出し use
に渡すという使い方になる。
const stylus = require('stylus'); stylus(source) .use(require('plugin-name')()) .render(/*...*/);
外側の関数にはオプションを渡すことが出来る。
require
で読み込んで関数を直接呼び出すだけなので、好きな数の引数を好きな型で渡すことができる。
use(path)
、 CLI の --use
オプションとAPIを揃えるために、オプションはオブジェクト1個に限定したほうがいいかもしれない。
stylus(source) .use(require('plugin-name')({foo, 'bar'})) .render(/*...*/);
CLI の --use name
オプション
--use
に続けてプラグイン名を渡すと読み込んでくれる。
短縮形は -u
。
$ stylus --use plugin-name path/to/source.styl # あるいは $ stylus -u plugin-name path/to/source.styl
--with
に続けて文字列を渡すと、 JS としてパースされ外側の関数にオプションとして渡される。
なぜかドキュメントに載ってないし、 --help
にも出てこない。
この場合複数の引数は指定できないので、複数のオプションを扱いたい場合はオブジェクトや配列で渡す必要がある。
$ stylus -u plugin-name --with '{ foo: true }' path/to/source.styl
Stylus のトークンを表現するクラス
ここで記載する API はごく一部です。
より詳しいクラスの詳細はここらへんを見ると良さそう。
これらのコンストラクタを new
して Stylus のトークンを自前で生成することもできます。
トークンクラスへのアクセス
evaluator.renderer.nodes.*
で Stylus のトークンクラスにアクセスできる。
evaluator
は JavaScript API の define(name, fn)
で定義した関数内から this
で参照することができる。
const fn = function() { return new this.renderer.nodes.String('hello world'); }; module.exports = function() { return function(renderer) { renderer .define('hello-world', fn); }; };
以下、各クラスのコンストラクタの使い方、プロパティの型、コンソールに出力した結果です。
Expression
式を表すクラス。
nodes
内の要素が複数であれば tuple
もしくは list
となる。(要素が一つの場合を長さが1の tuple
と言うこともできる)
tuple
、list
の違いは isList
で判定できる。
constructor
Expression(isList)
isList
: boolean
instanceMethod
push(node)
:node
をthis.nodes
に追加するnode
: node
instanceProperty
nodes
: arrayisList
: boolean
{ lineno: 1, column: 14, filename: 'index.css', nodes: [ { lineno: 1, column: 14, filename: 'index.css', val: 'some string', string: 'some string', prefixed: false, quote: '\'' } ], isList: undefined }
Object
Stylus
のハッシュを表すクラス。
vals
には JS のオブジェクトが入っており、各キーにはさらに Stylus のトークンが入る。
constructor
Object()
instanceMethod
set(key, value)
:value
の値をkey
にセットするkey
: stringvalue
: node
instanceProperty
vals
: object
{ lineno: 1, column: 10, filename: 'index.css', vals: { foo: { lineno: 1, column: 19, filename: 'index.css', nodes: [Object], isList: undefined } } }
String
Stylus
の文字列を表すクラス。
constructor
String(val, quote)
val
: string
instanceProperty
string
: stringval
: string
{ lineno: 1, column: 14, filename: 'index.css', val: 'some string', string: 'some string', prefixed: false, quote: '\'' }
Unit
単位付き(あるいは無し)の数値を表すクラス。
10px
、1.7em
、200ms
、0
などは全て Unit
のインスタンスとなる。
constructor
Unit(val, type)
val
: numbertype
: string
instanceProperty
val
: numbertype
: stringraw
: string
{ lineno: 1, column: 10, filename: 'index.css', val: 20, type: 'px', raw: '20px' }
RGBA
RGBA形式の色を表現するクラス。
#112233
, #11223344
, rgb(11,22,33)
, rgba(11,22,33,44)
などは全て RGBA
のインスタンスとなる。
16進数表記の時のみ raw
プロパティが存在し、16進数表記の文字列が取得できる。
constructor
RGBA(r, g, b, a)
r
: numberg
: numberb
: numbera
: number
instanceProperty
r
: numberg
: numberb
: numbera
: numberraw
: string
{ lineno: 2, column: 16, filename: 'index.css', r: 17, g: 34, b: 51, a: 0.26666666666666666, name: '', rgba: [Circular], raw: '#11223344' }
HSLA
HSLA形式の色を表現するクラス。
hsl(11,22,33)
, hsla(11,22,33,44)
などは全て HSLA
のインスタンスとなる。
constructor
HSLA(h, s, l, a)
h
: numbers
: numberl
: numbera
: number
instanceProperty
h
: numbers
: numberl
: numbera
: number
{ lineno: 3, column: 12, filename: 'index.css', h: 11, s: 22, l: 33, a: 0.4, hsla: [Circular] }
Ident
auto
、inherit
、none
等のトークンを表現するクラス。
constructor
Ident(name, val, mixin)
name
: string
instanceProperty
name
: stringstring
: string
{ lineno: 1, column: 13, filename: 'index.css', name: 'auto', string: 'auto', val: null, mixin: false }
Call
cubic-bezier()
等の関数呼び出しを表現するクラス。
constructor
Call(name, args)
name
: stringargs
: expression
instanceProperty
name
: stringargs
: expression
{ lineno: 1, column: 40, filename: 'index.css', name: 'cubic-bezier', args: { lineno: 1, column: 20, filename: 'index.css', nodes: [ [Object], [Object], [Object], [Object] ], isList: undefined, map: {} } }
テスト
記事の公開時点では Stylus v0.54.5 を対象にテストしています。
all-user/inspect-stylus