1844 字
9 分钟
项目驱动,从零入门 React 前端(一)
2025-10-29

导言#

之前笔者学前端都是浅尝辄止,只会抄个界面下来调 elementUI 这种组件库,没学过任何一个前端框架。但是笔者一直觉得前端很有意思,只是没空去学习,现在笔者决定借助大模型,一边开发一边学习 React 开发,并且基于 DenoSupabase 创建个人全栈应用。

React 据说所有东西都是拆成组件的,很适合喂给 AI 慢慢看,也很适合逐步开发,而且 deno 已经完全兼容了 next.js 这经典的生态。

从零创建一个 Fresh 应用#

笔者想先抄一下 vitepress 的主页,来实现一版课题组主页。

既然已经开始用 Deno 了,这边先创建一个 Fresh 框架的应用:

Terminal window
deno run -Ar jsr:@fresh/init

接下来我们阅读示例项目的源码:

首先从 main.ts 看起

import { App, staticFiles } from "fresh";
import { define, type State } from "./utils.ts";
export const app = new App<State>();
app.use(staticFiles());
// Pass a shared value from a middleware
app.use((ctx) => {
ctx.state.test_global = "全局变量测试";
return ctx.next();
});
// this can also be defined via a file. feel free to delete this!
const exampleLoggerMiddleware = define.middleware(async (ctx) => {
console.log(`${ctx.req.method} ${ctx.req.url}`);
const res = await ctx.next();
console.log(`${ctx.req.method} ${ctx.req.url}` + `-> ${res.status}`);
return res;
});
app.use(exampleLoggerMiddleware);
// Include file-system based routes here
app.fsRoutes();
import { createDefine } from "fresh";
// This specifies the type of "ctx.state" which is used to share
// data among middlewares, layouts and routes.
export interface State {
state: string;
test_global: string;
}
export const define = createDefine<State>();

这个展示了 Fresh 的一些核心特性,包括下面这些:

  • export const app = new App<State>(); 创建一个 app 实例,而 State 则是一个自定义的上下文类型,自己可以随便改,把一些全局变量存在这里就行了,不过生命周期只有一个 http 请求。这里我提供了个 stringtest_global 方便理解。
  • app.use(async (ctx) => {...}); 这里主要是介绍 app.use() 的用法。use() 是用来添加中间件的。我们还可以用文件导入,这里用 Middleware helper 就好,直接 define.middleware(...) ,这个会返回一个 use() 可以使用的自定义 middleware

这里的写法类似下面的

import { App, staticFiles } from "fresh";
import { type State } from "./utils.ts";
import { LoggerMiddleware } from "./middleware/LoggerMiddleware.ts";
export const app = new App<State>();
app.use(staticFiles());
// Pass a shared value from a middleware
app.use((ctx) => {
ctx.state.test_global = "全局变量测试";
return ctx.next();
});
app.use(LoggerMiddleware);
// Include file-system based routes here
app.fsRoutes();
./middleware/LoggerMiddleware.ts
import { define } from "../utils.ts";
// this can also be defined via a file. feel free to delete this!
export const LoggerMiddleware = define.middleware(async (ctx) => {
console.log(`${ctx.req.method} ${ctx.req.url}`);
const res = await ctx.next();
console.log(`${ctx.req.method} ${ctx.req.url}` + `-> ${res.status}`);
return res;
});

OK,接下来看我们经典的 index.tsx,因为本质上是 fsRoute() 所以这里实际上很方便,在 route 目录下找到就好了。不过 Fresh 有一个很神奇的东西,叫 App wrapper,这里先看

我们先看 _app.tsx, 他相当于一个 App wrapper ,也就是对全局的一个包裹,不过从 fs 的角度来说,似乎是 _app.tsx 这种用法更自然。

import { define } from "../utils.ts";
export default define.page(function App({ Component }) {
return (
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uestc-ndsl-hpc-web</title>
</head>
<body>
<Component />
</body>
</html>
);
});

