做一个叫 t3code 的迷你全栈项目是我最近几个月比较得意的一件事。一开始只是一时冲动想给手头几个零散的工具做个统一入口结果做着做着就发现这套思路特别适合那些“不想把摊子铺得太大但又需要落地产物”的小体量开发者。这篇文章就把 t3code 从设计到落地的全过程拆开讲清楚包括我踩过的坑、抠过的细节、以及几个你大概率也会撞上的问题。1. t3code 这个名字背后的设计思路1.1 项目到底想解决什么t3code 听上去像是一个代号其实拆开看就是“三层代码”。我当时手上零零散散积了一堆脚本和小工具有处理数据的有做文件整理的还有几个给团队内部用的查询接口。它们散落各处有的跑在别人机器上有的只是个单文件 Python 脚本想找个数据还得问同事。t3code 就是一键把这些东西收拢在一起的项目——前端负责给入口后端负责统一调度中间用一层清晰的接口把彼此封起来。所以 t3code 的定位不是“大而全的平台”而是“小而美的工具箱”。如果你也是一个经常被临时需求打断的开发者手里攒了很多一次性代码或者想给团队搭一个轻量级的内部工具集合这套思路可以直接借鉴。它不强求微服务、不强求 K8s甚至不强求你写多少单元测试核心就是把杂乱的代码变成有序的、可维护的、带界面的产品。1.2 为 t3code 选型时的三个原则做 t3code 时我给自己定了三条硬指标也算是对项目“不过度设计”的约束。第一依赖最少。能用标准库解决的绝不引第三方包。中间检查了几轮发现有些地方为了省一点点代码量挂了很大的依赖实际上完全没必要把依赖减下去之后打包体积和部署成本都明显下降。第二要能一眼看懂。团队里我不是唯一的接手人任何新成员打开项目在五分钟内能定位到功能对应的代码文件而不是在一个巨大的目录树里迷路。结构上保持前后端分离的经典布局但内部按“功能”而非“类型”划分模块。第三必须能快速部署。t3code 的使用场景包括临时演示、内部自用偶尔还要搬到客户的服务器上所以部署流程被压到两条命令以内。任何需要繁琐配置的东西一律砍掉哪怕牺牲一点花哨能力。这三条原则直接决定了 t3code 的全套技术栈。往后的所有决策我都反复拿它们来检验这东西是不是必须的换了别人能不能马上看懂换一台 Linux 机器能不能跑起来这其实是一个很好的自查方法建议你上手任何项目都先写三条自己的原则。2. 核心细节拆解三层架构每层该管什么2.1 为什么一定要分三层有人说“一个文件把逻辑写完不香吗”对于一次性脚本确实没问题但 t3code 的定位是工具集合很快功能就会从三五个涨到十几个如果不分层最后改一个功能就要全局搜索半天。我拆的三层是这么分工的。前端层展示与交互负责渲染页面、收集输入、把请求发给后端。所有 HTML 模板、静态资源都在这一层。接口层逻辑调度这一层是最重要的一层负责接收前端的请求做参数校验、身份判断然后调用具体功能模块。它不关心业务是怎么实现的只关心请求是否合法、结果怎么返回。服务层具体能力真正干活的代码比如数据处理、文件转换、第三方 API 对接全部装在这一层。每个服务模块保持独立互不调用。这种分层最直接的好处是你想给工具集合增加一个功能基本就是加一个服务模块再在接口层加一个路由前端配一个入口页面。改动能控制在核心位置不会动不动就牵连原有功能。2.2 数据存储方案的取舍t3code 最早是直接用 JSON 文件存储数据毕竟刚开始数据量小读写都方便。但功能多了以后并发读写就出现了问题两个请求同时写文件会互相覆盖。后来我把存储切换成了 SQLite理由很直接它不需要单独安装数据库服务就是一个文件完美贴合“快速部署”的约束。用 SQLite 还有一个隐藏优势——备份特别方便直接复制 .db 文件就行。我在接口层做了统一的数据库访问封装这样以后真到了数据量爆炸、必须换 MySQL 或 PostgreSQL 的那一天只需要改封装层内部实现所有上层代码都不用动。2.3 接口设计不想踩坑就靠这几点接口层是最容易出幺蛾子的地方我总结了四个约束t3code 全程遵守下来确实省了很多事。永远有统一返回格式。不管是成功还是失败返回 JSON 永远是{ code: 0, message: ok, data: ... }这样。前端只需要解析一次会话结束还是代码错误一清二楚。入参校验不能信任前端。所有参数在后端再做一遍完整性校验尤其是从 URL 拿到的参数。接口超时全部设置上限。t3code 里凡是可能调用外部服务的操作我都设置了超时时间宁可返回一个“超时请重试”的提示也不让前端一直傻等。错误不能只返一个“出错了”。日志里要带上下文比如出错的是哪个接口、哪个参数、哪一行触发。另外接口层单独开日志文件不跟业务日志混在一起排查问题时定位效率高很多。注意接口层不仅是“转发请求”它是你整个程序的安全边界。前面的校验做得多认真后面的服务层就能多省心。这是我在 t3code 项目里感受最深的一条。3. 实操过程全记录从环境准备到功能完整落地3.1 环境准备这一步我帮你踩过坑了t3code 的运行时环境很简单一个比较新的 Node.js 版本我用的是 20.x一个包管理器 npm再加上一个编辑器Windows、macOS、Linux 都可以。我自己主要在 Ubuntu 服务器和 macOS 上跑Windows 上用 WSL 跑过一次也没问题。你可能会问怎么没提数据库这正是故意为之——SQLite 是嵌入式数据库不需要提前安装服务Node.js 的包里面已经带了驱动。这个选择在后续部署时省了非常多的事你不必在一台新机器上先装 MySQL 再初始化密码才能启动项目。3.2 初始化项目目录结构t3code 的目录结构是这样设计的核心是“看起来像一个大项目但每个子目录里没有任何杂物”。t3code/ ├── frontend/ # 前端工程Vite React │ ├── index.html │ ├── src/ │ │ ├── App.jsx │ │ ├── api/ # 封装所有请求 │ │ └── pages/ ├── server/ # 后端工程Express │ ├── index.js # 启动入口 │ ├── router/ # 接口层路由 │ ├── services/ # 服务层业务 │ ├── db/ # SQLite 封装 │ └── utils/ └── start.sh # 一键启动脚本实际我是用命令创建这个结构的mkdir t3code mkdir t3code/{frontend,server,server/router,server/services,server/db}。用{a,b,c}这是一种批量创建目录的小技巧一次建一整组非常方便。3.3 后端服务从零写三步跑通核心接口t3code 后端我用的是 Express因为它生态成熟且没有太多魔法。写一个最简启动入口代码如下。// server/index.js const express require(express); const app express(); app.use(express.json()); app.get(/api/health, (req, res) { res.json({ code: 0, message: ok, data: { time: Date.now() } }); }); app.listen(3210, () { console.log(t3code server running at http://localhost:3210); });运行node server/index.js浏览器访问/api/health拿到 JSON说明后端活了。要注意app.use(express.json())是必须的没有这一行的话接口接收前端传来的 JSON body 时拿到的会是 undefined这是新手很容易踩的坑。接下来把路由拆到独立文件里每个模块的路由自己注册。一个功能模块对应一个 router 文件和一个 service 文件。比如我做一个“统计文本字数”的工具模块就是 demoRouter 和 demoService逻辑十分清晰。// server/router/demo.js const express require(express); const router express.Router(); function validateParams(req, res, next) { const { text } req.body; if (typeof text ! string || text.length 1000) { res.status(400).json({ code: 1, message: 参数非法, data: null }); return; } req.validatedText text; next(); } router.post(/word-count, validateParams, (req, res) { res.json({ code: 0, message: ok, data: { count: req.validatedText.length } }); }); module.exports router;看到这个写法你可能会好奇校验逻辑为什么要放在一个独立的validateParams函数里而不是直接在路由回调里写。原因是这样可以把“校验参数”和“处理业务”两个关注点分开将来万一校验规则复杂起来比如要检查是不是敏感词命中、是否超长、是否空字符串这些规则都集中在独立的中间件里改动就非常安全。3.4 前端页面用 Vite 搭起来前端我是用 Vite 加的 React选它的原因就是“快”。npm create vitelatest frontend -- --template react一条命令就完成初始化。然后把请求封装到统一的 api 文件里这样页面里不直接写 fetch 而是统一走封装好的请求方法。// frontend/src/api/index.js const BASE_URL /api; export async function request(path, options {}) { const resp await fetch(${BASE_URL}${path}, { headers: { Content-Type: application/json }, ...options, body: options.body ? JSON.stringify(options.body) : undefined, }); const result await resp.json(); if (result.code ! 0) { throw new Error(result.message || 请求失败); } return result.data; }这样做还有个好处是以后如果想给所有请求自动加 token、加时间戳或者做统一埋点就只需要改这一个文件。前端页面里调用就非常清爽了// frontend/src/pages/Home.jsx import { request } from ../api; async function handleCount() { const data await request(/demo/word-count, { method: POST, body: { text: hello t3code }, }); setResult(data.count); }这里有个细节我在开发时用了 Vite 的代理配置把/api转发到后端的3210端口这样前端请求就完全不需要写完整的 URL最终部署时不管是反代还是直接静态托管改动都非常小。具体配置见下。// frontend/vite.config.js export default { server: { proxy: { /api: http://localhost:3210, }, }, };3.5 数据模块落地文件型数据的读写防坑在 t3code 里有几个工具模块需要保存历史记录。最开始我用fs.writeFile每次全覆盖写入 JSON 文件很快发现两个请求同时写会丢数据。后来我换成了 sqlite3 的better-sqlite3库同步写法简单不需要处理回调。// server/db/index.js const Database require(better-sqlite3); const path require(path); const db new Database(path.join(__dirname, t3code.db)); db.exec( CREATE TABLE IF NOT EXISTS task_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ); function insertTask(name) { const stmt db.prepare(INSERT INTO task_log (task_name) VALUES (?)); return stmt.run(name); } module.exports { insertTask };这里解释一下为什么用better-sqlite3而不是其他数据库库这个库是同步 API 的代码写起来像写普通函数一样不用一层层回调或 async/await。对于一个内部工具项目来说读写量远没到“需要异步来提速”的程度同步代码的可读性和出错率比回调风格友好太多。真要未来数据量涨到几十万行SQLite 只要开启 WAL 模式也照样顶得住日常使用。3.6 一键启动脚本从裸机到运行只敲一条命令t3code 部署时我希望“一条命令起全部”所以在根目录放了一个start.sh脚本。它做的事很简单先检查依赖是否安装如果没装就自动安装然后启动后端再用静默方式托管前端构建产物。#!/bin/bash cd $(dirname $0) echo [t3code] checking dependencies... if [ ! -d server/node_modules ]; then echo [t3code] installing server dependencies... cd server npm install cd .. fi if [ ! -d frontend/node_modules ]; then echo [t3code] installing frontend dependencies... cd frontend npm install cd .. fi echo [t3code] building frontend... cd frontend npm run build cd .. echo [t3code] starting server... cd server node index.js因为后端在构建好的前端目录用一句app.use(express.static(../frontend/dist))就能把静态文件一起托管掉部署步骤真正压缩到“安装 Node、克隆代码、运行 start.sh、访问页面”。4. 常见问题与排查技巧实录4.1 问题速查表我实测遇到的五个典型故障做 t3code 过程中遇到不少莫名其妙的问题这里挑几个有代表性的整理成速查表你遇到相似问题时可以直接对照。症状原因解决方法前端请求接口返回 404Vite 代理没有匹配到/api前缀检查vite.config.js的 proxy 配置提交数据后接口返回 400后端没有加express.json()中间件在 Express 入口处添加app.use(express.json())数据写入后重启丢失数据库文件路径写到了临时目录检查 db 路径是否用了process.cwd()而不是__dirname前端页面打不开后端没有托管前端构建产物用express.static指向frontend/dist端口被占用上一个 t3code 实例还在运行lsof -i:3210查进程并杀掉我最想重点提醒的是第二条这个错误我第一次做时毫无头绪前端明明把 JSON 传过去了后端怎么都拿不到 req.body 的内容。折腾了十几分钟才发现就是少了express.json()。这种问题就是典型的“看似玄学实则是少了一行代码”。4.2 三个让我拍大腿的避坑经验第一点是SQLite 数据库路径写死了 process.cwd()。当时我从项目根目录启动没事但从别的目录启动后数据库文件被创建到了执行目录下导致重启后读不到数据。后来我统一用path.join(__dirname, t3code.db)解决。第二点是热更新和实际部署的行为差异。开发时 Vite 代理把接口转到后端部署后静态页面和后端在同一端口服务但前端页面里如果写死了http://localhost:3210部署到服务器后就会失效。正确做法是像上面那样只写相对路径/api域名变化都不用改代码。第三点是路由文件一多就容易文件顶部相互引用。我写的时候开门见山地要求“功能之间不能互相调用”后面为清晰就定了这个规则。如果你允许模块互相 import很快方法的归属就混沌了。一旦发现一个 service 文件里 import 了另一个 service 文件就要停下来重新思考模块划分。4.3 从 t3code 里抽象出的通用增量迭代法项目跑通之后最让我惊讶的不是功能本身而是增量迭代的顺畅程度。其实 t3code 的维护模式是可以抽出来的为每一个新需求建一个需求描述条目然后“加服务接口、加路由、加前端入口”三步走一个功能从立项到上线通常也就一小时。t3code 未来如果能持续更新我打算把服务层部分从“内置模块”变成“插件模式”写一个简单的 manifest 声明加载指定目录下的服务文件。这次的经验证明了一件我一直坚信的事一套清晰的分层架构 克制但完整的技术选型远比堆砌一堆高深框架更能让你走得远。我个人实际做下来最明显的感受是t3code 的价值不在代码量而在“约束”——先定清楚边界再填内容项目就会一直往收敛方向走。如果你手里也有几段一直在角落吃灰的脚本不妨试着用这套思路给他们安个家你会看到一个还不错的“工具小产品”是怎么从零长出来的。