この記事の目次
「地図」を検索した直後に「写真」へ変えたのに、最後は地図の結果が表示された。通信や処理にかかる時間が違うと、始めた順と終わる順は一致しません。検索結果の表示順を守るには、完了した結果が今も表示対象かを、書き込む直前に確かめます。
この記事では、関数とPromiseを少し読める人向けに、架空の資料検索を作ります。Promiseは後で成功または失敗が決まる処理の結果を表すものです。Node.jsとテキストエディターを使い、通信の代わりに待ち時間のある関数で順序の逆転を再現します。画面や検索サーバーは用意しません。
最後に終わった検索が、最新とは限らない
利用者が欲しいのは、最後に入力した検索語への答えです。例えば地図の取得が遅く、写真の取得が速ければ、次の順番になります。待ち時間は挙動を再現するための仮値で、実サービスの速度を示すものではありません。
| 起きたこと | 表示を変えてよいか |
|---|---|
| 地図の検索を開始する | 地図を検索中と表示する |
| 完了前に写真の検索を開始する | 写真を検索中へ更新する |
| 写真の結果が先に届く | 写真の結果を表示する |
| 地図の結果が後から届く | 写真の表示を保ち、地図の結果は使わない |
毎回awaitを使っていても、別々に呼び出した検索同士が自動で順番待ちになるわけではありません。MDNのawaitの説明では、待機するのはそれを含むasync関数の実行です。その間に、次の操作から別の呼び出しが始まることがあります。
全部を開始順に表示すればよい、という問題でもありません。古い検索の完了を待たず、新しい検索を先に使いたいからです。今回は各呼び出しに番号を付け、最新の番号だけに表示更新を許します。
呼び出しごとの番号を、表示する直前に比べる
次をlatest-search.mjsとして保存し、node latest-search.mjsで実行してください。renderは画面へ反映する処理の代役です。ここでは状態をコンソールへ出し、どの結果が採用されたかを見ます。
function createSearch(request, render) {
let latest = 0;
return async function search(raw) {
const mine = ++latest;
const query = raw.trim();
if (query === "") {
render({ phase: "idle", query: "", items: [] });
return;
}
render({ phase: "loading", query, items: [] });
try {
const items = await request(query);
if (mine !== latest) return;
render({ phase: "ready", query, items });
} catch {
if (mine !== latest) return;
render({ phase: "error", query, items: [] });
}
};
}
function mockSearch(query) {
const slow = query === "地図" || query === "失敗";
return new Promise((resolve, reject) => {
setTimeout(() => {
if (query === "失敗") reject(new Error("模擬の取得失敗"));
else resolve([`${query}の資料`]);
}, slow ? 800 : 100);
});
}
async function runPair(first, second) {
const states = [];
const search = createSearch(mockSearch, state => states.push(state));
await Promise.all([search(first), search(second)]);
console.log(first, "→", second || "空欄");
console.log(states);
}
await runPair("地図", "写真");
await runPair("失敗", "写真");
await runPair("地図", "");
最初の組ではloadingが地図、写真の順に出て、最後のreadyは写真だけになります。地図の結果は到着してもstatesへ追加されません。二番目でも最後は写真のreadyです。古い失敗のerrorが新しい成功を消さないことを確認できます。
三番目は、地図のloadingの後に空欄のidleが入り、そこで表示更新が終わります。待ち時間が過ぎても地図へ戻りません。Promise.allはここでは両方の呼び出しが終わるまで実験の終了を待つために使い、表示する順を決める役割は持たせていません。
最新番号は共有し、自分の番号は固定する
latestはcreateSearchが作った一つの検索機能の中で共有されます。呼び出しが来るたびに増え、mineはその時点の番号を覚えます。地図が1、写真が2なら、地図の完了時にはmineが1、latestが2なので表示を更新しません。
比較をawaitより前だけで済ませるのが一つ目の失敗です。開始直後はどの呼び出しも自分が最新なので、比較を通ってしまいます。その後、待っている間に新しい検索が始まることを防げません。修正では結果を受け取った後、renderの直前へ比較を置きます。
再確認は、最初の組でreadyが写真一つになることです。番号比較を一時的に外した練習用コピーと比べると、地図が最後へ追加される違いを観察できます。修正版では待ち時間を変えても、「最後に始めた検索だけが採用される」という条件を保ちます。
また、入力のたびにcreateSearchそのものを呼び直すと、latestが0へ戻り、呼び出し間で比較できません。画面へ組み込むときは初期化で一度作ったsearchを使い続けます。複数の独立した検索欄なら、それぞれ別のsearchを持たせる設計が考えられます。
古い失敗と、空欄に戻す操作も対象にする
二つ目の失敗は、成功側だけ番号を確認し、catchでは必ずエラーを表示することです。写真が成功した後に、古い検索の失敗が届くと、画面全体がエラーへ戻ります。成功と失敗は別の出口なので、どちらも更新前の確認が必要です。
修正ではcatchにも同じ比較を置きます。二番目の組で、写真のreadyの後にerrorがないことを確かめてください。実際の取得で失敗と0件を分ける処理は取得結果を分類する記事の範囲です。今回の番号はその結果の意味を変えず、今の画面へ使ってよいかだけを決めます。
空欄のときに番号を増やさずreturnするのも見落としやすい点です。古い検索が最新の番号を持ったままになり、後から結果が戻ります。完成例では空欄の判定より先に番号を増やし、検索を行わない操作でも以前の結果を無効にしています。
「読み込み中」を別のフラグで管理し、finallyで必ず消す設計にも注意が必要です。古い処理の終了が、新しい処理の読み込み表示を消す場合があります。この例ではphaseに状態をまとめ、採用した状態だけをrenderへ渡します。表示する文字列や部品は、そのphaseから決める構成です。
表示を守ることと、処理を中止すること
番号が古くなっても、mockSearchの待ち時間や処理自体は続きます。この方法は表示の採用条件であり、通信量を減らす仕組みではありません。実際のfetchを止めたい場合は、AbortController.abortの説明にある中止の仕組みを別途組み合わせます。
中止と表示の確認は目的が異なります。中止に対応していない処理や、すでに完了して次の加工に進んだ結果も含め、反映する直前の判定を考えます。逆に、履歴としてすべての結果を残す処理へ、この「最新だけ」の条件をそのまま当てると必要な情報を落とします。
画面から検索欄を取り外す場合は、イベント解除や待機中の処理の扱いも必要です。ここでは一回のスクリプト実行中だけ使う関数に絞っています。URLの検索条件を復元する記事で扱う入力の復元とは別に、戻ってきた結果をいつ反映するかを設計してください。
成功・失敗・取り消しで、最後の状態を調べる
実験の最後はstatesの最後の要素だけでなく、途中に古い結果が混ざっていないかも見ます。次の四条件を確認してください。
- 地図から写真へ続けて呼ぶと、readyは写真だけで、itemsは「写真の資料」になる。
- 失敗から写真へ続けて呼ぶと、写真の成功後に古いerrorが追加されない。
- 地図から空欄へ変えると、最後はidleで、待った後も地図の結果が追加されない。
- 写真から失敗へ変えると、最後は失敗のerrorになる。最新の失敗まで隠してはいけない。
同じ検索語を続けた場合も、番号で呼び出しを区別できます。検索語の文字列が同じかどうかだけでは、どちらの操作への応答か決められません。JavaScriptの無料レッスンで表示処理へつなぐ際は、成功する一回だけでなく、順序が逆になる組も確認用に残しましょう。
記載内容へのご質問や訂正のご連絡は、hello@junkbranding.comまでお願いします。