ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

T3 Stack 实战:tRPC + TypeScript 构建全栈类型安全应用

T3 Stack 实战:tRPC + TypeScript 构建全栈类型安全应用

我最近刚把拖了挺久的 t3code 项目收尾。简单说,这是一个基于 T3 Stack 搭起来的全栈应用,技术栈就是 Next.js + TypeScript + Tailwind CSS + tRPC + Prisma + NextAuth.js。很多人看到这一串名字就头疼,但它想解决的其实只有一件事:让前端到后端整个链路都类型安全,前后端协作不再靠手写接口文档活着。如果你正在选全栈方案,或者在犹豫要不要用这套组合,这篇就是一份踩坑记录加实操笔记,适合刚上手的人,也适合已经跑通但想深入理解原理的人。

我见过不少团队,后端写好 REST 接口,前端再照着文档手写一堆 interface,接口字段一变,前端页面直接崩,不崩的也全是any。T3 这套东西的吸引力就在于,它把“类型信息”当成一次链路的核心资产,从数据库模型到 API 参数再到前端组件,类型一路穿透。t3code 作为这个技术选型的载体,帮我验证了一件事:团队不大、迭代速度要求高、前后端都想由少数几个人维护的场景,这套组合真的很合拍。

1. t3code是什么:一个以类型安全为轴心的全栈组合

1.1 为什么是 t3code:从选型纠结说起

开始搭 t3code 之前,我其实在三个方案之间犹豫过:传统 REST + OpenAPI、GraphQL、以及 tRPC。第一个方案最成熟,团队里每个人都会,但维护成本集中在对接口文档和类型定义的同步上,只要有一次“我改了后端但忘了更新前端”,后面就全是扯皮。GraphQL 解决了一部分问题,但为了一个中小型内部业务去引入 schema、resolver、codegen 那一整套体系,有点杀鸡用牛刀。

tRPC 提供的是另一条路:不生成文档,因为“接口函数”本身就是类型安全的。你可以在前端直接写api.post.all()这样的调用,IDE 会提示输入类型、自动带出返回值类型。这不是什么魔法,本质是 TypeScript 的类型推导在前端 import 了后端的 router 类型,TS 帮你做了静态检查,运行时再走 HTTP。选它,等于把“约定”交给编译器去守,比文档靠谱得多。

T3 Stack 把 tRPC 和 Next.js、Prisma、NextAuth 组合在一起,正好覆盖了全栈应用最常遇到的四件事:页面与渲染、API 层、数据库访问、登录鉴权。它不是新技术,而是把社区里已经在用的一套“组合拳”做成一个默认的模板工程。t3code 这个名字,就是我当时给这个模板工程起的代号,用下来之后我有种很直接的感受:它不是让你多学一套框架,而是把你从前端到数据库之间所有手写胶水代码的环节,换成了编译器帮你校验。

1.2 六个成员,各扛一块

如果非要把这套技术栈拆开,每个组件解决的核心问题大概是这样的:

成员核心职责在 t3code 里扮演的角色
Next.js应用框架和渲染层路由、页面、服务端组件、API 路由的宿主
TypeScript静态类型系统所有类型推导的基础,没有 TS 就没有 tRPC 的“类型穿透”
Tailwind CSS样式方案页面 UI 的原子化样式,不用写独立 CSS 文件
tRPCAPI 层通信让前后端共享函数式调用,类型安全的全栈边界
PrismaORM 与数据库迁移数据建模、表结构同步、数据库访问
NextAuth.js身份认证登录会话、JWT、OAuth,配合 tRPC 做权限控制

这里面最容易被低估的是 TypeScript。没有它,tRPC 点语法调用、Prisma 的模型类型、NextAuth 的 session 类型,全都是空谈。很多人刚接触 T3 时会觉得 tRPC 看起来像 RPC 框架,其实它更像“类型优先的 API 粘合层”。每一环的类型都能被下一环消费,最终组装成一个从数据库到 UI 控件都严丝合缝的闭环。