随后我们阅读 index.tsx:

import { useSignal } from "@preact/signals";
import { Head } from "fresh/runtime";
import { define } from "../utils.ts";
import Counter from "../islands/Counter.tsx";
export default define.page(function Home(ctx) {
const count = useSignal(3);
console.log("Shared value " + ctx.state.shared);
return (
<div class="px-4 py-8 mx-auto fresh-gradient min-h-screen">
<Head>
<title>Fresh counter</title>
</Head>
<div class="max-w-3xl mx-auto flex flex-col items-center justify-center">
<img
class="my-6"
src="/logo.svg"
width="128"
height="128"
alt="the Fresh logo: a sliced lemon dripping with juice"
/>
<h1 class="text-4xl font-bold">Welcome to Fresh</h1>
<p class="my-4">
Try updating this message in the
<code class="mx-2">./routes/index.tsx</code> file, and refresh.
</p>
<Counter count={count} />
</div>
</div>
);
});

这个展示了 Fresh 的一些核心特性,包括下面这些:

  • export default define.page(function Home(ctx) { ... }); 是用来给页面提供类型提示的,避免只有在浏览器运行、用户点击时才报错。Home 则是一个 function 在前端可能以组件的想法更合适。
  • const count = useSignal(3); 类似声明一个局部变量,更准确的说是一个带自动通知机制的响应式变量,可以用数据表示状态。
  • console.log 则是经典的控制台日志,前端第一课。
  • return(...) 看起来像返回的是 html 实际上这里返回的是一个 jsx,是 react 的写法。

下面再看一下其他的几个 fresh 提供的 define 的属性

import { define } from "../utils.ts";
export default define.page(function App({ Component }) {
return (
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>uestc-ndsl-hpc-web</title>
</head>
<body>
<Component />
</body>
</html>
);
});

这里相当于提供了这个文件代表的界面。

看完了这些,基本上对于 fresh 如何编写有了初步的认识,接下来笔者将会设计一个 Navbar 的场景,然后利用 freshlayout 特性。

Navbar 的设计和应用#

我们接下来设计一下 navbar,当然我们不能完全自己写,那么很多操作写起来很麻烦。

先按官方的教程导入我们的 daisyui

Terminal window
deno i -D npm:daisyui@latest

不过现在的 style.cssasset 目录下。

@import "tailwindcss";
@plugin "daisyui";

现在我们就可以用 daisyui 的组件了。

我们设计一个 logo ,一个黑夜模式按钮加上一个 menu 菜单如下

./routes/index.tsx
import { asset } from "fresh/runtime";
import { MdModeNight } from "@preact-icons/md";
interface NavbarProps {
text?: string;
logo_url?: string;
navItems?: string[];
home_click?: () => void;
dark_mode_click?: () => void;
}
export default function Navbar({
text = "Navbar",
logo_url = asset("img/avatar-test.jpg"),
navItems = ["Item1", "Item 2", "Item 3"],
home_click,
dark_mode_click,
}: NavbarProps) {
return (
<div className="navbar bg-primary text-primary-content rounded-box justify-between">
{/* Logo */}
<button
type="button"
className="btn btn-xs sm:btn-xs md:btn-sm lg:btn-md xl:btn-lg btn-ghost text-md btn-secondary"
onClick={home_click}
>
<div className="avatar">
<div className="w-10 rounded-full">
<img
src={`${logo_url}`}
alt="Logo"
/>
</div>
</div>
{text}
</button>
{/* 右侧容器:导航和黑夜模式 */}
<div className="flex items-center gap-4">
{/* 导航菜单 */}
<ul className="menu menu-xs sm:menu-xs md:menu-sm lg:menu-md xl:menu-lg menu-horizontal menu-ghost rounded-box text-md">
{navItems.map((item, index) => (
<li key={index}>
<a>{item}</a>
</li>
))}
</ul>
{/* 黑夜模式 */}
<button
type="button"
className="btn btn-xs sm:btn-sm md:btn-md lg:btn-lg xl:btn-xl btn-ghost btn-circle btn-secondary"
onClick={dark_mode_click}
>
<MdModeNight />
</button>
</div>
</div>
);
}

