併發控制與請求排程:高效管理 HTTP 請求
當同時發起大量請求時,如何避免壓垮伺服器或瀏覽器?本篇將介紹請求佇列、併發限制、限流策略等技術。
一、 為什麼需要併發控制?
1.1 瀏覽器限制
瀏覽器對同一域名的併發連線數有限制:
| 瀏覽器 | HTTP/1.1 最大連線數 |
|---|---|
| Chrome | 6 |
| Firefox | 6 |
| Safari | 6 |
| Edge | 6 |
超過限制的請求必須排隊等待。
1.2 伺服器保護
1.3 常見場景
| 場景 | 問題 | 解決方案 |
|---|---|---|
| 批量上傳 | 同時上傳 100 張圖片 | 限制併發數 |
| 列表渲染 | 同時載入 50 張縮圖 | 視窗內優先 |
| 自動儲存 | 快速編輯產生大量請求 | 防抖 + 取消 |
| 搜尋建議 | 每個字元觸發請求 | 防抖 + 取消舊請求 |
二、 併發限制器
2.1 基本實作
javascript
class ConcurrencyLimiter {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.currentCount = 0;
this.queue = [];
}
async execute(task) {
// 如果超過限制,排隊等待
if (this.currentCount >= this.maxConcurrent) {
await new Promise((resolve) => this.queue.push(resolve));
}
this.currentCount++;
try {
return await task();
} finally {
this.currentCount--;
// 通知下一個等待的任務
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
}
}
}
}
// 使用
const limiter = new ConcurrencyLimiter(3);
const urls = ["/api/1", "/api/2", "/api/3", "/api/4", "/api/5"];
await Promise.all(urls.map((url) => limiter.execute(() => fetch(url))));
// 最多同時 3 個請求2.2 帶優先級的佇列
javascript
class PriorityLimiter {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.currentCount = 0;
this.queue = []; // { priority, resolve }
}
async execute(task, priority = 0) {
if (this.currentCount >= this.maxConcurrent) {
await new Promise((resolve) => {
// 插入正確位置(優先級高的在前)
const item = { priority, resolve };
const index = this.queue.findIndex((q) => q.priority < priority);
if (index === -1) {
this.queue.push(item);
} else {
this.queue.splice(index, 0, item);
}
});
}
this.currentCount++;
try {
return await task();
} finally {
this.currentCount--;
if (this.queue.length > 0) {
const next = this.queue.shift();
next.resolve();
}
}
}
}
// 使用
const limiter = new PriorityLimiter(3);
// 高優先級(如 HTML/CSS)
limiter.execute(() => fetch("/critical.css"), 10);
// 低優先級(如圖片)
limiter.execute(() => fetch("/image.jpg"), 1);三、 請求佇列
3.1 序列佇列
一個接一個執行:
javascript
class SerialQueue {
constructor() {
this.queue = [];
this.running = false;
}
async add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
}
async process() {
if (this.running || this.queue.length === 0) return;
this.running = true;
while (this.queue.length > 0) {
const { task, resolve, reject } = this.queue.shift();
try {
const result = await task();
resolve(result);
} catch (error) {
reject(error);
}
}
this.running = false;
}
}
// 使用:確保請求按順序完成
const queue = new SerialQueue();
queue.add(() => fetch("/api/1")); // 先完成
queue.add(() => fetch("/api/2")); // 再執行
queue.add(() => fetch("/api/3")); // 最後執行3.2 批量處理
javascript
async function batchFetch(urls, batchSize = 5) {
const results = [];
for (let i = 0; i < urls.length; i += batchSize) {
const batch = urls.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map((url) => fetch(url).then((r) => r.json()))
);
results.push(...batchResults);
// 可選:批次間延遲
if (i + batchSize < urls.length) {
await sleep(100);
}
}
return results;
}
// 使用
const urls = Array.from({ length: 100 }, (_, i) => `/api/item/${i}`);
const results = await batchFetch(urls, 10);四、 請求合併(Batching)
4.1 DataLoader 模式
將短時間內的多個請求合併成一個:
javascript
class DataLoader {
constructor(batchFn, options = {}) {
this.batchFn = batchFn;
this.delay = options.delay ?? 10;
this.maxBatchSize = options.maxBatchSize ?? 100;
this.queue = [];
this.timeout = null;
}
async load(key) {
return new Promise((resolve, reject) => {
this.queue.push({ key, resolve, reject });
if (this.queue.length >= this.maxBatchSize) {
this.dispatch();
} else if (!this.timeout) {
this.timeout = setTimeout(() => this.dispatch(), this.delay);
}
});
}
async dispatch() {
clearTimeout(this.timeout);
this.timeout = null;
const batch = this.queue;
this.queue = [];
const keys = batch.map((item) => item.key);
try {
const results = await this.batchFn(keys);
batch.forEach((item, index) => {
item.resolve(results[index]);
});
} catch (error) {
batch.forEach((item) => item.reject(error));
}
}
}
// 使用
const userLoader = new DataLoader(async (ids) => {
// 一次請求多個用戶
const response = await fetch(`/api/users?ids=${ids.join(",")}`);
return response.json();
});
// 這些會被合併成一個請求
const [user1, user2, user3] = await Promise.all([
userLoader.load(1),
userLoader.load(2),
userLoader.load(3),
]);五、 請求去重
5.1 相同請求去重
javascript
class RequestDeduplicator {
constructor() {
this.pendingRequests = new Map();
}
async fetch(url, options = {}) {
const key = this.getKey(url, options);
// 如果相同請求正在進行,返回相同的 Promise
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key);
}
const promise = fetch(url, options).finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, promise);
return promise;
}
getKey(url, options) {
return `${options.method || "GET"}:${url}`;
}
}
// 使用
const dedup = new RequestDeduplicator();
// 這兩個只會發送一次請求
const [res1, res2] = await Promise.all([
dedup.fetch("/api/user"),
dedup.fetch("/api/user"),
]);5.2 帶 TTL 的快取
javascript
class CachedFetcher {
constructor(ttl = 5000) {
this.cache = new Map();
this.ttl = ttl;
}
async fetch(url, options = {}) {
const key = this.getKey(url, options);
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.response.clone();
}
const response = await fetch(url, options);
this.cache.set(key, {
response: response.clone(),
timestamp: Date.now(),
});
return response;
}
getKey(url, options) {
return `${options.method || "GET"}:${url}`;
}
clear() {
this.cache.clear();
}
}六、 防抖與節流
6.1 防抖(Debounce)
等待一段時間沒有新輸入後才執行:
javascript
function debounce(fn, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
return new Promise((resolve) => {
timeoutId = setTimeout(async () => {
resolve(await fn.apply(this, args));
}, delay);
});
};
}
// 使用:搜尋建議
const debouncedSearch = debounce(async (query) => {
const response = await fetch(`/api/search?q=${query}`);
return response.json();
}, 300);
input.addEventListener("input", async (e) => {
const results = await debouncedSearch(e.target.value);
renderResults(results);
});6.2 節流(Throttle)
限制執行頻率:
javascript
function throttle(fn, limit) {
let lastCall = 0;
let lastResult;
return function (...args) {
const now = Date.now();
if (now - lastCall >= limit) {
lastCall = now;
lastResult = fn.apply(this, args);
}
return lastResult;
};
}
// 使用:滾動載入更多
const throttledLoadMore = throttle(async () => {
const response = await fetch("/api/more");
return response.json();
}, 1000);
window.addEventListener("scroll", () => {
if (isNearBottom()) {
throttledLoadMore();
}
});6.3 帶取消的防抖
javascript
function debouncedFetch(delay) {
let timeoutId;
let controller;
return async function (url, options = {}) {
// 取消前一個請求
if (controller) {
controller.abort();
}
clearTimeout(timeoutId);
controller = new AbortController();
const signal = controller.signal;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(async () => {
try {
const response = await fetch(url, { ...options, signal });
resolve(response);
} catch (error) {
if (error.name !== "AbortError") {
reject(error);
}
}
}, delay);
});
};
}
// 使用
const searchFetch = debouncedFetch(300);
input.addEventListener("input", async (e) => {
try {
const response = await searchFetch(`/api/search?q=${e.target.value}`);
const results = await response.json();
renderResults(results);
} catch (error) {
// 處理錯誤(AbortError 已被過濾)
}
});七、 取消管理
7.1 集中式取消控制
javascript
class RequestManager {
constructor() {
this.controllers = new Map();
}
async fetch(key, url, options = {}) {
// 取消同 key 的舊請求
this.cancel(key);
const controller = new AbortController();
this.controllers.set(key, controller);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} finally {
// 清理(如果還是這個 controller)
if (this.controllers.get(key) === controller) {
this.controllers.delete(key);
}
}
}
cancel(key) {
const controller = this.controllers.get(key);
if (controller) {
controller.abort();
this.controllers.delete(key);
}
}
cancelAll() {
for (const controller of this.controllers.values()) {
controller.abort();
}
this.controllers.clear();
}
}
// 使用
const manager = new RequestManager();
// 搜尋:同組只保留最新
async function search(query) {
return manager.fetch("search", `/api/search?q=${query}`);
}
// 頁面卸載時取消所有
window.addEventListener("beforeunload", () => {
manager.cancelAll();
});7.2 React Hook 整合
javascript
function useCancellableFetch() {
const controllerRef = useRef(null);
useEffect(() => {
controllerRef.current = new AbortController();
return () => {
controllerRef.current?.abort();
};
}, []);
const fetchData = useCallback(async (url, options = {}) => {
return fetch(url, {
...options,
signal: controllerRef.current?.signal,
});
}, []);
return fetchData;
}
// 使用
function MyComponent() {
const fetchData = useCancellableFetch();
useEffect(() => {
fetchData("/api/data")
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") {
setError(err);
}
});
}, []);
}八、 實戰:圖片懶載入
javascript
class LazyImageLoader {
constructor(options = {}) {
this.concurrency = options.concurrency ?? 3;
this.limiter = new ConcurrencyLimiter(this.concurrency);
this.observer = this.createObserver();
}
createObserver() {
return new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
},
{ rootMargin: "200px" } // 提前 200px 開始載入
);
}
observe(img) {
img.dataset.src = img.src;
img.src = "placeholder.svg";
this.observer.observe(img);
}
async loadImage(img) {
await this.limiter.execute(async () => {
return new Promise((resolve, reject) => {
const realSrc = img.dataset.src;
const tempImg = new Image();
tempImg.onload = () => {
img.src = realSrc;
img.classList.add("loaded");
resolve();
};
tempImg.onerror = reject;
tempImg.src = realSrc;
});
});
}
}
// 使用
const loader = new LazyImageLoader({ concurrency: 3 });
document.querySelectorAll("img[data-lazy]").forEach((img) => {
loader.observe(img);
});總結
| 技術 | 用途 |
|---|---|
| 併發限制 | 控制同時請求數量 |
| 請求佇列 | 序列或優先級處理 |
| 請求合併 | 多個請求合併為一個 |
| 請求去重 | 避免重複請求 |
| 防抖/節流 | 限制請求頻率 |
| 取消管理 | 取消過時或不需要的請求 |
> **核心原則**:
- 控制併發數,保護伺服器
- 取消不需要的請求,節省資源
- 合併重複請求,減少網路開銷
- 按優先級排程,優化用戶體驗
進階挑戰
- 實作一個支援暫停/恢復的批量下載器。
- 建立一個請求佇列,支援優先級、取消、重試。
- 實作一個「滾動到可見時載入」的無限滾動列表。