1.3 谁适合用 t3code,谁别硬凑

老实说,T3 这套组合不是万能的,它有自己的甜点区。

比较适合的场景有三类。第一类是内部工具和后台系统,这类应用通常没有外部第三方调用,API 只要自己前端用,tRPC 这种“内部函数调用”的模式非常舒服。第二类是创业团队或者个人全栈项目,一个仓库搞定前后端,减少沟通成本,快速验证业务。第三类是本身已经深度使用 TypeScript 的团队,类型系统对你来说不是负担而是底稿,tRPC 会放大这个优势。

但也有不适合的情况。如果你的产品需要开放 API 给第三方开发者,那你需要的是一份完整的 OpenAPI 文档,而不是让外部团队去“类型穿透”,这个时候 REST + 文档或者 GraphQL 更合适。如果你的服务是重实时应用,比如在线协作、聊天室,T3 默认的 HTTP 短连接方式并不理想,虽然 tRPC 也支持 WebSocket subscription,但复杂度会比直接上 WebSocket 网关高不少。还有一个很容易踩的坑是团队技能栈:如果团队里全是后端思维的人,前端手写 JS 都费劲,强行上 T3 只会把学习成本转嫁给所有人。工具是好工具,但选型永远要对着团队现状来。

2. 初始化与工程骨架:create-t3-app 背后到底生成了什么

2.1 一条命令建项目:初始化选项逐一拆

t3code 我是用官方脚手架初始化的,命令只有一条:

npx create-t3-app@latest t3code

执行之后会进入交互式选项界面,每一项都需要想清楚,不是无脑回车。

第一个是选 App Router 还是 Pages Router。现在新项目默认都是 App Router,T3 的模板也已经全面转向 App Router。除非你维护的是老项目,否则没什么好犹豫的。第二个是 TypeScript,这个选项基本默认勾选,没有 TS 的 T3 等于把灵魂丢了。第三个是 Tailwind CSS,如果你接受原子化 CSS 就选上,它会顺手把 PostCSS 配置和全局样式文件生成好。第四到第六个是 tRPC、Prisma、NextAuth.js,这三项我建议同时勾选。理由很简单:T3 的核心卖点是整套类型安全闭环,单独只用其中一个,价值会大打折扣。比如只用 Prisma 不用 tRPC,你仍然得手写 API 路由和类型定义;只用 tRPC 不用 Prisma,数据库模型那一端的类型推导又缺失了。

初始化过程会生成一堆文件,同时提示你配置环境变量。这里第一次上手的人很容易犯一个错误:以为可以跳过,先跑起来再说。create-t3-app 自带了一个env.mjs校验脚本,关键环境变量缺失时,开发服务器会直接拒绝启动。所以乖乖去填.env,尤其是DATABASE_URL和NEXTAUTH_SECRET。

2.2 关键目录和文件的作用

初始化的目录结构,核心部分长这样:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ ├── auth/[...nextauth]/route.ts │ │ │ └── trpc/[trpc]/route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ ├── root.ts │ │ │ └── trpc.ts │ │ └── db.ts │ ├── trpc/ │ │ ├── react.ts │ │ └── server.ts │ └── env.mjs └── .env

最值得先看的是src/server/api/trpc.ts,它是整个 tRPC 服务的核心工厂文件:

// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { type createTRPCContext } from "./context"; import { ZodError } from "zod"; const t = initTRPC.context<typeof createTRPCContext>().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, }, }; }, }); export const createTRPCRouter = t.router; export const publicProcedure = t.procedure;

这里做了几件事。第一,用initTRPC创建了一个带上下文类型的实例,后续所有 router 和 procedure 都会基于这个实例创建。第二,自定义了错误格式化,Zod 校验失败时会把字段级的错误信息传到前端,这样表单页面就能直接展示哪一项不合法。第三,导出了publicProcedure,也就是所有不需要登录就能访问的接口基础。

