全栈视角:从零构建一个现代化的 Todo 应用

Todo 应用是学习全栈开发的"Hello World",但它能完美地串联起现代 Web 开发的所有核心概念。我们将构建一个具备增删改查、实时更新等功能的单页面应用。

技术选型

  • Next.js 15: 用于构建服务器渲染的 React 应用。
  • TypeScript: 增加类型检查,提升代码质量。
  • Tailwind CSS: 用于快速构建响应式界面。
  • Drizzle ORM: 作为 ORM 管理数据库。
  • PostgreSQL: 作为数据库存储 Todo 项。
  • tRPC: 用于构建类型安全的 API。
  • Zod: 用于验证和解析 API 请求参数。
  • @tanstack/react-query: 用于管理客户端数据缓存和状态。

开发环境

  • Node.js 18+: 确保安装最新版本的 Node.js。
  • npm: 或使用 Yarn/PNPM 作为包管理器。
  • PostgreSQL: 安装并运行 PostgreSQL 数据库。
  • Drizzle ORM: 全局安装 Drizzle ORM 命令行工具。

用 Docker 起本地 PG(可选):

bash 复制代码
docker run --name pg13 -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=app -p 5432:5432 -d postgres:13

连接串(示例):postgres://postgres:postgres@localhost:5432/app

项目结构

lua 复制代码
.
├── drizzle
│   ├── meta
│   │   ├── 0000_snapshot.json
│   │   └── _journal.json
│   └── 0000_lively_gravity.sql
├── src
│   ├── app
│   │   ├── api/trpc/[trpc]
│   │   │   └── route.ts
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── trpc-provider.tsx
│   ├── server
│   │   ├── db
│   │   │   ├── index.ts
│   │   │   └── schema.ts
│   │   ├── routers
│   │   │   └── todo.ts
│   │   ├── root.ts
│   │   └── trpc.ts
│   └── trpc
│       └── react.ts
├── README.md
├── biome.json
├── drizzle.config.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
└── tsconfig.json

项目初始化

bash 复制代码
npx create-next-app@latest todo --ts
cd todo

选择 App Router、ESLint、Tailwind。

安装与配置 PostgreSQL、Drizzle 及相关依赖

bash 复制代码
pnpm add drizzle-orm drizzle-kit pg zod @trpc/server @trpc/client @trpc/react-query @tanstack/react-query superjson

新增开发脚本(package.json)

json 复制代码
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build --turbopack",
    "start": "next start",
    "lint": "biome check",
    "format": "biome format --write",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

环境变量(.env):

bash 复制代码
DATABASE_URL=postgres://postgres:postgres@localhost:5432/app
NODE_ENV=development

Drizzle 配置(drizzle.config.ts):

typescript 复制代码
import type { Config } from "drizzle-kit";

export default {
  schema: "./src/server/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
} satisfies Config;

数据库连接(src/server/db/index.ts):

typescript 复制代码
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle(pool);

数据表

Schema(src/server/db/schema.ts):

typescript 复制代码
import { pgTable, serial, varchar, boolean, timestamp } from "drizzle-orm/pg-core";

export const todos = pgTable("todos", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 200 }).notNull(),
  completed: boolean("completed").notNull().default(false),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

生成并执行迁移:

bash 复制代码
pnpm db:generate
pnpm db:migrate

执行后会在 drizzle/ 目录看到迁移 SQL,并把表建好。 (可选)打开可视化:

bash 复制代码
pnpm db:studio

