船經過海面時,身後會留下一道航跡。但過不了多久,浪一打,痕跡就散了。
船過水無痕,本來很正常;可是假如我們真的想回頭找到剛剛經過的地方,就不能只靠海面替我們記住。
網站也是一樣。看了好幾張專輯之後,想回頭找剛剛那一張,卻怎麼也想不起名字;剛剛搜尋過的關鍵字,也得再打一次。如果網站什麼都不記,離開之後就像沒發生過。
所以今天要做最近瀏覽和最近搜尋。但「記住」這件事,也帶來另一個問題:什麼應該留下?留下多久?留下來的資料,又能相信多久?
Day 19 做了收藏,今天要做的則是最近瀏覽和最近搜尋。三種功能看起來都是「記住使用者的東西」,性質卻有一些不一樣:
| 收藏 | 最近瀏覽 | 最近搜尋 | |
|---|---|---|---|
| 怎麼產生的 | 使用者主動按愛心 | 網站自動記錄 | 網站自動記錄 |
| 代表什麼 | 「我喜歡這個」,一個決定 | 「我看過這個」,一段足跡 | 「我找過這個」,一段足跡 |
| 存多久 | 直到使用者取消 | 只留最近幾筆 | 只留最近幾筆 |
| 不見了會怎樣 | 使用者可能會生氣 | 有點可惜,但沒關係 | 相對可能比較無感 |
| 需要跨裝置嗎 | 需要 | 通常不需要 | 通常不需要 |
收藏是使用者做的決定,網站有責任好好保存,所以 Day 19 把它存進資料庫,還做了跨裝置同步和 RLS。最近瀏覽和最近搜尋,是網站順手記下的足跡,方便使用者回頭找。資料的重要性,決定了要花多少成本保存它。
當然,最近瀏覽或是搜尋紀錄,也可以實作成可以跨裝置同步的。但在今天的文章中,我們會先以不跨裝置同步來實作。
瀏覽器和伺服器都能存資料,常見的位置有四種:
| 位置 | 關掉頁面後還在嗎 | 換裝置看得到嗎 | 要登入嗎 | 適合 |
|---|---|---|---|---|
| 記憶體(React state) | 不在 | 看不到 | 不用 | 目前畫面的暫時狀態 |
| sessionStorage | 關掉分頁就沒了 | 看不到 | 不用 | 只在這次瀏覽有用的資料 |
| localStorage | 還在 | 看不到 | 不用 | 偏好設定、最近瀏覽 |
| 資料庫 | 還在 | 看得到 | 通常要 | 收藏、訂單這類帳號資料 |
最近瀏覽和最近搜尋,考量到未登入訪客以及比較少情況會跨裝置使用,所以這次選擇存放在 localStorage。也就是說,不需要登入、不需要新增資料表,也不用處理 Day 19 那種登入前後的合併問題。
如果發現自己開發的網站,該紀錄的資料卻消失了,可以詢問 AI 資料儲存的位置,來判斷消失的可能原因。
最直覺的做法,是把看過的專輯「整份」存起來:名稱、封面、藝人。下次打開,直接顯示。
但專輯資料是有可能會修改的。管理員修正了專輯名稱、換了封面,甚至把重複的專輯刪掉,localStorage 裡那份卻不會跟著變。使用者看到的,就會是一份過期的資料。
所以這次選擇只記 專輯的識別碼:
["armageddon", "new-jeans", "ive-switch"]
就像服務生記得你上次點了哪幾道菜(餐點名稱),而不是把上次的菜留著。這是一筆紀錄,下次要上菜時,還是照廚房現在的做法重新出餐。(餐點做法、原料可能會換,但菜名可能還是一樣)
顯示時,再用目前的專輯資料把內容還原回來。找不到的專輯,代表已經不存在,就略過並從紀錄中移除。
唯一資料來源(Single Source of Truth)
同一份資料,只讓一個地方當正式版本。專輯資料的正式版本在 Supabase 資料庫中;localStorage 只記「看過哪幾張」,不另外保存一份專輯內容。顯示時,以目前取得的專輯資料為準,而不是把 localStorage 裡的東西當成正式資料。
如果兩邊都存,就會有兩份「真相」。若資料不一致,會需要去確認哪一份才是對的,比較麻煩一點。
不過,這樣又出現一個新問題:每次顯示最近瀏覽,都要重新取得專輯資料,不會浪費嗎?
講到「把資料留下來」,很多人會想到快取(cache)。但留下來的資料,不一定都是快取。
儲存,是因為之後還需要這份資料;快取,是因為不想再花同樣的成本取得一次。
儲存的資料如果不見了,就真的沒了;快取的資料不見了沒關係,再拿一次就好,因為正式版本還在別的地方。
用第一篇的餐廳來比喻:倉庫是儲存,食材丟了就真的沒了;廚房旁的備料台是快取,醬汁倒掉沒關係,再從倉庫拿食材重做一份就好。
所以,存在 localStorage 裡的東西,不一定是快取。要看裡面放的是什麼、為什麼放,例如:
| localStorage 裡放的東西 | 比較像什麼 |
|---|---|
| 最近瀏覽的專輯識別碼 | 使用者的行為紀錄 |
| 深色模式的設定 | 偏好設定 |
| 還沒送出的表單內容 | 暫存 |
| 從伺服器拿回來的專輯資料,為了下次少拿一次 | 快取 |
localStorage 只是一個存放的位置。同一個位置,可以放紀錄,也可以放快取。
快取的做法其實不難:資料拿過一次,就先存一份複本在比較近的地方,下次直接用。

