☰
t3code 全栈实战:基于 T3 Stack 从零构建在线判题系统
2026/10/14 2:21:26 网站建设 项目流程

1. 项目概述与动机

1.1 t3code 是什么

我第一次见到"t3code"这个名字的时候,第一反应是"这又是什么技术缩写",后来仔细一琢磨,它其实代表了一个非常清晰的方向:用 T3 Stack 技术栈去构建一个编码练习平台。T3 是 Tobias Lütke 最早提出的全栈开发组合,核心是 TypeScript、Tailwind CSS、Next.js 这三件套,再加上 tRPC、Prisma、NextAuth 这三个深度集成的工具,整个项目都由 create-t3-app 一键初始化出来。t3code 在这个基础上做了一层业务包装:登录、题目列表、提交代码、判定结果,本质上就是一个极简的 online judge(在线判题系统)练习项目。

这个项目最适合谁?两类人。第一类是刚学完 React 和 Node 基础、想完整走一遍现代全栈开发流程的前端开发者,第二类是公司内部要做快速原型验证、想用最小成本搭出"能登录、能写数据、能部署上线"的完整应用的工程师。t3code 的价值不在算法题本身,而在于它把 T3 技术栈的每个环节都串起来走了一遍,从数据库建模到 API 暴露再到前端类型安全,全程不扯皮、不绕弯。

1.2 为什么选 T3 而不是传统 REST 架构

我在决定做 t3code 之前,其实纠结过一阵子:要不要用传统的 Express + REST API + React 的组合?后来对比之后彻底放弃了。传统架构最大的问题是类型安全要靠人肉维护,前端定义一个接口类型,后端定义一套字段,两边一旦不同步,报错的时候你根本不知道是哪一层的锅。T3 的思路完全不同:tRPC 让 API 像函数调用一样,从后端的 query 函数到前端的 useQuery hook,类型全程自动推导,改一处全链路生效。

另一个原因是我实在受够了"起服务、配跨域、写 Swagger 文档"这套流程。tRPC 不需要单独开 HTTP 接口文档,不需要处理 CORS(除非你要做前后端分离),甚至不需要手动写网络请求层。你只需要在 server 端定义好 router,然后在 client 端 import 进来,直接调用。类型、序列化、错误处理全部内置,开发体验非常接近"写完一个函数立刻就能用"的状态。

当然 T3 也有自己的适用边界。如果你要做的是开放 API 给第三方开发者,那还是老老实实用 REST + OpenAPI 规范;如果团队里全是后端转前端、对数据库和 Node 很熟但对 React 类型体系不熟的,tRPC 的魔法会让你调试时有点摸不着头脑。做 t3code 这种内部闭环项目,T3 是最舒服的选择。

2. 技术选型与工具链解析

2.1 核心依赖版本与选择逻辑

t3code 实际跑起来依赖的版本号如下,我全部截取自项目 lock 文件里实测的版本,不是网上随便抄来的:

依赖版本说明
Next.js14.2.xApp Router 模式,Server Components 优先
React18.3.xNext 14 绑定的 React 版本
TypeScript5.5.x全项目类型严格检查,strict 开满
tRPC11.x前后端类型安全通信的基石
Prisma5.x数据库 ORM,负责建模和迁移
Tailwind CSS3.4.x原子化样式方案
NextAuth.js4.24.x认证解决方案

版本选择上有一条重要经验:不要刚出 RC 就去追新。我记得有段时间 Next.js 15 发布了,网上大量文章推荐升级,我抱着试错的心态升级过一次,结果 App Router 的几个 API 变了,导致 t3code 的中间件逻辑要重写,白折腾一天。做练习型项目,稳定压倒一切,锁在 Next.js 14 + React 18 是当时最稳的组合。

2.2 技术栈组合背后的设计哲学

T3 Stack 的底层逻辑可以用一句话概括:"类型安全是默认值,不是可选加分项"。Next.js 提供全栈框架能力和服务端渲染,TypeScript 提供编译期检查,tRPC 在 API 层保持端到端类型,Prisma 让数据库字段和 TypeScript interface 一一对应,Tailwind 则解决了样式层拼装的问题,NextAuth 把登录流程变成一个插件式的模块。每一层都像是给上一层加固的铠甲。

