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