Pattern:自動續抓直到湊滿 quota
Pattern:自動續抓直到湊滿 quota
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 的關係
- 跟 #61 Pattern:推進 query(待補):A 是最優、B 是 source 不支援時的退路
- 跟 #62 Pattern:誠實進度 UX(待補):B 撞到上限後 fallback 到誠實 UX
判讀徵兆
| 訊號 | 該做的事 |
|---|---|
| Source 不支援 filter、要湊滿 N 個結果 | 用本 pattern |
| 寫了 while loop 但沒上限 | 補 MAX_BATCHES + MAX_TIME_MS |
| Input 改變時舊的續抓還在跑 | 補 AbortController |
| 結束時不知道是「湊滿」「掃完」「撞上限」 | 補三個 flag、UI 分支顯示 |
| Match 稀疏、續抓 50 次才湊到 1 個 | 換策略 — B 不適合稀疏 case |
核心原則:自動續抓的價值在「使用者透明」、但成本是「上限保護必要」。沒上限的 B 比 silent post-filter 更糟(會拉爆)。