说一个我在实践中特别受用的点:Prisma schema 里定义了Problem模型的字段后,在 tRPC router 里用prisma.problem.findMany()查询,返回值直接就已经是前端能识别的强类型数据了。前端要展示题目列表,根本不需要再手动声明一个ProblemDTO接口,因为类型已经随着查询结果推导到了组件里。这就是类型端到端的威力,少了至少 30% 的样板代码。

2.3 数据库选型:SQLite 起步,Postgres 上线

t3code 这种练习项目,数据库选型有一个非常实用的策略:本地开发用 SQLite,部署上线再切 PostgreSQL。create-t3-app 初始化时可以直接选 SQLite,零配置、文件即数据库、没有任何网络依赖,非常适合快速跑通业务逻辑。等要部署到 Vercel 时,再换成 Neon 或 Supabase 的 Postgres,只需要改一下DATABASE_URL环境变量,重新跑一次迁移就行。

切换过程中有一个必须注意的细节:Prisma schema 里的provider要改几次。SQLite 不支持数组类型、不支持Json类型在某些操作上的便利性,比如题目测试用例这样的数据结构,在 SQLite 里存 JSON 字符串比较别扭,Postgres 可以直接用Json字段。所以我在设计 schema 时一开始就用了String存储测试用例的 JSON 字符串,避免后面切换数据库时推倒重来。

model Problem { id String @id @default(cuid()) title String content String difficulty String testCases String // JSON string, compatible with both SQLite and Postgres createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

这个设计在切库的时候一次坑都没踩,算是给后来人提个醒:如果你项目后面可能要上 PG,那 schema 里尽量避开数据库强相关的类型,统一用字符串承载复杂结构,代价只是序列化多一步,换来的是切换成本接近于零。

3. 环境搭建与工程初始化

3.1 create-t3-app 初始化的正确姿势

初始化 t3code 的第一步,用 create-t3-app 生成工程骨架,命令很简单:

npm create t3-app@latest t3code

执行之后会出现交互式选项,这里我强烈建议按下面的组合勾选:Next.js、TypeScript、Tailwind CSS、tRPC、Prisma、NextAuth。六个全选,一步到位。你可能会问,为什么连 NextAuth 一起选,后面登录功能不是可以自己写吗?没错,但 create-t3-app 会帮你把 NextAuth 的依赖配置、[...nextauth].ts路由、session 类型声明全部铺好,省掉至少四十分钟的配置时间。

初始化完成后,项目结构里核心目录如下:

  • src/server/api/routers/:所有 tRPC router 的定义位置
  • src/server/api/root.ts:汇总所有子 router 的入口
  • src/server/db.ts:PrismaClient 单例
  • src/app/api/trpc/[trpc]/route.ts:HTTP 入口
  • src/trpc/:客户端调用的 provider 封装

这里有个特别容易被新手忽略的点:src/server/db.ts里创建 PrismaClient 时,一定要在globalThis上挂单例,避免 Next.js 开发模式下热重载创建无数个连接。create-t3-app 已经帮你处理好了,但如果你以后自己从零写 Next 全栈,记得抄这句话:

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;

3.2 目录结构与代码组织方式

t3code 的目录组织完全遵守 T3 约定的分层方式,我在写的过程中体会到这种分层的价值:router 负责聚合查询逻辑,procedure 负责暴露给前端,schema 负责校验入参,页面组件只消费数据。改一个功能时你清楚地知道去哪一层改。

我把业务代码大概分成了三块:

  • 认证模块:放在src/server/api/routers/auth.ts,处理用户注册时初始化用户 profile
  • 题目模块:problem.ts,负责题目列表、题目详情、提交记录
  • 用户模块:user.ts,负责查询当前用户提交历史、通过/失败统计

页面方面用了 App Router 的布局嵌套:根布局里统一加载<TRPCReactProvider>和<SessionProvider>,中间再套一层导航栏。这里一个关键经验是:SessionProvider默认是客户端组件,所以必须把它放到一个"use client"的文件里再引入到根布局,否则会报错。这不是什么高深的问题,但第一次遇见的同学可能会卡很久。

3.3 环境变量配置清单

环境变量是工程初始化的最后一步,也是很多人忽略的一步。t3code 里实际用到的环境变量如下:

DATABASE_URL="file:./db.sqlite" NEXTAUTH_SECRET="your-secret" NEXTAUTH_URL="http://localhost:3000"

NEXTAUTH_SECRET运行openssl rand -base64 32生成。NEXTAUTH_URL本地开发就是 localhost,上线之后改成线上域名。注意 NextAuth 的NEXTAUTH_URL如果没设置,在某些 provider 流程下会直接报 "UntrustedHost" 错误,排查方式也很简单:确认环境变量和当前访问域名完全一致,http 和 https 都不能混。

4. 核心功能实现与实操细节

4.1 数据库模型设计的几个取舍

t3code 的核心数据模型围绕三个实体展开:用户(User)、题目(Problem)、提交记录(Submission)。设计时我特意约束了模型的边界,没有做成复杂的多态关联,因为练习项目越简单越不容易出问题。

User 模型在最开始是一张白纸,没有自定义字段。但我发现提交记录要区分"谁做的",于是直接在 Submission 里加了userId外键,通过 Prisma 的关系关联查用户信息。这里我踩过一个坑:如果 User 模型默认没有submissions反向字段,在前端想要一次查询出用户和其提交列表时就得多写一次查询,所以 schema 里务必定义好反向关系:

model User { id String @id @default(cuid()) name String? email String? @unique submissions Submission[] }

4.2 题目列表与详情的 tRPC 实现

题目列表的实现是 tRPC 里最简单的publicProcedure查询。之所以用 public 而不是 protected,是因为 t3code 允许未登录用户浏览题目,只有提交代码时才要求登录。这个业务约束映射到 API 层非常直接:

import { createTRPCRouter, publicProcedure, protectedProcedure } from "~/server/api/trpc"; import { db } from "~/server/db"; export const problemRouter = createTRPCRouter({ list: publicProcedure.query(async () => { const problems = await db.problem.findMany({ select: { id: true, title: true, difficulty: true }, orderBy: { createdAt: "desc" }, }); return problems; }), detail: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const problem = await db.problem.findUnique({ where: { id: input.id }, }); return problem; }), });

