第 13 章:Fresh 框架
第 13 章:Fresh 框架
13.1 Fresh 简介
Fresh 是 Deno 官方的全栈 Web 框架,核心特点:
| 特性 | 说明 |
|---|---|
| 岛屿架构 | 默认零 JS,交互组件按需加载 |
| 即时渲染 | SSR 优先,首屏速度极快 |
| 边缘部署 | 专为 Deno Deploy 优化 |
| TypeScript 原生 | 零配置 TS 支持 |
| 文件系统路由 | 基于目录结构的路由 |
| JIT 构建 | 无需预构建步骤 |
Fresh vs Next.js
| 特性 | Fresh | Next.js |
|---|---|---|
| 运行时 | Deno | Node.js |
| 默认 JS | 零 JS | 包含框架 JS |
| 岛屿架构 | ✅ 原生 | 部分支持 (RSC) |
| 构建步骤 | 无需预构建 | 需要构建 |
| 部署目标 | Deno Deploy | Vercel |
| 学习曲线 | 较低 | 中等 |
13.2 创建 Fresh 项目
# 创建项目
deno run -A jsr:@fresh/init my-fresh-app
# 或者使用 npm 方式
npm init fresh my-fresh-app
# 进入项目
cd my-fresh-app
# 启动开发服务器
deno task start
项目结构
my-fresh-app/
├── deno.json # 配置文件
├── dev.ts # 开发入口
├── main.ts # 生产入口
├── fresh.gen.ts # 自动生成的路由表
├── static/ # 静态资源
│ └── favicon.ico
├── islands/ # 岛屿组件(客户端交互)
│ └── Counter.tsx
├── routes/ # 路由页面
│ ├── index.tsx # 首页 /
│ ├── about.tsx # /about
│ └── api/
│ └── hello.ts # API 路由
└── components/ # 服务端组件
└── Header.tsx
13.3 路由系统
文件系统路由
| 文件路径 | URL |
|---|---|
routes/index.tsx | / |
routes/about.tsx | /about |
routes/blog/index.tsx | /blog |
routes/blog/[slug].tsx | /blog/hello-world |
routes/api/users.ts | /api/users |
routes/[...rest].tsx | 404 或捕获所有路由 |
页面路由
// routes/index.tsx
import { useSignal } from "@preact/signals";
import { Head } from "$fresh/runtime.ts";
export default function HomePage() {
return (
<>
<Head>
<title>首页</title>
</Head>
<div>
<h1>欢迎使用 Fresh</h1>
<p>这是一个服务端渲染的页面</p>
</div>
</>
);
}
动态路由
// routes/users/[id].tsx
import { PageProps } from "$fresh/server.ts";
export default function UserPage(props: PageProps) {
const id = props.params.id;
return (
<div>
<h1>用户详情</h1>
<p>用户 ID: {id}</p>
</div>
);
}
数据加载(Handler)
// routes/users/[id].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
interface User {
id: number;
name: string;
email: string;
}
export const handler: Handlers<User | null> = {
async GET(req, ctx) {
const { id } = ctx.params;
// 从数据库或 API 获取数据
const user = await fetchUserById(id);
if (!user) {
return ctx.renderNotFound();
}
return ctx.render(user);
},
};
export default function UserPage({ data }: PageProps<User | null>) {
if (!data) return <p>用户不存在</p>;
return (
<div>
<h1>{data.name}</h1>
<p>Email: {data.email}</p>
</div>
);
}
API 路由
// routes/api/users.ts
import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
// GET /api/users
async GET(req) {
const users = await fetchAllUsers();
return Response.json(users);
},
// POST /api/users
async POST(req) {
const body = await req.json();
const user = await createUser(body);
return Response.json(user, { status: 201 });
},
// PUT /api/users
async PUT(req) {
const body = await req.json();
const user = await updateUser(body);
return Response.json(user);
},
};
13.4 岛屿架构(Islands)
什么是岛屿架构?
┌─────────────────────────────────────────┐
│ 服务端渲染的 HTML │
│ │
│ ┌─────────┐ 静态内容 ┌─────────┐ │
│ │ Header │ ←→ │ Footer │ │
│ └─────────┘ └─────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 🏝️ 岛屿:Counter │ │
│ │ ┌─────────────────────────┐ │ │
│ │ │ [−] 0 [+] ← 可交互 │ │ │
│ │ └─────────────────────────┘ │ │
│ │ 只有这个组件加载 JS │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ 🏝️ 岛屿:TodoList │ │
│ │ 只有这个组件加载 JS │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
创建岛屿组件
// islands/Counter.tsx
import { useState } from "preact/hooks";
interface CounterProps {
initialCount?: number;
}
export default function Counter({ initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
return (
<div class="counter">
<button onClick={() => setCount(c => c - 1)}>−</button>
<span class="count">{count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
在页面中使用:
// routes/index.tsx
import Counter from "../islands/Counter.tsx";
export default function HomePage() {
return (
<div>
<h1>我的应用</h1>
<p>下面是交互组件:</p>
<Counter initialCount={10} />
</div>
);
}
更多岛屿示例
// islands/SearchBar.tsx
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
export default function SearchBar() {
const query = useSignal("");
const results = useSignal<string[]>([]);
const loading = useSignal(false);
useEffect(() => {
if (query.value.length < 2) {
results.value = [];
return;
}
const timer = setTimeout(async () => {
loading.value = true;
const res = await fetch(`/api/search?q=${encodeURIComponent(query.value)}`);
results.value = await res.json();
loading.value = false;
}, 300);
return () => clearTimeout(timer);
}, [query.value]);
return (
<div>
<input
type="text"
value={query.value}
onInput={(e) => query.value = (e.target as HTMLInputElement).value}
placeholder="搜索..."
/>
{loading.value && <p>搜索中...</p>}
<ul>
{results.value.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
);
}
// islands/ThemeToggle.tsx
import { useEffect } from "preact/hooks";
export default function ThemeToggle() {
useEffect(() => {
const theme = localStorage.getItem("theme") || "light";
document.documentElement.setAttribute("data-theme", theme);
}, []);
const toggle = () => {
const current = document.documentElement.getAttribute("data-theme");
const next = current === "light" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
};
return <button onClick={toggle}>切换主题</button>;
}
13.5 布局与组件
服务端组件
// components/Layout.tsx
import { Head } from "$fresh/runtime.ts";
interface LayoutProps {
title: string;
children: preact.ComponentChildren;
}
export default function Layout({ title, children }: LayoutProps) {
return (
<>
<Head>
<title>{title} - 我的应用</title>
<link rel="stylesheet" href="/styles/global.css" />
</Head>
<div class="app">
<header>
<nav>
<a href="/">首页</a>
<a href="/about">关于</a>
<a href="/blog">博客</a>
</nav>
</header>
<main>{children}</main>
<footer>
<p>© 2024 我的应用</p>
</footer>
</div>
</>
);
}
// routes/about.tsx
import Layout from "../components/Layout.tsx";
export default function AboutPage() {
return (
<Layout title="关于">
<h1>关于我们</h1>
<p>这是一个使用 Fresh 构建的网站。</p>
</Layout>
);
}
13.6 中间件
中间件基础
// routes/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";
export async function handler(req: Request, ctx: MiddlewareHandlerContext) {
const start = Date.now();
// 继续处理请求
const response = await ctx.next();
const ms = Date.now() - start;
console.log(`${req.method} ${new URL(req.url).pathname} - ${ms}ms`);
return response;
}
认证中间件
// routes/dashboard/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";
export async function handler(req: Request, ctx: MiddlewareHandlerContext) {
const token = req.headers.get("Cookie")?.match(/token=([^;]+)/)?.[1];
if (!token || !isValidToken(token)) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
ctx.state.user = await getUserFromToken(token);
return await ctx.next();
}
13.7 样式
Tailwind CSS
Fresh 内置了 Tailwind CSS 支持:
export default function StyledPage() {
return (
<div class="min-h-screen bg-gray-100">
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-gray-800">
欢迎
</h1>
<p class="mt-4 text-gray-600">
这是使用 Tailwind 样式的页面
</p>
</div>
</div>
);
}
13.8 状态管理
使用 Signals
// islands/ShoppingCart.tsx
import { signal, computed } from "@preact/signals";
interface Product {
id: number;
name: string;
price: number;
}
const cart = signal<Product[]>([]);
const total = computed(() =>
cart.value.reduce((sum, p) => sum + p.price, 0)
);
export default function ShoppingCart() {
const addItem = (product: Product) => {
cart.value = [...cart.value, product];
};
const removeItem = (id: number) => {
cart.value = cart.value.filter(p => p.id !== id);
};
return (
<div>
<h2>购物车 ({cart.value.length} 件)</h2>
<ul>
{cart.value.map(item => (
<li key={item.id}>
{item.name} - ¥{item.price}
<button onClick={() => removeItem(item.id)}>删除</button>
</li>
))}
</ul>
<p>总计: ¥{total.value}</p>
</div>
);
}
13.9 部署到 Deno Deploy
# 推送到 GitHub 仓库
# 在 Deno Deploy 中:
# 1. 登录 https://dash.deno.com
# 2. 创建新项目
# 3. 选择 GitHub 仓库
# 4. 设置入口文件为 main.ts
# 5. 自动部署
# 或使用 deployctl
deployctl deploy --project=my-app main.ts
13.10 本章小结
| 要点 | 说明 |
|---|---|
| 岛屿架构 | 默认零 JS,交互组件按需加载 |
| 文件路由 | routes/ 目录决定 URL 结构 |
| 岛屿组件 | islands/ 目录中的组件可交互 |
| Handler | 数据加载和请求处理 |
| 中间件 | _middleware.ts 实现横切逻辑 |
| 部署 | 最佳选择是 Deno Deploy |
📖 扩展阅读
下一章:第 14 章:代码规范 → 配置 Deno 的 lint 和 format 工具。