避免 AJAX 造成的 Race condition

Eason Lin
18 min readAug 21, 2022

--

Photo by Braden Collum on Unsplash

今天這篇主要想記錄的是在技術書籍上偶然讀到的一個名詞「Race condition」,以及它在前端實際使用 AJAX 串接 API 時可能造成的問題。

什麼是 Race condition?

節錄 Wikipedia 的解釋:

"它旨在描述一個系統或者進程的輸出依賴於不受控制的事件出現順序或者出現時機"

那麼,Race condition 在前端領域會如何發生呢?

前端在開發上,AJAX 送出後到得到回應的時機由於需要看伺服器的狀況,如果頻繁地送出請求,就可能會出現「後面的請求先於前面的請求先得到回應」的情況:

如果同一個 DOM 區塊進行渲染的話,晚被發出請求 B 的結果就會被請求 A 覆蓋

實際操作

我準備了一個基於 Node.js 的微型專案用來演練這樣的狀況,新增一個資料夾並於資料夾內新增檔案及對應內容:

首先是用來呈現畫面的 index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<input type="text" maxlength="1" />
<button>查詢</button>
<p></p>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.27.2/axios.min.js"
integrity="sha512-odNmoc1XJy5x1TMVMdC7EMs3IVdItLPlCeL5vSUPN2llYKMJ2eByTTAIiiuqLg+GdNr9hF6z81p27DArRFKT7A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script>
const myButton = document.querySelector("button");
const myInput = document.querySelector("input");
const outputNode = document.querySelector("p");
myButton.addEventListener("click", async () => {
outputNode.textContent = "查詢中...";
const startWith = myInput.value;
const { data } = await axios.post("/api/names", { startWith });
if (data.matching && data.matching.length) {
outputNode.textContent = data.matching.join("、");
} else {
outputNode.textContent = "未找到任何結果";
}
});
</script>
</body>
</html>

index.html 內包含了一個用來輸入的 input、用來觸發查詢的 button 及回應回來時用來渲染內容的 pscript 部分載入了 axios 並撰寫了很簡單的邏輯,input 可以輸入一個字母用來查詢符合第一個字的名字,例如輸入 A 會得到 Alex, Albert, Atlas。button 在被點擊時會取出 input.value 並發送 POST 請求至 /api/names,在取得回應資料後將內容渲染至 p 中。

接著是會用 node 執行的 index.js

const express = require("express");
const path = require("path");
const app = express();
const port = process.env.PORT || 8080;
let isDelayingLonger = true;app.use(express.json());app.get("/", function (req, res) {
res.sendFile(path.join(__dirname, "/index.html"));
});
app.post("/api/names", function (req, res) {
const mockData = ["Alex", "Albert", "Atlas", "Bruce", "Ben", "Benson"];
let matching = [];
if (typeof req.body.startWith !== "undefined") {
matching = mockData.filter((name) => name.charAt(0) === req.body.startWith);
}
if (isDelayingLonger) {
setTimeout(() => {
res.json({ matching });
}, 6000);
} else {
setTimeout(() => {
res.json({ matching });
}, 3000);
}
isDelayingLonger = !isDelayingLonger;
});
app.listen(port);
console.log("Server started at http://localhost:" + port);

index.js 會占用 8080 PORT,並在開啟 localhost:8080 時顯示上方的 index.html。這邊使用了 isDelayingLonger 變數,它會在接收到 /api/namesPOST 請求時被用來決定請求要延遲約 3 秒或 6 秒回應,並切換為 truefalse,藉此來模擬不同回應時間造成的時間差,並有一個存放了六個分別以 A、B 開頭姓名的 mockData 用來模擬假資料。

最後則是 package.json

{
"name": "race-condition",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "node index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.1"
}
}

輸入 npm install 對安裝所需的套件,完成後便可以透過 npm run dev 啟動伺服器。

打開 localhost:8080,會發現頁面上如同 index.html 中的描述,只有一個 inputbutton 和一個內容為空的 p。查詢按鈕的功能會在我輸入字母時幫我發送請求並取得符合的文字:

假如使用者在查詢中被觸發後改變了主意,將 B 清除後輸入 A 又送出查詢,可能就會發生像這樣的情況:

由於後發出的 A 的查詢請求比 B 的查詢請求更早被回應,畫面出現了 A 的查詢結果,但 B 的請求在不久後也被回應了, 於是覆蓋了 A 的查詢結果,便造成了 Race condition。

解決方案 A--在請求時阻擋使用者操作

在等待請求期間阻擋使用者繼續操作導致請求多次被發出是個做法:

myButton.addEventListener("click", async () => {
outputNode.textContent = "查詢中...";
myButton.setAttribute("disabled", true);
myInput.setAttribute("disabled", true);
const startWith = myInput.value;
const { data } = await axios.post("/api/names", { startWith });
if (data.matching && data.matching.length) {
outputNode.textContent = data.matching.join("、");
} else {
outputNode.textContent = "未找到任何結果";
}
myButton.removeAttribute("disabled");
myInput.removeAttribute("disabled");
});

但如果我們做的是使用者在輸入時會即時查詢的功能,例如 udemy 的搜尋功能:

阻擋操作顯然就不是一個理想的做法。

解決方案 B--在下一個請求被發出時,取消前一個請求

在開始前,可以先將 script 部分回復:

const myButton = document.querySelector("button");
const myInput = document.querySelector("input");
const outputNode = document.querySelector("p");
myButton.addEventListener("click", async () => {
outputNode.textContent = "查詢中...";
const startWith = myInput.value;
const { data } = await axios.post("/api/names", { startWith });
if (data.matching && data.matching.length) {
outputNode.textContent = data.matching.join("、");
} else {
outputNode.textContent = "未找到任何結果";
}
});

如果在多次請求中,只有最後一次請求的結果是有用的,我們可以只保留最後一次發出的請求,並將先前的請求取消掉。

以 axios 為例,使用 AbortController

axios 自 v0.22 開始,便支援 AbortControllerAbortController 不是 axios 專屬,而是 Web 原生就能支援的 API,它讓我們可以在需要時中止掉發出的請求。我們可以使用它的 constructor 來建立一個 AbortController

let controller = new AbortController();

在請求被回應的等待期間,可以透過 abort 方法來中斷請求,例如:

controller.abort();

但是同一時間可能有多個請求正在等待回應,如何讓 AbortController 得知要中斷的請求是哪一個呢?

我們將 AbortControllerAbortController.signal 作為一個與發出請求溝通的媒介,以 axios 為例,可以將它作為第三個參數 signal 的值:

const { data } = await axios.post(
"/api/names",
{ startWith },
{
signal: controller.signal,
}
);

如果將 controller.abort() 複製起來,到畫面中開啟 F12 切換到 Console,於輸入框中輸入並點擊按鈕發出請求,在請求被回應以前在 Console 發出 controller.abort(),這時候 Console 應該會出現:

切換到 Network,就會發現發出請求的 status 變成了 canceled

那可以怎麼在程式碼中實作呢?我們只要在請求發出以前呼叫 controller.abort 就好,例如:

const myButton = document.querySelector("button");
const myInput = document.querySelector("input");
const outputNode = document.querySelector("p");
let controller = null;
myButton.addEventListener("click", async () => {
if (controller) {
controller.abort();
}
controller = new AbortController();
outputNode.textContent = "查詢中...";
const startWith = myInput.value;
const { data } = await axios.post(
"/api/names",
{ startWith },
{
signal: controller.signal,
}
);
if (data.matching && data.matching.length) {
outputNode.textContent = data.matching.join("、");
} else {
outputNode.textContent = "未找到任何結果";
}
});

我們將 controller 的起始值設為 null,原因是因為頁面載入後的第一個請求前面不會有其他相同的請求等待回應,也因此在函式中會對 controller 做一次檢查,如果不為 null 才進行 abort,並將 controller 賦予 new AbortController 讓他可以在需要的時候被取消。

回到畫面,如果照著一開始操作的流程:

  • 輸入 B 點擊查詢
  • 在請求回來前輸入 A 點擊查詢

由於前一個請求已被取消,因此即使前一個請求較晚被回應,後續也不會被渲染到畫面上。

如果 axiosv0.22 以前的版本,axios 也有實作 CancelToken ,一個類似 AbortController 的機制,這邊就直接附上程式碼:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<input type="text" maxlength="1" />
<button>查詢</button>
<p></p>
<!-- 更換版本 -->
<script
src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.4/axios.min.js"
integrity="sha512-lTLt+W7MrmDfKam+r3D2LURu0F47a3QaW5nF0c6Hl0JDZ57ruei+ovbg7BrZ+0bjVJ5YgzsAWE+RreERbpPE1g=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script>
const myButton = document.querySelector("button");
const myInput = document.querySelector("input");
const outputNode = document.querySelector("p");
let controller = null;
myButton.addEventListener("click", async () => {
if (controller) {
controller.cancel(); // 改為呼叫 cancel
}
controller = axios.CancelToken.source(); // 改為呼叫 axios.CancelToken.source()
outputNode.textContent = "查詢中...";
const startWith = myInput.value;
const { data } = await axios.post(
"/api/names",
{ startWith },
{
cancelToken: controller.token, // 調整要傳入的鍵值
}
);
if (data.matching && data.matching.length) {
outputNode.textContent = data.matching.join("、");
} else {
outputNode.textContent = "未找到任何結果";
}
});
</script>
</body>
</html>

應該也能達成一樣的效果:

上面提到 AbortController 是原生的 API,最後也附上 fetch 的作法:

const myButton = document.querySelector("button");
const myInput = document.querySelector("input");
const outputNode = document.querySelector("p");
let controller = null;
myButton.addEventListener("click", async () => {
if (controller) {
controller.abort();
}
controller = new AbortController();
outputNode.textContent = "查詢中...";
const startWith = myInput.value;
const response = await fetch("/api/names", {
method: "POST",
signal: controller.signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ startWith }),
});
const data = await response.json();
if (data.matching && data.matching.length) {
outputNode.textContent = data.matching.join("、");
} else {
outputNode.textContent = "未找到任何結果";
}
});

--

--

Eason Lin
Eason Lin

Written by Eason Lin

Frontend Web Developer | Books

No responses yet