ARTICLE DETAIL

资讯详情

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

T3 Stack 全栈项目实战:用 tRPC + Prisma 打造代码片段管理工具

T3 Stack 全栈项目实战:用 tRPC + Prisma 打造代码片段管理工具

手头有个做了两个多月的全栈小项目,一直用的代号就是 t3code。最初它只是我想验证一下 T3 Stack 到底能不能扛住真实业务的小玩具,后来发现这个组合把我之前最头疼的三件事——前后端类型不同步、接口联调效率低、代码重复维护——全解决了,于是干脆把它做成了一款团队内部的代码片段管理工具。这篇文章就是 t3code 从 0 到 1 的完整复盘,包括我为什么不先用传统 REST API、tRPC 的 Router 到底怎么设计、Prisma 连表踩了哪些坑、部署到服务器上又炸过几次。如果你正在做 Next.js 或 React 全栈方向的项目,想把前后端打通这件事做得更省心,这份笔记可以直接当参考。

1. t3code 到底是个什么项目:代码片段管理工具的技术选型复盘

1.1 为什么说"前后端类型不同步"是最大的隐性成本

t3code 最初的定位很简单:团队里每个人手头都积攒了一堆常用代码片段,散落在各个聊天记录、本地文件、甚至纸质笔记里,想找的时候翻半天。做一个轻量的共享平台,让每个人能上传、分类、检索代码片段,这是典型的中后台工具型需求。

这类项目有一个很突出的痛点:传统的"后端写接口 + 前端写类型定义"模式,在快速迭代时成本高得离谱。现实中大多数团队都是这么干的:后端把 DTO 写一遍,前端在 TypeScript 的 interface 里再抄一遍,两边各维护一份字段清单。等接口版本一更新,全局搜字段名改半天,漏改一处就到运行时炸一个红色报错。t3code 是团队内部工具,我不想把时间花在这种重复劳动上,也不想为了一个查询功能单独抽一层 service。

后来我决定用 T3 Stack 来重构整个项目。T3 Stack 的核心思路是用一套严格类型约束的体系,把"数据模型、服务端逻辑、前端调用"三层彻底打通。TypeScript 提供语言层面的类型,tRPC 把函数调用变成前后端共享的链路,Prisma 负责数据库层的类型生成。这样一来,删一个字段、改一个返回值类型,所有引用点会在编译期直接报错,而不是等到用户点了某个按钮才崩。

1.2 T3 Stack 四件套各自的角色与配合逻辑

T3 Stack 是四个工具的组合:Next.js 负责页面和渲染,TypeScript 提供基础类型,Tailwind CSS 负责样式,tRPC 负责前后端接口调用。可选组件里我加上了 Prisma 做 ORM,NextAuth 做登录认证。

很多人第一次听 tRPC 会懵:这不是把服务端函数直接暴露给前端吗,安全吗?其实 tRPC 不是 RPC 中间件那种远程调用,它本质上是基于 HTTP 的调用约定。每个 procedure 都可以配置鉴权、参数校验和错误处理,安全边界取决于你怎么设计,而不是它天生就是敞开的。你可以把 tRPC 理解成一份前后端共享的"函数目录":后端在 router 里定义好函数,前端像是直接引入一个本地模块来调用,但它背后实际走的是 HTTP 请求。

Next.js 在这个组合里负责页面路由和服务端能力。t3code 的页面有列表页、详情页、编辑页,全部由 Next.js 的 Pages Router 管理。Tailwind CSS 我本来还担心不好上手,实际用下来确实适合工具型项目——不需要维护一堆 CSS 文件,在 JSX 里直接写原子类,配合复制粘贴一个组件就能快速成型。

1.3 为什么不选 REST 和 GraphQL

我看到不少人在做类似工具时会先纠结:要不要用 GraphQL?我的意见很直接:不要为了一个团队内部工具引入 GraphQL。GraphQL 的强项是大前端多端的复杂数据聚合场景,但它的 schema 设计、resolver 分层、缓存策略,在小团队里很容易变成新的维护负担。REST 的方式虽然稳妥,但 URL 设计、方法映射、请求/响应类型定义,每一环都得手动对齐,做个 CRUD 就够折腾了。