【圖 1|有快取和沒有快取】
| 名詞 | 意思 |
|---|---|
| 命中(hit) | 快取裡有,直接用 |
| 沒命中(miss) | 快取裡沒有,只好去資料來源拿 |
| 過期(stale) | 快取裡有,但資料來源已經更新了,這份是舊的 |
| 失效(invalidation) | 把過期的快取作廢,下次重新拿 |
| 有效期限(TTL) | 設定快取能用多久,時間到就自動作廢 |
快取可以放在很多地方。越靠近使用者,通常取得越快;但只要存了一份複本,就得決定它能用多久,以及來源更新後怎麼讓它作廢:
瀏覽器記憶體 → 瀏覽器快取 → CDN → 伺服器快取 → 資料庫
(通常最快、最近) (最遠,但通常是正式/正確版本)
這是簡化的示意,用來說明複本可以存在不同的地方;不代表每次請求都會一層一層經過,資料會不會過期,也不是由距離決定的。
換成餐廳:
| 網站 | 餐廳 |
|---|---|
| 資料庫 | 倉庫:食材最齊全、最準,但離得最遠,每跑一趟都要時間 |
| 伺服器快取 | 廚房旁的備料台:常用的醬汁、切好的配料先放在手邊 |
| CDN | 各地分店的冷藏櫃:熱門商品先放在離客人近的地方 |
| 瀏覽器快取 | 已經送到桌上的水壺:客人自己倒,不用再叫服務生 |
剛剛那幾個名詞,在餐廳裡是這樣的:備料台上有,直接用,就是命中;沒有,跑一趟倉庫,就是沒命中;醬汁放太久不新鮮了,就是過期;每份備料貼上保存期限,就是有效期限;菜單改了配方,舊的醬汁要先倒掉,就是失效。
真正的配方,記在主廚的配方本上(資料庫)。備料台上的醬汁,只是照著配方先做好的一份。配方改了,要以配方本為準。
所以快取最麻煩的,通常不是怎麼存,而是什麼時候它已經不能用了。因此資料的更新週期通常會是快取實作方式的重要判斷依據之一。
兩者都在伺服器那一側,但存的東西不一樣:
| CDN | 伺服器快取 | |
|---|---|---|
| 在哪裡 | 分散在世界各地、離使用者近的伺服器 | 自己的網站伺服器(例如 Next.js)裡 |
| 常見內容 | 可以重複使用的回應:圖片、程式檔、整頁 HTML | 資料或計算結果:例如查到的專輯清單 |
| 私人資料 | 通常用來放大家可以共用的內容;放私人資料風險很高,除非設定得很仔細 | 一樣要區分清楚,不能因為在伺服器端就假設安全 |
| 適合 | 公開、大家看到都一樣的內容 | 頁面每次產生,但其中某些資料不用每次查 |
CDN 像是把做好的成品,先放在離客人近的地方;伺服器快取則是廚房裡的半成品,菜還是現做,只是省掉一些準備工作。
瀏覽器這一側也有好幾種,很容易混在一起:
| 是什麼 | 誰決定存什麼 | 什麼時候消失 | 算快取嗎 | |
|---|---|---|---|---|
| 瀏覽器記憶體 | 程式執行時的變數,例如 React state、Next.js 記住剛看過的頁面 | 程式 | 重新整理或關掉分頁 | 用來暫存時算 |
| 瀏覽器快取 | 瀏覽器自動保存下載過的圖片、程式檔 | 伺服器回應的 Cache-Control |
依伺服器的規則決定何時要重新確認;空間不夠時也可能被提早清掉 | 算 |
| localStorage | 程式主動寫入的資料 | 程式 | 沒有內建的有效期限,通常會保留到被程式、使用者或瀏覽器清除為止 | 看放了什麼(第四節) |
| sessionStorage | 跟 localStorage 一樣,但每個分頁各自一份 | 程式 | 關掉分頁 | 看放了什麼 |
最容易搞混的是瀏覽器快取和 localStorage。瀏覽器快取是瀏覽器自動做的,程式通常不直接操作,而是由伺服器告訴瀏覽器能存多久;localStorage 則是程式主動寫進去的,而且沒有內建的有效期限。
快取還要分清楚:這份複本,可以給誰?
| 例子 | 可以給誰 | |
|---|---|---|
| 公開快取 | 專輯名稱、封面 | 所有人都可以共用 |
| 私人快取 | 我的收藏、後台的回報 | 只能給特定的人 |
備料台上的醬汁,每位客人的菜都能用;但客人寄放的酒,只能拿給寄酒的那位客人,拿錯人就出事了。
私人資料也可以快取,例如瀏覽器記住自己的收藏。真正不能做的,是把私人資料放進大家共用的快取。
Day 21 後台的回應使用 no-store,代表這份回應不應被儲存下來重複使用。對包含管理資料的頁面來說,這是保守又合理的做法,也避免它被當成可以共用的內容。
Day 20 講了渲染方式,今天講快取,兩者很容易混在一起:
渲染方式決定頁面什麼時候、在哪裡被做出來;快取決定做過的結果,能不能留下來重複使用。
有三句話值得記住:
這也回答了 Day 20 留下的一個問題。Day 20 發現,在專案中,因為根 layout 讀了每個人各自的登入狀態,完整頁面不能給所有人共用。但那說的是完整頁面。整頁不能共用快取,不代表其中所有資料都不能快取。 頁面裡的公開資料,仍然可以另外快取。專輯資料是公開的、很少變動,正適合放進公開快取。
一般來說,如果資料不太會變、讀取的次數很多,大家看到的又都一樣 ,就很適合加上快取,可以減少對資料庫的請求 。專輯資料其實就符合這些條件。
不過這次先不實作資料快取。只有 25 張專輯,每次查詢都很快;加了快取,就得開始處理「資料改了多久才看得到」。等資料量或流量真的需要時,再來決定有效期限和失效的方式。
| 規則 | 做法 |
|---|---|
| 什麼時候記錄 | 打開專輯詳情時。完整詳情頁和側邊欄共用同一個元件,記錄寫在這裡,兩種入口都涵蓋 |
| 存什麼 | 只存專輯識別碼 |
| 幾筆 | 最多 10 筆,新的在前 |
| 重複看同一張 | 移到最前面,不重複 |
| 找不到的專輯 | 略過,並從紀錄中移除 |
| 查詢失敗時 | 顯示暫時無法載入,不刪除紀錄。查詢失敗,和確認資料不存在,不是同一件事 |
| 清除 | 提供清除按鈕 |
最近瀏覽分成兩個階段:記錄和顯示。
階段一:記錄看過哪一張