初始化 tRPC(服务器与客户端)

  1. 服务器端基础(src/server/trpc.ts):

    typescript 复制代码
    import { initTRPC } from "@trpc/server";
    import { ZodError } from "zod";
    
    const t = initTRPC.context<{}>().create({
      errorFormatter({ shape, error }) {
        return {
          code: -1,
          message:
            error.cause instanceof ZodError
              ? error.cause.issues.map((issue) => issue.message).join("")
              : shape.message,
          data: null,
        };
      },
    });
    
    export const publicProcedure = t.procedure;
    export const router = t.router;
  2. tRPC 适配 Next.js App Router(src/app/api/trpc/[trpc]/route.ts):

    typescript 复制代码
    import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
    import { appRouter } from "@/server/root";
    
    const handler = (req: Request) => {
      return fetchRequestHandler({
        endpoint: "/api/trpc",
        req,
        router: appRouter,
      });
    };
    
    export { handler as GET, handler as POST };
  3. tRPC todo 路由(src/server/routers/todo.ts):

    typescript 复制代码
    import { desc, eq } from "drizzle-orm";
    import { z } from "zod";
    import { db } from "@/server/db";
    import { todos } from "@/server/db/schema";
    import { publicProcedure, router } from "@/server/trpc";
    
    export const todoRouter = router({
      list: publicProcedure.query(async () => {
        return db.select().from(todos).orderBy(desc(todos.createdAt));
      }),
    
      create: publicProcedure
        .input(z.object({ title: z.string().min(1).max(200) }))
        .mutation(async ({ input }) => {
          const [row] = await db
            .insert(todos)
            .values({ title: input.title })
            .returning();
          return row;
        }),
    
      toggle: publicProcedure
        .input(z.object({ id: z.number(), completed: z.boolean() }))
        .mutation(async ({ input }) => {
          const [row] = await db
            .update(todos)
            .set({ completed: input.completed })
            .where(eq(todos.id, input.id))
            .returning();
          return row;
        }),
    
      remove: publicProcedure
        .input(z.object({ id: z.number() }))
        .mutation(async ({ input }) => {
          await db.delete(todos).where(eq(todos.id, input.id));
          return { ok: true };
        }),
    });
  4. tRPC 根接口(src/server/root.ts):

    typescript 复制代码
    import { todoRouter } from "./routers/todo";
    import { router } from "./trpc";
    
    export const appRouter = router({
      todo: todoRouter,
    });
    
    export type AppRouter = typeof appRouter;
  5. 客户端(src/trpc/react.ts):

    typescript 复制代码
    import { createTRPCReact } from "@trpc/react-query";
    import type { AppRouter } from "@/server/root";
    
    export const trpc = createTRPCReact<AppRouter>();
  6. 创建 tRPC Provider src/app/trpc-provider.tsx

    typescript 复制代码
    "use client";
    
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { httpBatchLink } from "@trpc/client";
    import { type ReactNode, useState } from "react";
    import { trpc } from "@/trpc/react";
    
    export function TRPCProviders({ children }: { children: ReactNode }) {
      const [queryClient] = useState(() => new QueryClient());
      const [trpcClient] = useState(() =>
        trpc.createClient({
          links: [
            httpBatchLink({
              url: "/api/trpc",
            }),
          ],
        }),
      );
    
      return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
          <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
        </trpc.Provider>
      );
    }
  7. 在 app/layout.tsx 中挂载 Provider:

    tsx 复制代码
    import type { Metadata } from "next";
    import { Geist, Geist_Mono } from "next/font/google";
    import "./globals.css";
    import { TRPCProviders } from "./trpc-provider";
    
    const geistSans = Geist({
      variable: "--font-geist-sans",
      subsets: ["latin"],
    });
    
    const geistMono = Geist_Mono({
      variable: "--font-geist-mono",
      subsets: ["latin"],
    });
    
    export const metadata: Metadata = {
      title: "Create Next App",
      description: "Generated by create next app",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{
      children: React.ReactNode;
    }>) {
      return (
        <html lang="en">
          <body
            className={`${geistSans.variable} ${geistMono.variable} antialiased`}
          >
            <TRPCProviders>{children}</TRPCProviders>
          </body>
        </html>
      );
    }

Todo CRUD 端到端打通

  1. 前端页面(src/app/page.tsx):

    tsx 复制代码
    "use client";
    
    import { useState } from "react";
    import { trpc } from "@/trpc/react";
    
    export default function HomePage() {
      const utils = trpc.useUtils();
      const [title, setTitle] = useState("");
    
      const { data: todos = [], isLoading } = trpc.todo.list.useQuery();
    
      const createTodo = trpc.todo.create.useMutation({
        onSuccess: () => utils.todo.list.invalidate(),
      });
    
      const toggleTodo = trpc.todo.toggle.useMutation({
        onSuccess: () => utils.todo.list.invalidate(),
      });
    
      const removeTodo = trpc.todo.remove.useMutation({
        onSuccess: () => utils.todo.list.invalidate(),
      });
    
      const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (!title.trim()) return;
        createTodo.mutate({ title });
        setTitle("");
      };
    
      return (
        <main className="mx-auto max-w-xl p-6 space-y-4">
          <h1 className="text-2xl font-bold">tRPC × Drizzle × Next.js Todo</h1>
    
          <form className="flex gap-2" onSubmit={handleSubmit}>
            <input
              className="flex-1 border rounded px-3 py-2"
              placeholder="添加待办..."
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
            <button
              className="px-4 py-2 rounded bg-black text-white disabled:opacity-50"
              type="submit"
              disabled={createTodo.isPending}
            >
              {createTodo.isPending ? "添加中..." : "添加"}
            </button>
          </form>
    
          {isLoading ? (
            <p>加载中...</p>
          ) : (
            <ul className="space-y-2">
              {todos.map((t) => (
                <li
                  key={t.id}
                  className="flex items-center gap-3 border p-2 rounded"
                >
                  <input
                    type="checkbox"
                    checked={t.completed}
                    onChange={(e) =>
                      toggleTodo.mutate({ id: t.id, completed: e.target.checked })
                    }
                  />
                  <span className={t.completed ? "line-through text-gray-500" : ""}>
                    {t.title}
                  </span>
                  <button
                    className="ml-auto text-red-600"
                    onClick={() => removeTodo.mutate({ id: t.id })}
                  >
                    删除
                  </button>
                </li>
              ))}
            </ul>
          )}
        </main>
      );
    }

完整项目地址

github.com/letconstvar...

相关推荐
恋猫de小郭1 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅8 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端