こんにちは。ForSchool事業部の石上です。今年の抱負はラーメンを月2食に抑えることです。今の所はなんとか達成できております。
さて今回は、Studyplus for School(以下、社内での呼び方でFSと書きます)のフロントエンドで、どうやってAPIリクエストの競合を回避したかという話について書きます。
背景
FSのフロントエンドには、非同期の処理をするためにredux-thunkを使っています。
Reduxで非同期処理といえばredux-sagaとredux-thunkどっちを使うのか、というのがよく話題に上がると思います。FSでのredux-thunkの採用理由は単純で、使い方をすぐ理解できるからでした。結果としてactionに非同期処理が入ってくることによる苦しみを味わうことになったのですが、その話はまた別でしたいと思います(今回の話もその一部です)。
FSのフロントエンドはシングルページアプリケーション(以下、SPA)です。HTMLを毎回ダウンロードするのではなく、必要なデータを必要なときにAPIから取得して、画面の特定の部分を更新します。
そのため何も考えずに実装をすると、うっかり間違った画面を表示することになります。まずはその問題について簡単に、なるべく具体的な例で書いていきます。
検索状態に対して画面に表示される結果が合わなくなる可能性
FSには、生徒一覧を表示する画面があります。この画面はとても一般的な機能を持っていて、検索条件を指定すると画面が更新されて、それにマッチする生徒が表示されます。
SPAでなければ、検索条件のクエリパラメータをもとにSQLで生徒一覧を取得、それをHTMLに埋め込んで表示という流れになるかと思います。
SPAの場合は、検索条件のクエリパラメータをつけたAPIのURLへリクエストを投げ、その結果を画面に表示します。基本的にはSPAでない場合と変わりはないですね。
ただ、気をつける必要があるのはその結果の反映順序です。APIへのリクエストとレスポンスは、工夫をせずに行うと、リクエストした順番とは違う順番でレスポンスを処理する可能性があります。
今回の例で考えてみます。高校生のタグをつけられた生徒を取得するリクエストの直後に、中学生のタグをつけられた生徒を取得するリクエストをしたとします。工夫をせずにただリクエストを投げた場合、選択したタグは高校生なのに表示されるのは中学生の生徒一覧、ということが起きうるのです。
解決方法
うちの場合、AbortControllerというブラウザの機能を利用してこの問題を回避しています1。Abortとは中断という意味の英単語なので、中断制御するやつという感じですね。機能もまさにその名のとおりです。
使い方は簡単で、このMDNのリンクに書かれている例の通りです。これにコメントを書き加えると以下のような感じです。
// AbortControllerを生成 var controller = new AbortController(); var signal = controller.signal; var downloadBtn = document.querySelector('.download'); var abortBtn = document.querySelector('.abort'); downloadBtn.addEventListener('click', fetchVideo); // 中断ボタンをクリックすると abortBtn.addEventListener('click', function() { // リクエストを中断する controller.abort(); console.log('Download aborted'); }); function fetchVideo() { ... // fetchの引数にAbortControllerのsignalを指定 fetch(url, {signal}).then(function(response) { ... }).catch(function(e) { reports.textContent = 'Download error: ' + e.message; }) }
これをredux-thunkの中で使うために、以下のような実装にしました。
- APIリクエストの処理を担当するクラスをつくる
- そのクラスに、AbortControllerも管理させる
- interruptGetというメソッドを生やして、そのメソッドでAPIを叩いたときは、競合するリクエストを中断してからリクエストを投げるようにする
- AbortErrorはキャッチして無視する(エラー表示などはしないようにしておく)
client.interruptGet('/api/hoge') .then(res => { dispatch(getHogeSuccess(res)) }) .catch(err => { if (err.name === 'AbortError') { return } dispatch(getHogeError(err)) })
ライブラリを入れず素朴に実装したつもりが、初見の人にはやや実装がわかりにくくなってしまった感もあります。ただ、これを利用したリクエストの挙動をブラウザで見てみると、やっていることはわかりやすいはずです。以下は検索条件のタグを2つ指定している状態から、ががっと2回のクリックでタグを外した様子です。下に見えているのがChromeのNetworkタブで、ここに発生したAPIリクエストが表示されています。
1回目のクリックで生徒APIへのリクエストを投げようとしますが、すぐ次のクリックによってそれが中断され、Statusがcanceled
になっています。上記したinterruptGetでAPIを叩くと、必ずそれ以前の同APIへのリクエストをキャンセルするようになっているため、画面に反映されるのは最後に投げたリクエストのレスポンスとなります。
その他のアプローチ
以下のようなアプローチもあるかと思います。
thunkの中で状態を見て、1つ前のリクエストの処理が完全に終わるまで次のリクエストを投げないようにする
このやり方は、Redux作者のDan Abramov氏のスクリーンキャストで紹介されています。
実はこのスクリーンキャストはこの記事を書いているときに知りまして、観てみたらAbortControllerをつかった実装よりもよさそうだと感じました。今回紹介した画面を今後リファクタリングする際には、画面のstateの正規化をした上でこの方法を採用したいと思います。
takeLatestという関数があるらしい
redux-sagaを使っているのであれば、これが使えそうです。FSではredux-thunkを利用しているのですが、この問題のためにredux-sagaへ乗り換えるということはしませんでした。
まとめ
素朴にやってみたものの、thunk側でAbortErrorをキャッチして無視しないといけない不便さもあります。今後はそういった約束事を意識しないでも、正しい状態を保てるようなつくりへとリファクタリングしていくことが必要だと感じています。
-
対応ブラウザによって、polyfillが必要です。↩