【圖 2|記錄最近瀏覽】
這一段只動到識別碼,不需要問伺服器,也不需要登入。
階段二:在首頁顯示
localStorage 只記得看過哪幾張,名稱和封面不在裡面。所以首頁要分兩段:先由伺服器載入首頁本身,再由瀏覽器拿識別碼,直接向 Supabase 取得專輯資料。

【圖 3|在首頁顯示最近瀏覽】
這裡沒有另外寫一支 API。專輯本來就是公開資料(Day 15),瀏覽器用公開金鑰查詢,由 RLS 決定只能讀、不能改;公開金鑰可以出現在瀏覽器裡,secret key 則絕對不行。這也是 Day 19 收藏用過的做法。
未來如果流量大了,我就會考慮替專輯資料做快取,如此一來不用每一次找一張專輯的詳情資料都要去資料庫撈一次,而是可以直接從快取取得。
這也是 Day 20 的延續:一個頁面不需要為了最近瀏覽,整頁改成在瀏覽器渲染。 伺服器讀不到 localStorage,所以只有讀取紀錄的那一塊,交給瀏覽器處理。
伺服器產生的畫面,和瀏覽器第一次顯示的畫面,都先放同樣尺寸的佔位;讀完紀錄後再換成內容。這樣伺服器和瀏覽器的第一個畫面會一致,也能減少載入時的版面跳動。