注意select只查了需要的字段,避免把 testCases 这种内部字段一股脑塞给前端。这个习惯在真实项目中特别重要:哪怕类型安全,也不代表你可以把敏感字段全量暴露。题目的测试用例如果提前泄露给前端,那练习平台的意义就没了。

前端调用的方式同样直接:

const { data: problems, isLoading } = api.problem.list.useQuery();

在这里,problems的类型不需要你声明,tRPC 的useQuery会自动从 router 定义里推导。这种体验用过一次就回不去了:你再也不用打开浏览器 DevTools 看 Response 结构对不对了,编译器已经帮你确认过了。

4.3 代码提交与判定流程

提交代码是 t3code 里最有挑战的一部分,因为要模拟一个简单的判题逻辑。设计不算复杂:前端把代码文本和题目 ID 发给后端,后端把代码写入一个临时目录,调用 Node 的child_process执行用户代码,传入预定义的测试用例,比对输出结果。

核心实现思路如下:

import { exec } from "child_process"; import { promisify } from "util"; import fs from "fs"; import path from "path"; const execAsync = promisify(exec); async function runCodeAgainstTestCases(code: string, testCases: { input: string; expected: string }[]) { const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "tmp-")); const filePath = path.join(tmpDir, "solution.js"); fs.writeFileSync(filePath, code); const results = []; for (const testCase of testCases) { try { const { stdout } = await execAsync(`node ${filePath}`, { input: testCase.input, timeout: 3000, }); results.push({ passed: stdout.trim() === testCase.expected.trim(), actual: stdout.trim(), }); } catch (error) { results.push({ passed: false, error: (error as Error).message }); } } fs.rmSync(tmpDir, { recursive: true, force: true }); return results; }

这个方案有很明显的玩具性质,比如它支持不了多文件项目、管不了内存泄漏、也不隔离系统调用,但作为一个练习项目的判题逻辑足够了。如果你想继续深挖,可以引入 Docker 做容器级隔离,这一步在优先级排序上并不着急。

