Pattern 一句話

抓一批 → filter → 不夠就再抓 → 湊滿 N 個 match 或 source 結束。

對應 #59 Filter × Source 合成策略 的策略 B。


何時用、何時不用

  • Source 不支援 server-side filter(不能用策略 A)
  • 不能控 build pipeline 重 index(不能用策略 C)
  • Match 密度可預期、不會稀疏到要拉光整個 dataset
  • 使用者期望「filter 後自動湊夠 N 個」、不要手動續抓

不用

  • Source 支援 server-side filter(直接用策略 A)
  • Match 稀疏、可能拉光整個 dataset 才湊到 N(換 D 誠實 UX)
  • Source cardinality 大(10 萬筆)、不能拉太多次

必要元件

元件 1:Quota 跟上限

1const TARGET = 10;       // 期望湊滿的 match 數
2const MAX_BATCHES = 20;  // 最多續抓次數(保護)
3const MAX_TIME_MS = 5000; // 最大時間(保護)

沒有上限 = 稀疏時拉爆。兩個上限缺一不可。

元件 2:Loop with break conditions

 1async function fetchUntilQuota(matches, target = TARGET) {
 2  const collected = [];
 3  const start = Date.now();
 4  let batchCount = 0;
 5
 6  while (
 7    collected.length < target &&
 8    hasMore() &&
 9    batchCount < MAX_BATCHES &&
10    Date.now() - start < MAX_TIME_MS
11  ) {
12    const batch = await fetchNext();
13    collected.push(...batch.filter(matches));
14    batchCount++;
15  }
16
17  return {
18    collected,
19    reachedQuota: collected.length >= target,
20    exhaustedSource: !hasMore(),
21    hitLimit: batchCount >= MAX_BATCHES || Date.now() - start >= MAX_TIME_MS,
22  };
23}

返回值含三個 flag、UI 用來判斷該顯示哪個狀態(湊滿 / 抓完無更多 / 撞到上限)。

元件 3:可中斷

 1async function fetchUntilQuota(matches, target, signal) {
 2  // ...
 3  while (...) {
 4    if (signal?.aborted) throw new DOMException('aborted', 'AbortError');
 5    const batch = await fetchNext({ signal });
 6    // ...
 7  }
 8}
 9
10// 使用
11const ctrl = new AbortController();
12input.addEventListener('input', () => {
13  ctrl.abort();
14  ctrl = new AbortController();
15  fetchUntilQuota(matches, 10, ctrl.signal);
16});

使用者改 query / filter 時能立刻取消舊的續抓。沒有可中斷 = 競態 bug(舊 query 的結果晚到、覆蓋新 query 的)。


UX 配套

載入中顯示進度

1<div class="loading">已掃 <strong>24</strong> 筆 / 已命中 <strong>3 / 10</strong></div>

不顯示進度 = 使用者不知道是在等還是卡住。

結束時顯示原因

結束原因顯示
reachedQuota「找到 10 個結果」
exhaustedSource「全部掃完、共找到 K 個」
hitLimit「已掃 N 筆、找到 K 個。要繼續找嗎?」

不區分原因 = 使用者不知道為什麼停(同 #57 三狀態問題)。


反例

反例 1:沒上限

1while (collected.length < target && hasMore()) {
2  collected.push(...(await fetchNext()).filter(matches));
3}
4// 稀疏 match → 拉光整個 source

反例 2:沒 abort signal

1input.addEventListener('input', async () => {
2  const r = await fetchUntilQuota(matches);
3  render(r);  // 舊 query 的結果可能覆蓋新 query
4});

反例 3:每批序列化等

1for (let i = 0; i < MAX; i++) {
2  const batch = await fetchNext();  // 序列、慢
3  // ...
4}

如果 source 支援平行 fetch(多個 page 同時抓) → 改成平行更快:

1const batches = await Promise.all([fetch(0), fetch(1), fetch(2)]);

但平行有 over-fetch 風險(湊滿後其他批白抓) — 適合 match 密度高的情境。


跟其他 Pattern 的關係


判讀徵兆

訊號該做的事
Source 不支援 filter、要湊滿 N 個結果用本 pattern
寫了 while loop 但沒上限補 MAX_BATCHES + MAX_TIME_MS
Input 改變時舊的續抓還在跑補 AbortController
結束時不知道是「湊滿」「掃完」「撞上限」補三個 flag、UI 分支顯示
Match 稀疏、續抓 50 次才湊到 1 個換策略 — B 不適合稀疏 case

核心原則:自動續抓的價值在「使用者透明」、但成本是「上限保護必要」。沒上限的 B 比 silent post-filter 更糟(會拉爆)。