[{"content":"🤞 完整範例：Github Repo │ 👻 Live Demo：點我看 demo\n本專案實作略過點擊次數統計及來源訪客分析。\n短網址（Short URL）是什麼？ 短網址（Short URL）是將原始長網址透過 URL shortening 技術轉換為較短字串的結果，使連結更容易分享、記憶與管理。常見服務如 Bitly 與 TinyURL。\n範例 原始網址（Original URL）： https://www.google.com/maps/place/Taipei+101+Shopping+center/@25.0341017,121.5642863,19z/data=!3m1!5s0x3442abb6d95eb43b:0xbfc3edc9e6aa3050!4m6!3m5!1s0x3442abb6da80a7ad:0xacc4d11dc963103c!8m2!3d25.0341222!4d121.5640212!16s%2Fg%2F11fx91ft3n?entry=ttu 短網址（Short URL）：\nhttps://url-shortener-doe.vercel.app/twLgDN 🥺🥺🥺 由於本專案部署於 Vercel，並使用其提供的免費子網域作為服務域名，而該網域本身字元較長，因此短網址的整體長度也相對受到影響（所以本篇範例和 Demo 的短網址會比較長喔）。\n例如使用 reurl.cc 產生的短網址長度就只有這樣：https://reurl.cc/53ZNyR\n為什麼需要短網址？ 縮短冗長 URL，節省字元空間 便於在字數受限的平台（如社群媒體、簡訊）分享 提升連結外觀整潔度與可讀性 可追蹤點擊次數、來源、地理位置等數據 可自訂短網址 slug，強化品牌辨識 隱藏原始網址中的參數與路徑結構 部分服務支援設定連結到期時間，控制存取期限 短網址的實際運作流程 當使用者點擊一個短網址時，系統背後其實會經歷一連串非常快速的查詢與轉址流程。從使用者的角度來看只是一次點擊，但在系統內部則包含「識別 → 查詢 → 重新導向」三個核心步驟。\n整體流程可拆解如下：\n1. 使用者請求短網址 使用者在瀏覽器輸入或點擊短網址：https://url-shortener-doe.vercel.app/twLgDN ，此時請求會被送往短網址服務的伺服器。這類伺服器通常是邊緣節點（Edge server）或 API server。\n2. 系統解析短碼（Short Code） 伺服器會從 URL path 中解析出短碼：twLgDN，這個短碼是整個系統的核心 key。\n3. 查詢對應的原始網址（Original URL） 系統會透過 key-value lookup 查詢儲存層，例如：Redis、Database、Cache layer。\nGET twLgDN → https://www.google.com/maps/place/Taipei+101+Shopping+center/@25.0341017,121.5642863,19z/data=!3m1!5s0x3442abb6d95eb43b:0xbfc3edc9e6aa3050!4m6!3m5!1s0x3442abb6da80a7ad:0xacc4d11dc963103c!8m2!3d25.0341222!4d121.5640212!16s%2Fg%2F11fx91ft3n?entry=ttu\n如果存在則回傳原始網址；如果不存在則回傳 404 或錯誤頁面。\n4. 執行 HTTP Redirect 當查詢成功後，系統會回傳 HTTP redirect response，瀏覽器收到後會自動導向原始網址。\n5. 使用者被導向目標網站 從使用者的角度來看很簡單：點擊一個短連結，接著被帶到目標頁。但實際上，這個過程背後已經完成一次完整的 lookup + routing process。\n延伸補充：邊緣運算（Edge Computing）在短網址中的角色 隨著技術演進，現代大型短網址服務通常會搭配邊緣運算（Edge Computing）架構，以降低轉址延遲（Redirect Latency）並提升存取效能。\n其主要角色包括：\n極致的跳轉速度： 透過全球分佈的邊緣節點，短網址的「解析」與「跳轉」將會在離使用者最近的伺服器完成，將延遲降至最低。 動態分流與導向： 在邊緣端直接判斷使用者的裝置、語言或地理位置，即時決定要跳轉到哪個目標頁面，無需回傳至遠端中心伺服器處理。 第一線安全過濾： 在邊緣節點即時攔截惡意機器人（Bots）或阻斷服務攻擊（DDoS），在威脅到達主系統前就先進行清洗與防護。 即時數據預處理： 點擊數據在邊緣端先進行去識別化或初步匯整，提升後續大數據分析的效率與隱私安全性。 💡 不過需要注意的是：\nEdge Computing 並不是短網址服務的必要條件。對於小型專案而言，單一 API server 搭配 Redis 通常已足夠。 然而隨著 Serverless 與 Edge 平台的普及，即使是小型專案，也能透過 Vercel Edge Functions、Cloudflare Workers 或 Upstash Redis 等服務，以較低成本實現低延遲的全球存取能力。\n系統架構設計 整體架構圖 （TODO）\nEdge Computing 在短網址中的角色 短網址服務對於延遲（Latency）相當敏感，因為每一次短網址存取都需要即時完成重新導向（Redirect）。假設所有請求都集中回傳至單一的後端伺服器處理，當使用者與伺服器距離較遠時，可能會增加重新導向的時間並影響使用體驗。\nEdge Computing 的核心概念，是將請求處理邏輯部署到更靠近使用者的邊緣節點（Edge Nodes），降低請求的往返距離與網路延遲。\n因此在短網址系統中，Edge 通常負責接收短網址請求、解析短網址、查詢 URL 映射及快速回傳 HTTP redirect。\n為什麼選擇 Redis 在面對短網址服務這種「高併發讀取、結構極其簡單」的應用場景時，傳統的關聯式資料庫往往顯得過於沉重，而 Redis 則非常符合所有需求：\nKey-Value 結構完美契合：短網址的本質就是極其單純的鍵值對（Key-Value）對照。Short Code (Key) → Original URL (Value) Redis 內建的 String 結構提供了 O(1) 的查詢時間複雜度，能實現微秒（mu）等級的記憶體讀取速度，這是一般 SQL 資料庫做索引查詢無法比擬的。 執行緒安全的原子操作（Atomic Operations）：短網址服務通常需要統計點擊次數。在傳統資料庫中，高併發的 UPDATE counter = counter + 1 容易遇到資料鎖定（Locking）或競爭條件（Race Condition）問題。而 Redis 的 INCR 指令是執行緒安全的原子操作，能夠輕鬆吞吐每秒數萬次的點擊計數，完全不卡頓。 內建 cache 與過期機制（TTL）：許多短網址（例如行銷活動連結）具有時效性。Redis 內建的 EXPIRE 機制允許我們直接為每個短網址設定過期時間（Time-To-Live，簡稱為 TTL）。時間一到，資料會自動從記憶體中抹除，省去了寫排程去定期清理舊資料的麻煩。 進階資料結構的彈性：除了單純的網址映射，Redis 還提供了 List、Hash 與 Set 等結構。這讓我們在後續擴充功能時，可以輕鬆地實作像是「限制同一 IP 的點擊頻率」或是「只保留最近 5 筆短網址紀錄」等進階功能。 Serverless Architecture 在現代 Web 開發中，維護一台 24 小時開機、需手動調整規格的傳統伺服器，不論在成本還是維運上都是負擔。短網址服務天然具備「流量不規律」的特性（例如行銷活動簡訊發出瞬間流量暴增，平時則極低），而這正是 Serverless 架構的優勢所在：\n解決傳統資料庫的 Connection Pool 問題： 在 Serverless 環境中（如 Vercel Functions），每一次請求進來都會觸發一個獨立的容器實例。當瞬間湧入萬人點擊時，會同時啟動上萬個實例，這會導致傳統資料庫的 Connection Pool 瞬間爆滿而崩潰。 Upstash HTTP/REST 連線的絕佳優勢： Upstash Redis 專為 Serverless 設計，它不使用傳統持久性的 TCP 連線，而是透過 HTTP/REST API 進行操作。這意味著無論前端併發請求有多高，都不會發生連線耗盡（Connection Exhaustion）的錯誤。 自動彈性伸縮（Auto-scaling）： Serverless 架構讓開發者不需要擔心主機當掉或流量爆掉。在沒有流量時，基礎設施成本為零（Pay-as-you-go）；當爆發性流量進來時，Vercel 與 Upstash 會在毫秒內自動擴展，承接所有請求，實現真正的免運維（Ops-less）。 Tech Stack Next.js - Full Stack Framework 提供 Fullstack 開發能力，整合 API Routes、App Router 與 Edge Runtime，適合建構具備前後端整合能力的短網址服務。 Upstash Redis - Redis 為記憶體型 Key-Value Database，具備極低延遲（Low Latency）與 O(1) 查詢特性，非常適合 redirect-heavy 的短網址查詢場景。Upstash 同時提供 Serverless 架構，適合部署於 Vercel。 Nano ID - 用於生成短網址代碼（Short Code），具備短字串、低碰撞率與 URL-safe 特性，適合作為短網址識別碼。 TypeScript - 提供靜態型別檢查，提升程式可維護性與開發時的型別安全。 Vercel - 提供 Serverless Deployment 與全球 Edge Network，可降低 redirect latency，並與 Next.js 深度整合。 短碼（Short Code）生成策略 本專案的短碼生成採用：Nano ID\nNano ID 是目前相當流行的隨機 ID 生成工具，常見於短網址服務、分散式系統、前後端唯一識別碼生成及 Serverless／Edge 應用等。\n其核心概念是：使用高品質亂數產生短字串，例如：twLgDN、a8KxP2、Qw91Zm。\n為什麼選擇 Nano ID？ 相較於傳統的 Auto Increment ID，Nano ID 有幾個非常適合短網址服務的特性。\n1. 短碼不可預測 若使用遞增 ID：/1、/2、/3，攻擊者很容易進行枚舉攻擊（Enumeration Attack），也就是暴力猜測所有短網址；而 Nano ID 使用隨機生成：/twLgDN、/a8KxP2。由於短碼之間沒有明顯規律，因此能達到有效降低私有連結被猜測、避免爬蟲批量掃描、防止資料外洩等的目的。\n2. 適合分散式與 Serverless 架構 Nano ID 的核心優勢在於完全去中心化。它不依賴資料庫自動遞增（Auto Increment）、中央發號器或單一機器序號，因此任何 Edge Function 或 Serverless 實例都能在本地端獨立生成唯一短碼。\n這種特性讓它成為 Vercel Edge Functions、Serverless API 以及多區域（Multi-region）部署的絕佳選擇。由於生成過程中無需向中央資料庫「預先索取 ID」，不僅大幅降低了網路延遲，也實現了低系統耦合度。\n3. 短碼長度可自訂 例如以下可生成 6 位短碼：\nnanoid(6); 若需要更低的碰撞率，可增加短碼長度，生成更多位數的短碼：\nnanoid(8); 碰撞率（Collision Probability） Nano ID 雖然是隨機生成，但理論上仍可能發生 collision（碰撞），即兩個網址產生相同短碼（不過實務上機率極低）。\n假設當使用 Base62 字元集、長度為 6 時，此時總組合數約為 62^6^ ≈ 5.68×10^10，大約 568 億種組合。\n因此在一般的中小型短網址服務中，碰撞率通常可以被忽略。但若系統規模進一步擴大，則可以透過增加短碼長度、collision retry 機制或者 database unique constraint 等的方法進一步降低風險。\nNano ID 的實作 import { nanoid } from \u0026#39;nanoid\u0026#39;; const shortCode = nanoid(6); 生成結果\ntwLgDN 接著系統會將 shortCode → Original URL 這個 key-value 映射儲存至資料庫。當使用者請求 /twLgDN 時，系統即可查詢對應原始網址並重新導向。\n自訂短碼 除了自動生成外，部分短網址服務也支援使用者自訂短碼。例如：/summer-sale、/black-friday 等。\n自訂短碼的優點包含更容易記憶、適合品牌行銷及提升可讀性等；但系統需額外處理例如名稱衝突、敏感詞過濾等的問題。\n本專案主要仍以 Nano ID 自動生成為主。\n短網址生成流程實作 在 Next.js 的 App Router 中，會在 app/api/shorten/route.ts 建立一個 POST 路由來處理短網址的生成。\nURL Validation 在將網址存入資料庫之前，先進行驗證：\nfunction isValidUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === \u0026#39;http:\u0026#39; || url.protocol === \u0026#39;https:\u0026#39;; } catch (e) { return false; } } const vError = getValidationError(url); if (vError) { return setValidationError(vError); } 生成短碼 這裡採用前面提到的 Nano ID，並限制長度為 6 碼：\nimport { nanoid } from \u0026#39;nanoid\u0026#39;; const shortCode = nanoid(6); // 例如: \u0026#34;twLgDN\u0026#34; 把高併發的環境考慮進去，單純依賴隨機性存在著微小的碰撞風險－－即兩個不同的使用者在同一毫秒內，隨機生成了完全相同的短碼（呃雖然機率極小）。\n但為了達成 100% 的唯一性保證，還是採取了先預佔、再確認的方式：\nexport async function generateUniqueCode(length: number = 6): Promise\u0026lt;string\u0026gt; { let attempts = 0; // 當前重試次數 const maxAttempts = 5; // 重試次數上限 while (attempts \u0026lt; maxAttempts) { // 1. 產生隨機短碼 const shortCode = nanoid(length); /** * 2. 原子性預佔 (Atomic Reservation)： * 利用 Redis 的 \u0026#39;SET\u0026#39; 指令搭配 NX 與 EX 選項，實作輕量級的分散式鎖 * * - `reserved:${shortCode}`: 專門的命名空間，隔離預佔鎖與實際資料 * - \u0026#39;1\u0026#39;: 佔位用的任意值，可替換成其他值，我們只關心這個 Key 存不存在 * - nx: true (Set if Not Exists): 核心原子操作。只有當 Key 不存在時才能寫入成功 * - ex: 60 (Expire): 設定 60 秒的防死鎖 TTL。若後續寫入程式崩潰，此 ID 仍會自動釋放 */ const reserved = await redis.set(`reserved:${shortCode}`, \u0026#39;1\u0026#39;, { nx: true, ex: 60, }); // 3. 預佔成功，代表此 ID 目前無人使用，可回傳該值 if (reserved === \u0026#39;OK\u0026#39;) { return shortCode; } // 4. 發生碰撞，計數器加 1 並重新嘗試 attempts++; console.warn( `Collision detected for code: ${shortCode}. Retrying... (${attempts}/${maxAttempts})` ); } // 5. 達到重試上限時的防禦性錯誤拋出 throw new Error( \u0026#39;Collision threshold reached: Could not generate a unique short code.\u0026#39; ); } 寫入 Redis 使用 Upstash Redis：\nimport { Redis } from \u0026#39;@upstash/redis\u0026#39;; const redis = Redis.fromEnv(); await redis.set(shortCode, originalUrl); 在這裡為了優化使用者體驗，使用 Redis Pipeline 一次執行兩個操作：\n寫入網址映射 Short Code (Key) → Original URL (Value) 並同步將此筆紀錄推入最近生成的 List（Capped Collection）中（這個 List 是為了在網站上列出最近生成的幾筆資料以供 demo 檢視） 使用 Redis Pipeline 寫入 Upstash Redis：\nimport { NextResponse } from \u0026#39;next/server\u0026#39;; import { Redis } from \u0026#39;@upstash/redis\u0026#39;; import { generateUniqueCode } from \u0026#39;@/lib/utils\u0026#39;; const SHORT_URL_EXPIRY_DAYS = 3; // 設定短網址有效期為 3 天 const redis = Redis.fromEnv(); export async function POST(req: Request) { try { const { url } = await req.json(); if (!url) return NextResponse.json({ error: \u0026#39;URL is required\u0026#39; }, { status: 400 }); // 1. 生成高併發防碰撞的唯一短碼 (例如: \u0026#34;twLgDN\u0026#34;) const shortCode = await generateUniqueCode(); /** * 2. 儲存網址映射關係 (Short Code -\u0026gt; Original URL) * - \u0026#39;ex\u0026#39;: 設定 3 天的生存時間 TTL (以秒為單位換算)。 * - 超過 3 天後，Redis 會自動從記憶體中剔除此 Key，避免資料無限膨脹。 */ await redis.set(shortCode, url, { ex: SHORT_URL_EXPIRY_DAYS * 24 * 60 * 60, }); /** * 3. 更新全域「最新 5 筆縮網址」紀錄清單 * 這裡使用 Redis Pipeline 將多個指令打包一次發送。 * 這樣能大幅減少 Serverless 環境與 Redis 之間的網路往返次數 (RTT)，並確保操作的連續性。 */ const tx = redis.pipeline(); tx.lpush(\u0026#39;recent_shortens\u0026#39;, shortCode); // 將新短碼推入清單最左側 tx.ltrim(\u0026#39;recent_shortens\u0026#39;, 0, 4); // 永遠只保留索引 0 到 4 (前 5 筆) await tx.exec(); // 一次執行 Pipeline 中的所有指令 // 4. 成功回傳短碼 return NextResponse.json({ shortCode }); } catch (error) { console.error(\u0026#39;Shorten API Error:\u0026#39;, error); return NextResponse.json({ error: \u0026#39;Server Error\u0026#39; }, { status: 500 }); } } 取得短網址 生成與儲存成功後，前端拿到的 shortCode 需組合成完整的短網址：\nconst shortUrl = `${window.location.origin}/${shortCode}`; Redirect 實作流程 為了讓使用者點擊短網址後能以最快速度跳轉，這裡不採用傳統的動態頁面路由（如 app/[id]/page.tsx），而是利用 Next.js 的 Middleware。Middleware 運行於 Edge Runtime，能在 HTTP 請求到達伺服器主體前進行攔截，將重新導向的延遲降至最低。\nDynamic Route Handling 在系統設計中，首頁（/）、API 路由（/api/*）以及 Next.js 內建的靜態檔案（/_next/*、favicon.ico）不應該被當作短網址進行解析。\n透過 Matcher 配置 與 條件過濾 來處理動態路徑：\n// 設定 Matcher 確保效能，利用正規表示式初步過濾掉靜態檔案與 API export const config = { matcher: [\u0026#39;/((?!api|_next/static|_next/image|favicon.ico).*)\u0026#39;], }; 並在 middleware.ts 進行攔截：\nconst path = req.nextUrl.pathname; // 過濾不需要攔截的路徑 (首頁、API、靜態檔案、帶有點副檔名的檔案) if ( path === \u0026#39;/\u0026#39; || path.startsWith(\u0026#39;/api\u0026#39;) || path.startsWith(\u0026#39;/_next\u0026#39;) || path.includes(\u0026#39;.\u0026#39;) ) { return NextResponse.next(); } // 提取短碼 (例如從路徑 \u0026#34;/abc\u0026#34; 中分離出 \u0026#34;abc\u0026#34;) const shortCode = path.split(\u0026#39;/\u0026#39;)[1]; 這樣一來，只有真正符合短網址特徵的請求（例如：yourdomain.com/twLgDN）才會觸發後續的查詢，確保服務的核心流量不會被不必要的檢查拖慢。\nRedis Query 當確定請求路徑為短碼後，Middleware 會直接在邊緣節點向 Upstash Redis 發起異步查詢。\n// 在邊緣節點直接查詢 Redis，時間複雜度為 O(1) const longUrl: string | null = await redis.get(shortCode); 💡 為什麼在這裡查詢能做到極速？\nNext.js 的 Middleware 部署在 Vercel 的全球分佈式網路（CDN 邊緣）上。配合 Upstash Redis 的 Global Database（全球同步）特性，不論使用者是在台灣、美國還是全球其他地方點擊連結，Middleware 都會向物理距離最近的 Redis 節點讀取資料。這大大降低了跨國網路回傳的巨大延遲。\nRedirect Response 如果 Redis 成功回傳了原始的長網址，便能立即回傳 HTTP 的 Redirect 進行重新導向：\n// 4. 若找到原始網址，執行重新導向 if (longUrl) { return NextResponse.redirect(new URL(longUrl, req.url), { status: 307, }); } 💡 為什麼這裡的 redirect 使用 307？\n在 HTTP 規範中，雖然 301、302 和 307 都能達到網址跳轉的效果，但 307 具備了短網址服務最核心的兩大優勢：\n完全 No Cache，確保點擊數據精確度 與會被瀏覽器強力 cache 的 301（永久導向） 不同，307 告訴瀏覽器這只是一個「臨時」的中轉站。這意味著每一次使用者點擊該短網址時，瀏覽器都必須乖乖回到我們的 Middleware 詢問新網址。這對於未來想要擴充「統計點擊次數」、「分析訪客來源（IP、國家、裝置）」等數據分析功能至關重要。 本專案實作略過點擊次數統計及來源訪客分析。 嚴格保留原始 HTTP 請求 Method 這是 307 用來解決傳統 302（暫時導向） 的關鍵。在舊規範的 302 下，如果原本的請求是帶有資料的 POST，部分瀏覽器在重新導向時會自作聰明地將其篡改並降級為 GET，導致資料遺失或請求出錯。 而 307 則強制規定瀏覽器「絕對不能」變更請求方法。如果外部服務透過 POST 或 PATCH 呼叫你的短網址，收到 307 後，瀏覽器會確保帶著原本的 Method 與 Payload 安全地重新導向至目標長網址。 Error Handling 如果短網址在 Redis 中找不到，這時候就會進入錯誤處理。在實務上，短網址失敗通常有以下兩種情境，我們需要針對它們進行優化設計：\n1. 不合法的惡意代碼或打錯字（Invalid Code） 當使用者不小心打錯字，或是機器人惡意掃描不存在的短碼時，Redis 會回傳 null。\n// 若找不到，則交給 Next.js 後續路由，最終觸發內建的 404 頁面 return NextResponse.next(); 2. 過期的短網址（Expired Code） 我在「寫入 Redis」章節中設定了 3 天的 TTL (Time-To-Live)。 當一個短網址建立了超過 3 天，Upstash Redis 就會自動將該 Key 徹底抹除。對 Middleware 而言，過期網址的表現跟「從未存在過的不合法代碼」是一模一樣的，Redis 同樣都會回傳 null。\n當然，如果想在實務的專案中區分「打錯字」與「已過期」，可以考慮改用 Redis 的 Hash 結構，內部記錄 expiredAt 時間戳，並在 Middleware 中與當前時間進行比較。如果過期，則轉而重新導向至一個顯示「該連結已過期」的提示頁面，從而增加使用者的體驗。\n本專案實作先略過連結過期的提示頁面。\n結論 打造現代短網址服務，本質上是將邊緣運算（Edge）與記憶體資料庫（Redis）結合的過程。核心可歸納為以下四點：\n1. 核心本質：極簡的 Key-Value 系統 短網址底層就是單純的 Key-Value 映射。捨棄複雜的 SQL 關聯式資料庫，回歸最扁平的鍵值對設計，在高併發架構下將效能最佳化。\n2. 讀取優化：Redis 有效支撐高併發下的計數操作 短網址屬於極端的「讀多寫少」場景。Redis 的 O(1) 記憶體查詢速度、執行緒安全的原子操作（如 INCR 計數），以及內建的 EXPIRE（TTL）空間回收機制，是應對突發流量的最優解方。\n3. 極致體驗：Edge Runtime 消除全球延遲 透過 Next.js 的 Edge Middleware 在 CDN 邊緣節點直接攔截請求，並搭配 Upstash 的全球同步資料庫，將傳統跨國網路的往返時間（RTT）降到最低，讓全球使用者能感受到無時間差的重新導向。\n4. Serverless 架構的優勢與限制 優勢： 免運維且成本彈性。特別是 Upstash 的 HTTP/REST 連線，徹底解決了 Serverless 實例瞬間暴增導致資料庫 Connection Pool 崩潰的問題。 限制： Edge Runtime 環境精簡，不適合執行重型運算。因此架構上必須「職責分離」——邊緣節點只負責核心的「攔截與轉導」，複雜的數據分析則應交由後台非同步處理。 透過 Next.js + Upstash Redis，用極少的 code 與近乎零的維運成本，實現了高可用、低延遲的現代化 Web 架構。\n🚀 完整範例 \u0026amp; Demo 本文的實作範例可由以下查閱：\n🤞 完整範例：Github Repo │ 👻 Live Demo：點我看 demo\n","permalink":"https://doreentseng.github.io/posts/2026-06-01-short-url/","summary":"\u003cp\u003e🤞 完整範例：\u003ca href=\"https://github.com/doreentseng/url-shortener\"\u003eGithub Repo\u003c/a\u003e\n   │   \n👻 Live Demo：\u003ca href=\"https://url-shortener-doe.vercel.app/\"\u003e點我看 demo\u003c/a\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003cblockquote\u003e\n\u003cp\u003e本專案實作略過點擊次數統計及來源訪客分析。\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch2 id=\"短網址short-url是什麼\"\u003e短網址（Short URL）是什麼？\u003c/h2\u003e\n\u003cp\u003e短網址（Short URL）是將原始長網址透過 URL shortening 技術轉換為較短字串的結果，使連結更容易分享、記憶與管理。常見服務如 \u003ca href=\"https://bitly.com/\"\u003eBitly\u003c/a\u003e 與 \u003ca href=\"https://tinyurl.com/\"\u003eTinyURL\u003c/a\u003e。\u003c/p\u003e\n\u003ch3 id=\"範例\"\u003e範例\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e原始網址（Original URL）：\u003c/strong\u003e \u003ca href=\"https://www.google.com/maps/place/Taipei+101+Shopping+center/@25.0341017,121.5642863,19z/data=!3m1!5s0x3442abb6d95eb43b:0xbfc3edc9e6aa3050!4m6!3m5!1s0x3442abb6da80a7ad:0xacc4d11dc963103c!8m2!3d25.0341222!4d121.5640212!16s%2Fg%2F11fx91ft3n?entry=ttu\"\u003ehttps://www.google.com/maps/place/Taipei+101+Shopping+center/@25.0341017,121.5642863,19z/data=!3m1!5s0x3442abb6d95eb43b:0xbfc3edc9e6aa3050!4m6!3m5!1s0x3442abb6da80a7ad:0xacc4d11dc963103c!8m2!3d25.0341222!4d121.5640212!16s%2Fg%2F11fx91ft3n?entry=ttu\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e短網址（Short URL）：\u003c/strong\u003e\u003cbr\u003e\n\u003ca href=\"https://url-shortener-doe.vercel.app/twLgDN\"\u003ehttps://url-shortener-doe.vercel.app/twLgDN\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003e🥺🥺🥺 由於本專案部署於 \u003ca href=\"https://vercel.com/\"\u003eVercel\u003c/a\u003e，並使用其提供的免費子網域作為服務域名，而該網域本身字元較長，因此短網址的整體長度也相對受到影響（所以本篇範例和 Demo 的短網址會比較長喔）。\u003c/p\u003e\n\u003cp\u003e例如使用 \u003ca href=\"https://reurl.cc/\"\u003ereurl.cc\u003c/a\u003e 產生的短網址長度就只有這樣：\u003ca href=\"https://reurl.cc/53ZNyR\"\u003ehttps://reurl.cc/53ZNyR\u003c/a\u003e\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch3 id=\"為什麼需要短網址\"\u003e為什麼需要短網址？\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e縮短冗長 URL，節省字元空間\u003c/li\u003e\n\u003cli\u003e便於在字數受限的平台（如社群媒體、簡訊）分享\u003c/li\u003e\n\u003cli\u003e提升連結外觀整潔度與可讀性\u003c/li\u003e\n\u003cli\u003e可追蹤點擊次數、來源、地理位置等數據\u003c/li\u003e\n\u003cli\u003e可自訂短網址 slug，強化品牌辨識\u003c/li\u003e\n\u003cli\u003e隱藏原始網址中的參數與路徑結構\u003c/li\u003e\n\u003cli\u003e部分服務支援設定連結到期時間，控制存取期限\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"短網址的實際運作流程\"\u003e短網址的實際運作流程\u003c/h2\u003e\n\u003cp\u003e當使用者點擊一個短網址時，系統背後其實會經歷一連串非常快速的查詢與轉址流程。從使用者的角度來看只是一次點擊，但在系統內部則包含「識別 → 查詢 → 重新導向」三個核心步驟。\u003c/p\u003e\n\u003cp\u003e整體流程可拆解如下：\u003c/p\u003e\n\u003ch3 id=\"1-使用者請求短網址\"\u003e1. 使用者請求短網址\u003c/h3\u003e\n\u003cp\u003e使用者在瀏覽器輸入或點擊短網址：\u003ca href=\"https://url-shortener-doe.vercel.app/twLgDN\"\u003ehttps://url-shortener-doe.vercel.app/twLgDN\u003c/a\u003e ，此時請求會被送往短網址服務的伺服器。這類伺服器通常是邊緣節點（Edge server）或 API server。\u003c/p\u003e\n\u003ch3 id=\"2-系統解析短碼short-code\"\u003e2. 系統解析短碼（Short Code）\u003c/h3\u003e\n\u003cp\u003e伺服器會從 URL path 中解析出短碼：\u003ccode\u003etwLgDN\u003c/code\u003e，這個短碼是整個系統的核心 key。\u003c/p\u003e\n\u003ch3 id=\"3-查詢對應的原始網址original-url\"\u003e3. 查詢對應的原始網址（Original URL）\u003c/h3\u003e\n\u003cp\u003e系統會透過 key-value lookup 查詢儲存層，例如：Redis、Database、Cache layer。\u003c/p\u003e","title":"短網址服務實作：基於 Next.js 與 Upstash Redis 的短網址服務實作與架構分析"},{"content":"🤞 完整範例：Github Repo │ 👻 Live Demo：點我看 demo\n專案被擴充得越來越大時，發現自己埋的坑越來越多。\n為了讓示範更清晰，本文先略過 TypeScript。\n前期 API 的呼叫方式 前期開發 React 專案時，我習慣將 API 請求統一集中在資料夾 /api 下，由每個 component 單獨呼叫使用。\n專案規模擴大後出現的架構問題 隨著專案的規模愈來愈大，功能愈來愈複雜時，同一個 API 在多處被呼叫，我開始碰到以下問題：\n每個 component 需各自維護 loading / error / data 等狀態，導致狀態管理分散、代碼冗餘 同一支 API 被多處重複呼叫，導致請求重複，造成不必要的 Network、後端壓力及 UX 延遲 資料 cache 的缺失，不同 component 中拿到的資料內容可能出現不一致，需額外處理 資料轉換邏輯分散，難以維護 錯誤處理容易不一致，難以維護，使用者體驗不穩定 跨 component 的資料共用困難，狀態同步成本高，可能需要不斷地通過 props 傳遞或使用 store ⋯⋯\n架構的重構：API / Service / Hook / Component 因此，我決定重新設計資料存取的架構。將原本由 component 直接呼叫 API 並處理所有相關邏輯的做法，依責任拆分為 API 層、Service 層、Hook 層與 Component 層。透過職責分離，讓每一層專注於單一責任，進而提升整體的可讀性、可維護性與可擴充性。\nAPI 層 → 專注於處理實際的 HTTP 請求與共用設定，例如 base URL、headers、token、以及通用的錯誤攔截等。 Service 層 → 負責封裝業務邏輯與資料轉換，將 API 回傳的資料整理為前端實際需要的通用的資料結構。 Hook 層（我使用 TanStack Query 的 useQuery / useMutation）→ 負責資料的獲取與 server state 的管理，包含快取、重新請求、錯誤處理與狀態管理。 Component 層 → 只關注 UI 呈現與互動邏輯，不再直接處理 API 呼叫與資料轉換。 API 層 API 層的核心原則：\n專注於資料請求：所有 HTTP 請求與共用設定集中在這裡，不做任何 UI 或業務邏輯處理。 統一設定：baseURL、headers、timeout 與 interceptor 全部集中，方便修改與管理。 可測試性與可替換性：上層 Service / Hook 可以獨立測試，未來若要替換 axios 或換 HTTP client，只需修改這一層。 我將所有 API 的共用設定與具體 API function 分開。使用 axios 進行示範。先建立一個共用的 API 配置，集中管理所有 HTTP 請求的基礎設定 /lib/axiosClient.js：\nimport axios from \u0026#39;axios\u0026#39;; const axiosClient = axios.create({ baseURL: \u0026#39;https://api.example.com/v1\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;application/json\u0026#39;, }, timeout: 10 * 1000, }); axiosClient.interceptors.request.use( async (config) =\u0026gt; { return config; }, (err) =\u0026gt; { return Promise.reject(err); } ); export default axiosClient; 具體的 API function /apis/classApi.js：\nimport axiosClient from \u0026#39;@/lib/axiosClient\u0026#39;; // 取得班級列表 export const fetchClasses = async () =\u0026gt; { const { data } = await axiosClient.get(\u0026#39;/classes\u0026#39;); return data; }; /apis/studentApi.js：\nimport axiosClient from \u0026#39;@/lib/axiosClient\u0026#39;; // 取得學生列表 export const fetchStudents = async () =\u0026gt; { const { data } = await axiosClient.get(\u0026#39;/students\u0026#39;); return data; }; // 刪除學生 export const deleteStudent = async (studentId) =\u0026gt; { const { data } = await axiosClient.delete(`/students/${studentId}`); return data; }; Service 層 Service 層介於 API 層與 Hook 層／Component 層之間：\n封裝業務邏輯：把原始 API 資料轉換成符合需求的格式，例如過濾、排序、計算等。 集中錯誤處理：可在此統一處理 API 錯誤、網路異常或回傳格式問題。 提高可維護性：當 API 回傳結構改變時，只需修改 Service 層，Component / Hook 不受影響。 整合多個 API：可在 Service 層將多個 API 結果組合、計算或轉換成複合資料供上層使用。 /services/classService.js\nimport { fetchClasses } from \u0026#39;@/apis/classApi\u0026#39;; export const getClassMap = async () =\u0026gt; { try { const classesData = await fetchClasses(); if (!classesData || !Array.isArray(classesData.classes)) { console.warn(\u0026#39;班級資料格式錯誤，返回空對照表\u0026#39;); return {}; } return Object.fromEntries( classesData.classes.map(c =\u0026gt; [c.id, `${c.grade}年${c.class}班`]) ); } catch (error) { return {}; } }; /services/studentService.js\nimport { fetchStudents, deleteStudent as apiDeleteStudent } from \u0026#39;@/apis/studentApi\u0026#39;; import { fetchClasses } from \u0026#39;@/apis/classApi\u0026#39;; import { getClassMap } from \u0026#39;./classService\u0026#39;; import { BusinessError } from \u0026#39;@/lib/errors\u0026#39;; // 一個 service 呼叫 API 並使用另一個 service export const getStudents = async () =\u0026gt; { try { const [studentsData, classMap] = await Promise.all([ fetchStudents(), getClassMap(), ]); return studentsData.students.map(s =\u0026gt; ({ ...s, classFullName: classMap[s.classId] || \u0026#39;Unknown\u0026#39;, })); } catch (error) { // 在取得學生列表有問題時，可在這裡統一降級處理 throw error; } }; // 刪除學生 export const deleteStudent = async (studentId) =\u0026gt; { try { // 驗證 studentId，統一在 studentService 中拋出刪除時找不到 studentId 的錯誤 if (!studentId) { throw new BusinessError(\u0026#39;學生 ID 不可為空\u0026#39;, \u0026#39;INVALID_STUDENT_ID\u0026#39;); } await apiDeleteStudent(studentId); } catch (error) { throw error; } }; Hook 層 Hook 層介於 Service 層與 Component 層之間，它的核心目的就是：把資料獲取與狀態管理邏輯封裝起來，讓 Component 可以專注於 UI：\n狀態自動化：管理 loading / error / stale 等的狀態，例如 isLoading、isError、data、refetch，component 不需要自己寫 useEffect + useState 快取與重新發送：透過成熟的套件（如 TanStack Query），可以實現資料快取，減少重複請求。 封裝資料獲取：直接呼叫 Service 層的 function，並返回 ready-to-use 的資料，可以把多個 service 組合的結果封裝成單一 hook，component 只需要拿資料就好。 /hooks/useUsers.js\nimport { useQuery } from \u0026#39;@tanstack/react-query\u0026#39;; import { getStudents } from \u0026#39;@/services/studentService\u0026#39;; export const useStudents = () =\u0026gt; { return useQuery({ queryKey: [\u0026#39;students\u0026#39;], // Query 的唯一鍵值，用來辨識和快取這筆 \u0026#39;students\u0026#39; 資料 queryFn: getStudents, staleTime: 1000 * 60, }); }; /hooks/useDeleteUser.js\nimport { useMutation, useQueryClient } from \u0026#39;@tanstack/react-query\u0026#39;; import { deleteStudent } from \u0026#39;@/services/studentService\u0026#39;; export const useDeleteStudent = (options = {}) =\u0026gt; { const { onSuccess: customOnSuccess, } = options; const queryClient = useQueryClient(); return useMutation({ mutationFn: deleteStudent, // 刪除成功後重新獲取學生列表 onSuccess: (data, studentId, context) =\u0026gt; { queryClient.invalidateQueries([\u0026#39;students\u0026#39;]); customOnSuccess?.(data, studentId, context); }, }); }; Component 層 Component 層是最接近 UI 的一層，它的核心職責就是：呈現使用者介面，而不需要知道資料從哪裡來，也不處理複雜的資料邏輯。\n簡單說，Component 層專注於「畫面」，把資料邏輯交給 Hook / Service 層處理：\n只負責 UI 的呈現：不直接呼叫 API，也不做資料過濾或組合，所有資料都透過 Hook 拿到 ready-to-use 的狀態。 使用 Hook 層取得資料：React Query hooks、custom hooks 等都在這裡使用，包含取得的資料和取得資料的狀態等。 回傳使用者互動事件：例如按鈕觸發 mutation（新增、刪除、更新）；component 不處理資料細節，只呼叫 hook 提供的 mutation。 /components/StudentComponent.jsx\nimport { useState, useEffect } from \u0026#39;react\u0026#39;; import { useStudents } from \u0026#39;@/hooks/useStudents\u0026#39;; import { useDeleteStudent } from \u0026#39;@/hooks/useDeleteStudent\u0026#39;; import { getUserFriendlyMessage } from \u0026#39;@/lib/errors\u0026#39;; function StudentDashboard() { const [successMessage, setSuccessMessage] = useState(\u0026#39;\u0026#39;); // 使用 hook 並取得錯誤狀態 const { data: students, isLoading, isError, error, refetch } = useStudents(); const { mutate: deleteStudent, isPending: isDeleting, isError: isDeleteError, error: deleteError, } = useDeleteStudent({ onSuccess: () =\u0026gt; { setSuccessMessage(\u0026#39;刪除成功！\u0026#39;); } }); // 載入中狀態 if (isLoading) { return \u0026lt;div\u0026gt;載入中...\u0026lt;/div\u0026gt;; } // 錯誤狀態 - 在 Component 層處理錯誤顯示 if (isError) { return ( \u0026lt;div\u0026gt; \u0026lt;div\u0026gt; \u0026lt;h3\u0026gt;無法載入學生資料\u0026lt;/h3\u0026gt; \u0026lt;p\u0026gt;{getUserFriendlyMessage(error)}\u0026lt;/p\u0026gt; \u0026lt;button onClick={() =\u0026gt; refetch()}\u0026gt; 重試 \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); } return ( \u0026lt;div\u0026gt; \u0026lt;h2\u0026gt;學生列表\u0026lt;/h2\u0026gt; {/* 刪除成功提示 */} {successMessage \u0026amp;\u0026amp; ( \u0026lt;div\u0026gt;{successMessage}\u0026lt;/div\u0026gt; )} {/* 刪除錯誤提示 - inline 顯示 */} {isDeleteError \u0026amp;\u0026amp; ( \u0026lt;div\u0026gt;{getUserFriendlyMessage(deleteError)}\u0026lt;/div\u0026gt; )} \u0026lt;ul\u0026gt; \u0026lt;li\u0026gt; \u0026lt;span\u0026gt;姓名\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;性別\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;班級\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;生日\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;\u0026lt;/span\u0026gt; \u0026lt;/li\u0026gt; {students.length === 0 ? ( \u0026lt;li\u0026gt;目前沒有學生資料\u0026lt;/li\u0026gt; ) : ( students.map((s) =\u0026gt; ( \u0026lt;li key={s.id}\u0026gt; \u0026lt;span\u0026gt;{s.name}\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;{s.gender}\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;{s.classFullName}\u0026lt;/span\u0026gt; \u0026lt;span\u0026gt;{s.birthday}\u0026lt;/span\u0026gt; \u0026lt;button onClick={() =\u0026gt; deleteStudent(s.id)} disabled={isDeleting} \u0026gt; {isDeleting ? \u0026#39;刪除中...\u0026#39; : \u0026#39;刪除\u0026#39;} \u0026lt;/button\u0026gt; \u0026lt;/li\u0026gt; )) )} \u0026lt;/ul\u0026gt; \u0026lt;/div\u0026gt; ); } export default StudentDashboard; 🚀 完整範例 \u0026amp; Demo 本文的所有概念、API / Service / Hook / Component 層分層策略，都已在範例專案中經過補充，完整呈現（但還是沒有加入 typescript 喔嘿）。\n🤞 完整範例：Github Repo │ 👻 Live Demo：點我看 demo\n","permalink":"https://doreentseng.github.io/posts/2026-03-18-react-data-fetching-architecture/","summary":"\u003cp\u003e🤞 完整範例：\u003ca href=\"https://github.com/doreentseng/react-data-fetching-architecture-example\"\u003eGithub Repo\u003c/a\u003e\n   │   \n👻 Live Demo：\u003ca href=\"https://react-data-fetching-architecture.vercel.app/\"\u003e點我看 demo\u003c/a\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e專案被擴充得越來越大時，發現自己埋的坑越來越多。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e為了讓示範更清晰，本文先略過 TypeScript。\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch2 id=\"前期-api-的呼叫方式\"\u003e前期 API 的呼叫方式\u003c/h2\u003e\n\u003cp\u003e前期開發 React 專案時，我習慣將 API 請求統一集中在資料夾 \u003ccode\u003e/api\u003c/code\u003e 下，由每個 component 單獨呼叫使用。\u003c/p\u003e\n\u003ch2 id=\"專案規模擴大後出現的架構問題\"\u003e專案規模擴大後出現的架構問題\u003c/h2\u003e\n\u003cp\u003e隨著專案的規模愈來愈大，功能愈來愈複雜時，同一個 API 在多處被呼叫，我開始碰到以下問題：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e每個 component 需各自維護 loading / error / data 等狀態，導致狀態管理分散、代碼冗餘\u003c/li\u003e\n\u003cli\u003e同一支 API 被多處重複呼叫，導致請求重複，造成不必要的 Network、後端壓力及 UX 延遲\u003c/li\u003e\n\u003cli\u003e資料 cache 的缺失，不同 component 中拿到的資料內容可能出現不一致，需額外處理\u003c/li\u003e\n\u003cli\u003e資料轉換邏輯分散，難以維護\u003c/li\u003e\n\u003cli\u003e錯誤處理容易不一致，難以維護，使用者體驗不穩定\u003c/li\u003e\n\u003cli\u003e跨 component 的資料共用困難，狀態同步成本高，可能需要不斷地通過 props 傳遞或使用 store\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e⋯⋯\u003c/p\u003e\n\u003ch2 id=\"架構的重構api--service--hook--component\"\u003e架構的重構：API / Service / Hook / Component\u003c/h2\u003e\n\u003cp\u003e因此，我決定重新設計資料存取的架構。將原本由 component 直接呼叫 API 並處理所有相關邏輯的做法，依責任拆分為 API 層、Service 層、Hook 層與 Component 層。透過職責分離，讓每一層專注於單一責任，進而提升整體的可讀性、可維護性與可擴充性。\u003c/p\u003e","title":"大型 React 專案中 API 層的演進與分層設計"},{"content":"拖了好久的文，終於來寫。\n這篇預設給熟悉Array.map()與for loop的朋朋閱讀。\n什麼情境下選擇改用Map 通常在從 server API 取得資料後、實際應用到前端之前，我會先進行資料轉換，調整成自己習慣的 key 命名及資料格式，並重組成更適合使用的資料結構。\n當需求只是單純的一對一轉換時，整個流程前後都是以 array 為主，使用Array.map()就能很好地完成這件事。\n不過當需求開始變成「一個 key 對應多筆資料」時，資料處理的重點變成「如何依照 key 累積資料」。這時我發現，使用 object 來暫存分組結果，並不能清楚表達我正在建立一個用來查找與分組的索引結構，因此改用Map，讓 code 本身更貼近實際的資料處理意圖。\n當資料是一對一時：Array.map() 當從 server API 取得資料後，如果需求只是將資料轉換成前端習慣的格式，例如調整 key 命名、轉換欄位值或補上預設值，這類一對一的資料轉換，使用Array.map()就已經非常足夠。\n例如，server 回傳的員工資料可能長這樣：\nconst employees = [{ \u0026#34;eid\u0026#34;: \u0026#34;202242\u0026#34;, // 員工編號 \u0026#34;did\u0026#34;: \u0026#34;d01\u0026#34;, // 部門 ID \u0026#34;fullname\u0026#34;: \u0026#34;Tom Smith\u0026#34;, // 員工全名 \u0026#34;gender\u0026#34;: 1 // 員工性別 }, { \u0026#34;eid\u0026#34;: \u0026#34;202414\u0026#34;, \u0026#34;did\u0026#34;: \u0026#34;d02\u0026#34;, \u0026#34;fullname\u0026#34;: \u0026#34;Chen Peiyu\u0026#34;, \u0026#34;gender\u0026#34;: 2 }, { \u0026#34;eid\u0026#34;: \u0026#34;202505\u0026#34;, \u0026#34;did\u0026#34;: \u0026#34;d01\u0026#34;, \u0026#34;fullname\u0026#34;: \u0026#34;Chang lin\u0026#34;, \u0026#34;gender\u0026#34;: 2 }]; 在前端實際使用前，我通常會先做一次資料轉換：\nconst genderMap = { 1: \u0026#34;male\u0026#34;, 2: \u0026#34;female\u0026#34; }; const updatedEmployees = employees.map((emp) =\u0026gt; ({ \u0026#34;employeeId\u0026#34;: emp.eid, \u0026#34;departmentId\u0026#34;: emp.did, \u0026#34;fullname\u0026#34;: emp.fullname, \u0026#34;gender\u0026#34;: genderMap[emp.gender] ?? \u0026#34;unknown\u0026#34; })); 在這種情境下，每一筆輸入資料都對應到一筆輸出資料，資料筆數前後保持一致，Array.map()能很清楚地表達「這一筆資料要被轉換成什麼樣子」。\n也正因為如此，當需求僅僅是 key 的重命名或資料格式的調整時，我認為Array.map()是最直覺、合適的做法，完全不需要額外的狀態或暫存結構。\n而這類一對一的轉換，實際上是在描述「資料如何被轉換」，而不是「資料之間的關係」。\n問題出現：當資料不再是一對一 然而當需求改為「依照某個欄位進行分組」時，問題就開始出現了。\n以資料employees為例，如果希望依照部門departmentId顯示員工清單，理想中的結果是：\n[ { departmentId: \u0026#34;d01\u0026#34;, employees: [ /* 2 位員工： Tom Smith、Chang lin */ ], }, { departmentId: \u0026#34;d02\u0026#34;, employees: [ /* 1 位員工：Chen Peiyu */ ], }, ]; 很明顯可以看出，此時的資料長度已不同於原來的employees長度。\n繼續使用原本的方法：object + for loop const genderMap = { 1: \u0026#34;male\u0026#34;, 2: \u0026#34;female\u0026#34;, }; const grouped = Object.create(null); for (const emp of employees) { if (!grouped[emp.did]) { grouped[emp.did] = []; } grouped[emp.did].push({ employeeId: emp.eid, departmentId: emp.did, fullname: emp.fullname, gender: genderMap[emp.gender] ?? \u0026#34;unknown\u0026#34;, }); }; // { // \u0026#34;d01\u0026#34;: [/* 2 位員工： Tom Smith、Chang lin */], // \u0026#34;d02\u0026#34;: [/* 1 位員工：Chen Peiyu */] // } 上面的grouped被用來暫存分組結果，其中 key 是departmentId，value 是該部門底下的employees。\n最後再做一次如下的轉換，便可以得到我所需要的資料結構。\nconst result = Object.entries(grouped).map( ([departmentId, employees]) =\u0026gt; ({ departmentId, employees, }) ); 以上的轉換從結果上來，整個分組的過程是正確的。\n然而這樣的寫法，問題不在於「結果是否正確」，而在於整個過程，有沒有清楚表達出「一個 key 對應多筆資料」的意圖。\n在這裡，object 同時被用來暫時存放資料，並作為 key 與其對應資料之間的索引。 但這層語意並無法從grouped的宣告本身看出來，而是必須透過後續賦值與操作的方式，才能逐步推斷。\n當資料或轉換過程在往後變得更複雜時，「一個 key 對應多筆資料」的意圖將會更難由這樣的過程得出。\n意識並建立「索引」：改用Map 於是我改用 Map－－本身就是設計用來專門描述 key 與其對應資料關係的索引結構。\nconst genderMap = { 1: \u0026#34;male\u0026#34;, 2: \u0026#34;female\u0026#34; }; const indexByDepartment = new Map(); for (const emp of employees) { if (!indexByDepartment.has(emp.did)) { indexByDepartment.set(emp.did, []); } indexByDepartment.get(emp.did).push({ employeeId: emp.eid, departmentId: emp.did, fullname: emp.fullname, gender: genderMap[emp.gender] ?? \u0026#34;unknown\u0026#34; }); } 在這段 code 中，indexByDepartment的角色非常明確：\nhas：檢查某個 key 是否已存在 set：建立 key 與其對應資料的關係 get：透過 key 取得並累積對應的資料 這些操作直接說明「一個 key 對應多筆資料」的意圖。\n實際上改用Map並沒有讓這段 code 的處理過程變得不同， 它和前一節的 object + for loop 在功能上是等價的； 真正的差異在於：索引的語意，從隱含在使用方式中，變成由資料結構本身所表達。\n當看到Map時，已經能夠讓讀者預期接下來的實作過程是圍繞著「查找、建立對應關係、累積資料」這些行為展開，而不需要從操作細節中反推其用途。\n也正因如此，在資料處理邏輯開始圍繞著「關係」而非「單筆轉換」時，Map會成為一個更貼近實際需求、也更容易被理解的選擇。\n最後再轉成前端實際應用所需要的 array 當然使用Map建立完索引，做完資料的對應和處理後，為了讓前端可以應用，要再將資料轉換回 array：\nconst result = Array.from( indexByDepartment.entries(), ([departmentId, employees]) =\u0026gt; ({ departmentId, employees, }) ); 最終可以得到結構如下的資料：\n[ { departmentId: \u0026#34;d01\u0026#34;, employees: [ { employeeId: \u0026#34;202242\u0026#34;, departmentId: \u0026#34;d01\u0026#34;, fullname: \u0026#34;Tom Smith\u0026#34;, gender: \u0026#34;male\u0026#34;, }, { employeeId: \u0026#34;202505\u0026#34;, departmentId: \u0026#34;d01\u0026#34;, fullname: \u0026#34;Chang lin\u0026#34;, gender: \u0026#34;female\u0026#34;, }, ], }, { departmentId: \u0026#34;d02\u0026#34;, employees: [ { employeeId: \u0026#34;202414\u0026#34;, departmentId: \u0026#34;d02\u0026#34;, fullname: \u0026#34;Chen Peiyu\u0026#34;, gender: \u0026#34;female\u0026#34;, }, ], }, ]; 結論就是，改用Map並不是為了改變最終資料格式，而是讓資料處理的過程，能更清楚地表達正在建立「一個 key 對應多筆資料」的關係。\n","permalink":"https://doreentseng.github.io/posts/2026-01-19-new-map/","summary":"\u003cp\u003e拖了好久的文，終於來寫。\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e這篇預設給熟悉\u003ccode\u003eArray.map()\u003c/code\u003e與\u003ccode\u003efor loop\u003c/code\u003e的朋朋閱讀。\u003c/p\u003e\u003c/blockquote\u003e\n\u003ch2 id=\"什麼情境下選擇改用map\"\u003e什麼情境下選擇改用\u003ccode\u003eMap\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003e通常在從 server API 取得資料後、實際應用到前端之前，我會先進行資料轉換，調整成自己習慣的 key 命名及資料格式，並重組成更適合使用的資料結構。\u003c/p\u003e\n\u003cp\u003e當需求只是單純的一對一轉換時，整個流程前後都是以 array 為主，使用\u003ccode\u003eArray.map()\u003c/code\u003e就能很好地完成這件事。\u003c/p\u003e\n\u003cp\u003e不過當需求開始變成「\u003cstrong\u003e一個 key 對應多筆資料\u003c/strong\u003e」時，資料處理的重點變成「如何依照 key 累積資料」。這時我發現，使用 object 來暫存分組結果，並不能清楚表達我正在建立一個用來查找與分組的索引結構，因此改用\u003ccode\u003eMap\u003c/code\u003e，讓 code 本身更貼近實際的資料處理意圖。\u003c/p\u003e\n\u003ch2 id=\"當資料是一對一時arraymap\"\u003e當資料是一對一時：\u003ccode\u003eArray.map()\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003e當從 server API 取得資料後，如果需求只是將資料轉換成前端習慣的格式，例如調整 key 命名、轉換欄位值或補上預設值，這類一對一的資料轉換，使用\u003ccode\u003eArray.map()\u003c/code\u003e就已經非常足夠。\u003c/p\u003e\n\u003cp\u003e例如，server 回傳的員工資料可能長這樣：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-js\" data-lang=\"js\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003eemployees\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;eid\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;202242\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 員工編號\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;did\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;d01\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 部門 ID\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;fullname\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Tom Smith\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 員工全名\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;gender\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e1\u003c/span\u003e \u003cspan class=\"c1\"\u003e// 員工性別\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e\u003c/span\u003e\u003cspan class=\"p\"\u003e},\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;eid\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;202414\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;did\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;d02\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;fullname\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Chen Peiyu\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;gender\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e},\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;eid\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;202505\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;did\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;d01\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;fullname\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;Chang lin\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;gender\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}];\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e在前端實際使用前，我通常會先做一次資料轉換：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-js\" data-lang=\"js\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003egenderMap\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"mi\"\u003e1\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;male\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"mi\"\u003e2\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;female\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e};\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"kr\"\u003econst\u003c/span\u003e \u003cspan class=\"nx\"\u003eupdatedEmployees\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eemployees\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003emap\u003c/span\u003e\u003cspan class=\"p\"\u003e((\u003c/span\u003e\u003cspan class=\"nx\"\u003eemp\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e({\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;employeeId\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003eemp\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003eeid\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;departmentId\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003eemp\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003edid\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;fullname\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003eemp\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003efullname\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e  \u003cspan class=\"s2\"\u003e\u0026#34;gender\u0026#34;\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003egenderMap\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nx\"\u003eemp\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"nx\"\u003egender\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e??\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;unknown\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e在這種情境下，每一筆輸入資料都對應到一筆輸出資料，資料筆數前後保持一致，\u003ccode\u003eArray.map()\u003c/code\u003e能很清楚地表達「這一筆資料要被轉換成什麼樣子」。\u003c/p\u003e\n\u003cp\u003e也正因為如此，當需求僅僅是 key 的重命名或資料格式的調整時，我認為\u003ccode\u003eArray.map()\u003c/code\u003e是最直覺、合適的做法，完全不需要額外的狀態或暫存結構。\u003c/p\u003e","title":"從 Array.map() 的一對一轉換，到用 Map 建立索引"}]