Astro Hacker News 模板实战:基于 SSR 与动态路由的 Hacker News 克隆
本文以 Astro 官方仓库中的 Hacker News 示例模板(examples/hackernews)为主体,完整讲解这个示例的创建方式、项目结构、路由设计与命令用法,并结合仓库中的真实源码(动态路由页面、API 封装层、条件渲染组件、Node 适配器配置)深入剖析它如何用纯服务端渲染(SSR)实现一个完整的 Hacker News 克隆站点。读完本文,你可以直接复刻该模板,并理解其中 [id].astro 动态路由、rest 路由 [...stories].astro 分页、output: 'server' + @astrojs/node 部署等核心机制的落地方式。
一、快速创建项目
模板提供了标准的一键创建命令:
npm create astro@latest -- --template hackernews
该命令会拉取 hackernews 模板生成完整项目,无需手工搭建路由与布局。模板对应的仓库目录为 examples/hackernews,其中 package.json 声明了核心依赖:astro ^7.2.10 与 SSR 适配器 @astrojs/node ^11.1.5,并要求 Node.js >=22.12.0(见 package.json)。
二、项目结构
README 中给出的目录结构如下:
/
├── public/
│ └── favicon.svg
├── src/
│ ├── components/
│ ├── layouts/
│ │ └── Layout.astro
│ └── pages/
└── stories/
└── [id].astro
└── users/
└── [id].astro
│ └── [...stories].astro
└── package.json
Astro 在 src/pages/ 目录中寻找 .astro 或 .md 文件,每个页面按其文件名映射为一个路由。由于 Hacker News 的文章列表与用户信息是持续变化的,示例使用 [id].astro 这样的动态路由,只在特定页面被请求时才构建页面。src/components/ 本身没有特殊含义,但约定存放 Astro/React/Vue/Svelte/Preact 组件。静态资源(如图片)放在 public/ 目录,例如 favicon.svg 在构建后会以 /favicon.svg 访问。
仓库实际文件布局与该结构一致,另有一个 src/lib/api.ts 封装 API 请求、src/types.ts 定义数据类型(见 src/types.ts)。
三、SSR 部署:@astrojs/node 与 standalone 模式
该模板是纯 SSR 站点,配置文件 astro.config.mjs 完整内容如下:
import node from '@astrojs/node';
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
});
两个关键配置:
output: 'server':让所有路由默认按服务端渲染模式处理。页面中的 frontmatter 在每次请求时于服务端执行,数据在请求时实时获取。adapter: node({ mode: 'standalone' }):使用@astrojs/node适配器,以 standalone 模式将站点部署到 Node 运行时目标上。standalone 模式会产出可独立分发的服务器目录,便于在容器或 Node 环境中直接启动。
其他部署目标(Cloudflare、Netlify、Vercel 等)可换用对应适配器,配置方式不变:只需在 adapter 中替换为相应适配器即可。
四、数据层:统一封装的两个 API
所有数据请求集中在 src/lib/api.ts,它按路径前缀路由到两个不同的数据源:
const story = (path: string) => `https://node-hnapi.herokuapp.com/${path}`;
const user = (path: string) => `https://hacker-news.firebaseio.com/v0/${path}.json`;
export default async function fetchAPI(path: string) {
const url = path.startsWith('user') ? user(path) : story(path);
const headers = { 'User-Agent': 'chrome' };
try {
let response = await fetch(url, { headers });
let text = await response.text();
try {
if (text === null) {
return { error: 'Not found' };
}
return JSON.parse(text);
} catch (e) {
console.error(`Received from API: ${text}`);
console.error(e);
return { error: e };
}
} catch (error) {
return { error };
}
}
从源码结构看,其设计要点有三:
- 双数据源分流:以
user开头的请求走 Hacker News Firebase 官方 API(user/<id>.json),其余走 HN API 代理服务(item/<id>、news?page=N等)。 - 容错优先:任何失败路径都返回
{ error }对象而非抛出异常,让页面层可以用条件渲染优雅降级。 - 类型契约:src/types.ts 定义了
IStory、IUser和可递归的IComment接口,页面中通过as IStory、as IUser断言获取类型安全。
五、路由设计:动态路由与 rest 分页
5.1 文章详情页:src/pages/stories/[id].astro
sources 目录下的 stories/[id].astro 从 Astro.params 取 id,请求时拉取文章与全部评论:
---
const { id } = Astro.params as { id: string };
const story = (await fetchAPI(`item/${id}`)) as IStory;
---
<Layout>
<header>
<a href={story.url} target="_blank"><h1>{story.title}</h1></a>
<Show when={story.domain}>
<span class="host">({story.domain})</span>
</Show>
...
</header>
<main>
<ul class="comment-children">
<For each={story.comments}>
{(comment: IComment) => <Comment comment={comment} />}
</For>
</ul>
</main>
</Layout>
这正是 README 所说"动态路由只在页面被请求时构建"的体现:frontmatter 中的 await 在每次请求时执行,/stories/123、/stories/456 各自命中同一模板。
5.2 用户详情页:src/pages/users/[id].astro
users/[id].astro 展示了 API 错误返回的降级用法——利用 Show 组件与 fallback 插槽,在用户不存在时显示 "User not found.":
---
const { id } = Astro.params as { id: string };
const user = (await fetchAPI(`user/${id}`)) as IUser;
---
<Show when={user}>
<Show when={!user.error}>
<h1 slot="fallback">User not found.</h1>
<h1>User : {user.id}</h1>
<ul class="meta">
<li><span class="label">Created:</span>{user.created}</li>
<li><span class="label">Karma:</span>{user.karma}</li>
...
</Show>
</Show>
5.3 列表与分页:src/pages/[...stories].astro
rest 路由 [...stories].astro 是理解路由映射的核心。rest 路由参数 stories 会接收 URL 首段,模板用它区分五个板块并做服务端分页:
const mapStories = {
top: 'news',
new: 'newest',
show: 'show',
ask: 'ask',
job: 'jobs',
};
const page = safeParseInt(Astro.url.searchParams.get('page'), 1);
const type =
Astro.params.stories && Astro.params.stories in mapStories
? (Astro.params.stories.toString() as keyof typeof mapStories)
: 'top';
const stories = (await fetchAPI(`${mapStories[type]}?page=${page}`)) as IStory[];
路由与 API 参数的映射关系为:/ 与非法路径回落到 top(对应 news),/new → newest,/show → show,/ask → ask,/job → jobs。分页通过 ?page=N 查询参数实现,页面内以 stories?.length >= 29 作为"还有下一页"的判断依据(HN 每页约 30 条,代理 API 每页 29 条)。这体现了 SSR 的优势:URL 完全可分享、可被爬虫索引,无需任何客户端状态。
六、组件层:条件渲染与递归列表
README 提到 src/components/ 是存放组件的约定位置,该模板在这里实现了一组非常有教学价值的通用组件:
6.1 Show:条件渲染 + fallback 插槽
components/Show.astro 全文仅两行核心逻辑,是 Angular 风格的条件容器:
{!!when ? <slot /> : <slot name="fallback" />}
when 为真时渲染默认插槽,否则渲染名为 fallback 的命名插槽——前文用户页的 "User not found." 就依赖这一机制。
6.2 For:可迭代对象的统一循环
components/For.astro 接收 each(任意 Iterable),用异步生成器逐项渲染默认插槽,并在集合为空时渲染 fallback 插槽,统一了"遍历 + 空态"两种处理。
6.3 Comment:Astro.self 实现无限层递归
评论天然是任意深度的树结构。components/Comment.astro 用 Astro.self 自引用实现递归渲染,无需客户端代码:
<Show when={comment.comments.length}>
<Toggle open>
<For each={comment.comments}>
{(comment: IComment) => <Astro.self comment={comment} />}
</For>
</Show>
</li>
Astro.self 在组件内部引用组件自身,配合 set:html={comment.content} 直接输出 API 返回的 HTML 评论正文,整个嵌套评论树完全在服务端一次性生成。
6.4 Layout 与 Nav
layouts/Layout.astro 提供 <html>、<head>(含 Astro.generator meta)与 <slot />,各页面通过插槽组合复用;components/Nav.astro 定义了 HN/New/Show/Ask/Jobs 五个导航链接,并用 aria-current={href === Astro.url.pathname ? 'page' : undefined} 在服务端标记当前页——这同样印证了导航状态判断发生在服务端渲染阶段。
七、常用命令
所有命令在项目根目录的终端中执行(见 README 命令表):
| 命令 | 作用 |
|---|---|
npm install |
安装依赖 |
npm run dev |
在 localhost:4321 启动本地开发服务器 |
npm run build |
构建生产站点到 ./dist/ |
npm run preview |
在部署前本地预览构建产物 |
npm run astro ... |
运行 astro add、astro check 等 CLI 命令 |
npm run astro -- --help |
查看 Astro CLI 帮助 |
对应 package.json 中的 scripts 定义:dev → astro dev、build → astro build、preview → astro preview、astro → 直接透传 astro 命令。由于配置了 Node 适配器且 output: 'server',build 产出的 dist/ 中除静态资源外还包含可运行的 SSR 服务器代码,preview 即在该服务器上运行。
TypeScript 方面,tsconfig.json 继承 astro/tsconfigs/strict 严格模式,并引入 Astro 生成的类型声明 .astro/types.d.ts,因此 Astro.params、Astro.url 等对象在页面中享有完整类型提示。
八、小结
这个模板的价值在于用最小代码展示了一套完整的 Astro SSR 工作流:
- 一条命令创建项目(
npm create astro@latest -- --template hackernews); output: 'server'+@astrojs/nodestandalone 完成 Node 端 SSR 部署配置;[id].astro动态路由承载请求时数据拉取,[...stories].astrorest 路由 + 查询参数实现服务端分页与多板块映射;fetchAPI统一错误兜底 +Show/For条件与循环组件 +Astro.self递归,全程零客户端 JS 即可渲染任意深度的评论树。
若你想在此基础上扩展(如加入 Content Collections、缓存策略或其他框架岛屿组件),建议从 examples/ 目录下的其他模板(如 ssr、blog)对照参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00