非同期リクエストの競合とキャンセル
二件の検索を開始し、後の要求から応答を返して表示が巻き戻る場面を作ります。次に要求番号で採用する応答を決め、最後にAbortControllerによる待機の取消しとサーバー側の完了記録を比較します。releaseは指定した要求への応答を許可する演習用操作です。
説明
検索を続けて送ったとき、先に送った要求が先に返るとは限りません。届いた応答をそのまま表示へ反映すると、古い検索結果で新しい結果を上書きすることがあります。また、待機を取り消してもサーバー側の処理まで必ず停止するわけではありません。
二件の検索を開始し、後の要求から応答を返して表示が巻き戻る場面を作ります。次に要求番号で採用する応答を決め、最後にAbortControllerによる待機の取消しとサーバー側の完了記録を比較します。releaseは指定した要求への応答を許可する演習用操作です。
1二件の検索を開始する
表示値を空にし、oldとnewの検索を同時に開始します。
(globalThis.display=""表示値を初期化,globalThis.oldRequest=fetch("/search?key=old")先の検索.then(r=>r.json()).then(v=>display=v.items[0]),globalThis.newRequest=fetch("/search?key=new")後の検索.then(r=>r.json()).then(v=>display=v.items[0]),await waitStarted二件の受付を確認(["old","new"]),{display,pending:["old","new"]})二件とも応答待ちです。各Promiseの末尾では、届いた結果を無条件でdisplayへ代入するようにしています。要求を開始した順だけでは、どちらの代入が最後になるか決まりません。
2新しい検索を先に返す
newの応答を許可して、そのPromiseの完了を待ちます。
(await release("new")newへの応答を許可,await newRequest反映まで待つ,{display})表示はnewの結果になります。この時点では最新の検索に合った表示です。oldはまだ応答待ちです。
3遅れた古い応答で上書きされる
続いてoldの応答を返します。
(await release("old")遅れていた応答を返す,await oldRequest古い結果の代入まで待つ,{display})表示がoldの結果へ戻ってしまいます。最後に届いた応答が、利用者の最後の検索とは限りません。通信がすべて成功していても、画面の状態としては誤った結果になります。
4要求に番号を付ける
新しい検索の番号だけを採用する関数を作り、二件を開始します。
(globalThis.latest=0,globalThis.accepted=[],globalThis.search=key=>{const number=++latest要求ごとの番号を保持;return fetch("/search?key="+key).then(r=>r.json()).then(v=>{const use=number===latest最新の要求かを判定;if(use)display=採用する応答だけ反映v.items[0];accepted.push({key,number,use});})},globalThis.firstSearch=search("first"),globalThis.lastSearch=search("last"),await waitStarted(["first","last"]),{latest})開始ごとにlatestを増やし、各要求は開始時の番号を保持します。応答が届いた時点で番号が最新かを確認し、古い要求なら表示へ反映しません。
5新しい応答を採用する
lastを先に完了させます。
(await release("last")最新の応答を先に返す,await lastSearch番号判定と反映を待つ,{display,accepted})番号2が最新なのでuseはtrueとなり、lastの結果を表示します。
6古い応答を採用しない
遅れてfirstを完了させます。
(await release("first")古い応答も完了させる,await firstSearch,{display,accepted採否と要求番号を確認})番号1のuseはfalseになり、表示はlastの結果を保ちます。通信自体は完了していても、画面の更新対象から外せます。応答の採否を決める処理と通信の取消しは別に設計できます。
7signalを要求に渡す
取消し用controllerを作り、cancelの検索を開始します。
(globalThis.controller=new AbortController()取消し用controller,globalThis.canceledRequest=fetch("/search?key=cancel",{signal:controller.signal要求にsignalを渡す}).then(r=>r.json()).catch(e=>({error:e.name})),await waitStarted(["cancel"]),{aborted:controller.signal.aborted取消し状態})この要求はcontroller.signalに結び付いています。まだabortedはfalseで、サーバーは要求を受け付けて応答を待たせています。
8応答待ちを取り消す
abortを呼び、Promiseの結果を取得します。
(controller.abort()待機を取り消す,{result:await canceledRequest取消しの結果,aborted:controller.signal.aborted取消し後の状態,display})要求のPromiseはAbortErrorで拒否され、signalは取消し済みになります。displayは変わりません。これは呼出し元がその応答を待つ処理を止めたという結果で、サーバーの業務処理の巻戻しを保証するものではありません。
9サーバー側の処理は別に完了し得る
取り消した要求の応答を許可し、サーバーの完了記録を待ちます。
(await release("cancel")サーバー側の処理を進める,await (async()=>{for(let i=0;i<200;i++){const r=await(await fetch("/pending")).json();if(r.completed.includes("cancel")サーバーの完了記録を確認)return{serverCompleted:true,display};await new Promise(resolve=>setTimeout(resolve,5));}throw Error("not completed")})())ブラウザが待機を取り消した後でも、サーバー側では処理を完了できました。更新操作では、取消しや切断を見て「実行されなかった」と決めつけず、操作IDなどで結果を照会できる設計が必要です。
10取消し済みsignalの再利用
同じsignalを別のFetchへ渡します。
await fetch("/api/item",{signal:controller.signal取消し済みsignalを渡す}).then(r=>({status:r.status}),e=>({error:e.name新しい要求の結果}))再びAbortErrorになります。AbortSignalは一度取り消すと元には戻りません。新しい操作には新しいAbortControllerを作ります。
まとめ
要求開始順と応答到着順は一致するとは限らず、古い応答で最新表示を上書きすることがあります。
開始時の番号を保持し、応答時に最新番号と照合すると採用対象を決められます。
AbortControllerは待機の取消しに使います。サーバー側の処理結果とは別に確認します。
操作履歴と解説を振り返れます。
ブラウザ / Fetch・通信記録
(globalThis.display="",globalThis.oldRequest=fetch("/search?key=old").then(r=>r.json()).then(v=>display=v.items[0]),globalThis.newRequest=fetch("/search?key=new").then(r=>r.json()).then(v=>display=v.items[0]),await waitStarted(["old","new"]),{display,pending:["old","new"]}){
"display": "",
"pending": [
"old",
"new"
]
}(await release("new"),await newRequest,{display}){
"display": "newの結果"
}(await release("old"),await oldRequest,{display}){
"display": "oldの結果"
}(globalThis.latest=0,globalThis.accepted=[],globalThis.search=key=>{const number=++latest;return fetch("/search?key="+key).then(r=>r.json()).then(v=>{const use=number===latest;if(use)display=v.items[0];accepted.push({key,number,use});})},globalThis.firstSearch=search("first"),globalThis.lastSearch=search("last"),await waitStarted(["first","last"]),{latest}){
"latest": 2
}(await release("last"),await lastSearch,{display,accepted}){
"display": "lastの結果",
"accepted": [
{
"key": "last",
"number": 2,
"use": true
}
]
}(await release("first"),await firstSearch,{display,accepted}){
"display": "lastの結果",
"accepted": [
{
"key": "last",
"number": 2,
"use": true
},
{
"key": "first",
"number": 1,
"use": false
}
]
}(globalThis.controller=new AbortController(),globalThis.canceledRequest=fetch("/search?key=cancel",{signal:controller.signal}).then(r=>r.json()).catch(e=>({error:e.name})),await waitStarted(["cancel"]),{aborted:controller.signal.aborted}){
"aborted": false
}(controller.abort(),{result:await canceledRequest,aborted:controller.signal.aborted,display}){
"result": {
"error": "AbortError"
},
"aborted": true,
"display": "lastの結果"
}(await release("cancel"),await (async()=>{for(let i=0;i<200;i++){const r=await(await fetch("/pending")).json();if(r.completed.includes("cancel"))return{serverCompleted:true,display};await new Promise(resolve=>setTimeout(resolve,5));}throw Error("not completed")})()){
"serverCompleted": true,
"display": "lastの結果"
}await fetch("/api/item",{signal:controller.signal}).then(r=>({status:r.status}),e=>({error:e.name})){
"error": "AbortError"
}演習完了