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

Deno 入门教程 / 第 13 章:Fresh 框架

第 13 章:Fresh 框架

13.1 Fresh 简介

Fresh 是 Deno 官方的全栈 Web 框架,核心特点:

特性说明
岛屿架构默认零 JS,交互组件按需加载
即时渲染SSR 优先,首屏速度极快
边缘部署专为 Deno Deploy 优化
TypeScript 原生零配置 TS 支持
文件系统路由基于目录结构的路由
JIT 构建无需预构建步骤

Fresh vs Next.js

特性FreshNext.js
运行时DenoNode.js
默认 JS零 JS包含框架 JS
岛屿架构✅ 原生部分支持 (RSC)
构建步骤无需预构建需要构建
部署目标Deno DeployVercel
学习曲线较低中等

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].tsx404 或捕获所有路由

页面路由

// 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 工具。