强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Deno 入门教程 / 第 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 工具。