t3code 这个场景本质上是:登录用户创建数据、读取数据、按标签或关键词搜索。所有调用都围绕固定的数据模型,没有复杂的多端差异。tRPC 恰好把最麻烦的"类型定义对齐"这个环节去掉了,服务端 router 里定义的 query 和 mutation,前端马上能拿到带类型的 hooks,不需要 OpenAPI 生成器,也不需要维护接口文档。

2. 用 create-t3-app 初始化 t3code,手把手过一遍脚手架关键选项

2.1 创建项目时的选项怎么勾

如果你从零开始,建议直接用官方脚手架,避免手动搭建时漏掉关键配置。t3code 就是npx create-t3-app@latest生成的,创建命令一行搞定:

npx create-t3-app@latest t3code

过程中会有一系列交互式选项。我第一次创建时只勾了 tRPC,跑起来才发现 Prisma 没装,context 里没法注入数据库实例,后面又手动补了一堆配置,白白折腾了半小时。所以我的建议是:如果你明确要做带存储的项目,一开始就把这些选项全勾上。

我最终选择的组合:

  • TypeScript:必选,整个 T3 Stack 的地基
  • Tailwind CSS:选,样式方案
  • tRPC:选,核心通信层
  • Prisma:选,数据库 ORM
  • NextAuth:选,登录认证
  • ESLint:选,保持代码规范

有一点要注意:Node.js 版本最好在 18 以上,我早期用 16 跑 build 时遇到过一些依赖兼容性问题。包管理器我用的是 pnpm,安装速度和磁盘占用都比 npm 舒服。

2.2 脚手架生成后的目录拆解

create-t3-app 生成的目录结构非常规整,t3code 的项目目录大概是这样的:

t3code/ ├── prisma/ │ └── schema.prisma ├── src/ │ ├── pages/ │ │ ├── api/ │ │ │ └── trpc/[trpc].ts │ │ ├── index.tsx │ │ ├── snippets/[id].tsx │ │ └── create.tsx │ ├── server/ │ │ ├── api/ │ │ │ ├── routers/ │ │ │ │ ├── snippet.ts │ │ │ │ ├── tag.ts │ │ │ │ └── root.ts │ │ │ └── trpc.ts │ │ ├── db.ts │ │ └── auth.ts │ ├── styles/ │ │ └── globals.css │ ├── utils/ │ │ └── api.ts │ └── env.js └── ...

