跳至主要內容
Skip to content

併發控制與請求排程:高效管理 HTTP 請求

當同時發起大量請求時,如何避免壓垮伺服器或瀏覽器?本篇將介紹請求佇列、併發限制、限流策略等技術。


一、 為什麼需要併發控制?

1.1 瀏覽器限制

瀏覽器對同一域名的併發連線數有限制:

瀏覽器HTTP/1.1 最大連線數
Chrome6
Firefox6
Safari6
Edge6

超過限制的請求必須排隊等待。

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);
});

總結

技術用途
併發限制控制同時請求數量
請求佇列序列或優先級處理
請求合併多個請求合併為一個
請求去重避免重複請求
防抖/節流限制請求頻率
取消管理取消過時或不需要的請求

> **核心原則**:

  • 控制併發數,保護伺服器
  • 取消不需要的請求,節省資源
  • 合併重複請求,減少網路開銷
  • 按優先級排程,優化用戶體驗

進階挑戰

  1. 實作一個支援暫停/恢復的批量下載器。
  2. 建立一個請求佇列,支援優先級、取消、重試。
  3. 實作一個「滾動到可見時載入」的無限滾動列表。

延伸閱讀與資源