跳轉到

6.2 前端實作:Astro 頁面、版型與互動#

本節目標#

有了 6.1 的施工圖,本節把「看的到的部分」蓋出來:用 Astro(第 3 章介紹過它為何是靜態優先首選)建立三種頁面——首頁文章列表、單篇文章、檔案下載列表——並用 vanilla JavaScript 接上 /api/*,完成留言表單互動。

完成後你會有一個完全可瀏覽的網站外殼(留言還送不出去,因為 API 是 6.3 的事)。

專案結構#

延續第 3 章的專案,加上章節約定好的資料夾:

blog/
├── astro.config.mjs
├── public/
│   └── style.css          # 全站共用樣式
├── src/
│   ├── layouts/Base.astro # 共用版型:<head>、header、footer
│   └── pages/
│       ├── index.astro    # F1:首頁文章列表
│       ├── posts/[slug].astro  # F2:單篇文章
│       └── files.astro    # F4:檔案列表
└── functions/             # 6.3 再實作

Astro 的 .astro 檔上半部是「前端程式碼」(build 跑一次),下半部是樣板。關鍵觀念:資料抓取發生在建置時或瀏覽器端,不在伺服器渲染——因為上線後根本沒有伺服器,只有 CDN。

共用版型#

---
// src/layouts/Base.astro
interface Props { title: string }
const { title } = Astro.props;
---
<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{title}</title>
  <link rel="stylesheet" href="/style.css" />
</head>
<body>
  <header>
    <a href="/" class="logo">邊緣部落格</a>
    <nav>
      <a href="/">文章</a>
      <a href="/files">檔案下載</a>
    </nav>
  </header>
  <main><slot /></main>
  <footer>© 2026 · Powered by Cloudflare Pages</footer>
</body>
</html>

頁面一:首頁文章列表#

策略(呼應 3.1 節 Jamstack):列表在 build 時由 Functions 抓 D1 資料預渲染;文章異動時觸發重新部署即可。

---
// src/pages/index.astro
import Base from '../layouts/Base.astro';

// build 時抓 API;本機 dev 時 wrangler pages dev 會把 /api 交給 Functions
const res  = await fetch(`${import.meta.env.PUBLIC_API_BASE}/api/posts?limit=10`);
const { data: posts } = await res.json();
---
<Base title="邊緣部落格">
  <h1>最新文章</h1>
  <ul class="post-list">
    {posts.map((p) => (
      <li>
        <a href={`/posts/${p.slug}`}>
          <h2>{p.title}</h2>
          <p>{p.excerpt}</p>
          <time>{p.created_at.slice(0, 10)}</time>
        </a>
      </li>
    ))}
  </ul>
</Base>

頁面二:單篇文章 + 留言區#

---
// src/pages/posts/[slug].astro
import Base from '../../layouts/Base.astro';
const { slug } = Astro.params;
const res = await fetch(`${import.meta.env.PUBLIC_API_BASE}/api/posts/${slug}`);
// 思考題:build 時 404 該怎麼辦?(見練習 2)
const { data: post } = await res.json();
---
<Base title={post.title}>
  <article>
    <h1>{post.title}</h1>
    <time>{post.created_at.slice(0, 10)}</time>
    <div class="body" set:html={post.body} />
  </article>

  <section id="comments">
    <h2>留言</h2>
    <ol id="comment-list"></ol>

    <form id="comment-form">
      <input name="author" placeholder="暱稱" maxlength="30" required />
      <textarea name="body" placeholder="說點什麼…" maxlength="500" required></textarea>
      <button>送出留言</button>
      <p id="comment-msg" aria-live="polite"></p>
    </form>
  </section>

  <script>
    // 留言是「建置後才會變動」的資料,所以用瀏覽器端 JS 動態載入,
    // 不吃 CDN 快取的舊頁面 —— 這是靜態優先架構的標準分工。
    const slug = location.pathname.split('/').pop();
    const list = document.getElementById('comment-list')!;

    async function loadComments() {
      const res = await fetch(`/api/posts/${slug}/comments`);
      if (!res.ok) return;
      const { data } = await res.json();
      list.innerHTML = '';
      for (const c of data) {
        const li = document.createElement('li');
        li.textContent = `${c.author}:${c.body}`; // textContent 防 XSS!
        li.append(Object.assign(document.createElement('time'),
          { textContent: c.created_at.slice(0, 16).replace('T', ' ') }));
        list.append(li);
      }
    }

    document.getElementById('comment-form')!.addEventListener('submit', async (e) => {
      e.preventDefault();
      const form = e.target as HTMLFormElement;
      const body = Object.fromEntries(new FormData(form));
      const msg = document.getElementById('comment-msg')!;
      const res = await fetch(`/api/posts/${slug}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      if (res.ok) { form.reset(); msg.textContent = '留言成功!'; loadComments(); }
      else        { const { error } = await res.json(); msg.textContent = error; }
    });

    loadComments();
  </script>
</Base>

兩個安全細節:

  • li.textContent = ...:留言內容永不使用 innerHTML,HTML 注入無效(第 8 章會再講 XSS 的 WAF 防線,這裡是第一道)
  • maxlength 只是友善提示;真正的長度限制在資料庫 CHECK 與 API 驗證(6.3)——前端限制可以繞過,後端不能省

頁面三:檔案列表#

---
// src/pages/files.astro
import Base from '../layouts/Base.astro';
const res = await fetch(`${import.meta.env.PUBLIC_API_BASE}/api/files`);
const { data: files } = await res.json();
const kb = (n: number) => n < 1024 * 1024
  ? `${Math.ceil(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`;
---
<Base title="檔案下載">
  <h1>可下載檔案</h1>
  <ul class="file-list">
    {files.map((f) => (
      <li>
        <a href={`/api/files/${f.key}`}>{f.key}</a>
        <span>{kb(f.size)}</span>
      </li>
    ))}
  </ul>
</Base>

<a href> 對下載連結來說其實夠了——R2 檔案由 6.3 的 API 以正確的 Content-Disposition 回應,瀏覽器就會觸發下載而不是導航。

樣式#

/* public/style.css:簡潔優先,藏色板給讀者自由發揮 */
:root { --fg: #1c1c1c; --accent: #f6821f; }  /* Cloudflare 橘 */
body   { max-width: 44rem; margin: 0 auto; padding: 1rem;
         font-family: system-ui, sans-serif; color: var(--fg); }
header { display: flex; justify-content: space-between; }
.post-list li { margin: 1.5rem 0; }
.post-list a  { text-decoration: none; color: inherit; }
.post-list a:hover h2 { color: var(--accent); }
#comment-form { display: flex; flex-direction: column; gap: .5rem; }
#comment-form button { align-self: flex-start; background: var(--accent);
                       color: #fff; border: 0; padding: .5rem 1rem; cursor: pointer; }

本機驗證#

npx wrangler pages dev     # 同時跑靜態頁 + functions/ + D1/R2 本地模擬

瀏覽 http://localhost:8788:首頁文章列表有資料(D1 本地模擬)、留言區送出會回 404(API 還沒寫)——這正是 6.2 完成、6.3 待開工的狀態。

小結與練習#

本節把施工圖的「使用者看得到的半張」蓋完:三種頁面、一個留言互動、版型與樣式。核心原則是資料的分工——列表與文章資料在 build 時預渲染進快取,留言與下載才走瀏覽器端的動態 fetch。

練習

  1. 把檔案列表加上圖示或副檔名標籤(副檔名可用 f.key.split('.').pop() 取出)。
  2. 文章頁在 build 時抓不到資料會讓 post 是 undefined 開頁即炸。練習加上 if (!res.ok) return Astro.redirect('/404'),並了解 Astro 的 getStaticPaths() 為何更適合預渲染固定文章集。
  3. 替留言表單加上送出中的 disabled 狀態,防止使用者連點造成重複留言(提示:button.disabled = true,送出完恢復)。