iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0

文章一多,列表很快就翻不完:該用分頁、無限滾動,還是加一個搜尋框?

分頁與無限滾動都在處理列表太長:把列表切開或分段載入。它們解決不了「我知道我要找哪一篇,只是翻不到」這件事。目標明確時,使用者需要的是直接跳到那篇文章。接下來做一個打字就能即時篩選的搜尋框,也加入標籤篩選。(分頁本身是個獨立題目,Day 16:分頁與轉址會專門講。)

做了搜尋後,還得決定它在哪裡執行:由 Astro 在 build 時先處理,還是交給瀏覽器?分工錯了,可能查不到資料,也可能把整包文章送進使用者的瀏覽器。

查一次,篩無數次

查詢歸伺服器,過濾歸瀏覽器。

判準是「這件事要做幾次」:

  • 查全部文章、排好序、濾掉草稿、把標籤去重:每個訪客拿到的結果都一樣,因此每次 build 做一次,所有人就能共用。
  • 使用者每打一個字、每點一個標籤就要重篩:每個人的結果不同,畫面也要立刻反應,因此交給瀏覽器處理。

兩種工作的執行次數不同,build 時與瀏覽器端的分工也不同。

為什麼瀏覽器不能自己查就好

有個很直覺的想法:既然要搜尋,讓島自己去 getCollection 拿資料不就好了?

getCollection 無法在瀏覽器執行。它是 build/server 端的 API,會讀取 Astro 的內容層;瀏覽器沒有這個能力。

這就是 islands 架構的邊界。官方文件這樣說明 island props 的限制:

Props that are passed to interactive framework components (using a client:* directive) must be serialized.

Passing functions to hydrated components is not supported.

Framework components

server 與 client 之間只能傳可序列化的資料,例如字串、數字、陣列與物件。函式不能跨過這條線,因此要先在伺服器這側查好,再把結果送到 client。

版本基準(2026-07-21 查證):本篇用到的 getCollectionrender()client:* 指令與 props 序列化規則都在 Astro v5 定案,v6、v7 沿用,沒有破壞性變更;實作版本為 v7.1.1。v4 的過期寫法列在下面的「容易誤會的地方」。

動手:伺服器這側查一次

先做列表頁。重點全在 frontmatter 那幾行:

---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import SearchFilter from '../../components/SearchFilter.vue';

export const prerender = true;

// 查一次:build 當下查全部、濾掉草稿、排好序。所有訪客共用這份結果。
const posts = (await getCollection('blog', ({ data }) => !data.draft))
  .sort((a, b) => a.data.day - b.data.day)
  // 只挑島內用得到的欄位再傳下去。
  .map((post) => ({
    id: post.id,
    title: post.data.title,
    description: post.data.description,
    day: post.data.day,
    tags: post.data.tags,
  }));
---

<SearchFilter posts={posts} client:load />

這段程式分成三個部分:

getCollection 的第二個參數是過濾函式。 回傳 true 的才留下。這裡用它濾掉 draft: true 的草稿,讓沒寫完的文章不會流到前端。

排序要自行指定。 官方文件對 collection 的產出順序有明確說明:

The sort order of generated collections is non-deterministic and platform-dependent.

Content collections

如果不排序,產出順序不固定,換台機器也可能不同。這裡依鐵人賽的 day 升冪排;一般部落格通常是 pubDate 降冪。

.map() 決定哪些欄位會送進使用者的瀏覽器,也直接影響頁面大小。

島這側:篩無數次

島收到純資料後,依照使用者當下的輸入算出該顯示哪幾篇:

<script setup lang="ts">
// src/components/SearchFilter.vue
import { computed, ref } from 'vue'

// 只宣告島內真的用得到的欄位——沒有 body、沒有整包 entry。
type Post = {
  id: string
  title: string
  description: string
  day: number
  tags: string[]
}

const { posts } = defineProps<{ posts: Post[] }>()

const query = ref('')
const activeTag = ref('')

// 全站標籤去重清單。純資料處理,跟框架無關。
const allTags = computed(() => [...new Set(posts.flatMap((post) => post.tags))].sort())

const filtered = computed(() => {
  const keyword = query.value.trim().toLowerCase()
  return posts.filter((post) => {
    if (activeTag.value && !post.tags.includes(activeTag.value)) return false
    if (!keyword) return true
    return (
      post.title.toLowerCase().includes(keyword) ||
      post.description.toLowerCase().includes(keyword)
    )
  })
})
</script>

這份實作不需要 fetchgetCollection 或其他請求。資料在頁面載入時就已經在手上,篩選只是在記憶體裡做陣列運算,所以打字時能即時反應。

