全栈视角:从零构建一个现代化的 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...

相关推荐
BumBle8 小时前
uniapp 用css实现圆形进度条组件
前端·vue.js·uni-app
杏花春雨江南8 小时前
npm error Could not resolve dependency:
前端·npm·node.js
嫂子的姐夫8 小时前
10-七麦js扣代码
前端·javascript·爬虫·python·node.js·网络爬虫
Komorebi_99998 小时前
Vue3 + TypeScript provide/inject 小白学习笔记
前端·javascript·vue.js
少吃一口都不行8 小时前
脚手架学习
前端·javascript·学习
地方地方8 小时前
手写JavaScript 深拷贝
前端·javascript
yeyuningzi8 小时前
npm升级提示error engine not compatible with your version of node/npm: npm@11.6.2
前端·npm·node.js
1024小神8 小时前
next 项目中的 'use client' 是什么意思
前端
我是华为OD~HR~栗栗呀8 小时前
24届-Python面经(华为OD)
java·前端·c++·python·华为od·华为·面试