提交记录落库时需要同时保证业务逻辑的正确性:一旦exec超时(比如用户写了死循环),要能捕获超时错误并标记为失败,而不是让整个请求挂死。我设置了 3 秒超时,实测对简单算法题是够用的,但如果你要支持复杂题目,这个值需要放到环境变量里动态配置。

4.4 登录与用户表单的联动

NextAuth 的接入在 t3code 里用的是 GitHub provider,这也是练习项目最省事的方案。你只需要在 GitHub OAuth Apps 里注册一个应用,拿到 Client ID 和 Client Secret,填到环境变量里即可。代码层面,authOptions里providers数组加一项:

providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), ],

写这里时我又想起一个老坑:GitHub OAuth 的回调地址必须精确配置成http://localhost:3000/api/auth/callback/github,多一个斜杠、少一个路径都登录不上,网上大量帖子在问这个问题,基本都在这里。另外,如果部署之后登录失败,先看 Vercel 日志里error=redirect_uri_mismatch这类关键字。

用户登录之后,t3code 会在首次访问提交页时自动创建一条用户记录。用protectedProcedure获取 session 中的user.id,然后 upsert 到 User 表。这个小功能让我体会到 protectedProcedure 的好用:它保证执行到这里时用户一定已经登录,session 字段一定可用,不用再自己写if (!session) throw new Error()这种防御代码。

5. 前端页面与交互细节

5.1 题目列表页的加载体验

t3code 的首页是题目列表,我用了 Tailwind 的卡片式布局,每张卡片显示标题、难度标记、状态徽章(是否已完成)。列表页加载时有三个状态要考虑:加载中、加载成功、加载失败。

加载中用一个简单的isLoading判断,显示骨架屏而不是转圈,视觉上现代很多。加载失败靠isError标志做一次错误提示,同时给一个重试按钮。这里我想特别提一下 tRPC 的useQuery返回值里还有个refetch,配合按钮触发重新请求,比刷新整页体验好得多。

const { data: problems, isLoading, isError, refetch } = api.problem.list.useQuery(); if (isLoading) return <ProblemListSkeleton />; if (isError) return <ErrorState onRetry={() => refetch()} />;

5.2 题目详情与代码编辑器的选型

详情页左边展示题目描述,右边放代码编辑器。编辑器我用的是 CodeMirror 6,没有选 Monaco(VS Code 同款),理由很简单:Monaco 体积太大,安装包几十 MB,而 CodeMirror 轻量可控,支持 JavaScript 高亮和基础补全,对练习场景完全够用。

CodeMirror 的核心安装:

npm install codemirror @codemirror/lang-javascript @uiw/react-codemirror

这里注意@uiw/react-codemirror是一个第三方封装组件,用起来比直接操作 CodeMirror 的 View 方便很多:

import CodeMirror from "@uiw/react-codemirror"; import { javascript } from "@codemirror/lang-javascript"; <CodeMirror value={code} height="400px" extensions={[javascript()]} onChange={(value) => setCode(value)} />

踩过的一个细节:onChange的触发频率非常高,每次按键都会触发 state 更新。如果你的页面在输入 100 行代码后有明显的卡顿,考虑用useDeferredValue或直接 debounce 一下。代码编辑器这种高频交互组件,性能优化比普通表单激进一点是合理的。

5.3 提交结果展示的交互逻辑

提交完成后,后端会返回每个测试用例的通过与否。前端展示上我用了一个非常直白的卡片列表:绿色的 PASS 红色 FAIL,附上期望输出和实际输出。这个设计虽然不花哨,但当测试用例数量多、用户代码出错时,能快速定位到错在哪一个输入下。

另外一个贴心的小交互是:提交按钮的状态。提交过程中按钮要变成 loading,禁用重复点击。同时把最近一次提交状态缓存在内存里,刷新页面后还能看到上一次提交结果,不至于让用户以为提交丢了。这个功能是用 React state 存了一个lastSubmission对象实现的,简单有效。

6. 部署上线与常见问题排查

6.1 部署到 Vercel 的完整流程

t3code 最终部署到 Vercel,整个流程比我想象中顺利。第一步是把代码推到 GitHub 仓库,然后在 Vercel 里创建新项目并关联仓库。Vercel 会自动检测到 Next.js 框架,安装依赖的命令也可以保持默认的npm install。