標籤清單由資料算出,不必另外維護設定:flatMap 把每篇的標籤攤平、Set 去重、sort 排序。這段純資料處理不依賴 Vue 或 Astro,換到其他語言仍然成立。

到底該送多少資料進島?

回到剛才那段 .map()。既然資料都要送,為什麼不整包送過去比較省事?

island 的 props 會被序列化,寫進 HTML 後送給每一個訪客。送得越多,頁面越大。

打開產出的 HTML 就能檢查。Astro 把 props 放在 <astro-island> 標籤上:

<astro-island props="{&quot;posts&quot;:[1,[[0,{&quot;id&quot;:[0,&quot;day-01-why-astro&quot;],
&quot;title&quot;:[0,&quot;已經會 React/Vue,為什麼還要看 Astro?&quot;],…

裡面就是那五個欄位,文章正文一個字都沒有。

同一個頁面、同一座島,只改 .map() 裡送什麼,量到的結果如下:

傳法 /blog/index.html 大小
只挑 5 個欄位(id / title / description / day / tags) 30,140 bytes
整包 ...post.data 再加 body 161,300 bytes

差 131,160 bytes,也就是 5.4 倍。這還只有 13 篇文章;差距會隨文章數線性放大,寫到 100 篇時也會等比例增加。

要驗證就改 .map() 的內容、跑 npm run build,再量 dist/ 裡那支 HTML 的大小。

只傳島內會用到的欄位。這座島用到的只有 id、標題、描述、標籤與 Day 編號,所以只送這五個欄位。body 送過去完全用不到,卻會增加每個訪客的下載量。

搜尋框該用哪個 client: 指令

接著要決定這座島何時啟動。三個指令的差別在 hydration 時機:

指令 什麼時候 hydrate 適合
client:load 頁面一載入就啟動 首屏可見、使用者隨時可能互動
client:idle 等瀏覽器空檔(requestIdleCallback 首屏但不急的次要功能
client:visible 捲到它進入畫面才啟動 在摺線以下、或載入成本高

Directives reference

三個指令都在取捨互動的即時性與首屏 JS 成本。

搜尋框位於頁面上方,使用者很可能一進頁就開始打字,因此選 client:load。島還沒 hydrate 時,輸入不會篩出結果;對這個位置的功能來說,互動延遲不值得拿幾 KB 的首屏 JS 來換。

反過來,如果你的篩選器放在頁面很下面,client:visible 就合理:使用者沒捲到那裡,這段 JS 根本不用載。

實際跑起來的樣子

以下三個狀態用的是同一份資料:

初始狀態,13 篇全列出來:

文章列表初始狀態,顯示 13 / 13 篇

在搜尋框打 vue,列表當場縮到 2 篇:

搜尋 vue 後顯示 2 / 13 篇,只剩 Day 1 與 Day 8

改點 islands 標籤,篩出 3 篇:

點選 islands 標籤後顯示 3 / 13 篇

三張畫面之間沒有網路請求,也沒有換頁。build 時備好的同一份資料,在瀏覽器裡反覆篩出不同結果。

那什麼時候該讓後端做搜尋?

前端篩選有適用條件,搜尋不必一律放在前端。

沒有通用答案,分工要看專案條件。後端已經有搜尋 API 時,不必在前端重做;列表原本就會把全部資料送進瀏覽器時,為了搜尋多打一支 API 反而多餘。直接照抄別人的架構,往往會忽略這些差異。

最常用的判準是:這份資料本來就要全部送到前端嗎?

  • → 資料反正都在瀏覽器了,再為了搜尋多打一支 API 是多餘的。前端篩最划算。
  • → 資料量大到不該全送(幾千筆商品、幾萬筆訂單),那搜尋就該留在後端,前端只送關鍵字、拿回結果。

本篇的 Astro 內容站屬於前者:文章清單本來就會完整列出,資料已經在頁面上,搜尋只是換個方式呈現同一份資料。

「一次塞大量資料」是這種做法的上限。前面的 5.4 倍顯示:程式不會因此壞掉,但頁面會隨資料增加而變重。需要持續檢查資料筆數,以及每筆送出的欄位。

想做真正的全文搜尋,往哪走?

前面的實作只比對 metadata,也就是 titledescription,因為只送了這兩段文字。若要搜尋文章內文,就不能把索引整包塞進 payload,否則會遇到前面量出的 5.4 倍差距。

先回答兩個問題。

第一條:要不要全文? 不需要全文時,前面的 metadata 比對就足夠;需要時,再看第二條。

第二條:內容在 build 時就定了嗎?

如果是(部落格、文件站這類內容站),可以用 Pagefind。它不讀原始碼,而是在 build 之後掃描產出的 HTML 來建索引,用分片處理前面量到的 payload 問題:

Pagefind's search index is split into chunks, so that searching in the browser only ever needs to load a small subset of the search index.

Pagefind 將索引分片,瀏覽器只載需要的那一小塊,每筆結果的資料則逐筆抓取。官方給的數字是 10,000 頁的站,全文搜尋總傳輸量在 300kB 以下。對照這篇量到的「13 篇、整包送 body 就 161KB」,兩者的差別在於索引是否分片。Astro 的文件框架 Starlight 預設就是用它,不需要任何設定。

但它有一條硬限制:

Pagefind cannot be enabled when the prerender option is set to false.

Starlight site searchPagefind

Pagefind 只讀 build 產出的靜態 HTML。SSR 或 on-demand 的頁面在 build 當下還不存在,因此無法進入索引。這是由產出方式決定的限制。

反過來,如果內容在 build 時還不確定(使用者產生的內容、要即時更新、要跟會員或收藏資料 join),就得進資料庫。這個系列的 capstone 使用 Turso,而 SQLite 的 FTS5 全文搜尋在 Turso 上是預載好的擴充,不用另外裝。

第三種選項是把搜尋交給託管服務。Algolia 的 DocSearch 對開發者文件與技術部落格免費,Starlight 也有官方 plugin 可以換上它。免費方案要先通過資格審查(官方列出的常見婉拒理由是網站還沒 production ready、或含有非技術性內容),核准後七天內要完成網域驗證,搜尋結果旁也必須保留它的 logo。不接受這些條件,就得自己開 Algolia 帳號。

Who can apply

方案 全文? 索引放哪 適合
手刻前端篩選(這篇) 只比 metadata 序列化進 HTML,隨頁面下載 數十篇、metadata 夠用
Pagefind build 後掃 HTML,分片按需下載 內容在 build 時就定的靜態站
資料庫全文搜尋(如 Turso FTS5) 資料庫,前端零索引 內容會變、要跟使用者資料 join
Algolia DocSearch 第三方託管 不想自己維護 relevance 與 UI

查證註記(2026-07-21):Astro 官方文件目前沒有搜尋專章,搜尋屬於「核心不管、交給 Starlight 與社群」的範圍;astro-pagefind 是社群專案,不是官方維護。Astro 官方文件站也關掉了 Pagefind、改用 Algolia DocSearch。方案仍要依站點性質與使用條件決定。

幾個容易誤會的地方

  • entry.render()entry.slug 已移除。 現在渲染是 import { render } from 'astro:content'await render(entry);識別欄位是 entry.id,不是 slug。這兩個都在 v5 移除,但網路上仍有大量停在 v4 的教學。看到 .render().slugtype: 'content',代表內容使用的是舊 API。
  • 搜尋比對的是你送過去的欄位。 這裡比對 titledescription,因為只送了這兩段文字。若要全文搜尋,請沿上一節的兩條分界線選方案。
  • 標籤自己也會變多。 13 篇文章長出了 30 個不重複標籤,整面標籤佔掉六行,反而把文章清單擠到下面。文章一多,標籤本身也跟著多,篩選器自己變成需要被篩選的東西。標籤適合「我想看某一類」,關鍵字搜尋適合「我知道自己在找什麼字」,兩者解決的不是同一個問題。
  • prerender = true 不影響搜尋。 有人會擔心靜態頁是不是就不能有互動。列表頁確實是 build 時就產好的靜態 HTML,但島的 JS 照樣會送到瀏覽器、照樣會 hydrate。靜態的是資料,不是互動。
  • .map() 出來的物件要能序列化。 純資料沒問題;如果不小心把函式或類別實例放進去,會在序列化那關失敗。

查詢放伺服器,互動放瀏覽器

列表頁在 build 時查詢、排序並挑出必要欄位,再把結果交給島反覆篩選。getCollection 的簽名會變,但 server/client 的分界不依賴單一 API:查詢在伺服器、互動在瀏覽器,中間只能傳可序列化的資料。

「送什麼進島」可以直接量測。這篇的 5.4 倍差距只改了一行 .map();遇到同類決定,先量輸出再選欄位。

Day 15 會從 /blog/day-01-why-astro 這類網址出發,說明動態路由與 getStaticPaths(),再區分哪些頁面在 build 時產生、哪些要等請求進來。SSG 與 SSR 的分界,延續的是這篇「查一次 vs 篩無數次」的判斷方式。

本日程式碼:step-14|只看這天的改動:step-13...step-14


上一篇
文章、作者與分類怎麼建立關聯?
下一篇
同一個 [slug].astro,為什麼有時候 build 時就算好、有時候有人開才算?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言