然后看src/server/api/root.ts,它是所有业务 router 的汇总点:

import { postRouter } from "./routers/post"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;

前端之所以能获得全链路类型提示,靠的就是这个AppRouter类型被导出到src/trpc/react.ts和src/trpc/server.ts里。你把浏览器开发者工具里的网络请求打开看,会发现 tRPC 的请求就是普通的 HTTP POST,请求体里是一段 JSON,但不是 REST 那样的“资源路径 + 动词”,而是“procedure 路径 + 输入参数”。

src/server/db.ts负责创建 Prisma 客户端,模板里会做一个全局缓存来避免开发环境热重载导致重复实例化:

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;

这个单例模式不是模板凭空加的。Next.js 在开发模式下会频繁重新编译模块,如果不做缓存,每次热更新都 new 一个 PrismaClient,很快你就会在终端看到一堆Too many connections或者PrismaClientAlreadyInitializedError。

2.3 从一张表开始:Prisma 模型设计与迁移

t3code 里我先建了一个非常典型的示例模型:用户和文章。Prisma schema 对应部分是这样写的:

model User { id String @id @default(cuid()) name String? email String? @unique image String? posts Post[] } model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([authorId]) }

有几个细节值得说明。主键我没有用自增数字,而是用了cuid(),分布式系统下不用担心并发冲突,URL 里出现也不会被轻易遍历猜出资源数量。onDelete: Cascade表示删除用户时,他名下所有文章会一起删掉,这个语义要提前想清楚,有些业务其实是希望保留文章的,那就得改成SetNull。@@index([authorId])是说按作者查文章会是一个高频查询,给外键加索引。

写完后执行迁移:

npx prisma migrate dev --name init

这条命令会创建迁移文件、执行 SQL、然后自动生成新的 Prisma Client。注意它默认会触发重新生成,如果你的环境变量没配好,同样会在这里报错。所以流程永远是:先配.env,再跑 migrate,最后启动开发服务器。

3. tRPC 核心链路:怎么把一次查询从数据库安全送到前端

3.1 定义 router 与 procedure

t3code 的业务代码,我先写了一个文章模块的 router,里面包含了列表、详情、创建三个典型操作。代码并不复杂,但每一段都值得细看:

// src/server/api/routers/post.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure, } from "../trpc"; export const postRouter = createTRPCRouter({ all: publicProcedure.query(async ({ ctx }) => { return ctx.db.post.findMany({ orderBy: { createdAt: "desc" }, include: { author: { select: { name: true, image: true } } }, }); }), byId: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.post.findUnique({ where: { id: input.id }, include: { author: true }, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(100), content: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id, }, }); }), });

all和byId是query,用于读操作,create是mutation,用于写操作。为什么 tRPC 要区分 query 和 mutation?因为底层接的是 React Query(TanStack Query),query 会被缓存、去重、支持重新验证,mutation 默认不会被缓存。这个区分直接影响后面前端页面的刷新策略。

每一个 procedure 都可以声明自己的input,这里用 Zod 定义 schema。前端调用时,输入参数会先被 Zod 校验,再进入业务逻辑。比如title的min(1)直接挡住了空字符串,max(100)限制了长度。这层校验在后端执行,不信任前端传参,同时也让前端获得了和这个校验完全一致的类型。第三方库不需要引入,tRPC 原生就接受任何能被parse的 schema。

注意create用的是protectedProcedure,而列表和详情是publicProcedure。这是 T3 模板自带的一个约定:公开的接口和需要登录的接口,在代码里一眼就能区分。

3.2 前端调用:React Query 与 tRPC 的配合

前端页面里调用上面这些 procedure,体验上确实像在调用本地函数。在客户端组件中:

"use client"; import { api } from "@/trpc/react"; export function PostList() { const { data, isLoading } = api.post.all.useQuery(); if (isLoading) return <p>加载中...</p>; return ( <ul> {data?.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }

api.post.all.useQuery()这一行,背后会做几件事:把post.all转换成 HTTP 地址,把 queryKey 和当前输入参数绑定,交给 React Query 管理请求状态,并且把返回值类型从后端 router 推导到前端组件里。你在 VSCode 里把鼠标移到data上,看到的就是 Prisma 查出来的完整类型,不是any,也不需要手动定义。

项目里还有一个“服务端组件中调用”的路径。T3 模板提供了src/trpc/server.ts,在异步服务端组件里可以这样写:

import { api } from "@/trpc/server"; export default async function Page() { const posts = await api.post.all(); // 然后把 posts 传给客户端组件,避免客户端再请求一次 return <PostList initialPosts={posts} />; }

这个能力特别适合首屏渲染场景。页面数据从数据库查出来后,直接在服务端拼进 HTML,客户端不需要再 loading 一遍。但用的时候要留意:服务端调用走的是createCaller,它执行时没有经过浏览器 HTTP 请求,所以依赖headers()和cookies()的上下文必须手动传,模板里如果没帮你封装好,很容易在以后再补。

3.3 mutation 后的数据刷新:缓存失效才是关键

新手做 t3code 最容易出现的一个现象是:创建完文章,列表没更新,刷新页面才有。原因就是没有处理 React Query 的缓存失效。

在 tRPC 的 useMutation 里,标准的做法是调用对应 query 的invalidate:

const utils = api.useUtils(); const createPost = api.post.create.useMutation({ onSuccess: async () => { await utils.post.all.invalidate(); }, });

invalidate不是简单地把缓存清掉,而是把标记为失效的 query 重新拉取一遍。如果你有多个页面都用了post.all,它们会被一起刷新,不用手动补“刷新列表”的逻辑,这是比router.refresh()更贴合数据层的方式。

这个机制理解透之后,整个应用的交互设计都会变得简单很多。比如删除一篇文章,onSuccess里同样调用utils.post.all.invalidate(),列表自动少一条;修改标题,utils.post.byId.invalidate({ id }),详情页自动更新。写业务代码时,你不需要操心“请求成功后在哪里手动改 state”,数据主权都交给 React Query 去处理。

3.4 鉴权不靠前端:protectedProcedure 实现

前端隐藏按钮、判断 session,这些都只是交互层面的“锦上添花”,真正的权限控制必须在后端执行。T3 模板在trpc.ts里提供了中间件机制,我把它扩展成一个标准写法:

// src/server/api/trpc.ts(续) const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);

这个中间件做的事很简单:从上下文里取出ctx.session,如果里面没有用户信息,直接抛一个UNAUTHORIZED错误。错误会按 tRPC 的协议返回给前端,前端在捕获错误后可以统一跳转登录页。

用的时候,凡是需要登录的接口,就把它从publicProcedure换成protectedProcedure,代码一换,权限立刻生效。上下文里的 session 是怎么来的?核心在src/server/auth.ts里,它把 NextAuth 的配置和 tRPC 的上下文串起来,本质上就是从 session cookie 里解析出登录态。中间件把 session 挂在ctx上,业务代码里直接用ctx.session.user.id就能拿到当前登录用户的 ID,这个值在后续创建文章、写评论这种场景里非常常用。

4. 实战避坑:t3code 开发中我踩过的五个问题

4.1 .env 校验和 NextAuth 密钥

create-t3-app 默认有个src/env.mjs,它在启动时会读取环境变量并用 Zod 校验。好处是配置缺失能立刻暴露,坏处是第一次跑的人经常被一堆报错吓住。

我遇到最多的问题集中在NEXTAUTH_SECRET上。很多人随手填一个"my-secret",然后登录后 session 不稳定,经常掉线。这个值在生产环境必须是一个足够随机的长字符串,可以用命令行生成:

openssl rand -base64 32

生成的字符串填进.env的NEXTAUTH_SECRET。如果你还用了自定义的NEXTAUTH_URL,注意别带末尾斜杠,不带协议反而会出问题。

还有一点容易忽略:env.mjs会校验环境变量,但如果你部署到 Vercel、Railway 这类平台,平台的 Dashboard 环境和本地.env是两套系统,记得两边保持一致。源码里可以提交.env.example,但别把真正的密钥提交进仓库。

4.2 Prisma 在本地与服务端的连接设置

本地开发时,DATABASE_URL写本地数据库地址就行。但部署到 Serverless 平台时,事情变复杂了。Serverless 环境下每个函数实例都会持有一份 Prisma Client,如果应用频繁被唤起,连接数会很快打满。

通用做法是区分两组环境变量:一组给业务代码用DATABASE_URL,一组给 Prisma 引擎连接用DIRECT_URL。前提是你的数据库服务商提供连接池,比如通过 PgBouncer 或 RDS Proxy 暴露一个池化连接地址。Prisma schema 里的datasource db可以配置directUrl:

datasource db { provider = "postgresql" url = env("DATABASE_URL") directUrl = env("DIRECT_URL") }

部署后在日志里看到P1001(无法连接)或者Too many connections,优先检查这两组变量是否配反了。本地跑 migrate 时,应该用DIRECT_URL直连数据库,否则走连接池时有些 DDL 语句会执行失败。

4.3 Server Components 里调用 caller 的注意事项

T3 模板虽然提供了服务端调用 tRPC 的能力,但方式变了。在 App Router 下,src/trpc/server.ts导出的api是一个通过cache()包装过的 caller。它默认会从当前请求上下文里读取 headers 和 cookies。

我踩过的坑是:在 Middleware 里做了登录态判断后,如果 Middleware 重写了很多请求路径,可能导致某些服务端组件在调用 caller 时拿到的 cookie 不完整,从而报出跟 session 解析相关的错误。排查时的思路是这样:先看是不是某个页面既能被匿名用户访问,又调用了protectedProcedure,这种情况其实是设计问题,不是 bug。再看是不是在纯服务端的工具函数里调用了 caller,比如在数据库定时任务或构建脚本里调用,这种场景下没有 HTTP 请求上下文,直接调必然出错。

一个比较稳妥的模式是:服务端组件只做“取数据并传给客户端组件”这件事,不要在里面写复杂的业务判断。客户端组件通过 tRPC 的useQuery自己去请求,这样请求链路始终有完整的浏览器上下文,排查起来也清爽。

4.4 session 类型补全

NextAuth 默认的Session.user类型只有name、email、image,但业务里经常需要id。如果不补类型,你会发现ctx.session.user.id直接报错。

在项目里加一个类型声明文件:

// src/types/next-auth.d.ts import { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { user: { id: string; } & DefaultSession["user"]; } }

这只是类型层面的声明,真正把id返回给前端,还要在 NextAuth 配置的callbacks里设置:

callbacks: { session({ session, token }) { if (session.user) { session.user.id = token.sub ?? ""; } return session; }, },

这个sub是 JWT 里用户 ID 的标准字段,NextAuth 在登录时会把值写进 token。两步都做完,前后端关于“当前用户”的数据结构才完全对齐。很多教程只让你加类型声明,不告诉你 session 回调还得写,结果就是类型对但运行时时值还是 undefined。

4.5 常见问题速查表

现象可能原因解决办法
前端useQuery一直 loadingTRPCReactProvider 没包裹应用或位置不对检查app/layout.tsx,确保TRPCReactProvider在最外层
mutation 成功后列表不更新没有处理 React Query 缓存失效在onSuccess里调用utils.xxx.invalidate()
服务端组件调用报 cookie/header 错误在无请求上下文的环境里调用了 caller只把 caller 用在请求链路内,或把数据获取移到客户端组件
部署后 Prisma 报 P1001Serverless 直连数据库连接数满配置连接池地址,并设置directUrl
访问 protectedProcedure 总是 401session 解析失败或 NEXTAUTH_SECRET 不一致检查 NextAuth 配置和密钥,打开日志确认 cookie
环境变量缺失导致启动失败env.mjs校验拦截对照.env.example填写完整变量,必要时设SKIP_ENV_VALIDATION=1临时跳过

还有一个小细节,常被忽略:在middleware.ts里不要直接读取数据库或调用 tRPC,因为 Middleware 默认跑在 Edge Runtime,Prisma 那套依赖 Node API 的代码在 Edge 上跑不通。要做登录判断,用next-auth/jwt的getToken就够了。

5. 聊聊更远一点:从跑通 demo 到规模化之前的朴素经验

5.1 脚手架到生产环境的差距

create-t3-app 生成的模板,定位是“快速起步”,不等于“生产优化”。本地跑得很顺,不代表部署到线上就万事大吉。我上线 t3code 时至少还要处理几件事。

第一是登录密钥必须在生产环境重新生成,不能沿用开发环境的测试值。第二是数据库要开启 SSL,DATABASE_URL 里带上?sslmoderequire或者使用连接池地址,很多数据平台默认 SSL 是关闭的,裸连容易中间人嗅探。第三是静态资源的缓存策略,Next.js 默认带了一些,但自定义的 font、图片、上传文件最好在托管层的 CDN 里配置过期时间。第四是日志和异常监控,本地开发你看终端就行,线上必须接入 Sentry 这类服务,把 Next.js 和 Prisma 的报错都上报起来。模板不包含这些,需要自己补。

一个小技巧是,部署前先在本地跑一次完整 build:

npm run build

很多类型错误、服务端组件使用不当的问题,在 build 阶段就会暴露。不要每天在 dev server 里自我感觉良好,上线前不跑 build 的教训我已经吃过很多次了。

5.2 可以扩展的方向

t3code 做完基础版本后,我梳理了几个比较自然的扩展方向。

一是加支付能力。Stripe 的 SDK 类型很好,配合 tRPC 可以做出“前端触发购买 -> 后端创建订单 -> Webhook 回调更新订单状态”的完整闭环,类型上全程通透。二是加后台管理页。用 NextAuth 给管理员单独配一套权限角色,在 tRPC 中间件里再判断一次角色字段,就能在同一套代码里隔离普通用户和管理员接口。三是给文章模块加评论、点赞这种社交功能,数据结构无非就是新增一两个 Prisma 模型,再挂一个 router,完全不用动架构。

如果想挑战一点,可以把 tRPC 的 HTTP 请求换到 WebSocket 传输层,tRPC 官方支持wsLink,配合订阅场景可以用。但我要提醒一句:别一开始就上 WebSocket,HTTP + React Query 已经覆盖绝大多数场景,过度设计只会增加复杂度。

5.3 我的四点真实感受

写完 t3code,我最明显的感觉是“类型安全并不是魔法,它只是把错误提前暴露了”。以前写接口联调,前端运行时才发现字段拼错,页面白屏才开始从网络面板往回查;现在写代码的时候 IDE 红色波浪线就把你拦住了,效率差异非常大。

第二点是,这套技术栈对“单人全栈”极其友好。一个人既要改数据库模型、又要调接口、又要写页面,如果每个环节都要重复维护类型定义,精力会被磨掉一大半。t3code 从定义模型到页面展示数据,一气呵成,很少需要切换到文档或类型定义文件。

第三点是,它不能完全替代后端工程。复杂查询优化、消息队列、分布式事务这些后端话题,T3 没有给你答案,它只是让你在小项目里少写很多胶水代码。项目一旦大到需要一个独立的接口平台,tRPC 的“亲前端”特性反而会变成束缚。

第四点是,别迷信模板。create-t3-app 给你的是一个起点,不是终点。真正属于你自己业务的代码,还是要认真组织,比如 router 怎么拆分、query key 怎么规划、上下文里应该放哪些数据,这些都要跟着业务形态走。t3code 系列项目我只能说它是“把更多注意力留给了业务问题”,而不是“从此不用再写后端代码”。这也恰恰是它最让我满意的地方。

返回列表