刚看到这些文件的时候,很多人会犯迷糊:为什么又有个 trpc.ts 在server/api/下面,又有个[trpc].ts放在pages/api/里?这两个文件各管各的:pages/api/trpc/[trpc].ts是 HTTP 入口,负责把/api/trpc/*的请求转发给 tRPC 处理器;server/api/trpc.ts是真正创建 Router 实例、定义 context、封装 procedure 的地方。

src/env.js也是一个容易被忽略的文件,它用来做环境变量的运行时校验。比如DATABASE_URL、NEXTAUTH_SECRET这类变量如果没配置,页面会在启动时报错而不是运行到一半才暴露问题。我建议第一次跑项目前,先复制.env.example成.env,把DATABASE_URL填好,否则prisma migrate会直接失败。

3. t3code 的核心链路:数据模型设计与 tRPC Router 的完整实现

3.1 Prisma Schema 设计:Snippet、Tag、User 三张表的关联关系

t3code 的数据模型不算复杂,但设计上有一个点值得展开说:标签系统。一开始我想偷懒,直接在 Snippet 表里存一个逗号分隔的字符串字段tags: "react,typescript"。后来意识到这样做搜索和统计都非常痛苦,比如你想查"用了 react 标签的 snippet",用字符串去匹配会很别扭。

所以我改成标准的多对多关系:一个 Snippet 可以关联多个 Tag,一个 Tag 也可以被多个 Snippet 使用。中间表SnippetTag用来维护关联关系。整个 Schema 长这样:

model User { id String @id @default(cuid()) name String? email String? @unique snippets Snippet[] } model Snippet { id String @id @default(cuid()) title String content String @db.Text language String @default("text") visibility String @default("team") authorId String author User @relation(fields: [authorId], references: [id]) tags SnippetTag[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Tag { id String @id @default(cuid()) name String @unique snippets SnippetTag[] } model SnippetTag { snippetId String tagId String snippet Snippet @relation(fields: [snippetId], references: [id]) tag Tag @relation(fields: [tagId], references: [id]) @@id([snippetId, tagId]) }

关于主键我用的是${id}字符串加cuid()生成,而不是自增 Int。原因是这个工具后续可能要部署到分布式环境里,cuid 可以在多个实例生成而不担心冲突。对于团队内部几百条数据来说,这个开销可以忽略不计。

3.2 从 Router 定义到 mutation 实现:创建代码片段的完整代码走读

Router 是 tRPC 的服务端核心。t3code 里的 snippetRouter 承担了列表查询和创建功能。我先看列表查询,它要支持按标签和关键词过滤:

import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; export const snippetRouter = createTRPCRouter({ list: publicProcedure .input( z.object({ tag: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { return ctx.db.snippet.findMany({ where: { ...(input.tag ? { tags: { some: { tag: { name: input.tag } } } } : {}), ...(input.search ? { title: { contains: input.search } } : {}), }, include: { author: { select: { id: true, name: true } }, tags: { include: { tag: true } }, }, orderBy: { createdAt: "desc" }, take: 50, }); }), create: protectedProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1), language: z.string().default("text"), tags: z.array(z.string()).default([]), }) ) .mutation(async ({ ctx, input }) => { const snippet = await ctx.db.snippet.create({ data: { title: input.title, content: input.content, language: input.language, authorId: ctx.session.user.id, tags: { create: input.tags.map((name) => ({ tag: { connectOrCreate: { where: { name }, create: { name }, }, }, })), }, }, }); return snippet; }), });

这段代码里有一个特别值得解释的点,就是connectOrCreate。标签是用户输入的,可能已经存在,也可能从没出现过。如果用connect,数据库会报错说找不到对应记录;如果用create,重复标签会违反唯一约束。connectOrCreate的意思是"有就连,没有就建",一条语句解决存在性判断,省掉先查询再写入的额外逻辑。

我的经验是:在 Prisma 的嵌套写入场景里,connectOrCreate是处理关联数据的默认思路,尤其适合标签、分类这类用户自由输入的关联项。

3.3 给 procedure 加权限控制:protectedProcedure 的原理

创建 snippet 必须登录,但浏览公开 snippet 可以匿名。t3code 的trpc.ts里定义了两个基础 procedure:publicProcedure和protectedProcedure。

import { initTRPC, TRPCError } from "@trpc/server"; import { ZodError } from "zod"; import { type Context } from "./context"; const t = initTRPC.context<Context>().create(); export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user }, }, }); });

其实它的原理很像中间件,或者说像洋葱模型:请求进来,先经过.use()里注册的函数,只有通过校验才会继续执行真正的业务逻辑。如果 session 里没有用户,直接抛出一个UNAUTHORIZED错误,前端会拿到 401 的响应。

我一开始犯过一个错误:在 route handler 里手动判断 session,每个操作都写一遍if (!session) throw ...。有了protectedProcedure,这个逻辑就被抽成了通用封装。新写一个需要登录的接口时,只需要把publicProcedure换成protectedProcedure,所有鉴权逻辑自动生效。

4. 前端怎么把这些 API 用起来:tRPC + React 的 hooks 调用模式

4.1 createTRPCNext 客户端初始化与 QueryClient 缓存

前端的核心入口是src/utils/api.ts,t3code 在这个文件里创建了一个带类型的 tRPC 客户端:

import { httpBatchLink } from "@trpc/client"; import { createTRPCNext } from "@trpc/next"; import type { AppRouter } from "~/server/api/root"; export const api = createTRPCNext<AppRouter>({ config() { return { links: [ httpBatchLink({ url: "/api/trpc", }), ], }; }, ssr: true, });

这段代码最核心的是createTRPCNext<AppRouter>。AppRouter是从服务端导出的类型,它把所有 router 的类型信息带到了前端。这就是 t3code 能实现"类型全链路打通"的关键:前端的api对象上有哪些方法、每个方法接受什么参数、返回什么类型,全部由服务端 Router 推导出来。

httpBatchLink这个链接需要特别说一下。它允许在一个 HTTP 请求里合并多个 procedure 调用。比如列表页有时会同时请求 snippet 列表和标签列表,tRPC 会把它们打包成一次请求发给服务端,减少网络往返。我第一次跑项目时发现 Network 面板里请求数比想象中少得多,就是这个 batch 机制在起作用。

ssr: true表示开启服务端渲染预取。这样进入列表页时,数据在服务端已经查好,HTML 直接带出来,既有利于首屏速度,也能避免部分内容在客户端闪烁。

4.2 在列表页用 trpc.snippet.list.useQuery() 的完整实践

接下来是我最满意的一部分:前端调用后端的代码,短得不像真的。在 t3code 的列表页组件里,查列表只需要一行:

import { api } from "~/utils/api"; const ListPage = ({ activeTag, keyword }: { activeTag?: string; keyword?: string }) => { const { data, isLoading } = api.snippet.list.useQuery({ tag: activeTag || undefined, search: keyword || undefined, }); if (isLoading) { return <div>加载中...</div>; } return ( <ul> {data?.map((snippet) => ( <li key={snippet.id}> <h3>{snippet.title}</h3> <p>{snippet.language}</p> <span>{snippet.author.name}</span> {snippet.tags.map(({ tag }) => ( <span key={tag.id}>{tag.name}</span> ))} </li> ))} </ul> ); };

有没有发现,这里没有手写 fetch、没有手动拼接 URL、没有手动处理 JSON 序列化。useQuery背后是 react-query 的完整机制:缓存、重试、失效、状态管理,全部被封装好了。而且data的类型是Snippet[],里面的author.name可以直接点出来,如果我把include里的字段名写错,TypeScript 会在编译期就提醒。

也有个反直觉的地方:data?.map里返回的列表项类型,和 Prisma 返回的记录是严格对应的。因为 tRPC 会把服务端返回类型完整地传递到前端,任何字段缺失或类型不匹配,在开发时就会报错而不是上线后才发现。

4.3 mutation 之后怎么让列表自动刷新

在创建页表单里,提交成功后需要让首页的列表自动更新。传统做法可能是刷新页面,或者手动调一次查询接口。t3code 里用 mutation 加 invalidation 的模式:

const utils = api.useUtils(); const createMutation = api.snippet.create.useMutation({ onSuccess: () => { utils.snippet.list.invalidate(); }, }); const handleSubmit = async (values: { title: string; content: string }) => { await createMutation.mutateAsync({ ...values, language: "typescript", tags: [] }); };

useUtils()返回的utils.snippet.list.invalidate()会把当前页面上所有使用snippet.list查询的缓存标记为过期,react-query 会自动重新请求最新数据。

刚接触这套模式的人可能会问:为什么不直接在 mutation 的返回结果里把新数据塞进缓存?理论上可以,但 invalidate 有一个好处——它对查询参数是全覆盖的。比如你现在有"全部标签列表"和"只显示 react 标签列表"两份缓存,新增一条带 vue 标签的数据,如果只操作缓存,你很难判断哪些查询需要更新;用 invalidate 则简单粗暴,所有相关查询整体失效,重新请求。

4.4 页面级数据预取:服务端渲染时怎么把数据塞进页面

t3code 的首页是公开的,我希望搜索引擎和直接访问的用户能看到内容,而不是白屏等待 JS 加载。这里用到createServerSideHelpers在getServerSideProps里预取数据:

import { createServerSideHelpers } from "@trpc/react-query/server"; import { appRouter } from "~/server/api/root"; import { createContext } from "~/server/api/context"; export const getServerSideProps = async () => { const ssg = createServerSideHelpers({ router: appRouter, ctx: await createContext(), }); await ssg.snippet.list.prefetch({ tag: undefined, search: undefined, }); return { props: { trpcState: ssg.dehydrate(), }, }; };

这一块我重点解释dehydrate的作用:它把 react-query 在服务端查到的数据序列化成一个纯对象,塞进页面的 props,客户端拿到后直接作为初始缓存,不会发起重复请求。说白了就是把服务端查到的数据"冻住"带到浏览器,省掉一次网络请求,同时保留了 react-query 后续的缓存更新能力。

如果页面里既有公开数据,又有登录数据,可以在 props 里一并预取,逻辑不会冲突。

5. t3code 上线前踩过的坑:Prisma 部署、鉴权回调、类型丢失

5.1 Vercel 部署时 Prisma 引擎报错的处理

这是我在 t3code 部署阶段遇到的第一个大坑。本地跑得风生水起,一扔到 Vercel 上,页面直接报PrismaClientInitializationError,控制台里密密麻麻的引擎二进制错误。

原因其实不复杂:Prisma 的 Client 是依赖查询引擎二进制文件的,这个二进制需要和运行环境匹配。本地是 macOS 或 Windows 的引擎,Vercel 上的 Node 运行环境通常是 Linux 的,默认打包上去的引擎就不匹配。

解决方式是在schema.prisma里显式声明二进制目标:

generator client { provider = "prisma-client-js" binaryTargets = ["native", "linux-debian-openssl-3.0.x"] }

改完之后重新执行prisma generate,再部署。如果你用 Docker 部署,也需要在构建镜像里执行prisma generate,否则同样会遇到引擎问题。这个坑属于"本地 OK、线上挂"的最典型代表,碰上时先别怀疑代码逻辑,大概率是环境差异。

5.2 NextAuth 回调地址与 NEXTAUTH_SECRET 的坑

t3code 的登录我用的 NextAuth。本地测试用 localhost 一切正常,部署到线上之后,点击登录按钮,跳转一圈居然回到了 404 页面。

这个问题的根因是NEXTAUTH_URL环境变量没有配置。本地运行时 NextAuth 会自动读取请求的 host,但线上环境必须显式把NEXTAUTH_URL设置为自己的域名,否则回调地址拼不出来。

还有一个更容易踩的坑:NEXTAUTH_SECRET。我没有自己生成,一开始想省事让 NextAuth 自动生成,结果每次服务重启或者 Vercel 重新构建后,会话加密密钥变了,用户登录状态全部失效。后来我用openssl rand -base64 32生成一个固定值,写进环境变量,彻底解决。

还有一个小坑专门提醒一下:如果你用 Google Provider 做登录,并且允许用户用邮箱注册,同一个邮箱如果切换登录方式会触发唯一约束报错。这不是 bug,是 NextAuth 默认不允许一个邮箱绑定多种登录方式。我花了一个晚上才搞清楚,当时一度怀疑是数据库有脏数据。

5.3 tRPC 服务端类型导出失败时怎么排查

tRPC 的类型安全依赖于"服务端导出类型到客户端"这个通道。一旦这条链路上出现类型不合法的问题,整个项目编译都会报错。我在 t3code 里遇到的典型案例是:想在 mutation 的 input 里传一个Date对象。

t3code 的创建表单里有个"定时上线"需求,需要传一个发布时间。我最初直接在 Zod schema 里写:

z.object({ publishAt: z.date(), })

结果前端调用时,TypeScript 直接红了一片。原因很直观:Date是一个类,没法在 HTTP 请求里直接序列化,tRPC 的 input 必须使用可序列化的数据类型。解决方案是传 ISO 字符串,到服务端再转成Date。

如果你在部署后遇到"forbidden"错误,也要检查一个细节:调用 tRPC 时,fetch 的 headers 里有没有Content-Type: application/json。tRPC 默认对 content-type 校验很严格,很多自定义的 fetch 封装会忽略这一点,导致请求被服务端拒绝。这个错误不报在 TypeScript 层,而是运行时才出现,所以更容易懵。

5.4 性能优化:避免 N+1 查询的 include 写法

t3code 的列表页一开始只查 snippet,然后循环里再去查每个 snippet 的 author 和 tags。数据量小的时候没感觉,等 snippet 数量到了几百条,列表页就开始明显变慢,数据库日志里的查询数量也很吓人。

这个就是经典的 N+1 问题:一次主查询加上 N 次关联查询。解法是在 Prisma 里用include一次把关联数据带出来:

const snippets = await ctx.db.snippet.findMany({ include: { author: { select: { id: true, name: true } }, tags: { include: { tag: true } }, }, });

但这里也有一个度的问题。如果列表页只需要展示标签名,不需要 tag 的完整信息,就不要 include 整个 tag 对象,用select只取需要的字段。include 太多关联会让单条 SQL 变得膨胀,查出来的数据量远超实际用途。我在 t3code 的列表页最终并没有对所有字段做全量 include,而是只带author.id, author.name和标签名,详情页才使用完整数据。

6. 我在 t3code 上沉淀的几个可以直接抄走的工具封装

6.1 统一的 errorFormatter 让前端别拿着一堆英文报错骂人

t3code 早期版本有个体验问题:用户提交表单时如果数据不合法,前端收到的错误信息是 tRPC 默认的英文报错,比如Expected string, received undefined。团队同事看了直摇头。

我后来在所有 procedure 创建时配置了统一的errorFormatter,把常见的 Zod 校验错误翻译成友好文案:

const t = initTRPC.context<Context>().create({ errorFormatter({ shape, error }) { if (error.cause instanceof ZodError) { return { ...shape, message: "提交的数据格式有误,请检查输入项", }; } return shape; }, });

这样做的好处是统一的:所有输入校验错误在前端表现为同一种提示,不再需要每个表单组件单独处理错误文案。如果你希望把具体字段的错误也透出,可以在cause里继续解析 ZodError 的issues,按字段名拼出更精确的信息,但要注意别把内部信息暴露给用户。

6.2 用 infiniteQuery 做滚动加载,别一次性把几千条丢给用户

snippet 列表数据量变大之后,一次性返回 50 条明显不够用。我一开始想的是加一个"加载更多"按钮,自己维护一个游标参数。后来发现 tRPC 的infiniteQuery才是更省心的方案。

服务端 router 需要先定义一个支持游标的 query:

infiniteList: publicProcedure .input( z.object({ take: z.number().default(20), cursor: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const items = await ctx.db.snippet.findMany({ take: input.take + 1, cursor: input.cursor ? { id: input.cursor } : undefined, orderBy: { id: "asc" }, }); let nextCursor: string | undefined = undefined; if (items.length > input.take) { const nextItem = items.pop(); nextCursor = nextItem!.id; } return { items, nextCursor }; }),

前端调用:

const { data, fetchNextPage, hasNextPage } = api.snippet.infiniteList.useInfiniteQuery( { take: 20 }, { getNextPageParam: (lastPage) => lastPage.nextCursor, } );

滚动到底部时触发fetchNextPage,新数据会按页追加到data.pages数组里。相比自己管理游标,这套机制把缓存和分页状态都接管了,代码量少很多,而且每个页面的缓存是独立命名的,切回来不会重新加载。

6.3 前后端共用一套 Zod schema

最后说一个我建议所有用 tRPC 的人都养成的习惯:把输入校验规则抽到共享文件里。

t3code 的创建表单和服务端 router 过去各写了一套校验:前端在表单里用 HTML 的required属性,服务端在 input 里用 Zod。两套规则一旦出现偏差,就会出现"前端觉得自己填得没问题,服务端却拒绝提交"的奇怪现象。

后来我把校验规则统一放到src/lib/validators/snippet.ts:

import { z } from "zod"; export const createSnippetSchema = z.object({ title: z.string().min(1, "标题不能为空").max(100, "标题太长了"), content: z.string().min(1, "内容不能为空"), language: z.string().default("text"), tags: z.array(z.string()).max(5, "标签最多选 5 个"), }); export type CreateSnippetInput = z.infer<typeof createSnippetSchema>;

服务端引入后直接在 input 里引用:

create: protectedProcedure.input(createSnippetSchema).mutation(...)

前端在表单提交前也做一次safeParse,不满足时直接显示error.issues[0].message。用户看到的中文提示和服务端返回的是同一套,不会再出现两边口径不一致的尴尬。

我后来新项目里的 router 结构,基本都是从 t3code 这版里直接复制过去微调,尤其是protectedProcedure封装和共享 Zod schema 这两个工具,省下来的重复劳动比我当初预想的多得多。如果你也准备搭一个全栈工具型项目,我建议先在这两处下功夫,后面写业务接口会顺手很多。

返回列表