<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>7LUN CHAPTER Blog</title>
        <link>https://www.7lunchapter.com/blog</link>
        <description>7LUN CHAPTER Blog</description>
        <lastBuildDate>Wed, 24 Jun 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-Hant</language>
        <item>
            <title><![CDATA[用 Nuxt 4 部落格開發]]></title>
            <link>https://www.7lunchapter.com/blog/hexSchool-2026</link>
            <guid>https://www.7lunchapter.com/blog/hexSchool-2026</guid>
            <pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[前言]]></description>
            <content:encoded><![CDATA[<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="前言">前言<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E5%89%8D%E8%A8%80" class="hash-link" aria-label="前言的直接連結" title="前言的直接連結" translate="no">​</a></h3>
<p>繼上一個 React 專案之後，這次我用 Nuxt 4 完成六角學院 2026 軟體工程師體驗營的部落格設計稿切版。和過去做 SPA 的經驗最大的不同，是這次必須正面理解 SSR（伺服器端渲染）——也因此開始真正理解 Nuxt 與 Vue 在執行流程上的差異，以及開發時需要注意的細節。</p>
<p>這篇文章主要整理本次使用 Nuxt 開發的學習歷程，內容涵蓋實作過程中的技術選型、遇到的問題與解法，以及對 SSR 開發模式的理解與反思，希望能為未來的自己留下完整紀錄，同時也提供給有類似需求的開發者作為參考。</p>
<p>另外也附上開發期間整理的基礎知識筆記 <a class="" href="https://www.7lunchapter.com/docs/nuxt/Nuxt%EF%BD%9CNuxt%204%20%E7%AC%AC%E4%B8%80%E6%AC%A1%E9%96%8B%E7%99%BC%E7%B4%80%E9%8C%84"><strong>開發時記錄下來的基礎知識點筆記</strong></a></p>
<ul>
<li class=""><strong>Live Demo</strong>：<a href="https://hex-blog-nu.vercel.app/" target="_blank" rel="noopener noreferrer" class=""><strong>HexSchool2026 - Nelson Blog</strong></a></li>
<li class=""><strong>GitHub</strong>：<a href="https://github.com/MalricHsu/hex-blog" target="_blank" rel="noopener noreferrer" class=""><strong>GitHub Repo</strong></a></li>
<li class=""><strong>使用技術</strong>：<code>Nuxt 4</code> / <code>Vue 3</code> / <code>@nuxt/content</code> / <code>Pinia</code> / <code>Bootstrap 5</code> / <code>Sass</code> / <code>Zod</code> / <code>Axios</code> / <code>Swiper</code></li>
<li class=""><strong>專案時程</strong>：2026.06.15 ~ 2026.06.24</li>
<li class=""><strong>網站部署</strong>：Vercel</li>
</ul>
<!-- -->
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="一-為什麼選擇使用-nuxt">一、 為什麼選擇使用 Nuxt<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E4%B8%80-%E7%82%BA%E4%BB%80%E9%BA%BC%E9%81%B8%E6%93%87%E4%BD%BF%E7%94%A8-nuxt" class="hash-link" aria-label="一、 為什麼選擇使用 Nuxt的直接連結" title="一、 為什麼選擇使用 Nuxt的直接連結" translate="no">​</a></h3>
<p>我過去主要使用 React 搭配 Vite 開發專案，因此這次體驗營其實也可以選擇用熟悉的技術完成。不過考量到自己一直沒有正式接觸過 Nuxt，因此決定把這次專案當成學習機會，嘗試使用 Vue 生態系中的全端框架來完成整個部落格網站。</p>
<p>實際開發後才發現，Nuxt 並不只是幫 Vue 加上一些便利功能而已。它最大的特色在於內建 <strong>SSR（Server-Side Rendering）</strong> 能力，讓頁面能先在伺服器完成渲染後再傳送給瀏覽器。雖然這次選擇 Nuxt 的原因不是為了解決 SEO 問題，但在理解 SSR 的過程中，也開始認識到它在搜尋引擎優化、首屏載入速度以及使用者體驗上的優勢。</p>
<p>除此之外，Nuxt 還提供檔案式路由、自動匯入以及 Nitro 內建後端等功能，讓前後端開發能整合在同一個專案中。對於第一次接觸 Nuxt 的我來說，這些功能雖然提升了開發效率，但也讓我必須重新理解程式究竟是在伺服器執行，還是在瀏覽器執行。</p>
<p>而這其中最重要、也最容易讓人混淆的，就是 SSR 的運作模式。因此在正式開始切版與功能開發之前，我先花了一些時間理解 Nuxt 的執行流程，以及它與一般前端 SPA 應用之間的差異。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="二-理解-nuxt-ssr-的執行流程">二、 理解 Nuxt SSR 的執行流程<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E4%BA%8C-%E7%90%86%E8%A7%A3-nuxt-ssr-%E7%9A%84%E5%9F%B7%E8%A1%8C%E6%B5%81%E7%A8%8B" class="hash-link" aria-label="二、 理解 Nuxt SSR 的執行流程的直接連結" title="二、 理解 Nuxt SSR 的執行流程的直接連結" translate="no">​</a></h3>
<p>我一開始對於 SSR 有個錯誤的想像，以為是「有些元件在伺服器渲染、有些在瀏覽器渲染」。後來才發現完全不是這樣。</p>
<p>正確的模型是：<strong>整頁先在伺服器跑一次，產出 HTML，送到瀏覽器畫出來；然後同一批元件在瀏覽器再跑一次，接管那串 HTML，讓它變成可以互動的頁面。</strong></p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">伺服器：整頁元件執行一次 → 產出 HTML → 送到瀏覽器</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                    ↓</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">瀏覽器：先把 HTML 畫出來（使用者馬上看到，但還不能互動）</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">                                    ↓</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">瀏覽器：再把「同一批元件」跑一次，接管這些 HTML → 變成可互動</span><br></div></code></pre></div></div>
<p>最後那個「接管」的動作有個專門名稱叫 <strong>Hydration（水合）</strong> ，是借用化學的比喻，把乾燥的靜態 HTML「加水」活化成有生命力、能互動的頁面。</p>
<p>理解這個模型後，很多事情就說得通了：Nav、Footer、文章頁......每個元件都是 <strong>「伺服器跑一次、瀏覽器再跑一次」</strong>，不是二選一。這也是 SSR 對 SEO 有利的原因——爬蟲一進來就拿到完整 HTML。</p>
<p>這裡整理我自己常回頭參考的對照表，記住「哪些程式碼會在伺服器跑」非常重要：</p>
<table><thead><tr><th>程式碼位置</th><th>伺服器會跑</th><th>瀏覽器會跑</th></tr></thead><tbody><tr><td><code>&lt;script setup&gt;</code></td><td>會</td><td>會（水合那次）</td></tr><tr><td><code>useFetch</code> / <code>useAsyncData</code></td><td>會，抓資料</td><td>不會，不重抓</td></tr><tr><td><code>onMounted</code></td><td>不會</td><td>只會在這裡</td></tr><tr><td>事件處理函式（<code>@click</code>）</td><td>不會</td><td>會，使用者操作時</td></tr><tr><td><code>&lt;ClientOnly&gt;</code> 內的元件</td><td>不會，跳過</td><td>只會在這裡</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="三-開發前-nuxt-幫你做好哪些事">三、 開發前 Nuxt 幫你做好哪些事<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E4%B8%89-%E9%96%8B%E7%99%BC%E5%89%8D-nuxt-%E5%B9%AB%E4%BD%A0%E5%81%9A%E5%A5%BD%E5%93%AA%E4%BA%9B%E4%BA%8B" class="hash-link" aria-label="三、 開發前 Nuxt 幫你做好哪些事的直接連結" title="三、 開發前 Nuxt 幫你做好哪些事的直接連結" translate="no">​</a></h3>
<p>理解 SSR 的執行流程後，就正式開始切版與功能開發。這個階段最大的感受是：Nuxt 幫開發者省下了不少基礎設置的時間，讓我能更專注在畫面與功能實作上。</p>
<p>最有感的是檔案式路由。以前接觸 React 時，需要額外規劃與維護路由設定；在 Nuxt 中則是直接透過 <code>pages/</code> 目錄建立頁面，例如 <code>pages/index.vue</code> 對應首頁，<code>pages/blog/[id].vue</code> 對應文章詳細頁。只要建立檔案，路由就會自動產生，開發體驗相當直覺。</p>
<p>版型管理則透過 Layouts 處理。我將網站共用的 Header、Footer 抽到 <code>layouts/default.vue</code>，各頁面只需要關注自己的內容。若特定頁面需要不同版型，也能透過 <code>definePageMeta()</code> 指定其他 Layout，避免在每個頁面重複撰寫相同結構。</p>
<p>另一個讓我印象深刻的是自動匯入機制。無論是 <code>components/</code> 裡的元件、Vue 提供的 Composition API，或是自己撰寫的 <code>composables/</code>，都不需要手動 <code>import</code>。雖然方便，但也因此踩到一個小坑：Pinia 的 Store 必須放在 <code>stores/</code> 目錄下才能被自動偵測，如果誤命名成 <code>store/</code>，Nuxt 就不會自動匯入。另外新增這類特殊目錄後，有時需要重新啟動開發伺服器，Nuxt 才會重新建立索引。</p>
<blockquote>
<p>順帶一提，這次使用的是 Nuxt 4。和許多 Nuxt 3 教學不同，Nuxt 4 預設將前端程式碼集中在 <code>app/</code> 目錄下，例如 <code>app/pages</code>、<code>app/components</code>、<code>app/layouts</code> 等。因此在查資料時，如果看到教學中的 <code>pages/</code> 位於專案根目錄，需要先確認版本差異，避免照著操作卻找不到對應位置。</p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="四-透過-markdown-管理文章資料">四、 透過 Markdown 管理文章資料<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E5%9B%9B-%E9%80%8F%E9%81%8E-markdown-%E7%AE%A1%E7%90%86%E6%96%87%E7%AB%A0%E8%B3%87%E6%96%99" class="hash-link" aria-label="四、 透過 Markdown 管理文章資料的直接連結" title="四、 透過 Markdown 管理文章資料的直接連結" translate="no">​</a></h3>
<p>骨架搭好後，下一步就是放入文章內容。</p>
<p>一開始我沒有打算把文章資料寫在 JavaScript 陣列裡，因為內容一多不但難維護，也失去了部落格「寫文章」的感覺。因此我選擇使用 <code>@nuxt/content</code>，把每篇文章都獨立寫成 Markdown 檔案，讓內容與程式碼分離。</p>
<p>在 Nuxt 中，只要建立 <code>content/blog/1.md</code> 這類檔案，就能像平常寫筆記一樣使用 Markdown 撰寫文章，支援標題、圖片、表格、程式碼區塊等常見格式。同時還能搭配 Frontmatter 存放文章資訊，例如日期、分類、封面圖片等。</p>
<p>為了讓這些欄位有型別檢查，我先在 <code>content.config.ts</code> 中定義 collection 與 schema：</p>
<div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">import</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> defineContentConfig</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> defineCollection</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> z </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">from</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"@nuxt/content"</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">export</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">default</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">defineContentConfig</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  collections</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    blog</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">defineCollection</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      type</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"page"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      source</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"blog/**/*.md"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      schema</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">object</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        description</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">optional</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        image</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        categories</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">array</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        date</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        dateFormatted</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        views</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">number</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        shares</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">number</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>這段設定的作用是告訴 Nuxt：<code>blog</code> collection 底下的每篇文章都必須符合這個資料結構。如果 Frontmatter 少了必要欄位，或型別不符合定義，開發階段就能提早發現問題。</p>
<p>部落格<code>index</code>頁面資料，透過 <code>queryCollection()</code> 查詢：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token literal-property property" style="color:#36acaa">data</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> blogs </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">useAsyncData</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"blog-list"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">queryCollection</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"blog"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">all</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>取得資料後，就能在頁面中渲染文章列表；而部落格詳細頁則可以透過 <code>&lt;ContentRenderer&gt;</code> 將 Markdown 內容轉換成 HTML 顯示。</p>
<p>對於個人部落格來說，這種模式很適合。文章本質上就是 Markdown 檔案，可以直接用 Git 版控，不需要另外架設資料庫或後台系統。如果未來要做成多人使用、可在線上發文的平台，再改用資料庫會比較合適。</p>
<p>這裡先埋下一個伏筆：雖然平常開發時一切正常，但後來部署到 Vercel 時卻遇到了一個意料之外的問題。追查後才發現，<code>@nuxt/content</code> 底層其實使用了 SQLite，而這也成了整個部署過程中最值得記錄的一個坑。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="五理解-nuxt-的資料取得模式">五、理解 Nuxt 的資料取得模式<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E4%BA%94%E7%90%86%E8%A7%A3-nuxt-%E7%9A%84%E8%B3%87%E6%96%99%E5%8F%96%E5%BE%97%E6%A8%A1%E5%BC%8F" class="hash-link" aria-label="五、理解 Nuxt 的資料取得模式的直接連結" title="五、理解 Nuxt 的資料取得模式的直接連結" translate="no">​</a></h3>
<p>這一段是整個專案中我覺得最值得搞懂的地方。</p>
<p>剛接觸 Nuxt 時，我一直分不清 <code>useAsyncData</code>、<code>useFetch</code>、<code>$fetch</code> 和 <code>axios</code> 到底差在哪。後來才發現，答案其實都跟 SSR 有關。</p>
<p>在 Nuxt 中，頁面第一次載入時會先由伺服器執行一次，產生包含資料的 HTML 後再傳給瀏覽器。因此最大的差異不是「<strong>能不能抓到資料</strong>」，而是「<strong>抓到的資料能不能被瀏覽器重用</strong>」。</p>
<p><code>useAsyncData</code> 與 <code>useFetch</code> 會在 SSR 階段取得資料，並把結果一起傳給瀏覽器。等到 Hydration 時，瀏覽器直接使用這份資料，不需要再次發送請求。</p>
<p>但如果在 <code>&lt;script setup&gt;</code> 中直接使用 <code>$fetch</code> 或 <code>axios</code>，Nuxt 不會幫忙傳遞資料，因此同一段程式碼可能在伺服器執行一次、瀏覽器再執行一次，造成重複請求。</p>
<p>理解這個差異後，四種工具的定位就變得很清楚：</p>
<table><thead><tr><th>工具</th><th>頁面載入時（SSR）</th><th>按鈕事件中</th><th>特點</th></tr></thead><tbody><tr><td><code>useAsyncData()</code></td><td>推薦</td><td>不適用</td><td>支援 SSR，可執行任何非同步邏輯</td></tr><tr><td><code>useFetch()</code></td><td>推薦</td><td>不適用</td><td><code>useAsyncData</code> 的 API 封裝版</td></tr><tr><td><code>$fetch()</code></td><td>不建議</td><td>推薦</td><td>單純發送請求，不處理 SSR 資料傳遞</td></tr><tr><td><code>axios</code></td><td>不建議</td><td>可用</td><td>第三方套件，與 Nuxt SSR 無整合</td></tr></tbody></table>
<p>我後來的理解其實很簡單：</p>
<blockquote>
<p><strong>進頁面就要顯示的資料，用 <code>useAsyncData()</code> 或 <code>useFetch()</code>；使用者操作後才需要發送的請求，用 <code>$fetch()</code> 或 <code>axios</code>。</strong></p>
</blockquote>
<p>實際套用到這次專案：</p>
<ul>
<li class="">文章列表頁使用 <code>useAsyncData()</code></li>
<li class="">文章詳細頁使用 <code>useAsyncData()</code></li>
<li class="">其他透過 API 取得的頁面資料使用 <code>useFetch()</code></li>
<li class="">聯絡表單送出使用 <code>axios</code></li>
</ul>
<p>例如文章資料是透過 <code>queryCollection()</code> 查詢 Markdown，因此使用 <code>useAsyncData()</code>：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> </span><span class="token literal-property property" style="color:#36acaa">data</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> blogs </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">useAsyncData</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"blog-list"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function" style="color:#d73a49">queryCollection</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"blog"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">all</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>而如果是單純向 API 取得資料：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> data </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">useFetch</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"/api/posts"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>直接使用 <code>useFetch()</code> 會更簡潔。</p>
<p>另外，如果專案同時使用 <code>axios</code> 和 <code>$fetch</code>，還要注意兩者回傳格式並不相同。</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// axios</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> res </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> axios</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token spread operator" style="color:#393A34">...</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token console class-name">console</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">log</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">res</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">data</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// $fetch</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> res </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">$fetch</span><span class="token punctuation" style="color:#393A34">(</span><span class="token spread operator" style="color:#393A34">...</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token console class-name">console</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">log</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">res</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>錯誤處理的寫法也不同：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// axios</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">error</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">response</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">message</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// $fetch</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">error</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">message</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>這個差異看起來不大，但我在撰寫聯絡表單錯誤訊息時就因此踩過一次坑，花了一點時間才發現是兩套 API 的設計不同。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="六建立前後端共用的表單驗證機制">六、建立前後端共用的表單驗證機制<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E5%85%AD%E5%BB%BA%E7%AB%8B%E5%89%8D%E5%BE%8C%E7%AB%AF%E5%85%B1%E7%94%A8%E7%9A%84%E8%A1%A8%E5%96%AE%E9%A9%97%E8%AD%89%E6%A9%9F%E5%88%B6" class="hash-link" aria-label="六、建立前後端共用的表單驗證機制的直接連結" title="六、建立前後端共用的表單驗證機制的直接連結" translate="no">​</a></h3>
<p>聯絡表單是這次專案中第一個完整串起前端、後端與 API 的功能，也是我第一次在 Nuxt 裡實際體驗全端開發的流程。</p>
<p>實作過程中，我學到一個很重要的觀念：</p>
<blockquote>
<p><strong>表單驗證應該同時存在於前端與後端。</strong></p>
</blockquote>
<p>前端驗證的目的，是讓使用者能立即看到錯誤訊息，提升操作體驗；後端驗證則是最後一道防線，因為任何人都可以直接呼叫 API，繞過前端檢查。</p>
<p>問題在於，如果前後端各自維護一套驗證規則，很容易出現不一致的情況：例如前端更新了規則，但後端沒有同步，或反過來導致行為不一致。</p>
<p>因此我選擇把驗證邏輯集中管理，並用 Zod 來定義 schema：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token comment" style="color:#999988;font-style:italic">// shared/utils/schema.js</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword module" style="color:#00009f">export</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> contactSchema </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">object</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">name</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">min</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">2</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"請輸入姓名"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">phone</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">regex</span><span class="token punctuation" style="color:#393A34">(</span><span class="token regex regex-delimiter" style="color:#36acaa">/</span><span class="token regex regex-source language-regex anchor function" style="color:#d73a49">^</span><span class="token regex regex-source language-regex" style="color:#36acaa">09</span><span class="token regex regex-source language-regex char-set class-name" style="color:#36acaa">\d</span><span class="token regex regex-source language-regex quantifier number" style="color:#36acaa">{8}</span><span class="token regex regex-source language-regex anchor function" style="color:#d73a49">$</span><span class="token regex regex-delimiter" style="color:#36acaa">/</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"手機號碼格式不正確"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">email</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">email</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"請輸入正確格式"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">note</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> z</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">string</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">optional</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>Zod 採用宣告式寫法，可以一次描述所有驗證規則，比起手動寫一堆 <code>if</code> 判斷更清楚，也讓驗證邏輯集中在同一個地方，後續維護成本更低。</p>
<p>驗證時使用 <code>safeParse()</code>：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> contactSchema</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">safeParse</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">formData</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>它不會像 <code>parse()</code> 一樣在失敗時直接拋錯，而是 <strong>回傳一個結果物件</strong> ，包含 <code>success</code> 與 <code>error</code>，讓程式可以自行決定後續處理方式。對於表單這種「使用者本來就可能輸入錯誤」的場景特別適合。</p>
<p>前端送出邏輯如下：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token function-variable function" style="color:#d73a49">submitForm</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> contactSchema</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">safeParse</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token literal-property property" style="color:#36acaa">name</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> name</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token literal-property property" style="color:#36acaa">phone</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> phone</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token literal-property property" style="color:#36acaa">email</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> email</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token literal-property property" style="color:#36acaa">note</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> note</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> contactSchema</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">safeParse</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">formData</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword control-flow" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">result</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">success</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    toast</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">error</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      result</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">error</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">issues</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">map</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">i</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> i</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">message</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">join</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"、"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword control-flow" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> res </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> axios</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">post</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"/api/send-contact"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    result</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">data</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  toast</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">success</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">res</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">data</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">message</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>後端同樣使用這份 schema 再驗證一次：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword module" style="color:#00009f">export</span><span class="token plain"> </span><span class="token keyword module" style="color:#00009f">default</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">defineEventHandler</span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">async</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">event</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> body </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">readBody</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">event</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> result </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> contactSchema</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">safeParse</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">body</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword control-flow" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">result</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">success</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword control-flow" style="color:#00009f">throw</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">createError</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token literal-property property" style="color:#36acaa">statusCode</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">400</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">      </span><span class="token literal-property property" style="color:#36acaa">message</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> result</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">error</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">issues</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">map</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">i</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> i</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">message</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">join</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"、"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword control-flow" style="color:#00009f">return</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token literal-property property" style="color:#36acaa">success</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token literal-property property" style="color:#36acaa">message</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"已送出"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>這樣即使有人跳過前端直接呼叫 API，也仍然會被後端驗證擋下來。</p>
<p>另外在錯誤處理上也有幾個 Nuxt 的細節：</p>
<ul>
<li class=""><code>createError()</code> 要使用 <code>message</code></li>
<li class="">HTTP 狀態碼要使用數字，例如 <code>400</code></li>
<li class="">前端接收錯誤時，可透過 <code>error.data.message</code> 取得後端回傳訊息</li>
</ul>
<p>這些看似小細節，如果寫錯不一定會立刻報錯，但實際上會花不少時間在 debug。</p>
<p>回頭看這個功能，雖然只是簡單的聯絡表單，但卻完整串起了 Nuxt 的全端流程：共用驗證邏輯、前端表單、Server API 與錯誤處理都在同一個專案內完成，不需要額外拆成前後端兩個系統。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="七ssr-開發中的踩雷紀錄">七、SSR 開發中的踩雷紀錄<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E4%B8%83ssr-%E9%96%8B%E7%99%BC%E4%B8%AD%E7%9A%84%E8%B8%A9%E9%9B%B7%E7%B4%80%E9%8C%84" class="hash-link" aria-label="七、SSR 開發中的踩雷紀錄的直接連結" title="七、SSR 開發中的踩雷紀錄的直接連結" translate="no">​</a></h3>
<p>功能都完成後，在測試與部署階段遇到幾個 SSR 專屬的問題。回頭看，其實都可以用前面那張「程式碼在哪裡執行」的表來解釋。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-swiper-輪播的-hydration-mismatch">1. Swiper 輪播的 hydration mismatch<a href="https://www.7lunchapter.com/blog/hexSchool-2026#1-swiper-%E8%BC%AA%E6%92%AD%E7%9A%84-hydration-mismatch" class="hash-link" aria-label="1. Swiper 輪播的 hydration mismatch的直接連結" title="1. Swiper 輪播的 hydration mismatch的直接連結" translate="no">​</a></h4>
<p>首頁打開時，Console 出現一整排紅字：<code>Hydration node mismatch</code>。
這個錯誤的意思是：伺服器先產生了一份 HTML，但瀏覽器接手後重新渲染時，兩邊的結果不一樣。
Swiper 剛好會在瀏覽器初始化時「自己改 DOM 結構」，例如加入 <code>swiper-wrapper</code>、<code>swiper-pagination</code> 這些元素。</p>
<p><strong>結果就變成：兩邊不一致 → hydration mismatch。</strong></p>
<ul>
<li class=""><strong>伺服器輸出的 HTML（沒有 Swiper 結構）</strong></li>
<li class=""><strong>瀏覽器初始化後的 DOM（被 Swiper 改過）</strong></li>
</ul>
<p><strong>解法很直接：用 <code>&lt;ClientOnly&gt;</code> 包起來。</strong></p>
<div class="language-vue codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-vue codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">&lt;ClientOnly&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  &lt;IndexSwiperBlog /&gt;</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">&lt;/ClientOnly&gt;</span><br></div></code></pre></div></div>
<p>意思是：這個元件只在瀏覽器渲染，伺服器直接跳過。這樣就不會出現兩邊畫面不同的問題。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-伺服器沒有-window">2. 伺服器沒有 window<a href="https://www.7lunchapter.com/blog/hexSchool-2026#2-%E4%BC%BA%E6%9C%8D%E5%99%A8%E6%B2%92%E6%9C%89-window" class="hash-link" aria-label="2. 伺服器沒有 window的直接連結" title="2. 伺服器沒有 window的直接連結" translate="no">​</a></h4>
<p>第二種錯誤是很經典的：<code>window is not defined</code>。
原因是 Nuxt 在 SSR 時會先在「伺服器」執行一次程式碼，而伺服器沒有瀏覽器 API，所以像這些都不存在：</p>
<ul>
<li class=""><code>window</code></li>
<li class=""><code>document</code></li>
<li class=""><code>localStorage</code></li>
</ul>
<p>例如：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token dom variable" style="color:#36acaa">localStorage</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">getItem</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"token"</span><span class="token punctuation" style="color:#393A34">)</span><br></div></code></pre></div></div>
<p>在 SSR 階段會直接報錯。</p>
<p>解法有幾種：</p>
<ul>
<li class="">放進 <code>onMounted()</code>（等瀏覽器載入後才執行）</li>
<li class="">用 <code>&lt;ClientOnly&gt;</code></li>
<li class="">或判斷是否在 client 環境</li>
</ul>
<p>另外像 <code>Date.now()</code>、<code>Math.random()</code> 這種「每次都會變的值」，也不建議放在 setup 最外層，否則伺服器和瀏覽器算出來不同，也可能導致畫面不一致。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-部署與-nuxtcontent-的-sqlite">3. 部署與 @nuxt/content 的 SQLite<a href="https://www.7lunchapter.com/blog/hexSchool-2026#3-%E9%83%A8%E7%BD%B2%E8%88%87-nuxtcontent-%E7%9A%84-sqlite" class="hash-link" aria-label="3. 部署與 @nuxt/content 的 SQLite的直接連結" title="3. 部署與 @nuxt/content 的 SQLite的直接連結" translate="no">​</a></h4>
<p>最後一個問題出現在部署。
這次使用 <code>@nuxt/content</code> 來管理 Markdown，它底層使用 SQLite 來儲存與查詢文章資料。
在本機開發時一切正常，但部署到 Vercel 時要理解一件事：</p>
<blockquote>
<p><strong>Vercel 這種 serverless 環境，和「永遠開著的伺服器」不一樣。</strong></p>
</blockquote>
<p>但實際情況是，這次專案沒有因此壞掉，原因在於 Nuxt Content 的運作方式：</p>
<ul>
<li class="">在 build 階段，Nuxt 會先讀取 <code>content/</code> 的 Markdown 檔案，整理成可查詢的資料結構</li>
<li class="">並且在需要的頁面進行 prerender（預先產生 HTML）</li>
</ul>
<p>所以大多數頁面在上線後，其實是「直接使用 build 時已經準備好的內容」，而不是每次請求都即時去查 SQLite。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="實務結論">實務結論<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E5%AF%A6%E5%8B%99%E7%B5%90%E8%AB%96" class="hash-link" aria-label="實務結論的直接連結" title="實務結論的直接連結" translate="no">​</a></h4>
<p>這一段踩坑後，我得到一個很簡單的理解方式：</p>
<ul>
<li class="">只要會用到 <code>window</code> / <code>document</code> / 第三方會動 DOM 的套件（像 Swiper）
→ 幾乎都要注意 SSR，必要時用 <code>&lt;ClientOnly&gt;</code></li>
<li class="">不確定會不會 SSR 出問題的程式
→ 就先想「伺服器會不會也跑一次？」</li>
<li class=""><code>@nuxt/content</code> 在部署時的重點不是「完全不查資料庫」
→ 而是「Nuxt 會在 build 階段先把內容準備好，讓 runtime 盡量不用即時查」</li>
</ul>
<p>SSR 最大的坑其實不是 bug 很難修，而是：<strong>你以為只有瀏覽器會跑的程式，其實伺服器也跑了一次</strong></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="八-nuxt-中的-seo-實作紀錄">八、 Nuxt 中的 SEO 實作紀錄<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E5%85%AB-nuxt-%E4%B8%AD%E7%9A%84-seo-%E5%AF%A6%E4%BD%9C%E7%B4%80%E9%8C%84" class="hash-link" aria-label="八、 Nuxt 中的 SEO 實作紀錄的直接連結" title="八、 Nuxt 中的 SEO 實作紀錄的直接連結" translate="no">​</a></h3>
<p>在 Nuxt 裡，我是直接把 SEO 設定放在 <code>app.vue</code>，用 <code>useSeoMeta</code> 先把整個網站的預設資訊補齊。Nuxt 會自動把這些設定轉成 HTML 的 meta tags，包含 Open Graph（LINE、Facebook 分享時的預覽卡片）以及 Twitter Card。</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">useSeoMeta</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">description</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"Nelson 的設計與前端作品集，分享 UIUX 設計、網頁設計與前端開發的實務經驗。"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">ogType</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"website"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">ogSiteName</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"Nelson Blog"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">ogImage</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token string" style="color:#e3116c">"https://github.com/hexschool/2022-web-layout-training/blob/main/2026-web-camp/index_person.png?raw=true"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token literal-property property" style="color:#36acaa">twitterCard</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token string" style="color:#e3116c">"summary_large_image"</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>這一層設定的概念其實是「全站預設值」，像是網站名稱、描述、分享圖片。之後每一頁如果沒有特別設定，就會先套用這一份。
這樣做的好處是，不需要每一個頁面都重複寫 SEO，而是先在最上層建立一個基準。</p>
<p>也可以把它理解成：<strong><code>app.vue</code> 做的是「網站的預設 SEO」，而不是「每一頁的 SEO」</strong>。</p>
<p>真正需要變動的是動態頁，例如文章頁，SEO 會跟著資料變化，所以要用函式寫法：</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token function" style="color:#d73a49">useSeoMeta</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function-variable function" style="color:#d73a49">title</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> blog</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token operator" style="color:#393A34">?.</span><span class="token plain">title</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function-variable function" style="color:#d73a49">description</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> blog</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token operator" style="color:#393A34">?.</span><span class="token plain">description</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token function-variable function" style="color:#d73a49">ogImage</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> blog</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">value</span><span class="token operator" style="color:#393A34">?.</span><span class="token plain">image</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>這樣 Nuxt 才會在資料載入後重新更新 meta，避免一開始就寫死。</p>
<blockquote>
<p><strong>整體來看，Nuxt 的 SEO 模型其實很直覺：</strong>
<strong>先在 <code>app.vue</code> 設一個「全站預設」，再在需要的頁面「覆蓋成動態內容」。</strong></p>
</blockquote>
<p>做完這一步之後會有一個很明顯的感受：<strong>Nuxt 不只是幫你做 SSR，也把 SEO 這一層的基礎流程一起整理好了。</strong></p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="九-專案心得">九、 專案心得<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E4%B9%9D-%E5%B0%88%E6%A1%88%E5%BF%83%E5%BE%97" class="hash-link" aria-label="九、 專案心得的直接連結" title="九、 專案心得的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-觀念先行比急著寫-code-更省時間">1. 觀念先行，比急著寫 code 更省時間<a href="https://www.7lunchapter.com/blog/hexSchool-2026#1-%E8%A7%80%E5%BF%B5%E5%85%88%E8%A1%8C%E6%AF%94%E6%80%A5%E8%91%97%E5%AF%AB-code-%E6%9B%B4%E7%9C%81%E6%99%82%E9%96%93" class="hash-link" aria-label="1. 觀念先行，比急著寫 code 更省時間的直接連結" title="1. 觀念先行，比急著寫 code 更省時間的直接連結" translate="no">​</a></h4>
<p>這次最大的體會是：文章裡遇到的幾個坑——Swiper、window、雙重抓取——追根究柢其實都來自同一件事：沒有先搞清楚「哪些程式碼會在伺服器執行」。</p>
<p>一旦把 SSR 的心智模型和那張執行對照表建立起來，很多問題其實不用查文件，自己就能推得出來。</p>
<blockquote>
<p><strong>框架的規則看起來很多，但背後通常只有一條主線。先抓住主線，細節才不會變成死記。</strong></p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-遇到-hydration-mismatch-不需要慌">2. 遇到 hydration mismatch 不需要慌<a href="https://www.7lunchapter.com/blog/hexSchool-2026#2-%E9%81%87%E5%88%B0-hydration-mismatch-%E4%B8%8D%E9%9C%80%E8%A6%81%E6%85%8C" class="hash-link" aria-label="2. 遇到 hydration mismatch 不需要慌的直接連結" title="2. 遇到 hydration mismatch 不需要慌的直接連結" translate="no">​</a></h4>
<p>這個錯誤大多來自兩種情況：使用了時間、亂數、視窗寬度等「會變動的值」，或是第三方套件在瀏覽器動態改 DOM。
對應方式其實很固定：</p>
<ul>
<li class="">會動 DOM 的元件 → 用 <code>&lt;ClientOnly&gt;</code></li>
<li class="">只該在瀏覽器跑的邏輯 → 放進 <code>onMounted</code></li>
</ul>
<blockquote>
<p><strong>理解原因之後，這個原本看起來很嚇人的紅字，其實只是 SSR 中最容易處理的一類問題。</strong></p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-驗證一定要前後端一起做">3. 驗證一定要前後端一起做<a href="https://www.7lunchapter.com/blog/hexSchool-2026#3-%E9%A9%97%E8%AD%89%E4%B8%80%E5%AE%9A%E8%A6%81%E5%89%8D%E5%BE%8C%E7%AB%AF%E4%B8%80%E8%B5%B7%E5%81%9A" class="hash-link" aria-label="3. 驗證一定要前後端一起做的直接連結" title="3. 驗證一定要前後端一起做的直接連結" translate="no">​</a></h4>
<p>前端負責體驗，後端負責安全，這兩者不能只選一邊。
這次用 <code>shared/</code> 搭配 Zod 共用 schema，是我覺得最乾淨的做法，也避免了「前後端規則不同步」的問題。</p>
<blockquote>
<p><strong>安全性永遠不能只依賴前端，因為前端一定可以被繞過。</strong></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="十總結">十、總結<a href="https://www.7lunchapter.com/blog/hexSchool-2026#%E5%8D%81%E7%B8%BD%E7%B5%90" class="hash-link" aria-label="十、總結的直接連結" title="十、總結的直接連結" translate="no">​</a></h3>
<p>從 React 的 SPA 開發，到這次第一次完整做 SSR 專案，最大的收穫其實只有一件事：</p>
<blockquote>
<p><strong>開始真正理解「程式碼在哪裡執行」。</strong></p>
</blockquote>
<p>Nuxt 一開始會讓人覺得規則很多，但當 SSR 這條主線建立起來之後，剩下的其實就是查文件和補細節。</p>
<p>這次從零到部署的完整過程，也讓我更清楚一件事：</p>
<ul>
<li class="">不是框架變難了，而是以前沒有看見它真正運作的方式。</li>
<li class="">希望這份紀錄可以幫正在學 Nuxt 的人，少踩一些我踩過的坑。</li>
</ul>]]></content:encoded>
            <category>專案作品</category>
            <category>Vue</category>
            <category>Nuxt</category>
            <category>六角學院</category>
        </item>
        <item>
            <title><![CDATA[從架構底層看懂四大部署平台]]></title>
            <link>https://www.7lunchapter.com/blog/deployChoose</link>
            <guid>https://www.7lunchapter.com/blog/deployChoose</guid>
            <pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[前言]]></description>
            <content:encoded><![CDATA[<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="前言">前言<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%89%8D%E8%A8%80" class="hash-link" aria-label="前言的直接連結" title="前言的直接連結" translate="no">​</a></h3>
<p>第一次部署網站時，我查了很多教學，幾乎都推薦用 GitHub Pages。但真正開始部署自己的專案後，才發現一個問題：不同專案適合的平台其實不一樣，卻很少有人解釋背後的原因。</p>
<p>後來深入了解才知道，這四個平台本來就是為了不同的使用情境而設計。它們底層的架構不同，也直接影響能提供的功能、適合的專案類型，以及後續可能遇到的成本與限制。</p>
<p>這篇文章會從平台架構開始介紹，整理四個平台的差異、各自的優缺點，以及實際部署時常遇到的限制與成本考量。另外，也會補充一些部署過程中常見但不容易理解的專有名詞，希望能幫助你在選擇平台時，更清楚知道哪一個才適合自己的專案。</p>
<!-- -->
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="本文涵蓋">本文涵蓋：<a href="https://www.7lunchapter.com/blog/deployChoose#%E6%9C%AC%E6%96%87%E6%B6%B5%E8%93%8B" class="hash-link" aria-label="本文涵蓋：的直接連結" title="本文涵蓋：的直接連結" translate="no">​</a></h4>
<ul>
<li class="">各平台底層架構與技術選型</li>
<li class="">隱藏限制與成本引爆點</li>
<li class="">專有名詞白話解析（CDN、Serverless、Cold Start、Edge Function 等）</li>
<li class="">選平台情境對照表</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="一github-pages最單純但也最受限">一、GitHub Pages：最單純，但也最受限<a href="https://www.7lunchapter.com/blog/deployChoose#%E4%B8%80github-pages%E6%9C%80%E5%96%AE%E7%B4%94%E4%BD%86%E4%B9%9F%E6%9C%80%E5%8F%97%E9%99%90" class="hash-link" aria-label="一、GitHub Pages：最單純，但也最受限的直接連結" title="一、GitHub Pages：最單純，但也最受限的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="底層在做什麼">底層在做什麼<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%BA%95%E5%B1%A4%E5%9C%A8%E5%81%9A%E4%BB%80%E9%BA%BC" class="hash-link" aria-label="底層在做什麼的直接連結" title="底層在做什麼的直接連結" translate="no">​</a></h4>
<p>GitHub Pages 背後是依賴 Fastly 這類 CDN 服務來分發靜態檔案。過去它高度綁定 Jekyll 這個靜態網站產生器，但現在已經全面轉向整合 <strong>GitHub Actions</strong>。</p>
<p>這代表：只要你能寫出正確的 Action 腳本，任何框架（React、Vue）都能打包後部署上去。但這也是它最大的限制，你能部署上去的，<strong>只有打包後的靜態檔案</strong>，完全沒有資料庫或後端運算能力。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="cicd-體驗">CI/CD 體驗<a href="https://www.7lunchapter.com/blog/deployChoose#cicd-%E9%AB%94%E9%A9%97" class="hash-link" aria-label="CI/CD 體驗的直接連結" title="CI/CD 體驗的直接連結" translate="no">​</a></h4>
<p>CI/CD 部分完全依賴 GitHub Actions，優點是高度客製化；缺點是沒有像 Vercel 那種「開箱即用」的 PR 預覽環境。你需要自己寫腳本，甚至搭配其他工具才能實現。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="隱藏限制">隱藏限制<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%9A%B1%E8%97%8F%E9%99%90%E5%88%B6" class="hash-link" aria-label="隱藏限制的直接連結" title="隱藏限制的直接連結" translate="no">​</a></h4>
<ul>
<li class=""><strong>流量限制</strong>：每月有 100GB 的頻寬軟限制（Soft Limit），超過雖然不一定立刻停用，但會收到警告。</li>
<li class=""><strong>開源限制</strong>：免費帳號的 Repo <strong>必須是 Public</strong>，才能免費使用 GitHub Pages。若要部署私有庫，需要升級 GitHub Pro 或 Team 方案。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="適合用在">適合用在<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%81%A9%E5%90%88%E7%94%A8%E5%9C%A8" class="hash-link" aria-label="適合用在的直接連結" title="適合用在的直接連結" translate="no">​</a></h4>
<p>個人履歷、開源專案說明文件、靜態部落格。</p>
<blockquote>
<p>參考：<a href="https://docs.github.com/en/pages" target="_blank" rel="noopener noreferrer" class="">GitHub Pages 官方文件</a> ／ <a href="https://docs.github.com/en/pages/getting-started-with-github-pages/about-github-pages#usage-limits" target="_blank" rel="noopener noreferrer" class="">使用量與限制</a></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="二vercel開發體驗業界標竿但商用成本要算清楚">二、Vercel：開發體驗業界標竿，但商用成本要算清楚<a href="https://www.7lunchapter.com/blog/deployChoose#%E4%BA%8Cvercel%E9%96%8B%E7%99%BC%E9%AB%94%E9%A9%97%E6%A5%AD%E7%95%8C%E6%A8%99%E7%AB%BF%E4%BD%86%E5%95%86%E7%94%A8%E6%88%90%E6%9C%AC%E8%A6%81%E7%AE%97%E6%B8%85%E6%A5%9A" class="hash-link" aria-label="二、Vercel：開發體驗業界標竿，但商用成本要算清楚的直接連結" title="二、Vercel：開發體驗業界標竿，但商用成本要算清楚的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="底層在做什麼-1">底層在做什麼<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%BA%95%E5%B1%A4%E5%9C%A8%E5%81%9A%E4%BB%80%E9%BA%BC-1" class="hash-link" aria-label="底層在做什麼的直接連結" title="底層在做什麼的直接連結" translate="no">​</a></h4>
<p>Vercel 本質上是建立在 AWS 和 Cloudflare 之上的「高級封裝」。它把你的前端專案拆解成三個部分：</p>
<ul>
<li class="">靜態資源 → 丟上全球 CDN</li>
<li class="">API 路由 → 轉換成 AWS Lambda（Serverless）</li>
<li class="">Middleware → 轉換成 Edge Functions（邊緣運算）</li>
</ul>
<p>理解這件事很重要。Vercel 並不是「幫你租一台伺服器」，而是把你的程式碼轉譯成不同形式，分散部署在各種雲端基礎設施上。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="cicd-體驗-1">CI/CD 體驗<a href="https://www.7lunchapter.com/blog/deployChoose#cicd-%E9%AB%94%E9%A9%97-1" class="hash-link" aria-label="CI/CD 體驗的直接連結" title="CI/CD 體驗的直接連結" translate="no">​</a></h4>
<p>這是 Vercel 最強的地方。只要發布一個 PR，Vercel 幾秒內就會生成一個獨立的預覽網址，甚至支援團隊直接在預覽頁面上留言（Comment on Preview）。對於需要讓 PM 或設計師 review 畫面的團隊來說，這個功能幾乎沒有替代品。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="隱藏成本">隱藏成本<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%9A%B1%E8%97%8F%E6%88%90%E6%9C%AC" class="hash-link" aria-label="隱藏成本的直接連結" title="隱藏成本的直接連結" translate="no">​</a></h4>
<ul>
<li class=""><strong>冷啟動（Cold Start）</strong>：因為 Serverless 的特性，API 一段時間沒被呼叫就會進入「休眠」。下次有人呼叫時，需要幾百毫秒到幾秒的「重新開機」時間。對於要求超低延遲的場景，這是個大問題。</li>
<li class=""><strong>團隊席位費極貴</strong>：免費版（Hobby）只限個人非商業用途。一旦升級 Pro（商用），每個團隊成員每月 <strong>$20 USD</strong>。人數多起來，費用會很快超出預期。</li>
<li class=""><strong>附加服務費</strong>：圖片最佳化（Image Optimization）、頻寬超標費用在流量暴增時非常可觀。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="適合用在-1">適合用在<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%81%A9%E5%90%88%E7%94%A8%E5%9C%A8-1" class="hash-link" aria-label="適合用在的直接連結" title="適合用在的直接連結" translate="no">​</a></h4>
<p>Next.js / React 專案、需要極速全球 CDN 與絕佳 SEO 的電商前端。</p>
<blockquote>
<p>參考：<a href="https://vercel.com/docs" target="_blank" rel="noopener noreferrer" class="">Vercel 官方文件</a> ／ <a href="https://vercel.com/pricing" target="_blank" rel="noopener noreferrer" class="">方案與定價</a> ／ <a href="https://vercel.com/docs/functions/serverless-functions" target="_blank" rel="noopener noreferrer" class="">Serverless Functions</a> ／ <a href="https://vercel.com/docs/functions/edge-functions" target="_blank" rel="noopener noreferrer" class="">Edge Functions</a></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="三render最接近自己租主機的現代替代方案">三、Render：最接近「自己租主機」的現代替代方案<a href="https://www.7lunchapter.com/blog/deployChoose#%E4%B8%89render%E6%9C%80%E6%8E%A5%E8%BF%91%E8%87%AA%E5%B7%B1%E7%A7%9F%E4%B8%BB%E6%A9%9F%E7%9A%84%E7%8F%BE%E4%BB%A3%E6%9B%BF%E4%BB%A3%E6%96%B9%E6%A1%88" class="hash-link" aria-label="三、Render：最接近「自己租主機」的現代替代方案的直接連結" title="三、Render：最接近「自己租主機」的現代替代方案的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="底層在做什麼-2">底層在做什麼<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%BA%95%E5%B1%A4%E5%9C%A8%E5%81%9A%E4%BB%80%E9%BA%BC-2" class="hash-link" aria-label="底層在做什麼的直接連結" title="底層在做什麼的直接連結" translate="no">​</a></h4>
<p>Render 主要託管在 AWS 和 GCP 上，但和 Vercel 的「無伺服器」方向完全不同，走的是<strong>容器化（Container-native）</strong> 架構。</p>
<p>你只要提供一個 <code>Dockerfile</code>，Render 就幫你建立成一個<strong>常駐執行的容器</strong>。這個差異很關鍵——常駐容器代表可以：</p>
<ul>
<li class="">跑長連線（WebSocket、遊戲伺服器）</li>
<li class="">執行背景任務（Background Workers）</li>
<li class="">保持連線到資料庫，不需要每次請求都重新建立</li>
</ul>
<p>同時 Render 也支援微服務架構，你可以建立內部 API 伺服器與資料庫，它們透過 Render 的私有網路溝通，完全不暴露在公開網際網路上。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="隱藏限制-1">隱藏限制<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%9A%B1%E8%97%8F%E9%99%90%E5%88%B6-1" class="hash-link" aria-label="隱藏限制的直接連結" title="隱藏限制的直接連結" translate="no">​</a></h4>
<ul>
<li class=""><strong>免費版會「睡覺」</strong>：免費的 Web Service 只要閒置 15 分鐘就會休眠，下次有人訪問時，喚醒時間可能長達 <strong>30 秒到 1 分鐘</strong>。對使用者體驗影響極大。有些開發者會寫腳本每 10 分鐘 Ping 它一次防止休眠，但這有違規風險。</li>
<li class=""><strong>編譯時間限制</strong>：免費方案每月只有 <strong>500 分鐘的 Build 時間</strong>，對大型專案來說容易耗盡。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="適合用在-2">適合用在<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%81%A9%E5%90%88%E7%94%A8%E5%9C%A8-2" class="hash-link" aria-label="適合用在的直接連結" title="適合用在的直接連結" translate="no">​</a></h4>
<p>全端應用程式、需要連接資料庫的 API 伺服器、爬蟲程式、Discord 機器人。</p>
<blockquote>
<p>參考：<a href="https://render.com/docs" target="_blank" rel="noopener noreferrer" class="">Render 官方文件</a> ／ <a href="https://render.com/docs/free#free-web-services" target="_blank" rel="noopener noreferrer" class="">免費方案限制</a> ／ <a href="https://render.com/docs/databases" target="_blank" rel="noopener noreferrer" class="">PostgreSQL 資料庫</a></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="四cloudflare-pages頻寬無上限但底層完全不一樣">四、Cloudflare Pages：頻寬無上限，但底層完全不一樣<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%9B%9Bcloudflare-pages%E9%A0%BB%E5%AF%AC%E7%84%A1%E4%B8%8A%E9%99%90%E4%BD%86%E5%BA%95%E5%B1%A4%E5%AE%8C%E5%85%A8%E4%B8%8D%E4%B8%80%E6%A8%A3" class="hash-link" aria-label="四、Cloudflare Pages：頻寬無上限，但底層完全不一樣的直接連結" title="四、Cloudflare Pages：頻寬無上限，但底層完全不一樣的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="底層在做什麼-3">底層在做什麼<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%BA%95%E5%B1%A4%E5%9C%A8%E5%81%9A%E4%BB%80%E9%BA%BC-3" class="hash-link" aria-label="底層在做什麼的直接連結" title="底層在做什麼的直接連結" translate="no">​</a></h4>
<p>Cloudflare Pages 不是部署在某個雲端廠商的機器上，而是<strong>直接部署在 Cloudflare 遍布全球 300 多個城市的邊緣節點</strong>上。</p>
<p>更關鍵的是，它的 Pages Functions 底層不是傳統的 Node.js 容器，也不是 AWS Lambda，而是基於 <strong>V8 Isolates</strong> 引擎。</p>
<p>這個技術選型帶來一個其他平台都比不上的優勢：<strong>幾乎 0ms 的冷啟動時間</strong>。V8 Isolates 是 Chrome 瀏覽器用來跑 JavaScript 的超快引擎，啟動速度極快，完全沒有傳統 Serverless 的冷啟動問題。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="隱藏限制-2">隱藏限制<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%9A%B1%E8%97%8F%E9%99%90%E5%88%B6-2" class="hash-link" aria-label="隱藏限制的直接連結" title="隱藏限制的直接連結" translate="no">​</a></h4>
<p>這個架構最大的痛點是 <strong>Node.js 相容性問題</strong>。因為底層是 V8 Isolates 而不是標準的 Node.js 環境，某些依賴 Node.js 原生模組（如 <code>fs</code>、<code>path</code>、特定加密庫）的後端套件無法直接運行，需要尋找替代方案或使用 Polyfill。</p>
<p>另外，免費版的函數 CPU 運算時間限制在 <strong>10ms 以內</strong>（付費版為 50ms）。這代表你不能在邊緣節點做太複雜的資料處理，只適合輕量的 API 轉發或資料庫讀寫。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="適合用在-3">適合用在<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%81%A9%E5%90%88%E7%94%A8%E5%9C%A8-3" class="hash-link" aria-label="適合用在的直接連結" title="適合用在的直接連結" translate="no">​</a></h4>
<p>高流量靜態網站、極度重視載入速度與安全性的全球化專案。</p>
<blockquote>
<p>參考：<a href="https://developers.cloudflare.com/pages/" target="_blank" rel="noopener noreferrer" class="">Cloudflare Pages 官方文件</a> ／ <a href="https://developers.cloudflare.com/pages/functions/" target="_blank" rel="noopener noreferrer" class="">Pages Functions</a> ／ <a href="https://developers.cloudflare.com/workers/reference/how-workers-works/" target="_blank" rel="noopener noreferrer" class="">V8 Isolates 原理</a></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="五進階規格比較表">五、進階規格比較表<a href="https://www.7lunchapter.com/blog/deployChoose#%E4%BA%94%E9%80%B2%E9%9A%8E%E8%A6%8F%E6%A0%BC%E6%AF%94%E8%BC%83%E8%A1%A8" class="hash-link" aria-label="五、進階規格比較表的直接連結" title="五、進階規格比較表的直接連結" translate="no">​</a></h3>
<table><thead><tr><th>比較維度</th><th>GitHub Pages</th><th>Vercel</th><th>Render</th><th>Cloudflare Pages</th></tr></thead><tbody><tr><td><strong>底層運行環境</strong></td><td>靜態 CDN</td><td>Serverless + Edge</td><td>Container（Docker 常駐容器）</td><td>Edge（V8 Isolates 邊緣運算）</td></tr><tr><td><strong>後端 / API 支援</strong></td><td>完全不支援</td><td>Serverless / 邊緣函數</td><td>支援完整後端常駐伺服器</td><td>邊緣函數（Workers）</td></tr><tr><td><strong>WebSocket / 長連線</strong></td><td>不支援</td><td>不支援</td><td>完美支援</td><td>不支援</td></tr><tr><td><strong>冷啟動延遲</strong></td><td>無（純靜態）</td><td>中等（約 0.5s～2s）</td><td>嚴重（免費版喚醒需 30s+）</td><td><strong>極低（0ms）</strong></td></tr><tr><td><strong>資安與防禦</strong></td><td>基礎保護</td><td>良好（內建基礎 WAF）</td><td>良好</td><td><strong>頂級（企業級 WAF 與 DDoS 防護）</strong></td></tr><tr><td><strong>商用成本引爆點</strong></td><td>幾乎沒有</td><td>團隊人數增加、附加功能用量</td><td>伺服器規格升級（RAM / CPU）</td><td>複雜運算需轉移架構</td></tr><tr><td><strong>平台綁定風險</strong></td><td>低（靜態檔案可帶走）</td><td><strong>高</strong>（深度使用特有 Edge 功能）</td><td>低（標準 Docker，可搬家）</td><td><strong>高</strong>（深度綁定專屬資料庫生態）</td></tr><tr><td><strong>CI/CD 自動化體驗</strong></td><td>需手動撰寫 GitHub Actions</td><td><strong>極致</strong>（自動 PR 預覽、一鍵 Rollback）</td><td>佳（PR 預覽需付費）</td><td>佳（支援無限 PR 預覽網址）</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="六專有名詞白話解析">六、專有名詞白話解析<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%85%AD%E5%B0%88%E6%9C%89%E5%90%8D%E8%A9%9E%E7%99%BD%E8%A9%B1%E8%A7%A3%E6%9E%90" class="hash-link" aria-label="六、專有名詞白話解析的直接連結" title="六、專有名詞白話解析的直接連結" translate="no">​</a></h3>
<p>這些詞在各平台的文件和介紹裡會一直出現，但大多數教學都假設你已經懂了。整理在這裡備查。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="部署與自動化">部署與自動化<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%83%A8%E7%BD%B2%E8%88%87%E8%87%AA%E5%8B%95%E5%8C%96" class="hash-link" aria-label="部署與自動化的直接連結" title="部署與自動化的直接連結" translate="no">​</a></h4>
<p><strong>CI/CD（持續整合與持續部署）</strong></p>
<p>以前工程師要把網站上線，需要手動打包、連線伺服器、覆蓋舊檔案。CI/CD 是把這個流程自動化的概念——你只要把程式碼 Push 到 GitHub，系統就自動測試、打包並更新到正式網站。</p>
<blockquote>
<p>📖 <a href="https://docs.github.com/en/actions" target="_blank" rel="noopener noreferrer" class="">GitHub Actions 文件</a></p>
</blockquote>
<p><strong>PR（Pull Request）</strong></p>
<p>團隊合作時，工程師寫完一段新功能，提出「合併請求」，讓其他人審查程式碼。Vercel 的「PR 預覽」讓這個功能更強大——新功能在正式上線前，先產生一個臨時網址，讓 PM 或 QA 直接點進去測試。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="雲端架構">雲端架構<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%9B%B2%E7%AB%AF%E6%9E%B6%E6%A7%8B" class="hash-link" aria-label="雲端架構的直接連結" title="雲端架構的直接連結" translate="no">​</a></h4>
<p><strong>PaaS（Platform as a Service / 平台即服務）</strong></p>
<p>就像租一間「水電裝潢都弄好」的店面。你只需要把商品（程式碼）擺進去就能開始營業，不用自己蓋房子（管底層作業系統、安全性修補）。Render 就是一種 PaaS。</p>
<blockquote>
<p><a href="https://aws.amazon.com/what-is/paas/" target="_blank" rel="noopener noreferrer" class="">什麼是 PaaS（AWS 說明）</a></p>
</blockquote>
<p><strong>Docker / Container（容器化）</strong></p>
<p>把程式碼、需要的套件、設定檔全部打包成一個「標準規格的貨櫃」。不管把它搬到哪台電腦或哪個雲端平台，執行結果都一模一樣。再也不會發生「在我電腦上明明就可以跑啊」的問題。</p>
<blockquote>
<p><a href="https://docs.docker.com/get-started/overview/" target="_blank" rel="noopener noreferrer" class="">Docker 入門指南</a></p>
</blockquote>
<p><strong>Serverless（無伺服器）</strong></p>
<p>其實還是有伺服器，只是<strong>你不需要管它</strong>。傳統租主機是包月制，不管有沒有流量都要付錢；Serverless 則是「有人觸發你的 API，系統才瞬間啟動一小塊運算資源」，用完立刻回收。省錢，但代價是冷啟動問題。</p>
<blockquote>
<p><a href="https://www.cloudflare.com/learning/serverless/what-is-serverless/" target="_blank" rel="noopener noreferrer" class="">Serverless 概念（Cloudflare Learning）</a></p>
</blockquote>
<p><strong>Cold Start（冷啟動）</strong></p>
<p>Serverless 為了省資源，沒人用時會進入「休眠」。當突然有訪客進來，系統需要「重新開機」，這段讓使用者覺得卡頓的等待時間就是冷啟動。從幾百毫秒到幾十秒不等，取決於平台。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="速度與效能">速度與效能<a href="https://www.7lunchapter.com/blog/deployChoose#%E9%80%9F%E5%BA%A6%E8%88%87%E6%95%88%E8%83%BD" class="hash-link" aria-label="速度與效能的直接連結" title="速度與效能的直接連結" translate="no">​</a></h4>
<p><strong>CDN（Content Delivery Network / 內容傳遞網路）</strong></p>
<p>全球的「物流發貨中心」。如果你的主機在美國，台灣的使用者連線過去會很慢。CDN 在全球各地建立節點，把靜態資源複製到離使用者最近的位置。台灣使用者就從台灣節點抓資料，載入速度瞬間提升。</p>
<blockquote>
<p><a href="https://www.cloudflare.com/learning/cdn/what-is-a-cdn/" target="_blank" rel="noopener noreferrer" class="">什麼是 CDN（Cloudflare）</a></p>
</blockquote>
<p><strong>Edge Computing（邊緣運算 / Edge Functions）</strong></p>
<p>CDN 的進階版。原本 CDN 只能放「靜態圖片」，邊緣運算讓後端程式碼也能直接在離使用者最近的全球節點上執行。使用者根本不用連回遠在美國的主機，在台灣的節點就直接算好資料回傳。</p>
<blockquote>
<p><a href="https://vercel.com/docs/functions/edge-functions" target="_blank" rel="noopener noreferrer" class="">Vercel Edge Functions</a> ／ <a href="https://developers.cloudflare.com/workers/" target="_blank" rel="noopener noreferrer" class="">Cloudflare Workers</a></p>
</blockquote>
<p><strong>V8 Isolates</strong></p>
<p>Google Chrome 用來執行 JavaScript 的超快引擎。Cloudflare 把它拿來跑後端程式，因為它啟動時間幾乎是 0 毫秒，直接解決了 Serverless 的冷啟動問題。</p>
<blockquote>
<p><a href="https://developers.cloudflare.com/workers/reference/how-workers-works/" target="_blank" rel="noopener noreferrer" class="">How Workers Works（Cloudflare）</a></p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="其他重要概念">其他重要概念<a href="https://www.7lunchapter.com/blog/deployChoose#%E5%85%B6%E4%BB%96%E9%87%8D%E8%A6%81%E6%A6%82%E5%BF%B5" class="hash-link" aria-label="其他重要概念的直接連結" title="其他重要概念的直接連結" translate="no">​</a></h4>
<p><strong>WebSocket / 長期連線</strong></p>
<p>傳統網頁是「一問一答」：使用者點一下，伺服器給一次資料。WebSocket 則是雙方建立一條「不中斷的雙向水管」，伺服器可以隨時主動把新資料推給使用者。常用於即時聊天室、股票看盤、多人連線遊戲。</p>
<blockquote>
<p>📖 <a href="https://developer.mozilla.org/zh-TW/docs/Web/API/WebSockets_API" target="_blank" rel="noopener noreferrer" class="">WebSocket API（MDN 中文）</a></p>
</blockquote>
<p><strong>WAF（網頁應用程式防火牆）&amp; DDoS（分散式阻斷服務攻擊）</strong></p>
<p>DDoS 是駭客操控大量中毒電腦同時湧入你的網站，把伺服器塞爆癱瘓。WAF 是站在網站前面的「高階保全」，負責過濾掉惡意的攻擊指令。</p>
<blockquote>
<p>📖 <a href="https://www.cloudflare.com/learning/ddos/what-is-a-ddos-attack/" target="_blank" rel="noopener noreferrer" class="">什麼是 DDoS（Cloudflare）</a> ／ <a href="https://www.cloudflare.com/learning/ddos/glossary/web-application-firewall-waf/" target="_blank" rel="noopener noreferrer" class="">什麼是 WAF（Cloudflare）</a></p>
</blockquote>
<p><strong>Vendor Lock-in（平台綁定 / 供應商鎖定）</strong></p>
<p>如果你用了太多某個平台的獨家功能，導致程式碼完全迎合它，未來想搬家時幾乎要全部重寫，這就是平台綁定風險。Vercel 和 Cloudflare 的專屬功能都有這個問題，選用時要先想清楚。</p>
<blockquote>
<p>📖 <a href="https://en.wikipedia.org/wiki/Vendor_lock-in" target="_blank" rel="noopener noreferrer" class="">Vendor Lock-in（Wikipedia）</a></p>
</blockquote>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="七心得">七、心得<a href="https://www.7lunchapter.com/blog/deployChoose#%E4%B8%83%E5%BF%83%E5%BE%97" class="hash-link" aria-label="七、心得的直接連結" title="七、心得的直接連結" translate="no">​</a></h3>
<p>比較到最後，我發現選平台其實沒有絕對的好壞，而是看你的專案需求。只要先想清楚下面三件事，通常就能很快做出選擇。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-專案需要後端嗎">1. 專案需要後端嗎？<a href="https://www.7lunchapter.com/blog/deployChoose#1-%E5%B0%88%E6%A1%88%E9%9C%80%E8%A6%81%E5%BE%8C%E7%AB%AF%E5%97%8E" class="hash-link" aria-label="1. 專案需要後端嗎？的直接連結" title="1. 專案需要後端嗎？的直接連結" translate="no">​</a></h4>
<p>如果只是靜態網站，像是作品集、部落格或形象網站，GitHub Pages 或 Cloudflare Pages 就已經足夠；但如果需要 API、資料庫或長時間運行的服務，就需要能部署後端的平台，例如 Render。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-你比較在意開發體驗還是長期成本">2. 你比較在意開發體驗，還是長期成本？<a href="https://www.7lunchapter.com/blog/deployChoose#2-%E4%BD%A0%E6%AF%94%E8%BC%83%E5%9C%A8%E6%84%8F%E9%96%8B%E7%99%BC%E9%AB%94%E9%A9%97%E9%82%84%E6%98%AF%E9%95%B7%E6%9C%9F%E6%88%90%E6%9C%AC" class="hash-link" aria-label="2. 你比較在意開發體驗，還是長期成本？的直接連結" title="2. 你比較在意開發體驗，還是長期成本？的直接連結" translate="no">​</a></h4>
<p>如果重視部署流程、CI/CD、PR Preview 等開發體驗，Vercel 依然是非常成熟的選擇，尤其適合團隊協作。不過在商業團隊中，也要將每位成員的席位費納入長期成本考量。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-專案流量與部署環境有哪些需求">3. 專案流量與部署環境有哪些需求？<a href="https://www.7lunchapter.com/blog/deployChoose#3-%E5%B0%88%E6%A1%88%E6%B5%81%E9%87%8F%E8%88%87%E9%83%A8%E7%BD%B2%E7%92%B0%E5%A2%83%E6%9C%89%E5%93%AA%E4%BA%9B%E9%9C%80%E6%B1%82" class="hash-link" aria-label="3. 專案流量與部署環境有哪些需求？的直接連結" title="3. 專案流量與部署環境有哪些需求？的直接連結" translate="no">​</a></h4>
<p>如果預期流量較高，或希望有完善的 CDN 與 DDoS 防護，Cloudflare Pages 的免費頻寬與全球網路會是很大的優勢。但如果後端需要依賴 Node.js 原生執行環境，則要先確認是否與 Cloudflare Workers 的執行模型相容。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="最後與ai討論後整理成一張情境對照表">最後與AI討論後整理成一張情境對照表：<a href="https://www.7lunchapter.com/blog/deployChoose#%E6%9C%80%E5%BE%8C%E8%88%87ai%E8%A8%8E%E8%AB%96%E5%BE%8C%E6%95%B4%E7%90%86%E6%88%90%E4%B8%80%E5%BC%B5%E6%83%85%E5%A2%83%E5%B0%8D%E7%85%A7%E8%A1%A8" class="hash-link" aria-label="最後與AI討論後整理成一張情境對照表：的直接連結" title="最後與AI討論後整理成一張情境對照表：的直接連結" translate="no">​</a></h4>
<table><thead><tr><th>情境</th><th>推薦平台</th><th>理由</th></tr></thead><tbody><tr><td>純靜態 HTML、個人履歷、部落格</td><td><strong>GitHub Pages</strong> 或 <strong>Cloudflare Pages</strong></td><td>最單純 / 速度最快</td></tr><tr><td>React / Vue / Next.js，追求極致 SEO 與開發體驗</td><td><strong>Vercel</strong></td><td>設定最少，開箱即用</td></tr><tr><td>傳統後端、需連接資料庫、跑爬蟲或做聊天室</td><td><strong>Render</strong></td><td>取代自己租主機的最佳方案</td></tr><tr><td>海量流量、重視資安防護、想體驗零冷啟動邊緣運算</td><td><strong>Cloudflare Pages</strong></td><td>頻寬免費且防禦力無人能敵</td></tr></tbody></table>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="資料來源">資料來源<a href="https://www.7lunchapter.com/blog/deployChoose#%E8%B3%87%E6%96%99%E4%BE%86%E6%BA%90" class="hash-link" aria-label="資料來源的直接連結" title="資料來源的直接連結" translate="no">​</a></h3>
<ul>
<li class=""><a href="https://docs.github.com/en/pages" target="_blank" rel="noopener noreferrer" class="">GitHub Pages 官方文件</a></li>
<li class=""><a href="https://vercel.com/docs" target="_blank" rel="noopener noreferrer" class="">Vercel 官方文件</a></li>
<li class=""><a href="https://render.com/docs" target="_blank" rel="noopener noreferrer" class="">Render 官方文件</a></li>
<li class=""><a href="https://developers.cloudflare.com/pages/" target="_blank" rel="noopener noreferrer" class="">Cloudflare Pages 官方文件</a></li>
<li class=""><a href="https://docs.docker.com/get-started/" target="_blank" rel="noopener noreferrer" class="">Docker 入門指南</a></li>
<li class=""><a href="https://developer.mozilla.org/zh-TW/docs/Web/API/WebSockets_API" target="_blank" rel="noopener noreferrer" class="">MDN — WebSocket API</a></li>
<li class=""><a href="https://www.cloudflare.com/learning/" target="_blank" rel="noopener noreferrer" class="">Cloudflare Learning Center</a></li>
</ul>]]></content:encoded>
            <category>GitHub Pages</category>
            <category>Vercel</category>
            <category>Render</category>
            <category>Cloudflare Pages</category>
        </item>
        <item>
            <title><![CDATA[YeStep 每一步，找回生活的呼吸]]></title>
            <link>https://www.7lunchapter.com/blog/yestep-project</link>
            <guid>https://www.7lunchapter.com/blog/yestep-project</guid>
            <pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[前言]]></description>
            <content:encoded><![CDATA[<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="前言">前言<a href="https://www.7lunchapter.com/blog/yestep-project#%E5%89%8D%E8%A8%80" class="hash-link" aria-label="前言的直接連結" title="前言的直接連結" translate="no">​</a></h3>
<p>一個以「把 Yes 變成 Step」為核心精神的整合性步道檢索平台，將分散的步道資訊收斂到單一介面，讓使用者依地區、難度、行走時間與景觀類型快速篩選，把「想出去走走」的念頭，轉化為實際可行的行程規劃。</p>
<ul>
<li class=""><strong>Live Demo</strong>：<a href="https://yestep.onrender.com/" target="_blank" rel="noopener noreferrer" class=""><strong>YeStep</strong></a></li>
<li class=""><strong>GitHub</strong>：<a href="https://github.com/MalricHsu/yestep" target="_blank" rel="noopener noreferrer" class=""><strong>GitHub Repo</strong></a></li>
<li class=""><strong>使用技術</strong>：<code>Vite </code>/ <code>React</code> / <code>React Router</code> / <code>Redux Toolkit</code> / <code>Bootstrap 5 </code>/ <code>Sass</code> / <code>Axios</code> / <code>JSON Server</code> / <code>JavaScript</code> / <code>Swiper</code> / <code>Lottie</code> / <code>Chart.js</code> / <code>Git</code> / <code>GitHub</code></li>
<li class=""><strong>團隊角色</strong>：組長 / 前端開發(React) / API 模擬與部署(JSON Server)</li>
<li class=""><strong>專案管理</strong>：Notion / GitHub / Discord</li>
<li class=""><strong>專案時程</strong>：2025.10.15 ~ 2026.02.28</li>
<li class=""><strong>網站部署</strong>：Render</li>
</ul>
<!-- -->
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="一-主題發想">一、 主題發想<a href="https://www.7lunchapter.com/blog/yestep-project#%E4%B8%80-%E4%B8%BB%E9%A1%8C%E7%99%BC%E6%83%B3" class="hash-link" aria-label="一、 主題發想的直接連結" title="一、 主題發想的直接連結" translate="no">​</a></h3>
<p>組隊初期討論主題時，組員中有一位是親子家庭使用者，分享她在尋找適合帶孩子走的步道時，常面臨資訊分散的問題：步道資料散落在政府網站、登山部落格與社群論壇之間，難以一次比較難度、路程與適合對象。我們以此為起點延伸思考目標客群，發現這個痛點不只存在於親子家庭——對忙碌的上班族、尋求身心療癒的使用者而言，「想走出去」與「實際成行」之間，往往卡在資訊整合度不足這道門檻。</p>
<p>YeStep 因此定位為一個整合性步道檢索平台，將分散的資訊收斂到單一介面，讓使用者依地區、難度、行走時間與景觀類型快速篩選，把「想出去走走」的念頭，轉化為實際可行的行程規劃。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="二-專案協作方式">二、 專案協作方式<a href="https://www.7lunchapter.com/blog/yestep-project#%E4%BA%8C-%E5%B0%88%E6%A1%88%E5%8D%94%E4%BD%9C%E6%96%B9%E5%BC%8F" class="hash-link" aria-label="二、 專案協作方式的直接連結" title="二、 專案協作方式的直接連結" translate="no">​</a></h3>
<p>整個專案歷時約 5 個月，採每週一次 75~90 分鐘的固定會議追蹤進度，並使用 Notion 管理專案進度與文件，透過 Git / GitHub 進行版本控制與協作開發。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="三-使用者故事">三、 使用者故事<a href="https://www.7lunchapter.com/blog/yestep-project#%E4%B8%89-%E4%BD%BF%E7%94%A8%E8%80%85%E6%95%85%E4%BA%8B" class="hash-link" aria-label="三、 使用者故事的直接連結" title="三、 使用者故事的直接連結" translate="no">​</a></h3>
<p>延續上個專案驗證有效的 MVP 思維，我們在開發前先盤點所有頁面，把「首頁、檢索頁、檢索詳細頁、主題活動頁」設定為核心必做項目，會員中心則列為選做。考量到上個專案最終成果偏向靜態切版展示，這次我們設定的核心目標是「讓網站真的動起來」——不只要切版完成，還要串接 API、有實際的篩選、搜尋與跳轉邏輯。會員中心在團隊餘力下也順利完成。</p>
<p>在功能權限上，我們依使用者狀態設計兩個層級：一般使用者可瀏覽所有步道資訊、使用篩選與搜尋，會員則額外擁有收藏步道、修改個人資料等功能。考量到「想收藏才願意註冊」是更自然的轉換動機，我們設計了一條漸進式的引導流程，未登入使用者點擊收藏時，會先彈出提示導向登入頁。若還沒有帳號，再進一步引導至註冊頁。讓註冊這件事從「先註冊才能用」，變成「因為想用所以註冊」。</p>
<p>針對三類目標使用者：忙碌上班族、親子家庭、尋求療癒的人。檢索頁採取多維度篩選設計，包含地區、難度、行走時間與景觀類型。不同族群可以用不同組合，找到真正適合自己的路線。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="四-網站地圖與-wireframe">四、 網站地圖與 Wireframe<a href="https://www.7lunchapter.com/blog/yestep-project#%E5%9B%9B-%E7%B6%B2%E7%AB%99%E5%9C%B0%E5%9C%96%E8%88%87-wireframe" class="hash-link" aria-label="四、 網站地圖與 Wireframe的直接連結" title="四、 網站地圖與 Wireframe的直接連結" translate="no">​</a></h3>
<p>網站地圖、流程圖與 Wireframe 由團隊在 Miro 上共同繪製。過程中由組內具 UI/UX 經驗的組員協助，把跨頁會重複使用的元件（如卡片）統一定義出來，讓後續切版時大家有共同的設計依據，也減少重工的成本。</p>
<ul>
<li class="">🔗 <a href="https://miro.com/app/board/uXjVJqXihk4=/?share_link_id=113737613315" target="_blank" rel="noopener noreferrer" class="">Miro Wireframe</a></li>
</ul>
<div align="center"><small style="color:#888"><i> YeStep網站地圖</i></small></div>
<p><img decoding="async" loading="lazy" alt="網站地圖" src="https://www.7lunchapter.com/assets/images/yestep%E7%B6%B2%E7%AB%99%E5%9C%B0%E5%9C%96-aa1071d0470cbc23266b40e32b6610b5.png" width="688" height="1536" class="img_ev3q"></p>
<div align="center"><small style="color:#888"><i> YeStep檢索詳細頁流程圖</i></small></div>
<p><img decoding="async" loading="lazy" alt="檢索詳細頁流程圖" src="https://www.7lunchapter.com/assets/images/yestep%E8%A9%B3%E7%B4%B0%E6%B5%81%E7%A8%8B%E5%9C%96-a3f2f560c040d68e79dba6ce9fadcf34.png" width="1440" height="832" class="img_ev3q"></p>
<div align="center"><small style="color:#888"><i> 檢索詳細 Wireframe 線框圖</i></small></div>
<p><img decoding="async" loading="lazy" alt="檢索詳細頁 Wireframe" src="https://www.7lunchapter.com/assets/images/yestep%E8%A9%B3%E7%B4%B0%E9%A0%81%E7%B7%9A%E7%A8%BF%E5%9C%96-99bc96a90d49c9ca5ffc30797ee0678a.png" width="1134" height="1158" class="img_ev3q"></p>
<p>設計過程中最有印象的取捨，是首頁主視覺 Banner 的呈現方式。最後決定做成「影片」與「步道圖片輪播(Swiper)」之間的切換式設計，讓使用者可以依當下的瀏覽心情，選擇喜歡的方式探索網站。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="五-ui-設計稿">五、 UI 設計稿<a href="https://www.7lunchapter.com/blog/yestep-project#%E4%BA%94-ui-%E8%A8%AD%E8%A8%88%E7%A8%BF" class="hash-link" aria-label="五、 UI 設計稿的直接連結" title="五、 UI 設計稿的直接連結" translate="no">​</a></h3>
<p>線稿完成後，團隊將整體想法與品牌方向整理出來，邀請專業 UI/UX 設計師協作，在 Figma 上產出完整的視覺設計稿，包含色票、字體規範、元件樣式與各頁面的高保真畫面。設計師加入後，介面從「能用」進階到「符合品牌氛圍」，也讓後續切版有了清楚一致的依據。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="六-專案架構">六、 專案架構<a href="https://www.7lunchapter.com/blog/yestep-project#%E5%85%AD-%E5%B0%88%E6%A1%88%E6%9E%B6%E6%A7%8B" class="hash-link" aria-label="六、 專案架構的直接連結" title="六、 專案架構的直接連結" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">yestep/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── public/                  # 靜態資源</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   └── logo.png</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── src/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── assets/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── images/          # 圖片資源（依頁面分類）</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── scss/            # 樣式檔案</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── _variables.scss</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── _variables-dark.scss</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── base/        # 基礎樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── components/  # 元件樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── layout/      # 佈局樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── page/        # 頁面樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   ├── util/        # 工具樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   │   └── all.scss     # 樣式進入點</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── videos/          # 影片資源</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── components/          # 共用元件（24 個）</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Nav.jsx          # 導覽列</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Footer.jsx       # 頁尾</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── HeroSwiper.jsx   # Hero 輪播</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── PopularTrails.jsx # 熱門步道</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── SearchBar.jsx    # 搜尋列</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── TrailCard.jsx    # 步道卡片</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── ...</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── data/                # 靜態資料</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── pages/               # 頁面元件</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Home.jsx         # 首頁</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── TrailSearchPage.jsx # 步道搜尋</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── TrailDetail.jsx  # 步道詳情</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── TrailTag.jsx     # 步道分類</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Theme.jsx        # 主題活動</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Member.jsx       # 會員中心</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Login.jsx        # 登入</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── Register.jsx     # 註冊</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── ProtectedRoute.jsx # 路由守衛</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── NotFound404.jsx  # 404 頁面</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── server/              # API 設定</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── api.js</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── slices/              # Redux Slices</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── authSlice.js     # 認證狀態</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── infoSlice.js     # 資訊狀態</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── utils/               # 工具函式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── error.js         # 錯誤處理</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── formatNumber.js  # 數字格式化</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── App.jsx              # 根元件</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── main.jsx             # 應用進入點</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── router.jsx           # 路由設定</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   └── store.js             # Redux Store</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── db.json                  # JSON Server 資料庫</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── server.cjs               # API 伺服器設定</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── index.html               # HTML 進入點</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── vite.config.js           # Vite 設定</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── package.json</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">└── .env                     # 環境變數</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="七-個人負責開發項目">七、 個人負責開發項目<a href="https://www.7lunchapter.com/blog/yestep-project#%E4%B8%83-%E5%80%8B%E4%BA%BA%E8%B2%A0%E8%B2%AC%E9%96%8B%E7%99%BC%E9%A0%85%E7%9B%AE" class="hash-link" aria-label="七、 個人負責開發項目的直接連結" title="七、 個人負責開發項目的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-路由系統">1. 路由系統<a href="https://www.7lunchapter.com/blog/yestep-project#1-%E8%B7%AF%E7%94%B1%E7%B3%BB%E7%B5%B1" class="hash-link" aria-label="1. 路由系統的直接連結" title="1. 路由系統的直接連結" translate="no">​</a></h4>
<p>使用 React Router 在 <code>router.jsx</code> 中集中管理所有路由配置，並依照頁面是否需要登入權限進行分層。</p>
<p><strong>ProtectedRoute — 路由守衛</strong></p>
<p>路由守衛這個概念課程中有帶，但當時還不太熟悉實際的應用情境，因此我主動詢問 AI、釐清它的運作原理後再實作出來。</p>
<p>具體做法是建立一個 <code>ProtectedRoute</code> 包裝元件：在元件內讀取 Redux 的登入狀態，未登入就用 <code>&lt;Navigate&gt;</code> 自動導向登入頁，登入後才渲染原本的子元件。</p>
<div class="language-jsx codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-jsx codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token function-variable function maybe-class-name" style="color:#d73a49">ProtectedRoute</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter punctuation" style="color:#393A34">{</span><span class="token parameter"> children </span><span class="token parameter punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"> token </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">useSelector</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">state</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> state</span><span class="token punctuation" style="color:#393A34">.</span><span class="token property-access">auth</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token dom variable" style="color:#36acaa">location</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">useLocation</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword control-flow" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">token</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword control-flow" style="color:#00009f">return</span><span class="token plain"> </span><span class="token tag punctuation" style="color:#393A34">&lt;</span><span class="token tag class-name" style="color:#00009f">Navigate</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">to</span><span class="token tag attr-value punctuation attr-equals" style="color:#393A34">=</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag attr-value" style="color:#e3116c">/login</span><span class="token tag attr-value punctuation" style="color:#393A34">"</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">state</span><span class="token tag script language-javascript script-punctuation punctuation" style="color:#393A34">=</span><span class="token tag script language-javascript punctuation" style="color:#393A34">{</span><span class="token tag script language-javascript punctuation" style="color:#393A34">{</span><span class="token tag script language-javascript" style="color:#00009f"> </span><span class="token tag script language-javascript keyword module" style="color:#00009f">from</span><span class="token tag script language-javascript operator" style="color:#393A34">:</span><span class="token tag script language-javascript" style="color:#00009f"> </span><span class="token tag script language-javascript dom variable" style="color:#36acaa">location</span><span class="token tag script language-javascript" style="color:#00009f"> </span><span class="token tag script language-javascript punctuation" style="color:#393A34">}</span><span class="token tag script language-javascript punctuation" style="color:#393A34">}</span><span class="token tag" style="color:#00009f"> </span><span class="token tag attr-name" style="color:#00a4db">replace</span><span class="token tag" style="color:#00009f"> </span><span class="token tag punctuation" style="color:#393A34">/&gt;</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword control-flow" style="color:#00009f">return</span><span class="token plain"> children</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>在 router 設定中只需要把需要保護的頁面用 <code>&lt;ProtectedRoute&gt;</code> 包起來，就能達到統一的權限控管，不需要在每個頁面內各自寫驗證邏輯。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-假資料-api-建置與部署">2. 假資料 API 建置與部署<a href="https://www.7lunchapter.com/blog/yestep-project#2-%E5%81%87%E8%B3%87%E6%96%99-api-%E5%BB%BA%E7%BD%AE%E8%88%87%E9%83%A8%E7%BD%B2" class="hash-link" aria-label="2. 假資料 API 建置與部署的直接連結" title="2. 假資料 API 建置與部署的直接連結" translate="no">​</a></h4>
<p>考量到課程主軸是前端、團隊裡也沒有後端組員，為了讓專案能真正串接 API 而不是寫死資料，我主動承擔了模擬後端的建置與部署工作。</p>
<p><strong>假資料來源與整理</strong></p>
<p>組員從政府 Open Data 平台找到合適的步道 API，我們以此為基礎做資料轉換，保留需要的欄位、移除不必要的內容，並依照前端使用情境補上自訂的 key value（例如收藏狀態、自訂標籤等）。</p>
<p><strong>Node.js 伺服器建置</strong></p>
<ul>
<li class="">使用 <code>json-server</code> 作為資料庫與伺服器核心。</li>
<li class="">使用 <code>json-server-auth</code> 作為權限驗證中間件，讓 API 支援 <code>POST /register</code>、<code>POST /login</code> 並自動生成 JWT token。</li>
<li class="">設計自訂的 middleware 解決 CORS 跨網域存取問題，並將 API 伺服器部署到 Render 平台上。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-狀態管理與全域訊息系統">3. 狀態管理與全域訊息系統<a href="https://www.7lunchapter.com/blog/yestep-project#3-%E7%8B%80%E6%85%8B%E7%AE%A1%E7%90%86%E8%88%87%E5%85%A8%E5%9F%9F%E8%A8%8A%E6%81%AF%E7%B3%BB%E7%B5%B1" class="hash-link" aria-label="3. 狀態管理與全域訊息系統的直接連結" title="3. 狀態管理與全域訊息系統的直接連結" translate="no">​</a></h4>
<p><strong>authSlice — 會員登入狀態管理</strong></p>
<p>使用 Redux Toolkit 的 <code>authSlice</code> 統一管理會員狀態。串接登入 API 成功後，將伺服器回傳的 <code>accessToken</code> 與 <code>user</code> 資料透過 <code>dispatch</code> 更新至全域 State，並同時將 Token 存入 Cookie 中（7 天效期），讓使用者在關閉瀏覽器後重開，仍能保持登入狀態。</p>
<p><strong>infoSlice + Toast 元件 — 全域訊息系統</strong></p>
<p>設計一套全域 Toast 通知機制，讓任何元件都可以透過 <code>dispatch(createMessage({ text, type }))</code> 觸發畫面右上角的提示訊息。訊息類型分為「成功」與「錯誤」兩種，分別對應收藏成功、API 連線失敗等情境。</p>
<p>這套架構讓「狀態變化 → 視覺呈現」完全解耦，後續任何新功能需要彈出提示時，只要一行 <code>dispatch</code> 就能完成。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-專案基礎建置與全域樣式">4. 專案基礎建置與全域樣式<a href="https://www.7lunchapter.com/blog/yestep-project#4-%E5%B0%88%E6%A1%88%E5%9F%BA%E7%A4%8E%E5%BB%BA%E7%BD%AE%E8%88%87%E5%85%A8%E5%9F%9F%E6%A8%A3%E5%BC%8F" class="hash-link" aria-label="4. 專案基礎建置與全域樣式的直接連結" title="4. 專案基礎建置與全域樣式的直接連結" translate="no">​</a></h4>
<p>延續上個專案使用 Bootstrap 5 + Sass 的經驗，這次依照設計師提供的 Figma 設計稿，將整套設計系統落地為 SCSS 變數，建立全網站共用的設計依據。主要包含：</p>
<ul>
<li class=""><strong>色票系統</strong>：依設計稿規範定義品牌主色與中性色階變數。</li>
<li class=""><strong>字級系統</strong>：自訂排版 class，統一全網站的文字層級與字重。</li>
<li class=""><strong>元件樣式</strong>：自訂圓角、間距與按鈕樣式，搭配 Bootstrap 既有 utility class 使用。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-頁面切版與功能">5. 頁面切版與功能<a href="https://www.7lunchapter.com/blog/yestep-project#5-%E9%A0%81%E9%9D%A2%E5%88%87%E7%89%88%E8%88%87%E5%8A%9F%E8%83%BD" class="hash-link" aria-label="5. 頁面切版與功能的直接連結" title="5. 頁面切版與功能的直接連結" translate="no">​</a></h4>
<p><strong>5-1. 登入 / 註冊頁</strong></p>
<p>完整實作登入與註冊流程，搭配 <code>react-hook-form</code> 處理表單驗證。</p>
<p><strong>登入流程</strong>：</p>
<ol>
<li class="">透過 <code>js-cookie</code> 將 token 存入 Cookie（7 天效期）</li>
<li class=""><code>dispatch</code> 至 Redux 更新登入狀態</li>
<li class="">透過 <code>infoSlice</code> 顯示「歡迎回來，OOO」的成功訊息</li>
<li class="">透過 <code>location.state</code> 記住登入前的來源頁面，讓使用者登入後自動回到原本想去的位置</li>
<li class="">錯誤訊息依錯誤類型分類顯示（如帳號密碼錯誤 vs 伺服器連線失敗）</li>
</ol>
<p><strong>5-2. 步道檢索詳細頁 (TrailDetail)</strong></p>
<p>詳細頁是整個專案中功能最密集的頁面，整合了 API 串接、收藏狀態管理、相關推薦、動態跳轉與 RWD 等多個面向。</p>
<p><strong>1. 資料載入策略：Promise.all 並行請求</strong></p>
<p>詳細頁進入時需要載入三筆資料：當前步道的詳細資訊、同系統的相關推薦、其他系統的相關推薦。改用 <code>Promise.all</code> 讓三支 API 同時發出請求，總等待時間以最慢的一支為準，能顯著縮短頁面首屏載入時間。</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">detailRes</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> centralRes</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> allRes</span><span class="token punctuation" style="color:#393A34">]</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">await</span><span class="token plain"> </span><span class="token known-class-name class-name">Promise</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">all</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token maybe-class-name">TrailsApi</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token template-string template-punctuation string" style="color:#e3116c">`</span><span class="token template-string string" style="color:#e3116c">/trails/</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:#393A34">${</span><span class="token template-string interpolation">id</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:#393A34">}</span><span class="token template-string template-punctuation string" style="color:#e3116c">`</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token maybe-class-name">TrailsApi</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"/trails?trail_system_like=中央山脈"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token maybe-class-name">TrailsApi</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"/trails"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p><strong>2. 相關推薦：分類隨機抽取</strong></p>
<p>推薦邏輯設計成兩種類型分開呈現：</p>
<ul>
<li class=""><strong>中央山脈脊梁國家步道系統</strong>：與當前步道屬於同一山脈系統，提供「延伸體驗」。</li>
<li class=""><strong>其他步道系統</strong>：跳脫當前山脈系統，提供「探索選項」。</li>
</ul>
<p>兩類資料各自從 API 結果中隨機抽取 3 筆，避免每次進詳細頁都看到一模一樣的推薦。</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> </span><span class="token function-variable function" style="color:#d73a49">getRandomTrails</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token parameter">arr</span><span class="token parameter punctuation" style="color:#393A34">,</span><span class="token parameter"> count </span><span class="token parameter operator" style="color:#393A34">=</span><span class="token parameter"> </span><span class="token parameter number" style="color:#36acaa">3</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> shuffled </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">[</span><span class="token spread operator" style="color:#393A34">...</span><span class="token plain">arr</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">sort</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token arrow operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0.5</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">-</span><span class="token plain"> </span><span class="token known-class-name class-name">Math</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">random</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword control-flow" style="color:#00009f">return</span><span class="token plain"> shuffled</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">slice</span><span class="token punctuation" style="color:#393A34">(</span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> count</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p><strong>3. 收藏功能與狀態管理</strong></p>
<p>收藏按鈕點擊後，依據使用者的「狀態」與「按鈕狀態」進行不同的流程處理：</p>
<ol>
<li class=""><strong>未登入</strong>：彈出 Toast 訊息提醒「請先登入才能收藏」，並延遲 1.5 秒自動跳轉至登入頁。</li>
<li class=""><strong>已登入，未收藏</strong>：向伺服器發送 <code>POST</code> 請求，成功後更新收藏狀態，並彈出「收藏成功」Toast。</li>
<li class=""><strong>已登入，已收藏</strong>：向伺服器發送 <code>DELETE</code> 請求，成功後解除收藏狀態，並彈出「已取消收藏」Toast。</li>
</ol>
<p><strong>4. Tag 標籤連動跳轉</strong></p>
<p>步道詳細頁中包含多個分類 Tag。使用者點擊任一 Tag 時，會透過 React Router 帶著查詢參數跳轉到標籤頁，自動檢索並列出所有包含該標籤的步道。</p>
<p><strong>5-3. 步道標籤頁 (TrailTag)</strong></p>
<p>專門處理從詳細頁點擊 Tag 跳轉過來的檢索結果。</p>
<p>使用 <code>useSearchParams</code> 監聯網址上的 <code>trail_tags</code> 參數，當參數改變時觸發 <code>useEffect</code> 重新向 API 發送請求。</p>
<p>開發時遇到一個有趣的技術問題：<code>trail_tags</code> 欄位在 JSON 檔案中是陣列格式，而 <code>json-server</code> 的預設 query 語法無法正確比對。最終使用 <code>_like</code> 模糊查詢解決：</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token keyword control-flow" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">searchParams</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"trail_tags"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> tagValue </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> searchParams</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token string" style="color:#e3116c">"trail_tags"</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  queryName </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token template-string template-punctuation string" style="color:#e3116c">`</span><span class="token template-string string" style="color:#e3116c">trail_tags_like=</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:#393A34">${</span><span class="token template-string interpolation">tagValue</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:#393A34">}</span><span class="token template-string template-punctuation string" style="color:#e3116c">`</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:#00009f">else</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  queryName </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> searchParams</span><span class="token punctuation" style="color:#393A34">.</span><span class="token method function property-access" style="color:#d73a49">toString</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p><strong>5-4. 404 頁面</strong></p>
<p>當使用者輸入不存在的路由時，會導向設計友好的 404 頁面，搭配 Lottie 動畫，提供回到首頁的引導按鈕。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="八-專案心得與挑戰">八、 專案心得與挑戰<a href="https://www.7lunchapter.com/blog/yestep-project#%E5%85%AB-%E5%B0%88%E6%A1%88%E5%BF%83%E5%BE%97%E8%88%87%E6%8C%91%E6%88%B0" class="hash-link" aria-label="八、 專案心得與挑戰的直接連結" title="八、 專案心得與挑戰的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-組長角色的延續與深化">1. 組長角色的延續與深化<a href="https://www.7lunchapter.com/blog/yestep-project#1-%E7%B5%84%E9%95%B7%E8%A7%92%E8%89%B2%E7%9A%84%E5%BB%B6%E7%BA%8C%E8%88%87%E6%B7%B1%E5%8C%96" class="hash-link" aria-label="1. 組長角色的延續與深化的直接連結" title="1. 組長角色的延續與深化的直接連結" translate="no">​</a></h4>
<p>這是我第二次擔任組長，延續上個專案驗證有效的 MVP 思維：先聚焦核心功能、確保準時上線，再評估加值功能。最終不只完成核心頁面，連原本列為選做的會員中心也順利上線。</p>
<p>這次團隊狀態比第一次複雜：原本一位組員因孩子受傷退出開發，另一位找到切版工作後時間變少，同時新加入一位有 UI/UX 經驗的前端。我在分工時讓對 React 還不熟悉的組員專注於切版任務，框架相關的問題則透過 AI 協作來補足。</p>
<p>到了專案中後期，我會主動 review 組員的 code，確認狀態管理的串接邏輯一致，也順手把部分重複的程式碼重構成可重用的元件。這個過程讓我從「只負責自己的頁面」變成「對整個專案結構有掌握度」，是這次組長角色最大的進化。</p>
<blockquote>
<p><strong>領導不是把所有事情扛起來，而是讓每個人都能在自己擅長的位置發揮。</strong></p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-ai-協作的個人方法論">2. AI 協作的個人方法論<a href="https://www.7lunchapter.com/blog/yestep-project#2-ai-%E5%8D%94%E4%BD%9C%E7%9A%84%E5%80%8B%E4%BA%BA%E6%96%B9%E6%B3%95%E8%AB%96" class="hash-link" aria-label="2. AI 協作的個人方法論的直接連結" title="2. AI 協作的個人方法論的直接連結" translate="no">​</a></h4>
<p>這次專案大量使用 AI 輔助開發，是我們能在 1~1.5 個月內完成的關鍵之一。我自己的習慣是先想好需求、再跟 AI 討論該用什麼語法或寫法比較合適，最後自己寫過一遍。</p>
<p>例如收藏功能的多狀態流程設計，我先把使用者狀態與對應行為釐清，再跟 AI 討論技術實作。又例如 ProtectedRoute 與 <code>location.state</code> 記住來源頁面，都是在我設定好需求後，請 AI 補上我還不熟悉的實作細節。</p>
<blockquote>
<p><strong>AI 給的是技術選項，但設計什麼樣的流程、解決什麼樣的問題，還是必須來自開發者自己的思考。</strong></p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-跨領域探索意外點燃對後端的興趣">3. 跨領域探索：意外點燃對後端的興趣<a href="https://www.7lunchapter.com/blog/yestep-project#3-%E8%B7%A8%E9%A0%98%E5%9F%9F%E6%8E%A2%E7%B4%A2%E6%84%8F%E5%A4%96%E9%BB%9E%E7%87%83%E5%B0%8D%E5%BE%8C%E7%AB%AF%E7%9A%84%E8%88%88%E8%B6%A3" class="hash-link" aria-label="3. 跨領域探索：意外點燃對後端的興趣的直接連結" title="3. 跨領域探索：意外點燃對後端的興趣的直接連結" translate="no">​</a></h4>
<p>這個專案最意料之外的收穫，是因為要建置假資料 API 而踏進了後端的領域。實作過程中為了搭配登入驗證、處理部署環境的差異、理解 middleware 順序，逐漸接觸到 Node.js 的基礎知識。這段跨領域的探索結束後，我發現自己對後端的興趣比想像中強，因此後續報名了 Node.js 的課程。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-技術挑戰回顧">4. 技術挑戰回顧<a href="https://www.7lunchapter.com/blog/yestep-project#4-%E6%8A%80%E8%A1%93%E6%8C%91%E6%88%B0%E5%9B%9E%E9%A1%A7" class="hash-link" aria-label="4. 技術挑戰回顧的直接連結" title="4. 技術挑戰回顧的直接連結" translate="no">​</a></h4>
<ul>
<li class=""><strong>Promise.all 並行請求</strong>：原本三支 API 串行打的版本載入速度偏慢，改用 Promise.all 同時發出後首屏時間縮短不少。</li>
<li class=""><strong>收藏功能的狀態流程設計</strong>：在一個按鈕裡處理未登入 / 未收藏 / 已收藏三種狀態，是最複雜的互動設計。先把流程畫出來再寫程式，比直接動手寫快很多。</li>
<li class=""><strong>JSON Server 陣列欄位查詢的小坑</strong>：標籤頁的 <code>trail_tags</code> 欄位是陣列，一般 query 抓不到資料，最終找到 <code>_like</code> 模糊查詢的解法。</li>
</ul>
<blockquote>
<p><strong>寫 code 之前先把邏輯想清楚，可以省下很多 debug 時間。</strong></p>
</blockquote>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-專題發表簡報">5. 專題發表簡報<a href="https://www.7lunchapter.com/blog/yestep-project#5-%E5%B0%88%E9%A1%8C%E7%99%BC%E8%A1%A8%E7%B0%A1%E5%A0%B1" class="hash-link" aria-label="5. 專題發表簡報的直接連結" title="5. 專題發表簡報的直接連結" translate="no">​</a></h4>
<ul>
<li class="">🔗 <a href="https://canva.link/047sh39ujhqcjlf" target="_blank" rel="noopener noreferrer" class="">20260301 專題發表簡報</a></li>
</ul>]]></content:encoded>
            <category>專案作品</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[伴你在日常 — 長照輔具電商平台]]></title>
            <link>https://www.7lunchapter.com/blog/withyourlife-project</link>
            <guid>https://www.7lunchapter.com/blog/withyourlife-project</guid>
            <pubDate>Fri, 03 Oct 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[前言]]></description>
            <content:encoded><![CDATA[<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="前言">前言<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E5%89%8D%E8%A8%80" class="hash-link" aria-label="前言的直接連結" title="前言的直接連結" translate="no">​</a></h3>
<p>一個以照顧者為核心的輔具資訊整合平台，希望透過友善的介面與清楚的分類，幫助家庭在長照旅程中更輕鬆地找到合適的輔具。</p>
<ul>
<li class=""><strong>Live Demo</strong>：<a href="https://duncanin.github.io/with_your_life/" target="_blank" rel="noopener noreferrer" class=""><strong>伴你在日常</strong></a></li>
<li class=""><strong>GitHub</strong>：<a href="https://github.com/Duncanin/with_your_life" target="_blank" rel="noopener noreferrer" class=""><strong>GitHub Repo</strong></a></li>
<li class=""><strong>使用技術</strong>：<code>Vite</code> / <code>Bootstrap 5</code> / <code>SCSS</code> / <code>GSAP</code> / <code>Leaflet</code> / <code>Git</code> / <code>GitHub</code></li>
<li class=""><strong>團隊角色</strong>：組長 / 前端切版</li>
<li class=""><strong>專案管理</strong>：Notion / GitHub / Discord</li>
<li class=""><strong>專案時程</strong>：2025.07.01 ~ 2025.10.02</li>
<li class=""><strong>網站部署</strong>：GitHub Pages</li>
</ul>
<!-- -->
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="一-主題發想">一、 主題發想<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E4%B8%80-%E4%B8%BB%E9%A1%8C%E7%99%BC%E6%83%B3" class="hash-link" aria-label="一、 主題發想的直接連結" title="一、 主題發想的直接連結" translate="no">​</a></h3>
<p>專題發想來自組員在護理現場的親身觀察。台灣長照輔具的資訊分散、專業術語多，許多家庭在第一次面對照顧需求時，光是選輔具就已經手足無措，找資料反而比照顧本身還累。</p>
<p>我們想做的不只是一個購物網站，而是一個讓照顧者真正用得上的平台——把複雜的輔具資訊整理清楚，用日常的語言說明用途，幫助家庭依照自己的環境與需求做出合適的選擇。品牌取名「伴你在日常」，也是希望這個平台能像一雙默默支持的手，陪著照顧者一起走過每一天。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="二-專案協作方式">二、 專案協作方式<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E4%BA%8C-%E5%B0%88%E6%A1%88%E5%8D%94%E4%BD%9C%E6%96%B9%E5%BC%8F" class="hash-link" aria-label="二、 專案協作方式的直接連結" title="二、 專案協作方式的直接連結" translate="no">​</a></h3>
<p>團隊使用 Notion 管理專案進度與文件，透過 Git / GitHub 進行版本控制與協作開發，並使用 Discord 保持日常溝通與討論，讓遠端協作也能維持一定的節奏與效率。此外，團隊每週也會安排約 2～3 小時的小組會議，進行進度同步、問題討論與工作分配，以提升整體協作效率與專案推進速度。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="三-使用者故事">三、 使用者故事<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E4%B8%89-%E4%BD%BF%E7%94%A8%E8%80%85%E6%95%85%E4%BA%8B" class="hash-link" aria-label="三、 使用者故事的直接連結" title="三、 使用者故事的直接連結" translate="no">​</a></h3>
<p>在規劃功能時，我們並非一開始就全部納入，而是考量到團隊成員同時在上課、每個人的時間與能力狀況不同，因此決定先確立核心頁面，再視情況延伸。最終確定的範圍包含首頁、關於我們、產品總覽、結帳頁面，以及原本列為選作的使用者頁面，後來由我負責並完整完成。</p>
<p>功能上我們依使用情境分為一般使用者與會員兩個層級：一般使用者可以瀏覽平台、依場景挑選商品、條件篩選與加入購物車，成為會員後則可以進入結帳、查看訂單、收藏商品與管理個人資料。</p>
<p>在設計過程中，我們也針對目標客群的使用情境進行思考，例如視力退化的長輩需要字體大、對比清楚的介面，家庭照顧者希望產品能依「使用場景」分類，不需要懂醫療術語也能快速找到所需商品。這些考量也實際影響了我們在頁面排版與分類方式上的決策。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="四-網站地圖與-wireframe">四、 網站地圖與 Wireframe<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E5%9B%9B-%E7%B6%B2%E7%AB%99%E5%9C%B0%E5%9C%96%E8%88%87-wireframe" class="hash-link" aria-label="四、 網站地圖與 Wireframe的直接連結" title="四、 網站地圖與 Wireframe的直接連結" translate="no">​</a></h3>
<p>確認好功能範圍後，我們使用 Miro 繪製網站地圖，整理出各頁面之間的架構與跳轉關係，再依此產出 Wireframe，規劃每個頁面的版面配置與使用者操作流程。這個步驟幫助我們在正式開發前對整體結構有共同的認知，也減少了後期來回修改的成本。</p>
<ul>
<li class=""><strong>Miro Wireframe</strong>：<a href="https://miro.com/app/board/uXjVJf63dD0=/" target="_blank" rel="noopener noreferrer" class=""><strong>伴你在日常 Wireframe</strong></a></li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-網站地圖-site-map">1. 網站地圖 (Site Map)<a href="https://www.7lunchapter.com/blog/withyourlife-project#1-%E7%B6%B2%E7%AB%99%E5%9C%B0%E5%9C%96-site-map" class="hash-link" aria-label="1. 網站地圖 (Site Map)的直接連結" title="1. 網站地圖 (Site Map)的直接連結" translate="no">​</a></h4>
<p>清楚定義了使用者在網站中的瀏覽動線，區分出「一般使用者」與「會員」在操作路徑上的差異，確保整體架構沒有遺漏。</p>
<p><img decoding="async" loading="lazy" alt="網站地圖" src="https://www.7lunchapter.com/assets/images/%E4%BC%B4%E4%BD%A0%E5%9C%A8%E6%97%A5%E5%B8%B8%E7%B6%B2%E7%AB%99%E5%9C%B0%E5%9C%96-55d7143b7f6b7917e1f710f326125b6f.png" width="1972" height="960" class="img_ev3q"></p>
<div align="center"><small style="color:#888"><i>▲ 伴你在日常 — 前台網站地圖</i></small></div>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-wireframe-線框圖">2. Wireframe (線框圖)<a href="https://www.7lunchapter.com/blog/withyourlife-project#2-wireframe-%E7%B7%9A%E6%A1%86%E5%9C%96" class="hash-link" aria-label="2. Wireframe (線框圖)的直接連結" title="2. Wireframe (線框圖)的直接連結" translate="no">​</a></h4>
<p>將功能收斂成具體的畫面版塊，在正式進入設計前先確認好排版比例、按鈕位置與操作邏輯。</p>
<p><img decoding="async" loading="lazy" alt="Wireframe" src="https://www.7lunchapter.com/assets/images/%E4%BC%B4%E4%BD%A0%E5%9C%A8%E6%97%A5%E5%B8%B8%E7%B7%9A%E7%A8%BF%E5%9C%96-ef2ed3bfa4994cf723c6a745de286d73.png" width="2244" height="1330" class="img_ev3q"></p>
<div align="center"><small style="color:#888"><i>▲ 伴你在日常 — 首頁 Wireframe 線框圖</i></small></div>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="五-ui-設計稿">五、 UI 設計稿<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E4%BA%94-ui-%E8%A8%AD%E8%A8%88%E7%A8%BF" class="hash-link" aria-label="五、 UI 設計稿的直接連結" title="五、 UI 設計稿的直接連結" translate="no">​</a></h3>
<p>完成 Wireframe 後，我們邀請專業的 UI/UX 設計師依照架構進行視覺設計，產出完整的切版設計稿。有了設計稿作為依據，後續在切版與前端實作上也更有方向，能更準確地還原設計細節。</p>
<ul>
<li class=""><strong>Figma 設計稿</strong>：<a href="https://www.figma.com/design/ewdYsnmVv3m5O82L71iuBj/%E3%80%90-%E5%85%AD%E8%A7%92-%E3%80%91-C-2-%E4%BC%B4%E4%BD%A0%E5%9C%A8%E6%97%A5%E5%B8%B8?t=KYwpd0qRSJf4cRuX-0" target="_blank" rel="noopener noreferrer" class=""><strong>伴你在日常 UI 設計稿</strong></a></li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="六-專案架構">六、 專案架構<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E5%85%AD-%E5%B0%88%E6%A1%88%E6%9E%B6%E6%A7%8B" class="hash-link" aria-label="六、 專案架構的直接連結" title="六、 專案架構的直接連結" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#393A34"><span class="token plain">with_your_life/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── assets/</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── photo/               # 圖片資源（依頁面分類）</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── index/           #   首頁用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── about/           #   關於頁用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── product/         #   商品頁用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── cart/            #   購物車頁用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── user/            #   會員頁用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── header/          #   Header 用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   ├── footer/          #   Footer 用圖</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   │   └── logo-icon/       #   Logo 與 favicon</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   └── scss/                # SCSS 樣式檔</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│       ├── all.scss          #   主入口，匯入所有模組</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│       ├── base/             #   基礎樣式 (reset, typography)</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│       ├── util/             #   變數、自定義 utilities</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│       ├── layout/           #   Header / Footer 樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│       ├── pages/            #   各頁面專屬樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│       └── Component/        #   共用元件樣式</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── layout/                  # EJS 共用樣板</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── header-loginIn.ejs    #   已登入狀態 Header</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── header-loginOut.ejs   #   未登入狀態 Header</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── footer.ejs            #   共用 Footer</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   └── link-icon.ejs         #   外部字體圖標引入</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── page/                    # 各頁面 HTML</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── index.html            #   首頁</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── about.html            #   關於我們</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── product.html          #   商品列表</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   ├── cart.html             #   購物車</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">│   └── user.html             #   會員中心</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── main.js                  # 全域 JS 進入點</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── index.html               # Vite 根 HTML</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── vite.config.js           # Vite 設定檔</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">├── package.json             # 套件依賴與腳本</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">└── .gitignore               # Git 忽略規則</span><br></div></code></pre></div></div>
<hr>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="七-個人負責開發項目">七、 個人負責開發項目<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E4%B8%83-%E5%80%8B%E4%BA%BA%E8%B2%A0%E8%B2%AC%E9%96%8B%E7%99%BC%E9%A0%85%E7%9B%AE" class="hash-link" aria-label="七、 個人負責開發項目的直接連結" title="七、 個人負責開發項目的直接連結" translate="no">​</a></h3>
<p>進入開發階段後，我主要負責以下項目：</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-專案基礎建置">1. 專案基礎建置<a href="https://www.7lunchapter.com/blog/withyourlife-project#1-%E5%B0%88%E6%A1%88%E5%9F%BA%E7%A4%8E%E5%BB%BA%E7%BD%AE" class="hash-link" aria-label="1. 專案基礎建置的直接連結" title="1. 專案基礎建置的直接連結" translate="no">​</a></h4>
<p>參考課程教材，動手設定專案的 Vite 建置環境，包含整合 EJS 樣板引擎、設定多頁面入口 (Multi-Page Application)、調整 build 後的輸出結構，以及配置 GitHub Pages 部署所需的 base 路徑。雖然部分設定是參考教材調整，但透過這次實作，對於前端建置工具與部署流程有了更實際的理解。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-全域變數設定">2. 全域變數設定<a href="https://www.7lunchapter.com/blog/withyourlife-project#2-%E5%85%A8%E5%9F%9F%E8%AE%8A%E6%95%B8%E8%A8%AD%E5%AE%9A" class="hash-link" aria-label="2. 全域變數設定的直接連結" title="2. 全域變數設定的直接連結" translate="no">​</a></h4>
<p>透過 Bootstrap 5 的 Sass 變數客製化機制，覆寫框架預設的顏色、字型、間距等變數，建立符合品牌風格的統一樣式設定，讓團隊在切版時能維持一致的視覺風格，也方便後續維護與調整。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-導覽列-navbar">3. 導覽列 (Navbar)<a href="https://www.7lunchapter.com/blog/withyourlife-project#3-%E5%B0%8E%E8%A6%BD%E5%88%97-navbar" class="hash-link" aria-label="3. 導覽列 (Navbar)的直接連結" title="3. 導覽列 (Navbar)的直接連結" translate="no">​</a></h4>
<p>負責導覽列的切版與互動邏輯，並依登入前後切分為兩種狀態：</p>
<ul>
<li class=""><strong>登入前</strong>：顯示「登入/註冊」按鈕，點擊後彈出 Bootstrap Modal，包含 Email 登入欄位、第三方登入選項 (Apple、Google、Facebook)，登入與註冊 Modal 之間也可互相切換。</li>
<li class=""><strong>登入後</strong>：將按鈕替換為「會員中心」下拉選單，整合訊息通知、我的收藏、訂單查詢、會員資料、登出等項目，並於購物車圖示加上未結帳商品數量的徽章 (badge)。</li>
</ul>
<p>在響應式設計上，桌機版採水平展開的選單搭配下拉式子選單，手機版則改為漢堡選單搭配 Collapse 折疊式子選單。Logo 也使用 <code>&lt;picture&gt;</code> 搭配不同尺寸的圖檔，讓不同裝置都能載入合適的視覺資源。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-購物車頁面">4. 購物車頁面<a href="https://www.7lunchapter.com/blog/withyourlife-project#4-%E8%B3%BC%E7%89%A9%E8%BB%8A%E9%A0%81%E9%9D%A2" class="hash-link" aria-label="4. 購物車頁面的直接連結" title="4. 購物車頁面的直接連結" translate="no">​</a></h4>
<p>完成購物車頁面的切版與 RWD，依照設計稿還原版面細節。此頁面結構較為複雜，包含購物車商品列表、加購商品專區、配送與付款資訊、優惠折扣，以及即時計算的金額明細區塊。在響應式設計上，桌機版以表格形式呈現商品列表，手機版則改以卡片式版面呈現，讓不同裝置的使用者都能有良好的瀏覽體驗。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="5-使用者中心頁面原專案選作項目">5. 使用者中心頁面（原專案選作項目）<a href="https://www.7lunchapter.com/blog/withyourlife-project#5-%E4%BD%BF%E7%94%A8%E8%80%85%E4%B8%AD%E5%BF%83%E9%A0%81%E9%9D%A2%E5%8E%9F%E5%B0%88%E6%A1%88%E9%81%B8%E4%BD%9C%E9%A0%85%E7%9B%AE" class="hash-link" aria-label="5. 使用者中心頁面（原專案選作項目）的直接連結" title="5. 使用者中心頁面（原專案選作項目）的直接連結" translate="no">​</a></h4>
<p>使用 Bootstrap 5 的 Tab 元件切換「我的訂單」、「收藏」、「個人資料」等分頁內容。為了讓使用者能從導覽列的下拉選單直接跳轉到指定分頁，我自行撰寫了一段 JS：監聽下拉選單的點擊事件、抓取連結中的 hash 值並存入 localStorage，搭配網址 hash 讓 Bootstrap Tab 自動切換至對應頁籤。</p>
<p>過程中也觀察到此功能在 Vite 開發環境下無法正常跳轉，但部署到正式環境後即可運作，是開發中印象深刻的除錯經驗。</p>
<h3 class="anchor anchorTargetStickyNavbar_Vzrq" id="八專案心得與挑戰">八、專案心得與挑戰<a href="https://www.7lunchapter.com/blog/withyourlife-project#%E5%85%AB%E5%B0%88%E6%A1%88%E5%BF%83%E5%BE%97%E8%88%87%E6%8C%91%E6%88%B0" class="hash-link" aria-label="八、專案心得與挑戰的直接連結" title="八、專案心得與挑戰的直接連結" translate="no">​</a></h3>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="1-第一次擔任組長的學習">1. 第一次擔任組長的學習<a href="https://www.7lunchapter.com/blog/withyourlife-project#1-%E7%AC%AC%E4%B8%80%E6%AC%A1%E6%93%94%E4%BB%BB%E7%B5%84%E9%95%B7%E7%9A%84%E5%AD%B8%E7%BF%92" class="hash-link" aria-label="1. 第一次擔任組長的學習的直接連結" title="1. 第一次擔任組長的學習的直接連結" translate="no">​</a></h4>
<p>這份專題是我第一次擔任小組組長，也是第一次從零開始開發網站，過程中其實相當緊張，擔心無法順利完成。所幸組員們都很配合專案節奏，讓我們得以提早於預期時間內完成。</p>
<p>由於過去曾有 PM 的經歷，我在規劃開發流程時採用「最小可行性方案 (MVP)」的思維——先讓組員們收斂想法、完成各頁面的核心版面，並將想加入的動畫或進階效果以備註方式保留，等基礎版面完成後再自由發揮。這樣的做法不只讓專案能穩步推進，也降低了組員因方向不一致而產生的壓力。</p>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="2-技術上的挑戰">2. 技術上的挑戰<a href="https://www.7lunchapter.com/blog/withyourlife-project#2-%E6%8A%80%E8%A1%93%E4%B8%8A%E7%9A%84%E6%8C%91%E6%88%B0" class="hash-link" aria-label="2. 技術上的挑戰的直接連結" title="2. 技術上的挑戰的直接連結" translate="no">​</a></h4>
<ul>
<li class=""><strong>Navbar 定位與 RWD</strong>：由於自己對於定位類型的切版還不熟悉，加上需同時符合響應式設計，實際做出來的版面與設計稿常有落差。當時我大量透過 AI 工具協作——把設計稿截圖貼上、描述問題，與 AI 一來一回討論寫法，逐步調整到接近設計的樣貌。這個過程也讓我學會如何更精準地提出技術問題、判斷建議是否合適。</li>
<li class=""><strong>Tab 切換邏輯</strong>：當時對 JS 還不熟練，為了讓使用者能從導覽列直接跳轉到 user 頁面的指定分頁，自己嘗試寫出搭配 localStorage 與 hash 的解法。原本只是想讓切版作業更完整，意外成為這次專案中最有成就感的技術突破之一。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="3-學到的東西">3. 學到的東西<a href="https://www.7lunchapter.com/blog/withyourlife-project#3-%E5%AD%B8%E5%88%B0%E7%9A%84%E6%9D%B1%E8%A5%BF" class="hash-link" aria-label="3. 學到的東西的直接連結" title="3. 學到的東西的直接連結" translate="no">​</a></h4>
<ul>
<li class="">對 <strong>Bootstrap 5</strong> 的文件閱讀更熟練，也能善用其格線系統處理版面與 RWD。</li>
<li class="">對 <strong>Sass 變數客製化</strong> 有更深入的理解，能配合品牌規劃整體樣式。</li>
<li class="">學會如何 <strong>運用 AI 工具加速開發</strong>，例如釐清技術問題、討論切版寫法、debug。</li>
<li class="">在團隊協作中，學會用 <strong>MVP 思維</strong> 幫助大家收斂想法，讓專案能在有限時間內穩定推進。</li>
</ul>
<h4 class="anchor anchorTargetStickyNavbar_Vzrq" id="4-專題發表簡報">4. 專題發表簡報<a href="https://www.7lunchapter.com/blog/withyourlife-project#4-%E5%B0%88%E9%A1%8C%E7%99%BC%E8%A1%A8%E7%B0%A1%E5%A0%B1" class="hash-link" aria-label="4. 專題發表簡報的直接連結" title="4. 專題發表簡報的直接連結" translate="no">​</a></h4>
<ul>
<li class=""><strong>20251003 專題發表簡報</strong>：<a href="https://canva.link/eh8523cp7wsekqw" target="_blank" rel="noopener noreferrer" class="">簡報連結</a></li>
</ul>]]></content:encoded>
            <category>專案作品</category>
            <category>切版網站</category>
        </item>
    </channel>
</rss>