如果让我用一个词概括过去一年做全栈项目最大的变化那就是类型终于开始“流动”了。把自己的项目代号定为 t3code 之后我不需要再为前后端接口的字段对齐反复翻文档编辑器里会直接告诉我哪个字段拼错了、哪个参数没传全。t3code 不是什么新框架而是我用 TypeScript、Tailwind CSS、tRPC、Next.js 这四样东西组合起来从零搭建一个网络书签收藏与标注工具的全过程记录。它的核心思路是让类型安全从数据库一路贯穿到页面按钮——这也是我这一年坚持选这套技术栈的根本原因。这篇文章会把项目从初始化、数据模型设计、tRPC 接口编写、登录认证接入、界面打磨到部署上线的完整链路拆开来讲同时把我在真实环境中踩过的坑和最终的修复方案一并交代清楚。适合两类人看一是想体验端到端类型安全、但不知道从哪下手的开发者二是想找一个能直接照抄、改改就能用的全栈项目骨架的人。我不会只给结论每一步都会解释为什么这样做。1. 立项思考t3code 是什么为什么我坚持选这套技术栈1.1 T3 的四个成员各自解决什么问题先拆一下 t3code 里的“t3”。它取自 T3 StackTypeScript、Tailwind CSS、tRPC、Next.js。这个组合的定位很明确——用一套语言、一套类型系统把一个全栈应用从数据库到 UI 全部打通。这四个成员各有分工TypeScript 是地基。没有它tRPC 的类型推断无从谈起。Next.js 负责框架层页面路由、服务端组件、API 入口都由它承载。tRPC 是核心中的核心它让服务端定义的接口类型可以直接被前端引用不需要额外生成 SDK也不需要手动维护 OpenAPI 文档。Tailwind CSS 解决样式层的问题原子化类名让界面成型速度大幅提升不用在 CSS 文件和组件文件之间反复切换。需要注意的是T3 不等于“最佳实践”本身它更像一个“经过验证的默认组合”。真要论生态成熟度REST 依然是老大哥但 t3code 这类项目在乎的不是生态而是开发体验和迭代速度。1.2 对比 REST 和 GraphQL为什么我选择了 tRPC在定方案之前我其实把三条路都摆出来对比过。REST 最稳定但问题也很现实我需要为每个资源写 Controller、定义请求校验、手写返回类型前端还要把接口地址和 DTO 再维护一遍。只要改动一个字段前后端至少两处同步修改。GraphQL 解决了过度获取的问题但引入 Schema 语言、Resolver、客户端缓存之后心智负担和配置成本都不低个人项目杀鸡用牛刀。tRPC 走的是另一条路不定义新的协议层直接在 TypeScript 函数上做类型推导。前端调用一个 query 就像调用本地函数返回值的类型自动跟着服务端走。我把三种方案的对比整理成了表格方便你按项目情况判断。方案类型安全前后端协议成本适合场景主要代价REST OpenAPI依赖手工维护高文档若漂移就失效需要开放 API 给第三方联调成本高GraphQL有 Schema 约束中需维护 Resolver数据聚合复杂的中大型项目工具链重tRPC端到端自动推导接近零类型即契约前后端同仓库的全栈项目对外暴露能力弱t3code 是一个典型的前后端同仓库应用没有第三方调用方所以 tRPC 的劣势不成立优势被完全放大。这也是我选它的第一个决策依据。1.3 这套组合的边界什么时候不要用它写到这里我要泼一盆冷水T3 这组合不适合所有项目。如果你要对外提供公共 APIREST 仍是更稳妥的选择因为 tRPC 的调用协议依赖 TypeScript 类型外部消费者接入成本高。如果你的团队前后端完全分离、前端仓库和后端仓库是两套代码tRPC 的收益也会大打折扣——毕竟跨仓库引用类型需要额外维护。另外如果你的项目有大量无类型脚本语言参与比如 Python 数据服务、Go 微服务那端到端类型安全本身就无从谈起。结论是t3code 的精神是“把类型安全变成默认值”但前提是项目本身具备同仓库、同语言、强类型这三个条件。我的书签工具正好满足所以我毫不迟疑地选了它。2. 从空白目录到首次提交初始化、数据模型与第一次运行2.1 用 create-t3-app 把骨架拉起来初始化这一步没什么好自己造的官方脚手架已经把最佳实践准备好了。我执行了下面这条命令npm create t3-applatest t3code过程中有几个交互选项我按项目需求勾选如下Next.jsApp Router、Tailwind CSS、tRPC、Prisma、NextAuth.js、ESLint。这里特别说一下App Router 和 Pages Router 我建议新项目直接选 App Router虽然 tRPC 在两种模式下都能跑但 App Router 的服务端组件能力可以让页面在很多场景下不再需要“先 loading 再显示数据”。初始化完成后的目录结构比我原本预期的要干净很多核心部分如下src/ app/ api/trpc/[trpc]/route.ts # tRPC 的 HTTP 入口 _components/ # 业务组件 layout.tsx page.tsx server/ api/ root.ts # 根路由聚合所有子路由 routers/ # 按业务域拆分的路由文件 trpc.ts # tRPC 实例、上下文、公共/受保护过程 auth.ts # NextAuth 配置 db.ts # Prisma 客户端单例 trpc/ server.ts # 服务端调用 tRPC 的辅助函数 react.tsx # 前端 hooks 的唯一出口第一次运行前有一个很容易被忽略的动作拷贝环境变量文件。cp .env.example .env不要跳过这一步。脚手架默认只在.env.example里放了占位符不拷贝的话 Prisma 和 NextAuth 会直接报错。我当年第一次用这个脚手架时就卡在这里还以为是安装出了问题。2.2 数据模型先于接口书签收藏工具的 Schema 设计我的业务设定很简单每个用户能收藏网络链接给链接打标签、写备注还能在书签下评论。功能看着不多但牵涉到的数据关系正好能覆盖 tRPC 项目的经典场景多对多、一对多、级联删除、复合唯一约束。我设计的 Prisma Schema 核心部分如下model User { id String id default(cuid()) name String? email String? unique image String? bookmarks Bookmark[] tags Tag[] comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt } model Bookmark { id String id default(cuid()) url String title String note String? userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) tags BookmarkTag[] comments Comment[] createdAt DateTime default(now()) updatedAt DateTime updatedAt index([userId, createdAt]) } model Tag { id String id default(cuid()) name String userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) bookmarks BookmarkTag[] unique([userId, name]) } model BookmarkTag { bookmarkId String tagId String bookmark Bookmark relation(fields: [bookmarkId], references: [id], onDelete: Cascade) tag Tag relation(fields: [tagId], references: [id], onDelete: Cascade) id([bookmarkId, tagId]) } model Comment { id String id default(cuid()) content String userId String user User relation(fields: [userId], references: [id], onDelete: Cascade) bookmarkId String bookmark Bookmark relation(fields: [bookmarkId], references: [id], onDelete: Cascade) createdAt DateTime default(now()) index([bookmarkId, createdAt]) }说说设计里几个关键决策。第一BookmarkTag用的是显式多对多连接表而不是 Prisma 的隐式多对多。原因在于我要让 Tag 归属到用户这样“我的标签”和“别人的标签”不会混在一起。如果只用隐式多对多Tag 就无法独立携带 userId多用户场景下会出问题。第二所有外键都加了onDelete: Cascade。删掉一个用户他名下的书签、标签、评论全部自动清理避免出现孤儿数据。第三我在Bookmark上加了index([userId, createdAt])因为最常用的查询就是“某个用户按时间倒序取书签列表”。这个复合索引能让排序和过滤都走索引数据量大了以后收益明显。Schema 写完后用两条命令同步到数据库npx prisma db push npx prisma generate如果你用的是正式环境之后应该换成prisma migrate dev来生成迁移文件。本地开发我图省事直接用 db push。2.3 初始化阶段最容易踩的三个坑第一坑忘了指定数据库类型。脚手架默认数据库是 SQLite它会给 DATABASE_URL 填一个file:./db.sqlite。本地没问题但如果你一开始就知道要上云数据库最好在初始化阶段就把驱动和连接串配好否则后面迁移还得改 provider 再重新生成迁移文件。第二坑改了 Schema 没有重新生成 Prisma Client。如果你只是改了schema.prisma然后直接跑 devtRPC 的 resolver 里引用ctx.db.bookmark.create时TypeScript 会提示“该属性不存在”因为 Client 还是旧的。记住顺序改 Schema →prisma generate→ 重启 dev server。第三坑一开始就想把 Schema 设计得尽善尽美。我在这个项目上犯过这个错头两天光顾着加字段、加索引业务逻辑一行没写。后来想通了Schema 追求的是“覆盖当前需求 预留合理扩展”而不是“预判所有未来可能性”。数据库模型可以通过迁移平滑演进过度设计才是拖垮项目进度的元凶。做完这一步我已经有了一个能跑的骨架和一个清晰的数据模型接下来才是 t3code 最有趣的部分——让类型真正流转起来。3. tRPC 实战让类型从数据库一路流到按钮点击3.1 Router 的组织方式按业务域拆别堆一个大文件tRPC 的写法和写普通 TypeScript 函数非常接近这也是它容易上手的原因。但我见过不少人把所有的 query 和 mutation 全塞进一个 root.ts 里几百行之后完全没法维护。t3code 里的做法是按业务域拆文件src/server/api/routers/ bookmark.ts tag.ts comment.ts以书签功能为例一个 router 可以是这样的// src/server/api/routers/bookmark.ts import { z } from zod; import { createTRPCRouter, protectedProcedure } from ~/server/api/trpc; export const bookmarkRouter createTRPCRouter({ list: protectedProcedure .input(z.object({ tagId: z.string().optional() }).optional()) .query(async ({ ctx, input }) { return ctx.db.bookmark.findMany({ where: { userId: ctx.session.user.id, ...(input?.tagId ? { tags: { some: { tagId: input.tagId } } } : {}), }, include: { tags: { include: { tag: true } }, }, orderBy: { createdAt: desc }, }); }), create: protectedProcedure .input( z.object({ url: z.string().url(), title: z.string().min(1), note: z.string().optional(), tagNames: z.array(z.string()).default([]), }), ) .mutation(async ({ ctx, input }) { const tagIds await Promise.all( input.tagNames.map(async (name) { const tag await ctx.db.tag.upsert({ where: { userId_name: { userId: ctx.session.user.id, name, }, }, update: {}, create: { userId: ctx.session.user.id, name, }, }); return tag.id; }), ); return ctx.db.bookmark.create({ data: { url: input.url, title: input.title, note: input.note, userId: ctx.session.user.id, tags: { create: tagIds.map((tagId) ({ tagId })), }, }, }); }), });这里有两个细节值得展开。第一protectedProcedure表示这个 procedure 要求用户已登录。它是在trpc.ts里基于公共过程扩展出来的会在业务代码执行前先检查ctx.session。如果 session 为空直接抛UNAUTHORIZED前端不用写任何判断就能感知到“未登录”。第二zod 承担了输入校验。url必须是合法 URLtitle不能为空tagNames默认空数组。校验失败时 tRPC 会返回带错误码的响应前端可以精准提示用户“链接格式不对”而不是把脏数据直接写进数据库。3.2 前端调用useQuery 和 useMutation 的完整链路tRPC 最爽的时刻就是前端 import 后端 router 的那一瞬间。脚手架生成的src/trpc/react.tsx已经帮你封装好了 React Query 的 hooks前端组件的写法如下use client; import { api } from ~/trpc/react; export function BookmarkList() { const { data, isLoading, error } api.bookmark.list.useQuery(); if (isLoading) return p加载中.../p; if (error) return p请求失败{error.message}/p; return ( ul classNamespace-y-2 {data?.map((bookmark) ( li key{bookmark.id} a href{bookmark.url} target_blank relnoreferrer classNametext-blue-600 hover:underline {bookmark.title} /a {bookmark.tags.map(({ tag }) ( span key{tag.id} classNameml-2 text-sm text-gray-500 #{tag.name} /span ))} /li ))} /ul ); }你注意看data的类型不用手写bookmark.tags的嵌套结构也是 Prisma 返回类型直接透传过来的。如果我在 service 里改了返回结构前端在编译期就会报错——这就是 t3code 的“类型契约”在起作用。提交数据的 mutation 写法也很直接use client; import { useState } from react; import { api } from ~/trpc/react; export function AddBookmarkForm() { const utils api.useUtils(); const [url, setUrl] useState(); const [title, setTitle] useState(); const { mutate, isPending } api.bookmark.create.useMutation({ onSuccess: () { utils.bookmark.list.invalidate(); // 关键让列表自动重新拉取 }, }); return ( form onSubmit{(e) { e.preventDefault(); mutate({ url, title, tagNames: [work, toread] }); }} input value{url} onChange{(e) setUrl(e.target.value)} placeholderhttps://example.com / input value{title} onChange{(e) setTitle(e.target.value)} placeholder标题 / button typesubmit disabled{isPending} {isPending ? 保存中... : 添加书签} /button /form ); }这里最容易被忽略的是onSuccess里的invalidate()。它告诉 React Query“这个查询的结果已经失效请重新请求。”如果你不写这一行mutation 虽然成功了但列表不会更新你会以为自己根本没写进去。这是 tRPC 新手最常踩的逻辑坑后面部署章节我还会再讲一次。3.3 类型安全不等于逻辑安全资源归属校验很多教程讲到 protectedProcedure 就停了好像“登录了”就等于“能操作”。但在真实业务里认证只解决“你是谁”的问题授权要解决“你能不能动这条数据”的问题。比如删除书签这个 mutation不能只判断“用户已登录”还要判断“这本书签确实属于当前用户”。否则用户 A 构造一个请求带上用户 B 的书签 ID就能把别人的数据删掉。这种问题在 tRPC 里非常容易发生因为它省掉了手写 URL 路由安全意识也容易跟着松懈。我最终的删除实现是这样的delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) { const bookmark await ctx.db.bookmark.findUnique({ where: { id: input.id }, select: { userId: true }, }); if (!bookmark || bookmark.userId ! ctx.session.user.id) { throw new TRPCError({ code: FORBIDDEN, message: 无权删除该书签, }); } return ctx.db.bookmark.delete({ where: { id: input.id } }); }),核心就一句先查归属再执行操作。这一步不能省也不能靠“前端不展示删除按钮”来兜底因为任何请求都可以被绕过 UI 直接构造。4. 认证链路与权限边界NextAuth 接入的完整记录4.1 NextAuth 的最小可运行配置t3code 的登录方案用的是 NextAuth.js v4。脚手架会生成src/server/auth.ts但默认只是给你一个空壳。我基于自己的需求做了扩展最终配置的核心部分如下import { getServerSession, type NextAuthOptions } from next-auth; import { PrismaAdapter } from next-auth/prisma-adapter; import { db } from ~/server/db; export const authOptions: NextAuthOptions { adapter: PrismaAdapter(db), session: { strategy: jwt }, providers: [], callbacks: { jwt({ token, user }) { if (user) { token.id user.id; } return token; }, session({ session, token }) { if (session.user) { session.user.id token.id as string; } return session; }, }, };这里我想重点说明两个决策。第一providers 数组我这里留空了。因为示例项目用的是邮箱/账号体系我只能提醒你常见的 GitHub、Google 登录只需要在 providers 里填入对应的 Client ID 和 Client Secret代码结构完全相同。真正需要警惕的是provider 的配置值不能写死在前端代码里必须通过环境变量注入。第二我在 callbacks 里把user.id写进了 JWT 和 session。这是为了在 tRPC 的受保护 procedure 里能直接拿到ctx.session.user.id。如果你不扩展 session 类型TypeScript 会一直报Property id does not exist on type User——这个报错不是 bug而是在提醒你类型定义没跟上。4.2 把 Session 注入 tRPC 上下文tRPC 的每个 procedure 都能通过ctx拿到公共数据所以最合理的做法是把 session 塞进 context 里。脚手架的src/server/api/trpc.ts里通常会有类似这样的代码export const createTRPCContext async (opts: { headers: Headers }) { const session await getServerSession(authOptions); return { db, session, headers: opts.headers, }; };有人会问为什么 session 要在这里拿一次不能在组件里拿一次再传进来原因是 tRPC 的调用往往发生在 React Query 内部如果依赖组件手动传 session很容易出现“某条链路忘了传”的漏洞。通过在上下文统一注入所有 procedure 都能无感读取当前用户这是安全性和便利性兼得的做法。一个常见的连带坑是如果你在非服务端环境比如纯客户端组件里调用getServerSession()NextAuth v4 会返回 null。所以 tRPC 的请求必须走服务端的 route handler让getServerSession有完整的请求上下文可用。这也是为什么 tRPC 的 HTTP 入口必须放在app/api/trpc/[trpc]/route.ts里。4.3 我遇到的三个认证问题及完整排查思路问题一本地能登录部署后一刷新就掉线。现场表现本地 dev 一切正常部署之后登录成功但只要刷新页面就回到未登录状态。日志里能看到MissingSecret或者 JWT 校验失败。排查链路我先检查了 Vercel 的环境变量面板发现NEXTAUTH_SECRET没有配置。本地开发时 NextAuth 会自动生成一个临时 secret但生产环境必须显式提供。修复方法很简单openssl rand -base64 32拿到一串随机字符串后填到生产环境的NEXTAUTH_SECRET变量里重新部署即可。这个问题的本质原因是本地临时 secret 与线上 secret 不一致刷新后 Cookie 里的 JWT 无法被验证通过。问题二tRPC 接口返回 401但页面没有跳转登录。现场表现用户登录过期后页面数据请求直接失败但界面停在一个“半死不活”的状态没有提示也没有跳转。排查链路这个问题不在服务端而在前端的错误处理。React Query 默认只在 console 里打印错误不会主动触发路由跳转。我的修复方案是封装一个全局的onError处理const trpcClient api.createClient({ links: [ httpBatchLink({ url: /api/trpc }), loggerLink({ enabled: () false }), ], }); // 在全局配置中判断 if (error.data?.code UNAUTHORIZED) { router.push(/login); }思路也很直白401 要么是没登录要么是登录过期统一交给一个全局地方处理而不是每个组件各自判断。问题三JWT 签名通过但登录状态“幽灵式”失效。现场表现用户明明还在操作突然某个接口返回 401再次刷新又恢复了。这种间歇性故障最让人头疼。排查链路后来定位到是NEXTAUTH_URL环境变量没有在线上正确配置。当 NextAuth 拿到的站点地址和实际访问地址不一致时Session Cookie 的 Domain/Path 校验会出现边界问题。修复就是把NEXTAUTH_URL明确设为线上正式域名。这里有个小教训凡是和 Cookie、域名、跨域有关的认证问题优先检查环境变量的域名配置不要把时间浪费在改业务代码上。5. Tailwind 下的界面落地从能用走向体面5.1 布局先行先把设计令牌定下来再开始写组件很多刚接触 Tailwind 的人容易陷入“边写边调、越调越乱”的状态因为原子类名太自由了没有约束就容易失控。我在 t3code 里的做法是先定设计令牌——颜色、字体、间距、圆角配置好tailwind.config.ts再开始写页面。书签工具这种信息密集型应用最怕的就是信息层级混乱。我把主色调、中性色、危险色都抽象成了语义变量// tailwind.config.ts import { type Config } from tailwindcss; export default { darkMode: class, theme: { extend: { colors: { primary: { DEFAULT: #2563eb, foreground: #ffffff, }, surface: { DEFAULT: #ffffff, muted: #f3f4f6, }, }, }, }, } satisfies Config;这样写的好处是视觉层面统一由变量控制改品牌色时只需要动一处配置组件代码不用大改。5.2 暗色模式切换不只是加一个 class书签工具一整天都要用暗色模式是刚需。我用next-themes配合 Tailwind 的darkMode: class策略实现。Providers 部分代码如下// app/providers.tsx use client; import { ThemeProvider } from next-themes; export function Providers({ children }: { children: React.ReactNode }) { return ( ThemeProvider attributeclass defaultThemesystem enableSystem {children} /ThemeProvider ); }这里有个非常隐蔽的坑如果ThemeProvider是在客户端渲染的首屏 HTML 里不会包含正确的主题 class浏览器会先亮一帧再切暗色出现“闪白”。解决方法是两件事一是让 Provider 尽早包裹在 layout 里二是给 html 标签加上suppressHydrationWarning告诉 React 服务端和客户端的属性差异是预期行为不要报警告。初次实现时我漏了suppressHydrationWarning控制台刷了一堆警告排查了一会儿才意识到这不是 bug而是水合差异的正常提示。5.3 移动端优先的响应式细节书签工具的使用场景很碎片化大部分时候我是在手机上看到一篇文章顺手存下来。所以列表页必须从手机屏幕开始设计。核心做法有两个一是用网格布局处理卡片在grid-cols-1、md:grid-cols-2、xl:grid-cols-3之间逐级提升密度二是保证触控元素的尺寸不低于 44×44 像素避免误触。我还给每个书签卡片做了一行操作区打开原文、复制链接、删除。这三个操作在手机上如果不加区分很容易点错。所以删除按钮除了用红色标识还加了一步确认弹窗而不是直接删除。这种交互细节看起来不起眼但真实使用频率很高体感差距非常大。6. 部署与排障从本地跑通到 Vercel 上线的完整链路6.1 上线前的准备清单部署前我列了一张清单对照检查避免上线后才发现低级问题。检查项本地验证方式线上配置要求数据库连接串.env里用本地 SQLite/Postgres云数据库连接串必须支持公网访问NEXTAUTH_SECRET本地可缺省必须显式设置NEXTAUTH_URL本地一般不需要必须设为正式域名Prisma Client 生成npx prisma generate构建时需自动执行数据库表结构同步prisma db push使用prisma migrate deploy这里特别提醒数据库表结构同步不能用 db push 跑线上因为 db push 不生成可追溯的迁移记录。正式环境要用迁移文件npx prisma migrate dev --name init # 提交生成的 migration 文件后线上执行 npx prisma migrate deploy这会保证测试环境、预发布环境和生产环境的表结构完全一致而不是“大概一样”。6.2 三次真实故障与排查链路复现部署不是一帆风顺的我把三次真实的故障链路完整写出来比直接给解决方案更能帮你建立排错直觉。故障一构建成功但页面打开就 500。现场表现Vercel 部署显示成功访问首页直接 500。查看 Runtime Log 看到PrismaClientInitializationError: Prisma Client could not locate the Query Engine。排查链路这一步我先判断是“Client 没生成”还是“Client 生成后找不到引擎”。在构建日志里搜索prisma generate发现根本没有执行——因为我把postinstall脚本漏掉了。create-t3-app 生成的 package.json 通常会带postinstall: prisma generate我在初始化时不小心删掉了。修复后重新部署500 消失。验证方式部署完成后在日志里确认prisma generate已执行再访问站点确认无报错。故障二打开书签列表白屏加超时。现场表现本地接口秒回部署之后列表请求一直在 pending最后超时。排查链路先从浏览器 Network 看是请求没发出去还是响应没回来。结果是请求发出去了但服务端迟迟不响应。然后想到一个可能性Vercel Serverless 环境的连接池和本地长连接不同Prisma 默认的连接数配置可能导致冷启动后等待数据库连接。修复方案是在 Prisma Client 初始化时限制连接池大小// src/server/db.ts const globalForPrisma globalThis as unknown as { prisma?: PrismaClient }; export const db globalForPrisma.prisma ?? new PrismaClient({ log: [error], }); if (process.env.NODE_ENV ! production) { globalForPrisma.prisma db; }代码本身是脚手架默认的关键是不要改成“每次请求都新建 PrismaClient”否则 Serverless 环境会很快把数据库连接耗尽。用全局单例是 Serverless 部署的标准姿势。故障三mutation 成功但列表 UI 永远不变。现场表现添加书签按钮点了之后接口返回成功数据库里也有记录但页面上的列表不出现新数据。排查链路这个问题和第 3 章提到的invalidate()是同一个坑。我先在浏览器里看 Network 请求确实创建成功了再看 React Query 的 devtools发现bookmark.list的缓存还是旧的没有标记为 stale。修复方法就是在onSuccess里加上utils.bookmark.list.invalidate()。这里有个更隐蔽的细节如果你打开的列表用了tagId作为参数而新增的 mutation 没有按同样的参数 key 去失效缓存那列表也不会刷新。invalidate 必须带着和查询一致的入参才能清理到正确的缓存 key。6.3 上线之后还能顺手做的三件事部署稳定之后我并没有急着加新功能而是做了三个低成本高收益的优化。第一给页面加上 sitemap 和基础 SEO 信息让公开页能被搜索引擎收录。书签工具虽然是个人工具但分享出去的公开页面如果不可索引等于把内容锁在黑盒里。第二用vercel.json配置缓存策略静态资源和公开接口的响应头都加上合适的 Cache-Control。这个改动几乎零成本但能显著降低冷启动时的请求压力。第三把关键操作的错误上报接入一个简单的日志平台。不用太复杂把console.error转发到一个 webhook 就够了。这样用户反馈“打不开”的时候我能直接看到服务端错误栈而不是靠猜。如果让我再走一遍 t3code 这个项目唯一想回到更早去做的事是在第一天就给所有受保护路由写上归属权校验而不是在项目后期才补。类型安全能拦住拼写错误但拦不住业务逻辑漏洞后者只能靠清晰的授权意识来兜底。技术栈选型、目录结构、代码规范这些都可以快速调整真正决定项目维护体验的往往是这些一开始不起眼的边界处理。
阅读完成 · 觉得有帮助?