稍做改动,迁移到 _layout.tsx 下:

./routes/_layout.tsx
import { define } from "../utils.ts";
import { asset } from "fresh/runtime";
import Navbar from "../components/navbar.tsx";
export default define.page(function Home({ Component }) {
return (
<div class="px-4 py-8 mx-auto min-h-screen bg-base-100">
<Navbar
text="UESTC NDSL HPC"
navItems={["Home", "Team", "Pub"]}
logo_url={asset("img/logo.png")}
/>
<Component />
</div>
);
});

最后的效果如下

image.png

接下来我们可以对标 vitepress 写一个 Hero 组件,初步掌握这个怎么写以后可以借助 AI 的力量了。我们实现了下面的 Hero.tsx 组件

import { asset } from "fresh/runtime";
interface HeroProps {
title?: string;
subtitle?: string;
tagline?: string;
logo_url?: string;
button_accent_text?: string;
button_secondary_text?: string;
cards?: { card_title: string; card_text: string }[];
}
export default function Hero({
title = "UESTC NDSL HPC",
subtitle =
"A group led by Dr. Zhang focusing on High Performance Computing and Numerical Linear Algebra.",
tagline = "University of Electronic Science and Technology of China",
logo_url = asset("img/logo.png"),
button_accent_text = "Learn More",
button_secondary_text = "Contact Us",
cards = [
{ card_title: "Cookies!", card_text: "We are using cookies for no reason." },
{ card_title: "Cookies!", card_text: "We are using cookies for no reason." },
{ card_title: "Cookies!", card_text: "We are using cookies for no reason." },
],
}: HeroProps) {
return (
<div>
<div className="w-full h-auto mx-auto bg-base-200 text-base-content rounded-box my-8">
<div className="hero-content gap-10 flex-col lg:flex-row items-center">
{/* Left: Text */}
<div className="flex-1 max-w-3xl">
<p className="sm:text-3xl md:text-4xl lg:text-5xl font-semibold tracking-tight bg-linear-to-r from-primary/80 to-secondary/80 bg-clip-text text-transparent">
{title}
</p>
<p className="mt-4 sm:text-xl md:text-2xl lg:text-3xl font-extrabold leading-[1.05]">
{subtitle}
</p>
<p className="mt-6 text-base opacity-70">{tagline}</p>
<div className="mt-6 flex gap-4">
<button
type="button"
className="btn btn-accent text-accent-content px-6 py-2 rounded-lg"
>
{button_accent_text}
</button>
<button
type="button"
className="btn btn-secondary text-secondary-content px-6 py-2 rounded-lg"
>
{button_secondary_text}
</button>
</div>
</div>
{/* Right: Image */}
<div className="w-full h-auto xs:w-3xs sm:w-2xs md:w-xs rounded-lg overflow-hidden bg-base-300 mx-auto lg:mx-0">
<img
src={`${logo_url}`}
alt="Logo"
className="w-full h-auto object-contain p-6" // No shadow here, just rounded corners
/>
</div>
</div>
</div>
{/* Cards Section */}
<div className="flex justify-center gap-4">
{cards.map((card, index) => (
<div key={index} className="card bg-neutral text-neutral-content w-96">
<div className="card-body items-center text-center">
<h2 className="card-title">{card.card_title}</h2>
<p>{card.card_text}</p>
</div>
</div>
))}
</div>
</div>
);
}

效果如图

image.png

再增加一个 pub 组件,效果如下:

image.png

效果喜人啊~

接下来我要实现主题切换和 Recent Publication 从 api 接口读取(从而方便加个后端来修改)。这就要用到 island,就之后再写了。