Data Flow and Filter Composition — Filter × Source 層錯位與五策略
設計 filter / sort / count / transform 等 stream 操作時、確保操作位置跟資料源同層、避免層錯位產生 silent 缺口。原則跨 UI / 後端 / 演算法管線通用 — 不只是前端問題。
適用:前端 paginated UI 加 filter、後端 API + middleware filter、演算法 pipeline 加 transform、map-reduce 加 post-filter、資料庫 materialized view 加 query。 不適用:純運算演算法(沒有 stream / 沒有 materialization 概念)、純 React state 管理(沒有外部 source)。
自包含聲明:閱讀本文件不需要先讀其他 reference。本文件涵蓋層錯位識別、五策略選擇、跨領域範例、playwright 驗證方法。
何時參閱本文件
| 訊號 | 該做的第一件事 |
|---|---|
即將寫 forEach(el => el.hidden = !matches(el)) | 停 — 確認 source 是不是分批 / streaming |
Source 是 pagefind.search() / paginatedFetch() / for await | filter 必須跟 source 同層、不能在 view 層後處理 |
| 「filter 後 0 筆但 source 還有未載入」可能發生 | 必須補自動續抓 / 推進 query / 誠實 UX |
| Backend middleware / response wrapper 加 filter | 推進 ORM query / SQL WHERE、不在 response 後 |
| 演算法 pipeline 末端 filter | 推進 pipeline stage 內、stream-aware |
| Map-reduce 完成後加 post-filter | 推進 map / reduce 階段 |
| 「畫面 / 結果對了但邊界 case 怪」 | 識別這是層錯位、不是 bug 修補能解 |
為什麼 filter × source 是個結構性議題
Filter 操作的定義是「從 stream 中過濾出符合條件的元素」 — stream 是隱含的對象。當 stream 被分層 materialize 時、filter 套在哪一層、決定它能「看見」的元素範圍:
| 層 | 能看到的範圍 | filter 結果的語意 |
|---|---|---|
| Source 層 | 完整 stream | 「stream 中所有符合的」 |
| Materialization 中 | 已 materialize 的部分 | 「目前載入的符合的」 |
| 下游(view / response) | Materialized 之後 + downstream filter 之前的子集 | 「下游可見的子集中符合的」 |
使用者 / 呼叫者意圖的「filter」通常是第一層(stream 全集)— 但寫程式當下手邊的對象通常是第三層(已 materialize 的 subset)。寫起來最便利的位置 ≠ 對齊意圖的位置。
這是 #67 寫作便利度跟意圖對齊反相關 在 stream 操作上的具體展現。
跨領域:同個結構、五個情境
情境 1:前端 UI + Pagefind paginated search
1// 反例:post-filter on view layer
2const all = await pagefind.search(query);
3all.results.slice(start, start + 10).forEach(render);
4document.querySelectorAll('.result').forEach(el => {
5 el.hidden = !matches(el); // view 層 filter
6});
7// 第二批全 hidden、使用者看到「load more 沒效果」
情境 2:後端 API + ORM middleware
1# 反例:middleware 在 pagination 之後 filter
2@app.route("/posts")
3def posts():
4 page = Post.objects.paginate(page=1, per_page=10)
5 return [p for p in page.items if p.author == "author_x"]
6 # 漏掉沒在這頁的符合項情境 3:Async iterator + take(N)
1# 反例:先 take 後 filter
2items = list(itertools.islice(stream(), 100))
3filtered = [x for x in items if matches(x)]
4# stream 後面可能還有符合的、但被 take 100 切斷了情境 4:Map-reduce + post-reduce filter
1[shards] → [map output] → [reduce]
2 ↓
3 [filter] ← 已是 reduce 後的結果Filter 應該在 map 階段(per-shard)或 reduce 內、不是 reduce 後。
情境 5:Materialized view + SELECT
1-- 反例:在 stale view 上 filter
2SELECT * FROM posts_summary WHERE author_id = 42;
3-- view 可能是某個時點的 snapshot、漏掉之後寫入的 posts
4
5-- 對例:filter 推進原表
6SELECT * FROM posts WHERE author_id = 42;五個情境共用結構:source 是分層 materialize 的、filter 套在下游 → silent 缺口。
五種解法策略
詳細展開見 #59 Filter × Source 合成策略五選一。本卡只列總覽:
| 策略 | 一句話 | 對 source 的需求 | 工程量 | UX 影響 |
|---|---|---|---|---|
| A | 把 filter 推進 source 的 query | 必須支援該 filter 條件 | 中-高 | 透明(無感) |
| B | 自動續抓直到湊滿 N 個 match | 任何分批 source | 中 | 透明(稍慢) |
| C | 預先建獨立 index(每種 mode 一份) | 能控 source 的 build pipeline | 高 | 透明(最快) |
| D | 誠實 UX 顯示「已掃 N / 命中 K」 | 任何 source | 低 | 顯眼(多按鈕) |
| E | 明示語意縮小(filter 範圍 = 已載入) | 任何 source | 最低 | 隱性語意縮小 |
選擇順序:A → C → B → D → E(不寫不告知的 silent 縮小、那是反模式)。
對應的 pattern 卡片:#60 自動續抓 / #61 推進 query / #62 誠實進度 UX / #65 多 index / #66 明示語意縮小
三變數決定策略選擇
選 A / B / C / D / E 看三個變數:
變數 1:Source capabilities
Source 支援哪些 server-side filter?
- 支援該 filter 條件 → A 最優
- 不支援、能控 build → 評估 C
- 都不行 → B / D / E
變數 2:Match 密度
每抓一批、預期多少筆 match?
- 高密度(每批 ≥ 1 個 match)→ B 自動續抓 OK
- 稀疏(要抓很多批才湊到一個)→ B 會拉爆、用 D / E
- 不可預期 → 加上限保護的 B + fallback 到 D
變數 3:UX 容忍度
使用者能接受多顯眼的「掃描範圍」UX?
- 完全不行(filter 是核心互動)→ A / C
- 可以顯示三數字 → D
- 一次性文字告知就行 → E
Playwright 驗證 filter × source 行為
寫完 filter 後、用 playwright 驗證是否有層錯位 silent 缺口。
驗證 1:「Load more 後 filter 後是否該有結果」
1async ({ page }) => {
2 await page.goto('/search/?q=pre');
3 await page.click('[data-scope="title"]'); // 選 title-only
4
5 // 載入第一批、量已掃 / 命中
6 const before = {
7 loaded: await page.$$eval('.result', els => els.length),
8 visible: await page.$$eval('.result:not([hidden])', els => els.length),
9 };
10
11 await page.click('button.load-more');
12 await page.waitForTimeout(500);
13
14 const after = {
15 loaded: await page.$$eval('.result', els => els.length),
16 visible: await page.$$eval('.result:not([hidden])', els => els.length),
17 };
18
19 // 層錯位徵兆:loaded 增加、visible 沒增加
20 return {
21 deltaLoaded: after.loaded - before.loaded,
22 deltaVisible: after.visible - before.visible,
23 isSilentGap: after.loaded > before.loaded && after.visible === before.visible,
24 };
25}驗證 2:「稀疏 case 是否拉爆」
1// 用一個極少 match 的 query 觸發 B 策略
2await page.goto('/search/?q=very_rare_keyword');
3await page.click('[data-scope="title"]');
4const startTime = Date.now();
5await page.waitForSelector('.scan-status', { timeout: 10000 });
6// 應該在 5s 內顯示「已掃完、共命中 K 個」、不該無限續抓
驗證 3:「使用者能否區分四狀態」
1const statusVisible = await page.locator('.filter-status').textContent();
2// 應該明示 loading / partial / end / empty 之一、不只是 spinner
寫成 playwright test 固化 — 未來架構改動時 CI 立刻發現 regression(#15 layout-tests-with-playwright)。
設計決策的 checklist
寫 filter 之前、跑這份 checklist:
- Source 是不是分批 / streaming / cached / lazy?(#63 資料源形狀)
- Filter 的定義域是已載入子集還是 source 全集?(使用者意圖三問、見 #58)
- Source 是否支援 server-side filter?(決定能不能用 A)
- Match 密度可預期嗎?(決定 B 是否可行)
- 三狀態(loading / empty / end)UX 怎麼區分?(#57)
- 對於「filter 後 0 筆」的情境、使用者能否區分「沒命中」vs「還沒抓到」?
Wrong vs Right 對照
範例 1:搜尋頁 title-only filter
錯:
1// pagefind 分批載入、view 層 post-filter
2input.addEventListener('input', async () => {
3 const results = await pagefind.search(input.value);
4 results.results.slice(0, 10).forEach(render);
5});
6
7document.querySelector('.scope-title').addEventListener('click', () => {
8 document.querySelectorAll('.result').forEach(el => {
9 const title = el.querySelector('.title').textContent;
10 el.hidden = !title.includes(query);
11 });
12});第二批 8 筆 title 不含 query → 全 hidden、使用者看到「load more 沒效果」。
對(策略 C:多 index + 切換):
1# Build 階段
2pagefind --site public --output-subdir _pagefind-all
3pagefind --site public --root-selector "article h1, article h2" --output-subdir _pagefind-title 1const indexes = {
2 all: await import('/_pagefind-all/pagefind.js'),
3 title: await import('/_pagefind-title/pagefind.js'),
4};
5
6input.addEventListener('input', async () => {
7 const pf = currentScope === 'title' ? indexes.title : indexes.all;
8 const results = await pf.search(input.value);
9 // results 已是「該 scope 的全集」、無層錯位
10 results.results.slice(0, 10).forEach(render);
11});對(策略 D:誠實進度 UX、保留 view 層 filter):
1<div class="filter-status">
2 已掃 <strong>24</strong> / <strong>~150</strong> 筆 — 命中 <strong>3</strong>
3 <button>再掃一批</button>
4</div>1// view 層 filter 保留、但 UI 顯示掃描範圍 + 提供續抓
2function updateStatus() {
3 const all = document.querySelectorAll('.result');
4 const visible = document.querySelectorAll('.result:not([hidden])');
5 document.querySelector('.scanned').textContent = all.length;
6 document.querySelector('.matched').textContent = visible.length;
7}範例 2:後端 API filter
錯:
1@app.route("/posts")
2def list_posts():
3 page = request.args.get('page', 1)
4 posts = Post.objects.paginate(page=page, per_page=10)
5 if author := request.args.get('author'):
6 return [p for p in posts.items if p.author == author]
7 return posts.items中間的 list comprehension 在 pagination 之後 filter — 漏掉沒在這頁的符合項。
對:
1@app.route("/posts")
2def list_posts():
3 query = Post.objects
4 if author := request.args.get('author'):
5 query = query.filter_by(author=author) # 推進 ORM
6 page = request.args.get('page', 1)
7 return query.paginate(page=page, per_page=10).itemsFilter 在 query 層、pagination 在 filter 之後、無層錯位。
自檢清單(dogfooding)
寫 filter / sort / count / transform 前:
- 我有沒有問「這個操作的對象是哪一層的 stream」?
- Source 是分批的嗎?是 → filter 必須同層或推進上游
- 寫了 view 層 filter?檢查:稀疏 case 會不會 silent 失敗?
- 用了 B(自動續抓)?有沒有 MAX_BATCHES + MAX_TIME_MS 上限保護?
- UX 能否區分「載入中 / 沒命中 / 還沒抓到 / 抓完了」四狀態?
- Playwright 驗證有沒有覆蓋「稀疏 case」「load more 後 visible 是否變」?
延伸閱讀
問題分析:
- #55 Filter 與 Source 的抽象層錯位 — 根因
- #56 視覺完成 ≠ 功能完成 — 「畫面對」是低資訊量訊號
- #57 Loading / Empty / End 三狀態的區分 — UX 落地
指令澄清(在 requirement-protocol skill):
- #58 篩選類指令的澄清時機 — 三問模板
解法策略:
- #59 Filter × Source 合成策略五選一 — 總覽
- #60-#62, #65-#66 五張 Pattern 卡片 — 各策略具體實作
抽象原則:
- #63 資料源的形狀決定 feature 的形狀 — 形狀是硬約束
- #64 Feature 操作要跟 Source 同層合成 — 跨領域通用原則
- #67 寫作便利度跟意圖對齊反相關 — meta-principle
- #68 驗收的時間軸:四個 checkpoint — 驗收策略
Last Updated: 2026-04-26 Version: 0.1.0
#skills #frontend-with-playwright #data flow #architecture #filter