第二步是配置环境变量。这一步最容易被遗漏:是的,你本地.env里的变量不会自动同步到 Vercel。需要去 Project Settings 里的 Environment Variables 手动添加DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL和 GitHub OAuth 的四个关键变量。我在这一步骤上吃过亏:本地能跑通登录,部署后点 GitHub 登录直接报错,查了半天发现就是环境变量没配全。

第三步是数据库迁移。由于本地用的是 SQLite,部署后要换成 Postgres。我在 Neon 上新建了一个免费的 Postgres 实例,把连接串填到 Vercel 的DATABASE_URL里。在本地更新 provider 后执行生成迁移,迁移文件推送到代码里,Vercel 构建的时候用prisma migrate deploy把表结构落到生产库。整个过程我写进了package.json的postinstall脚本里,避免每次部署忘记:

{ "scripts": { "postinstall": "prisma generate" } }

注意prisma migrate deploy不能放在 build 阶段,它应该提前手动执行,最好是在 CI 里单独跑一次,确保迁移顺序可控。如果你测试部署后发现读不到数据库的表,排查一下 Vercel 构建日志里有没有migrate deploy的记录,多半是漏了。

6.2 常见报错速查表

这篇博客发布前,我把 t3code 开发过程中遇到的所有问题整理成了一张表,每个问题都附上了排查方向和解决手段。

报错信息原因分析解决方案
P1001: Can't reach database serverDATABASE_URL 配置错误或网络不通去 Neon 控制台复制正确连接串,检查是否用了?sslmode=require
NEXTAUTH_URL未匹配导致 500环境变量域名和访问域名不一致重新赋值 NEXTAUTH_URL 为当前域名的完整地址
`Type 'stringundefined' is not assignable to type 'string'`session 数据在服务端组件里未严格收窄
prisma migrate dev一直卡住数据库连接池阻塞重启本地npx prisma studio进程,或删掉node_modules/.prisma后重新 generate
Warning: Prop className did not match服务端渲染和客户端渲染的 Tailwind 类名不一致检查是否在组件里动态拼接了不稳定的类名,或在tailwind.config中启用了 CSS 变量干扰
hydration failed水合错误服务端和客户端初次渲染结果不一致找到差异来源,通常是随机数、时间戳,或浏览器环境检测,在 useEffect 里初始化数据

这里面最有代表性的是水合错误。我最初在题目标题上用了Math.random()来生成一个"今日推荐"标签,本地开发时一切正常,部署后发现控制台报水合错误。原因很简单:服务端渲染时Math.random()的值和一个数字,但客户端 hydration 时再执行组件,又生成一个不同的数字,两边不一致就直接水合失败。解决方案是把这个随机值放到useEffect里设置,或者彻底放弃随机方案,改用确定性的轮换逻辑。这个问题在 Next.js 应用中非常典型,值得记住。

6.3 性能优化与安全加固的小实践

t3code 作为一个练习项目,性能优化没有走火入魔,但有几个基本操作值得提一下。

第一个是列表页的分页。题目数量一多,一次性返回几十条带着内容的记录会拖慢加载,所以我给problem.list加了take参数,默认只返回 20 条,后续按滚动位置增量加载。用 tRPC 实现分页并不复杂,input 里定义cursor和limit,通过 Prisma 的cursor分页逻辑完成。

第二个是代码执行的安全隐患。虽然用了 3 秒超时和临时目录,但exec本质上是直接调系统命令,用户代码里写一句process.exit()也能搞崩执行环境。真要严肃做在线判题,必须考虑容器级隔离。不过 t3code 定位是"学习 T3 技术栈",过度追求安全反而会让学习曲线变陡,惊险体验留给后续版本就好。

第三个是 NextAuth 的 Session 过期策略。默认一个月太长了,练习平台会话保持一两天比较合理。在authOptions里配置session: { strategy: "database" }并把maxAge设成 86400 * 7,这样用户在 7 天内保持登录,超过后自动失效。代码量不大,但对用户体验影响却很直接。

7. 测试与质量保障

7.1 用 Vitest 做单元测试

