t3code 这个名字,初看像个随手起的代号,其实拆开就明白:T3 来自现在前端圈挺火的 T3 Stack,code 则代表代码工程化。我私下维护这个项目已经快一年了,最早纯粹是因为受够了每次新建一个全栈项目都从零接一遍 TypeScript、Next.js、tRPC、Prisma 和 Tailwind。你可以把它理解成一套“按我的口味焊死”的起步骨架:既能直接跑业务,又保留改造成其他形态的余地。这篇文章我想完整讲讲 t3code 的设计思路、技术选型、实操步骤,以及我踩过的那些坑,给正在折腾全栈脚手架、或者想搞明白端到端类型安全到底怎么落地的人一个参考。
如果看到这你还不太确定它适不适合你,我给个简单判断标准:你是一个需要同时写前后端、又不想维护两套接口文档和个人端口的开发者或小团队,那你大概率能从这套东西里找到顺手的地方。接下来我尽量按“为什么这么做”和“到底怎么落地”两条线来讲,不会只给一堆配置文件。
1. 为什么会有 t3code:从重复劳动到统一起点
1.1 一句话解释这个项目是什么
t3code 本质上是把一套现代全栈应用的基础能力打包成一个可复用的工程项目起点。这个起点包含:以 Next.js 为首的前端运行框架、以 tRPC 为核心的接口通信层、以 Prisma 为默认的数据库访问层、以 Tailwind 为主的样式方案,以及配套的代码规范、环境变量管理和持续集成配置。
和 create-next-app 这种官方脚手架的区别在于,t3code 不只是生成一个“能跑”的页面项目,而是把前后端打通了。官方脚手架做完之后,你还要自己去找怎么调接口、怎么管理数据库表结构、怎么让 TypeScript 类型在接口调用两边保持一致。t3code 做的就是把这一层“接线”工作提前完成,让你拿到手的状态是:数据库模型定义好,就能在前端代码里获得完整的类型提示;前后端代码在同一个仓库里,改个字段名,编译器直接告诉你哪里要改。
我习惯把它叫“代码工作台”而不是“脚手架”,因为脚手架通常是一次性生成,t3code 更像一个持续维护、持续演化的底座。团队里其他人不必关心底层怎么搭,只需要关注业务代码。
1.2 踩过的坑让我决定自己封装
做这个项目的起因是我当时连续接手两个中型全栈项目,代码模式几乎一样:Next.js 负责页面和服务端能力,数据库用 PostgreSQL,接口层用的是自建的 REST API。看起来挺清晰,但实际上问题不少。
第一个问题是类型断层。后端写了一个返回User对象的接口,前端调用时为了省事,自己又定义了一个interface User。两边字段稍有不同,跑起来不报错,UI 上就莫名其妙地出现undefined。等发现问题,往往已经过了好几个页面。
第二个问题是重复劳动。每个项目都要配置 ESLint、Prettier、提交检查、环境变量样例、数据库迁移脚本。这些工作不复杂,但特别琐碎,每次部署环境都要重新踩一遍。
第三个问题是新成员的上手成本。一个新人加入,第一天通常是在配环境、理清目录结构、搞明白这个项目到底哪一层管数据、哪一层管页面。如果骨架不清晰,后面写出来的代码也会越来越乱。
后来我发现社区里已经有人在推 T3 Stack 的思路,主打 TypeScript 优先、类型安全、最小化复杂度。这和我的诉求很吻合,但公共模板毕竟是面向所有场景的,装了不少我根本用不到的示例功能。于是我就决定基于这个思路维护一套自己的模板,按实际项目里最常见的需求来裁剪,这就是 t3code 的由来。
1.3 与传统脚手架的核心差异
传统的脚手架核心思路是“帮你生成第一版代码”,生成完使命基本就完成了。t3code 不太一样的地方在于,它更看重“演化的能力”。
具体来说有三点差异。第一,把 API 层从“URL 路径”变成了“函数调用”。tRPC 让前端直接调用后端定义的函数,参数和返回值都由 TypeScript 自动推断,改后端签名,前端编译直接报错。第二,数据库 Model 是类型系统的源头。Prisma Schema 写好一个模型,tRPC 的查询返回类型、前端的类型定义、表单输入的校验类型全都从这份 Schema 派生。第三,模板自身持续升级。我每接到一个新的业务项目,如果发现某些通用逻辑值得沉淀,就会把改进反馈回 t3code,下一个项目从一开始就站在更好的基础上。
这也是为什么我会在后面的实际操作章节里反复强调一点:模板的价值不在于第一次生成时有多完整,而在于后续修改时系统能不能帮你保持一致性。
2. 核心技术栈与选型思路
2.1 选型全景与取舍逻辑
下面这张表几乎就是 t3code 的全部家当,每一项都是经过实际项目验证后的选择,而不是随便凑出来的:
| 层面 | 方案 | 为什么是它 |
|---|---|---|
| 语言 | TypeScript | 全栈统一语言,类型就是最廉价的文档 |
| 前端框架 | Next.js | 成熟稳定,支持 SSR/静态生成/API 路由 |
| 接口通信 | tRPC | 端到端类型推断,没有额外代码生成步骤 |
| 数据库 ORM | Prisma | Schema 即类型,迁移工具好用 |
| 样式 | Tailwind CSS | 约束优先,避免样式代码失控 |
| 校验 | Zod | 和 tRPC 配合,一端定义两端共用 |
| 代码规范 | ESLint + Prettier | 统一团队习惯,减少风格争议 |
| 仓库管理 | pnpm + Turborepo | 多包结构下依赖安装和任务编排更高效 |
这里想重点解释一下 tRPC 为什么是主角。传统开发中你至少要做三件事:写接口逻辑、定义接口文档(Swagger)、在前端根据文档再定义一遍调用类型。这套流程在接口少的时候不觉得累,接口一多,两边“同步”的工作量就很可观。tRPC 的思路是,后端定义了一个getUser的函数,函数签名本身就是接口定义,前端 import 这个函数后直接调用,函数的参数类型、返回类型全部自动带过来。
代价是什么?是这个模式的生态还比较年轻,部分场景里没有 REST 那么通用。但如果你维护的是一个前后端同仓库的全栈项目,这个代价几乎可以忽略。
2.2 类型安全如何贯穿三层
类型安全在 t3code 里不是一种口号,而是有一套具体的流动路径。
最顶层是数据库 Schema。在schema.prisma里定义一个Post模型,包含id、title、content等字段,然后运行prisma generate,TypeScript 类型就会自动生成。这个类型是唯一权威来源,任何手动改出来的、和它不一致的接口返回类型都算违规。
中间层是 tRPC Router。每个路由都声明了它接受什么样的输入,以及调用后返回什么。输入类型可以完全由 Zod Schema 定义,比如z.string().min(1)就代表“必须是长度不小于 1 的字符串”,前端传空字符串,后端直接拒绝,不需要你手写校验逻辑。
最前端是 React 组件。调用 tRPC 的useQuery拿到数据后,data的字段类型和 Prisma Model 完全一致。如果你写了一个data.unknownField,IDE 立刻画红线。
这条链路最迷人的地方是中间没有“手写类型”这个步骤。我见过太多项目因为手动维护类型而出现“假的类型安全”,t3code 刻意把出错的环节连根拔掉。
2.3 为什么是 monorepo(以及什么时候不需要)
t3code 本身是采用 pnpm workspace 的 monorepo 结构,但不是所有项目都适合这套。我最初也纠结过:业务代码这么简单,拆分多个包是不是过度设计?
后来我是这么想清楚的。monorepo 解决的是一个“边界问题”:当你的项目同时包含 Web 端、管理后台、后端逻辑、数据库模型、共享工具函数时,如果全放在同一个应用目录里,很容易出现“页面组件直接 import 数据库代码”的混乱局面。物理边界不清晰,认知负担就会膨胀。
t3code 的目录是这么拆的:
t3code/ ├─ apps/ │ ├─ web/ # Next.js 主应用 │ └─ admin/ # 可选的管理后台 ├─ packages/ │ ├─ db/ # Prisma Client、Schema、迁移脚本 │ ├─ api/ # tRPC Router 定义,不依赖 Next.js │ ├─ ui/ # 组件库 │ └─ config/ # 共享的 ESLint / TSConfig / Tailwind 配置 ├─ package.json └─ pnpm-workspace.yaml这里有个很关键的设计:packages/api不知道 Next.js 的存在。它只定义 tRPC 的 context 接口和路由,后续如果要新增一个纯 Node 服务,可以直接复用这套 API 定义,不用搬迁代码。
如果你的项目就是一个人维护、页面少于二三十个,没有拆分的必要,单目录反而更快。monorepo 的精神不是在层级数量上追求多,而是让每个模块的依赖方向清晰。
2.4 一版值得参考的目录结构
新手从 t3code 入手时,最容易迷路的是“我改的代码到底应该在哪个目录”。我建议按照下面这套约定来理解:
apps/web/src/app:页面级代码,一个文件夹对应一个路由。apps/web/src/components:纯展示组件,不直接调用 API。packages/api/src/routers:接口逻辑,按业务域划分,如user.ts、post.ts。packages/db/prisma:数据库 Schema 和迁移文件。packages/db/src:复用的数据访问函数,比如“根据 slug 查找文章”。
按这套约定,新增一个“文章详情页”的直觉路径是:先去schema.prisma加字段,再在routers/post.ts加一个getBySlug的 procedure,然后到apps/web/src/app/posts/[slug]建页面并调用。每个动作都有清晰的落点,不会出现到处乱放。
3. 从零到一:完整搭建与首个功能实操
3.1 初始化命令与环境准备
假设你已经从 GitHub 克隆了 t3code 仓库,第一件事是安装依赖。我强烈建议用 pnpm,因为 workspace 的依赖提升规则和软链接机制在 npm 里表现不稳定,尤其是多个包共享同一个 UI 组件时很容易出现 “Duplicate React” 的经典报错。
本地环境需要准备:
- Node.js 18+(推荐 20 LTS);
- pnpm 8+;
- 一个 PostgreSQL 实例,本地 Docker 启动最简单;
- 可选:Docker Compose 文件已在仓库里提供。
安装依赖:
pnpm install然后创建环境变量文件。仓库里提供了.env.example,直接复制为.env即可:
cp .env.example .env里面最关键的两项是DATABASE_URL和NEXTAUTH_SECRET,前者指向 PostgreSQL,后者是认证密钥。如果暂时不做登录功能,可以先把NEXTAUTH_SECRET填一个随机字符串,后面用得上。
3.2 数据库与 ORM 接入
接下来是把数据库跑起来并创建第一张表。如果你用 Docker,那么一条命令就能启动 PostgreSQL:
docker compose up -d然后进入packages/db目录,执行迁移:
cd packages/db pnpm prisma migrate dev --name init这条命令会做三件事:创建_prisma_migrations表、执行迁移 SQL、重新生成 Prisma Client 类型。执行完之后,你可以在数据库里看到空的表结构,同时在packages/db/node_modules/.prisma/client下生成对应的 TypeScript 类型。
第一次跑迁移时如果报Environment variable not found: DATABASE_URL,多半是因为.env文件的位置放错了。Prisma 默认从packages/db目录找.env,如果你把.env放在仓库根目录,需要在package.json的prisma配置里指定。我习惯的做法是在packages/db维护一个.env软链接,避免路径混淆。
3.3 编写第一个 tRPC 接口
我现在用一个最常用的场景演示:创建一篇带标题和内容的文章。
先在packages/db/prisma/schema.prisma里定义模型:
model Post { id String @id @default(cuid()) title String content String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }运行pnpm prisma migrate dev --name add_post,然后回到packages/api/src/routers/post.ts,定义第一个 tRPC procedure:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const postRouter = createTRPCRouter({ create: publicProcedure .input( z.object({ title: z.string().min(1, "标题不能为空"), content: z.string().min(1, "内容不能为空"), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.post.create({ data: { title: input.title, content: input.content, }, }); }), });这里有几个设计细节值得说明。publicProcedure表示这个接口无需登录即可访问,后续如果加了认证,你可以定义protectedProcedure,在ctx里注入当前用户信息。z.string().min(1)是最轻量的校验,它保证空字符串在进入数据库之前就被拦截,错误信息可以直接展示给前端。
定义完 procedure 后,还需要把它挂到根路由上。找到packages/api/src/root.ts,加入:
export const appRouter = createTRPCRouter({ post: postRouter, }); export type AppRouter = typeof appRouter;到这里后端接口定义就完成了。请注意,我们没有写任何“接口文档”,也没有把 URL 手动暴露出来。前端要调用它,只需要import { api } from "~/utils/api"。
3.4 前端完整调用与类型验证
前端调用 tRPC 的主流方式是基于 React Query 的 hooks。在apps/web/src/app/posts/page.tsx里写一个简单的创建和列表页面:
"use client"; import { api } from "~/utils/api"; export default function PostsPage() { const utils = api.useUtils(); const posts = api.post.list.useQuery(); const createPost = api.post.create.useMutation({ onSuccess: () => utils.post.list.invalidate(), }); return ( <div> <button onClick={() => createPost.mutate({ title: "test", content: "hello" }) } > 新增 </button> {posts.data?.map((post) => ( <div key={post.id}>{post.title}</div> ))} </div> ); }看起来只是一个普通 hooks 调用,但这行代码背后有一套完整的类型推导链路在跑:api.post.create.useMutation的参数类型来自 Zod 校验器,返回类型来自 Prisma 的PostModel。如果你传一个不存在的字段,比如contentz,IDE 会立刻报错,不用等到运行时。
这是 t3code 使用中让我最舒服的地方:类型提示不是“可用可不用的福利”,而是编译级别的约束。如果你刚接触,建议试试故意改动字段名,观察 TypeScript 是怎么引导你回到正确路径的。
3.5 细节优化:env、alias、lint
项目能不能长期健康地跑,除了主体功能之外,几个细节决定体验。第一是环境变量。我建议约定所有环境变量都从env.ts里导出,用 Zod 做启动时校验:
import { z } from "zod"; const envSchema = z.object({ DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), }); export const env = envSchema.parse(process.env);应用启动时如果缺少变量,会直接抛出清晰错误,而不是等运行到一半才炸。这比在代码里到处散落process.env.XXX安全得多。
第二是路径别名。每个包都在tsconfig.json里配置@/*指向自身源码目录,这样 import 时不用写一长串相对路径。要注意的是 Next.js 的tsconfig.json、jest.config、vitest.config都要同步维护这份 alias,否则测试和编译时行为不一致。
第三是 ESLint 规则。t3code 默认接入了eslint-config-t3,它包含了 TypeScript 推荐规则和 React Hooks 规则。我额外加了一条:禁止在组件外使用useQuery,因为懒加载逻辑一旦脱离 React 生命周期,缓存和状态的一致性就很难保证。这条规则虽然不能靠 ESLint 强制,但可以作为 code review 时的约定。
4. 代码自动化与工程习惯
4.1 为什么把代码生成当作核心能力
t3code 不只是静态模板,它里面内置了一套代码生成器,用来快速创建页面、接口、组件和数据库模型。这是个非常实用的设计,因为手动复制粘贴再改名的过程特别容易出错。你复制了一个文件,忘了改状态变量名,或者把接口地址拼错了,运行一会儿才暴露问题。
代码生成器解决的是“结构性重复”的问题。如果一个页面模板对你来说要写 5 分钟,那么生成器帮你把 4 分钟省下来,省下来的精力足够写好剩下的业务逻辑。生成器不是一个炫耀技术的东西,它是工程里最直观的杠杆。
4.2 一个最小生成器的实现思路
我用的生成器是基于 Plop 做的。Plop 的逻辑很朴素:定义一个 prompts 数组(询问用户输入),然后有一组模板文件,按照 answers 渲染成真实文件。
下面是最基础的添加 Router 的配置plopfile.mjs:
export default function (plop) { plop.setGenerator("router", { description: "创建新 tRPC router", prompts: [ { type: "input", name: "name", message: "router 名称(小写字母)" }, ], actions: [ { type: "add", path: "packages/api/src/routers/{{name}}.ts", templateFile: "plop-templates/router.hbs", }, { type: "modify", path: "packages/api/src/root.ts", pattern: /export const appRouter = createTRPCRouter\(\{/, template: " {{pascalCase name}}: {{name}}Router,\n$&", }, ], }); }模板文件router.hbs大概长这样:
import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc"; export const {{camelCase name}}Router = createTRPCRouter({ hello: publicProcedure .input(z.object({ name: z.string().optional() })) .query(async ({ ctx }) => { return { message: `hello ${ctx.currentUser?.name ?? "world"}`, }; }), });生成器不一定非要做得多复杂,它能保证的是:每一个新 Router 都和旧 Router 保持同样的格式、同样的错误处理方式、同样的代码风格。这一点在团队协作中的价值被严重低估了。
4.3 开发、检查、提交这一套流水线
T3code 的日常开发流程被我打磨成了三个命令:
pnpm dev # 启动开发服务器 pnpm check # 类型检查 + lint + 测试 pnpm commit # 交互式提交,使用 commitlint 规范pnpm check内部实际上是并发执行 typecheck、lint、test 三个任务:
{ "scripts": { "check": "turbo run typecheck lint test", "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings 0", "test": "vitest run" } }我故意把--max-warnings 0加上去。前端项目的 ESLint 经常出现“warning 无所谓”的心态,累积多了,警告就变成噪音,真正的错误反而被淹没。宁可让构建失败一次,也不要让 warning 天天挂在那边。
提交阶段我使用 commitlint + husky 的组合,commit message 必须符合type(scope): subject格式,比如feat(api): add create post procedure。这套约定看似约束,实际上让 later 的 git 历史检索和自动生成 changelog 都变得非常顺。
4.4 CI 里到底该跑哪些任务
很多项目的 CI 配置是“随便跑跑”,或者反过来“把该有的全跑了”。我实践中认为真正有必要的 CI 任务是:
name: CI on: pull_request: push: branches: [main] jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install - run: pnpm check - run: pnpm build这三个任务(install、check、build)的覆盖已经足够拦截 95% 的常规回归问题。如果业务里有端到端测试(Playwright 或 Cypress),再单独加一个 job,不要在 build 里把 e2e 也跑掉,那样会让一次 push 的反馈时间变得过长。
如果你问我为什么不跑单元测试覆盖率强制门槛,我的答案是:覆盖率数字很容易被虚荣指标绑架,真正有毒的代码通常是“没测到但数字很好看”的模块。把类型安全和接口契约守住,比单纯刷覆盖率更有价值。
5. 常见问题与排查技巧实录
5.1 问题速查表
维护 t3code 这一年,我整理了一张高频问题表,每次更新依赖后都会重新对照一遍。
| 症状 | 可能原因 | 排查步骤 | 解法 |
|---|---|---|---|
prisma generate报Environment variable not found | .env路径不对 | 在packages/db下执行echo $DATABASE_URL | 在对应目录建.env或软链接 |
前端调用 tRPC 提示No router matching | Router 没挂到根路由 | 打开root.ts检查导出 | 补挂路由并重跑pnpm dev |
| Tailwind 样式突然不生效 | 内容扫描配置缺少新目录 | 检查tailwind.config.ts的content | 加入新目录的 glob 规则 |
| 数据库字段变更后前端类型没更新 | 没重新生成 Prisma Client | 执行pnpm prisma generate | 在 package.json 写 prebuild 钩子 |
| tRPC mutation 成功后页面不刷新 | React Query 缓存未失效 | 打开 Network 看是否有请求 | 正确使用utils.post.list.invalidate() |
| Next.js 部署后页面数据是旧的 | 静态缓存或 ISR 缓存未过期 | 看构建日志和 fetch 缓存 | 调整revalidate或改用动态渲染 |
pnpm build时duplicate react | workspace 里有多个 React 版本 | 执行pnpm why react | 在pnpm-workspace.yaml增加overrides |
运行dev时 tRPC 连接被拒 | 前端和后端不在同一个端口上下文 | 查看NEXT_PUBLIC_API_URL | 通过 Next.js 同源代理,避免跨域 |
5.2 两个“反直觉”的典型案例
排查过程中有两个案例特别值得展开,因为它们都不报错,但行为明显不对。
第一个是“样式失效”。我当时用 Next.js 14 建新页面,发现 Tailwind 的container组件在页面里完全没生效,组件也没有任何报错。排查半天,发现tailwind.config.ts里的content数组只包含了./src/**/*.{ts,tsx},而新页面放在了app/目录下。虽然app也是src的子目录,但我当时用的是 glob 字符串,刚好匹配不上。解决方式是统一把扫描范围改成"./{src,app}/**/*.{ts,tsx}"。这提醒我:Tailwind 的配置文件改动后一定要重启 dev server,热更新并不会自动重新扫描。
第二个是“接口调用返回 undefined 但没人报错”。tRPC 本身有类型保护,但如果你在procedure里用了select或include,返回类型会被收窄。有个同事在查询里只select了title和id,但前端组件直接访问data.content,IDE 因为类型缺失而标红。由于他用了// @ts-ignore避开类型检查,最终渲染出来就是一大片空白。解决方案其实很简单:不手动关闭类型检查,让错误在编译阶段暴露出来。
5.3 升级依赖的避坑策略
依赖升级是我维护 t3code 里最谨慎的环节,尤其是这些核心库:next、react、prisma、trpc、tailwindcss。这些库两两之间的兼容性牵连太广,一次升级翻车,可能要消耗一个下午。
我给自己定了三条规则:
第一,核心依赖升级前先看 changelog 和 breaking changes 列表,确认是否需要修改配置文件。比如 Next.js 从 13 到 14 的 App Router 变化、Prisma 从 v5 到 v6 在输出类型上的调整,都不能只看“版本号升了没”。
第二,升级后完整跑一遍pnpm check和pnpm build,并且在真实浏览器里走一遍主要业务路径,不要只靠单元测试。类型报错往往只是冰山一角,运行时的数据获取方式、缓存策略都可能受影响。
第三,核心依赖的版本宁可落后一个大版本,也不要盲目追新。t3code 的目标是稳定,不是给依赖库做 beta 测试。社区里新项目可以激进,但被你当作起点的骨架项目,应该优先保证可用性。
6. 我从维护 t3code 里得到的几点体会
6.1 模板不是越复杂越好
最初设计 t3code 时,我总想把它做得“大而全”,认证、多租户、日志、监控、邮件服务全都想塞进来。后来发现,包罗万象的模板恰恰是最难被复用的模板。每个项目的业务方向不同,那些看似完善的基础能力反而成为定制时的阻碍。
现在我更信奉一种“默认最简,按需扩展”的原则。模板里只有最核心的链路:数据库、API、前端、样式、CI。认证也好,支付也好,都不放进默认配置,而是提供清晰的分支文档和集成示例。这样每个项目拿到 t3code 后不需要先删代码,直接就能开始写业务。
6.2 文档和示例代码才是最贵的资产
开源模板最容易被忽略的是使用文档。代码写得再整洁,新手也要花时间把所有文件看一遍。我在维护过程中花了不少时间写“示例代码”,而不是只写“说明文档”。比如演示“如何添加一个带鉴权的接口”,除了文字描述,还会提供一个完整的protectedProcedure例子,让人直接复制就能跑。
一个有点反共识的经验是:示例代码的正确性比“优雅”更重要。我会刻意把示例写得朴素一些,让读者更容易从里面看出设计意图,而不是为了展示高级技巧造成理解跳步。
6.3 给后来者的三条建议
如果看完这篇你也想搞一套自己的全栈工作台,我的建议是:第一,先从你最近一个真实项目里提取需求,而不是凭空设计理想架构。你维护的项目越多,越知道哪些配置是刚需、哪些是多余的。第二,把类型安全当作最高优先级来建设,它虽然会给你增加写代码时的“被提醒”感,但在项目变复杂之后会回报你省下的调试时间。第三,别怕推翻重来。t3code 的目录结构改过三次,第一次是加 monorepo,第二次是抽出 packages/api,第三次是调整 env 管理方式。每次推翻都有明确收益,而不是为了改而改。
t3code 目前还在我自己的新项目里持续使用,每隔几周就会根据实际踩坑反馈微调一点配置。这个项目给我的最大回报,不是省掉了每个项目的初始化时间,而是让我越来越确信一件事:好的工程模板不是一次写出来的,而是在反复使用、反复否定、反复修正中长出来的。如果你有类似的思路,希望上面的内容能帮你少走几步我走过的弯路。