【圖4|點擊幾張專輯後,顯示最近瀏覽(登入狀態)】

【圖5|登出後,最近瀏覽仍然存在】

【圖6|清除後,最近瀏覽紀錄消失】
最近搜尋用的是很類似做法,只是規則不同。最需要決定的是:什麼時候才算「搜尋過」?
如果每打一個字就記錄,紀錄會變成 a、ae、aes、aesp、aespa。專案中的搜尋,在 Day 20 已經改成要案下Enter 或是點擊搜尋按鈕才會送出才查詢,所以最近搜尋也可以跟著同一個時機:按 Enter 或搜尋按鈕送出時才記錄。
| 規則 | 做法 |
|---|---|
| 什麼時候記錄 | 正式送出搜尋時 |
| 存什麼 | 去掉前後空白的關鍵字;空白不記錄 |
| 幾筆 | 最多 5 筆,新的在前 |
| 重複搜尋 | 移到最前面;比對時不分英文大小寫,aespa 和 AESPA 算同一筆 |
| 顯示 | 點搜尋框時,出現下拉清單;點其中一筆直接套用,並移到最前面 |
| 清除 | 提供清除按鈕 |
搜尋紀錄存的是使用者自己打的字,共用電腦時,下一個人點開搜尋框就看得到。所以清除按鈕在這裡特別重要。

【圖7|最近搜尋紀錄(登入狀態)】

【圖8|最近搜尋紀錄(登出)】
| 測試 | 預期結果 |
|---|---|
| 依序看 A、B、C | 顯示 C、B、A |
| 再看 A | A 移到最前,不重複 |
| 從列表打開側邊欄 | 也被記錄 |
| 看超過 10 張 | 最舊的被移除 |
| 重新整理、關掉瀏覽器再開 | 紀錄還在 |
| 新開的無痕視窗 | 讀不到一般視窗的紀錄 |
| 專輯改名後 | 最近瀏覽顯示新名稱 |
| 專輯被刪除後 | 自動略過,不會出錯 |
| 手動清掉 localStorage | 最近瀏覽回到空狀態 |
| 按下清除 | 紀錄消失 |
| 搜尋時打字但沒送出 | 不會被記錄 |
| 按 Enter 送出 | 關鍵字被記錄 |
輸入 aespa 送出 |
記成 aespa |
| 空白直接送出 | 不會被記錄 |
| 點最近搜尋的關鍵字 | 直接套用搜尋 |
「專輯改名後」這一列很重要,它證明只存識別碼的設計真的有效:使用者看到的是目前取得的專輯資料,不是 localStorage 裡的舊複本。
| 產品 | 功能 | 存在哪裡 |
|---|---|---|
| 購物網站 | 最近看過的商品 | 通常存在瀏覽器;登入後可能同步到帳號(或是強制登入後才能瀏覽) |
| 影音平台 | 繼續觀看 | 跟隨著帳號(通常存在資料庫),因為使用者常在手機看一半、回家用電視接著看 |
| 地圖 App | 最近搜尋的地點 | 存在帳號或裝置,並提供清除紀錄 |
同樣是「最近」,影音平台的繼續觀看通常會跨裝置同步,因為它對使用者更重要。這又回到第一節:資料的重要性,決定了要花多少成本保存它。
今天做的只是最近瀏覽和最近搜尋,但真正學到的是:不是每一份資料都要永久存進資料庫,也不是每一次畫面出現,都必然要重新向資料來源取得。要不要加入快取,要看資料量、更新頻率和查詢成本;這次資料很少,所以選擇每次都用識別碼取得目前的資料,先不增加快取和失效的機制。
有些資料是使用者的紀錄,有些是暫存,有些是快取。差別不在放在哪裡,而在為什麼要存,以及可以相信它多久 。航跡終究會散。重要的不是全部留下,而是知道哪些值得記住,以及可以相信它多久,根據情況決定儲存資料的方式。
第三幕從一張資料表開始。到今天,網站已經能存資料、驗證輸入、辨認身分、分配權限,也知道哪些資料該記住、哪些該重新拿。第三幕到這邊就先告一個段落了,到目前為止,這個網站都還只在我的電腦上。開發、測試、正式的資料,也都放在同一個資料庫裡。
明天,我們將把它部署到網路上,讓他並不只能在我的電腦中開啟。
我們明天見。