t3code 到后期我才补上测试,现在回头看有点后悔:应该在核心判题逻辑写完后立刻写测试。判题模块是最典型的纯逻辑模块:输入代码、输出判定结果、不涉及网络和 UI,正好适合单测。

我用的是 Vitest 加@testing-library/react,对runCodeAgainstTestCases做了三组用例:正确代码返回全 PASS、错误代码返回部分 FAIL、执行超时返回错误标记。把这些用例写完后,你往后重构判题逻辑时就有了一张安全网,改坏了任何一条测试都会立刻红起来提醒你。测试代码绑进 CI 的npm test脚本,每次 push 自动跑。

7.2 用 ESLint 与 Prettier 统一代码风格

如果你是单人开发项目,ESLint 和 Prettier 可能感觉没用;但一旦项目要交接给别人,或者你自己三个月后回来看代码,统一的格式能省下大量沟通成本。create-t3-app 默认配置好了 ESLint,Prettier 需要额外安装:

npm install -D prettier eslint-config-prettier

随后在.eslintrc.cjs里加"prettier"到 extends 数组的末尾,让 prettier 接管格式化相关的规则。为了偷懒,我把format和lint都加到了 npm script 里,并在提交前手动跑一遍,避免把格式问题带上线。

8. 个人经验与扩展思考

8.1 开发过程中最大的三个教训

抛开具体的报错和修复,我从 t3code 整个开发过程中提炼出三个教训,它们对其他全栈项目同样适用。

第一个教训是"不要过早引入复杂抽象"。最开始我纠结于判题系统要不要支持多语言,做了个 plugin 机制的抽象层,写了半天把 JavaScript 一条路径跑通后,发现其他语言根本用不上。后来我把抽象层全部删掉,直接针对 JavaScript 写死逻辑,代码短了一半,可读性翻倍。功能没人用的时候,抽象就是负债。

第二个教训是"类型安全不等于不需要设计契约"。tRPC 确实让类型全链路打通了,但这不代表你可以随心所欲地改 schema。有好几次我在 Prisma 模型里加字段,顺手在 router 里改了返回结构,结果忘了前端某些页面在用,编译期没报错,运行时却拿不到字段导致页面白屏。tRPC 的类型检查只保证类型一致,不保证业务契约不变,字段缺失需要你保持自觉。

第三个教训是"写代码前先在纸上画清楚数据流"。t3code 的提交流程如果在实现前画一张简单的状态图,后面完全不会出现"前端不知道提交状态怎么存、判题结果怎么回"这种问题。T3 技术栈可以帮你省去协议的沟通成本,但业务逻辑的推演谁也替不了。

8.2 t3code 还能怎么扩展

如果你看完这篇博客想动手做一个类似的项目,我建议你在 t3code 的基础上按以下方向扩展,每个方向都有实际价值。

第一个方向是支持多语言判题。在判题模块里引入 Docker,把代码跑在一个可控的容器里,前端通过语言参数选择 JavaScript、Python 或 Go,后端分别用不同的镜像执行。这个扩展能让你真正理解"判题系统"的工程复杂度,但需要你同时掌握 Docker 的基本使用和资源隔离的概念。

第二个方向是排行榜和用户等级体系。t3code 目前只记录提交记录,你可以在此基础上统计每个用户的通过率、解题数、日均活跃,做成一个简单的积分系统。这类功能的实现会迫使你设计更复杂的查询逻辑,例如 group by、关联统计,对掌握 Prisma 的高级查询非常有帮助。

第三个方向是完整的比赛模式。定时开放题目、限制提交次数、隐藏测试用例、赛后自动评分,这些功能做起来很费劲但每一个都值得做。它会把你的 t3code 从一个个人练习工具推向一个半成熟的在线教育平台。

倒回来说,t3code 给了我很扎实的 T3 全栈手感。如果你属于那种"看了很多教程但还是不会合起来用"的人,拿这个项目当起点,按自己的节奏把每个模块走一遍,效果会比刷一百个视频都好。我自己写完这个项目之后,再去接触其他全栈框架,明显能感觉到类型安全和服务端渲染这套思维的迁移价值,这也是我觉得技术栈选择比代码量更重要的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询