十分钟搭出一个能跑起来的测试环境,我头一回听到这个说法时嘴角是往下撇的。凭经验,光是等依赖装完就够喝半杯咖啡,更别提页面点下去、接口返回一堆 502 的尴尬时刻。但真把需求拆细、把提示词写准,让生成的前端页面调通后端探测接口,确实能压在十分钟出头——前提是你清楚这十分钟到底花在哪,以及哪几件事千万不能塞进这十分钟里。这篇就聊我最近做的一个网络工具原型:一个本地跑的网络连通性诊断面板,输入域名,依次查 DNS 解析、TCP 端口连通性、HTTP 响应状态和耗时,用来快速判断"某个地址到底卡在哪一层"。整套东西从零到能点,靠的是代码生成平台先把骨架搭出来,人只负责收口。适合两类人看:一是想快速验证一个工具类想法、不想在环境上耗一整天的人;二是已经会用平台生成代码、但每次生成完都卡在联调那一步的人。
1. 把"网络工具原型"翻译成一份能交给平台的需求清单
1.1 敲一句"帮我做个网络工具",生成结果注定是废的
我见过太多人生成失败,问题不在平台,在输入。你丢过去一句"帮我做一个网络工具",平台只能靠猜:是测速的?是抓包的?是批量检测的?猜错的概率接近百分之百,于是它给你一个能跑的壳子,里面塞满你想删的按钮和看不懂的注释,你还得花二十分钟把它拆掉。这就是典型的"生成十分钟、清理半小时"。
代码生成平台本质上是把"需求描述"当作上下文,上下文越稀薄,它越倾向于走最保守的通用模板路线。所以要做的第一件事,不是打开平台,而是拿张纸把原型说清楚:输入是什么、点了之后依次发生什么、每层输出哪些字段、失败的时候界面显示什么。这四件事写明白,提示词的信息密度就够了。
我给自己这个原型的定义是这样的:单页应用,顶部一个输入框接收域名或 IP,下面三张结果卡片分别对应 DNS、TCP、HTTP,每张卡片显示"是否成功、耗时毫秒、失败原因码",右侧一条时间线把三段耗时按比例画出来。不需要登录、不需要历史记录、不需要数据库。把"不需要"的部分也明说出来,比只列功能更有效,因为平台最爱自作主张加的恰恰是数据库和登录。
1.2 我用的四段式提示词:边界、栈、契约、样例
提示词我没搞什么花活,就是四个固定段落,顺序不能乱。第一段写功能边界,第二段钉死技术栈,第三段把接口契约用 JSON 写死,第四段给一个期望输出的样例片段。之所以强推"契约先行",是因为生成类工具在跨前后端的地方最容易胡编字段名——后端返回resolveMs,前端读dnsTime,跑起来就是一屏undefined,你还得肉眼比对半天。
我实际用的输入大概长这样:
【功能边界】 做一个本地运行的单页网络诊断工具原型,只做三件事: 1. DNS 解析:输入域名,返回解析到的 IP 列表和耗时 2. TCP 探测:指定端口,返回是否可连接和握手耗时 3. HTTP 探测:返回状态码、响应头中的 server 字段、首字节时间 不包含:用户系统、历史记录、数据库、任何第三方云服务依赖。 所有探测在后端完成,前端只负责展示。 【技术栈】 前端 Vite + 原生 JavaScript(不要 React,不要 TypeScript) 后端 Node.js 20 + Express,仅使用内置模块 node:dns、node:net 不要在原型阶段引入 axios、lodash 这类额外依赖 【接口契约】 GET /api/diagnose?host=<域名>&port=<端口> 成功响应 200: { "host": "example.com", "dns": { "ok": true, "costMs": 23, "addrs": ["93.184.216.34"] }, "tcp": { "ok": false, "costMs": 3000, "reason": "TIMEOUT" }, "http": { "ok": true, "costMs": 118, "status": 200, "server": "nginx" } } 参数非法时返回 400,body 为 { "error": "INVALID_HOST" } 【输出要求】 先输出目录结构,再逐个文件输出完整内容, 每个文件第一行注释写明该文件的作用。这套写法的关键在第三段。契约一旦固定,平台生成的代码前后端就对得上了,省下的返工时间远远超过写这段 JSON 的时间。我自己的体会是:写提示词的那三分钟,是在替后面调试的那二十分钟买单。
1.3 生成结果回来,第一轮只看三个地方
生成的代码我不会立刻npm run dev,先通读三个点。第一看接口路由有没有写死在文件里,方便后面挪位置;第二看错误分支是不是只有try/catch却没有任何兜底字段,很多生成结果在超时时直接抛异常,前端拿不到结构化错误,只会显示"请求失败",这在诊断类工具里是致命的,因为"失败原因"本身就是产品功能;第三看它有没有偷偷引入你没提过的依赖。
这里补一个反直觉的经验:生成的代码越"完整",越值得警惕。有一次平台给我加了缓存层、加了重试、还加了两个我没要的健康检查接口,看起来专业,实际上每个额外模块都多一个出错点,而原型阶段你最不需要的就是出错点。宁可要一个丑但只有八十行、你能完整读完的后端文件。
另外提一句,域名和 IP 的输入校验一定要自己过一遍。生成结果里经常是if (!host)就算校验完了,实际得限制字符集,把空格、斜杠、中文冒号这类输入挡在入口,否则后面dns.lookup会抛一个你没法映射成用户能看懂原因的异常。
2. 十分钟里,真正省不掉的几个环节
2.1 先做时间账:十分钟到底被谁吃掉
"十分钟"这个数字容易让人误以为全程无摩擦,实际拆开来看,各段耗时差别很大。下面这张表是我连续做了几次之后记录的区间,能比较真实地反映预期:
| 环节 | 乐观估计 | 实测区间 | 主要变量 |
|---|---|---|---|
| 写提示词 + 生成骨架 | 40 秒 | 1 至 2 分钟 | 契约写得细不细 |
| 安装依赖 | 3 分钟 | 40 秒至 3 分钟 | 本机缓存、镜像配置 |
| 起后端 + 前端 | 20 秒 | 30 秒至 5 分钟 | 端口是否被占 |
| 前端调通第一条链路 | 2 分钟 | 2 至 5 分钟 | 跨域、字段是否对齐 |
| 补错误分支展示 | 1 分钟 | 1 至 3 分钟 | 生成代码的兜底质量 |
结论很清楚:真正不可压缩的是"装依赖"和"联调"两段,其余都是可控的。所以我的做法是在生成代码的同时并行开一个终端装依赖,不要串行等着。这一步听起来小,但能省下一分钟以上,而一分钟在十分钟的预算里占了一成。
还有一个隐性成本容易被忽略:首次运行时本机没有缓存,Node 的包管理会去拉元数据,这一下比后续安装慢得多,慢到让人怀疑是不是卡死了。如果你当天要试好几个原型,先把缓存暖起来,后面每个原型的启动都能快一大截。
2.2 版本对齐比装包更费神
我踩过最冤枉的一次坑,是本地默认运行时版本太低,代码用到了较新的内置模块,npm run dev直接语法报错,报错信息还指向一个跟真实原因毫无关系的行号。这类问题排查起来特别费时间,所以现在我的习惯是在项目根目录放两个小文件把版本钉死。
第一个是.nvmrc,写一行版本号:
20.11.0第二个是在package.json里补engines字段,加一句提示:
{ "name": "netkit-probe", "engines": { "node": ">=20.0.0" }, "scripts": { "dev": "concurrently -k -n api,web -c blue,green \"node server/index.js\" \"vite\"" } }engines本身不会强制拦截,但它能让你在装包时收到一条警告,比事后对着莫名其妙的报错猜要强得多。而dev脚本里用concurrently把前后端一起拉起来,也是有讲究的:-k参数的意思是任意一个进程退出就杀掉其他进程,没有这个参数,你Ctrl+C之后后端经常还挂在后台占着端口,下一次启动就报"端口已被使用",而这个错误信息在终端滚屏里特别容易被忽略,很多人会误以为是代码问题,白白排查十分钟。
顺带说一句-n和-c的作用:给两个进程打上不同颜色和名字的前缀,日志混在一起时能一眼分清是哪边输出的。原型阶段日志就是唯一的观测手段,这点排版上的投入回报率非常高。
2.3 一条命令起来的开发服务器
后端我固定监听一个不常用的端口,比如 8787,避开 3000、8080 这些被各种工具默认占用的位置。启动文件保持极简:
// server/index.js - 后端入口,只负责挂载路由和启动监听 import express from 'express' import diagnose from './routes/diagnose.js' const app = express() app.use(diagnose) const PORT = process.env.PORT || 8787 app.listen(PORT, '127.0.0.1', () => { console.log(`[api] listening on http://127.0.0.1:${PORT}`) })注意监听地址我写的是127.0.0.1而不是省略。省略时默认会监听所有网卡,在某些系统上会额外触发一层权限提示或者被安全软件拦一下,原型阶段完全没必要暴露给局域网,锁死在本地回环更省心,也更安全。这个细节生成代码通常不会替你做,但改起来只要两个词。
前端那边用 Vite 默认配置就行,端口冲突时它会自动往上递增,这点比手写构建工具友好很多。唯一需要自己补的是代理配置,这个放到第 4 章讲,因为它是联调阶段第一个会撞上的墙。
3. 让页面真的调到接口:三条探测链路怎么落地
3.1 DNS 解析探测:把"打不开"拆出第一层
诊断类工具的价值不在于告诉你"能通",而在于告诉你是哪一层不通。DNS 是第一层,也是最容易出问题的一层。实现上我用内置的dns.lookup而不是dns.resolve,原因是lookup走的是系统解析器,行为跟你日常访问时一致;resolve走的是直接查询,会绕过本机配置,结论可能和你实际体验对不上。原型阶段要的是"贴近真实体感",不是"标准答案"。
超时必须自己兜底。内置 DNS 查询没有提供超时参数,所以得用Promise.race包一层:
// server/probe/dns.js - DNS 探测,带 3 秒硬超时 import dns from 'node:dns/promises' const withTimeout = (promise, ms, label) => Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error(label), { code: label })), ms) ) ]) export async function probeDns(host, timeoutMs = 3000) { const t0 = performance.now() try { const addrs = await withTimeout( dns.lookup(host, { all: true }), timeoutMs, 'DNS_TIMEOUT' ) return { ok: true, costMs: Math.round(performance.now() - t0), addrs: addrs.map(a => `${a.address}/${a.family}`) } } catch (err) { return { ok: false, costMs: Math.round(performance.now() - t0), reason: err.code || 'DNS_FAIL' } } }这里有两个经验点。第一,把family一起带上很值钱,因为很多时候 IPv6 解析成功了但 IPv4 没有,或者反过来,用户看到的"时好时坏"往往就是这个原因,界面上直接标出来能省一大段解释。第二,超时时间不要设太短,三秒是个比较稳的平衡点,设成五百毫秒会因为公共解析服务的抖动产生大量假阴性,误导判断比不判断更糟。
另外注意performance.now()返回的是浮点数,输出给前端之前记得取整。这种小地方不处理,界面上就会出现23.000000000000004这种数字,看着不专业,虽然不影响功能,但这种细节在原型演示时格外扎眼。
3.2 TCP 端口探测:超时一定要自己兜住
TCP 层是最容易写出"挂着不返回"的一段。用node:net建连,必须自己处理三种结局:连上了、超时了、报错了。三者的语义完全不同,而生成代码经常只处理第一种。
// server/probe/tcp.js - TCP 端口探测,三态收敛 import net from 'node:net' export function probeTcp(host, port, timeoutMs = 3000) { return new Promise(resolve => { const t0 = performance.now() const socket = new net.Socket() let settled = false const finish = (ok, reason) => { if (settled) return settled = true socket.destroy() resolve({ ok, port, costMs: Math.round(performance.now() - t0), reason }) } socket.setTimeout(timeoutMs) socket.once('connect', () => finish(true, 'CONNECTED')) socket.once('timeout', () => finish(false, 'TIMEOUT')) socket.once('error', err => finish(false, err.code || 'CONN_ERROR')) socket.connect(port, host) }) }settled这个标志位不是多余的。timeout事件触发之后,底层连接可能还会补一个error事件上来,没有这个标志位,resolve会被调用两次,第二次静默失败,但日志里会看到重复记录,排查时很容易被带偏。这种细节属于"跑一次看不出来、跑一百次偶尔出错"的类型,很难靠肉眼测试发现,所以我习惯在写这类异步探测时先把状态机想清楚。
还有一个实践建议:端口探测别用localhost做测试目标,它在某些系统上会先尝试 IPv6 再回退 IPv4,导致每次耗时差异很大,看起来像是不稳定。测试时统一用127.0.0.1,结论才干净。
3.3 HTTP 层探测:HEAD 请求、TTFB 和重定向
HTTP 层我用 HEAD 请求而不是 GET,因为原型阶段只关心状态码和响应头,不需要下载正文,HEAD 能显著缩短耗时,也能避免拉到几兆文件把界面卡住。但 HEAD 有个坑:部分服务端不支持 HEAD,会返回 405 或者干脆异常断开,这时候需要降级成 GET 并立刻中断读取。
// server/probe/http.js - HTTP 探测,HEAD 优先,失败降级 GET export async function probeHttp(host, port = 443) { const url = `https://${host}/` const attempts = [ { method: 'HEAD', redirect: 'manual' }, { method: 'GET', redirect: 'manual' } ] for (const opt of attempts) { const t0 = performance.now() try { const res = await fetch(url, { ...opt, signal: AbortSignal.timeout(5000), headers: { 'user-agent': 'netkit-probe/0.1' } }) return { ok: res.status < 400, costMs: Math.round(performance.now() - t0), status: res.status, server: res.headers.get('server') || 'unknown' } } catch (err) { if (opt.method === 'GET') { return { ok: false, costMs: Math.round(performance.now() - t0), reason: err.name === 'TimeoutError' ? 'HTTP_TIMEOUT' : (err.cause?.code || 'HTTP_FAIL') } } } } }redirect: 'manual'这个设置很关键。默认会自动跟随重定向,你拿到的状态码是最终页面的状态码,反而看不出原始响应是不是 301,而诊断场景下"到底跳没跳"恰恰是有价值的信息。同理,err.cause?.code这层取值也别省,fetch抛出的异常外面那层信息很模糊,真正的原因码藏在cause里,探测类工具如果只报一个笼统的失败,等于白做。
首字节时间这个指标我没单独抽,原型阶段用总耗时近似就够了,真要精确测量需要用底层 HTTP 模块手动监听response事件,投入产出比在验证阶段不划算。先跑通,再精修,这是我做原型的固定原则。
4. 本地跑通之后必然撞上的几个坑
4.1 跨域与代理:Vite 的 proxy 到底改了谁
前端在 5173 端口,后端在 8787,直接fetch('/api/diagnose')请求会打到前端自己的开发服务器上,返回一个 404 或者首页 HTML,控制台里报的是 JSON 解析错误,看起来完全不像跨域问题,这是最容易走偏的一次排查。正确做法是在 Vite 配置里把/api前缀转发出去:
// vite.config.js - 开发期把 /api 转发到本地后端 import { defineConfig } from 'vite' export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://127.0.0.1:8787', changeOrigin: true } } } })changeOrigin的作用是改写请求头里的 Host,不改的话某些后端框架会因为 Host 不匹配做额外校验。这里要理解一件事:代理解决的是开发期的路径对齐问题,不是安全边界。它只在你本地开发服务器进程里生效,构建之后就没这层了。原型阶段用它完全没问题,但别把它当成"我的接口就是这么设计的"来理解。
排查这类问题的顺序我固定为三步:先在浏览器网络面板看请求 URL 落在哪个端口;再看响应内容是不是一段 HTML;最后才怀疑跨域。顺序颠倒会很浪费时间,因为跨域报错信息明确、容易识别,而代理没配对时报错信息往往非常含糊。
4.2 端口占用和僵尸进程
端口占用是原型阶段最高频的阻塞点,而且它的报错信息在不同系统上差异极大。我一般先查再杀:
# macOS / Linux 查占用 lsof -i :8787 # 杀掉指定进程 kill -9 <PID># Windows 查占用 netstat -ano | findstr :8787 # 按 PID 结束 taskkill /PID <PID> /F但我更推荐从根上避免:把上一节提到的concurrently -k用上,Ctrl+C时会把子进程一起带走。我吃过一次亏,是强行关掉终端窗口导致后端进程变成孤儿,之后两天里每次启动都报端口占用,而我一直以为是别的项目在跑,直到用lsof才看到那个进程已经挂了两天。
顺带说个细节:lsof -i :8787输出的 PID 有时候是父进程,杀掉之后子进程还在,得再查一次。所以杀完立刻重新查一遍确认端口空了,这个动作只要两秒,能避免"我明明杀了怎么还占着"的困惑。
4.3 依赖漂移与锁文件
生成代码里通常只有一个package.json,没有锁文件。这在单人单机时不明显,一旦你换机器或者隔几天再装,装到的可能是次版本更新过的包,行为出现细微差异。原型阶段最省事的做法是把package-lock.json提交进去,之后统一用npm ci安装。
npm ci和npm install的区别值得记牢,我做了个对照:
| 对比项 | npm install | npm ci |
|---|---|---|
| 依据 | package.json,可能更新锁文件 | 严格依据锁文件 |
| 锁文件不存在时 | 正常创建 | 直接报错退出 |
node_modules处理 | 增量更新 | 先删干净再装 |
| 适合场景 | 首次搭建、加新包 | 复现环境、CI、稳定启动 |
原型验证阶段我一般第一次用npm install,之后只要代码没加新依赖就一律npm ci。哪怕同一个项目里只差一个次版本号,探测类工具的结论也可能变,因为底层网络实现的行为会跟着运行时和库一起变。
4.4 生成代码里"看着对却跑不通"的三类写法
通读生成结果时,有三类写法我见到就必改。第一种是res.json里字段名和契约不一致,通常出现在后端改了实现但忘了对齐文档的地方,肉眼比对一眼就能抓出来,忽略了就要在浏览器里对着undefined发呆。第二种是把同步异常和异步异常混着处理,try/catch包住了await却漏了回调里的error事件,表现为偶尔整个接口不返回。第三种是路径大小写,尤其在跨系统时,某个import里写的是./probe/Dns.js而磁盘上是dns.js,本地恰好能跑,换台机器立刻炸。
这三类问题的共同点是本地首次运行不一定暴露,所以我现在的做法是生成之后先不点界面,先把所有import路径扫一遍,再把契约 JSON 和实现里的返回对象逐字段对一遍。这两步各花一两分钟,但能把后面最烦人的排查掐死在源头。
还有一个更隐蔽的:生成代码有时会在模块顶层直接执行副作用,比如启动时就发一次探测请求。放在原型里问题不大,但如果你后面想把它挂到测试里,顶层副作用会让导入这个模块的瞬间就发起网络请求,测试变得不确定。看到这种写法随手挪进函数里,成本极低。
5. 从一次性 Demo 到还能继续长的工程
5.1 目录分层:先砍掉那个扁平结构
平台生成的结构通常是所有文件堆在一个目录里,前后端混着。原型能跑,但你加第二个探测项的时候就得开始纠结文件往哪放了。我的做法是十分钟内先不动它,跑通之后再花五分钟整理成下面这个样子:
netkit-probe/ ├─ server/ │ ├─ index.js # 入口,挂路由 │ ├─ routes/ │ │ └─ diagnose.js # 聚合三类探测 │ └─ probe/ │ ├─ dns.js │ ├─ tcp.js │ └─ http.js ├─ web/ │ ├─ index.html │ └─ main.js ├─ vite.config.js └─ package.json核心思路是把"探测能力"和"接口编排"分开。probe/下面是纯粹的函数,输入参数、输出结构化结果,不碰 HTTP 上下文;routes/负责参数校验、并发调度、错误映射。分开之后好处立刻显现:想加第四个探测项,只需要在probe/加文件,在routes/里加一行并发调用,前端加一张卡片,改动范围完全可预期。混在一起的话,每次改动都要在一坨代码里找插入点。
并发调度也值得说一句。三类探测之间没有依赖,串行跑的话总耗时是三者之和,网络不通时每层都要等满超时,用户可能要盯着屏幕九秒。用Promise.allSettled并发跑,总耗时收敛到最慢的那一层,体验差别很大:
// server/routes/diagnose.js - 并发调度,单层失败不影响整体返回 const [dns, tcp, http] = await Promise.allSettled([ probeDns(host), probeTcp(host, port), probeHttp(host, port) ])用allSettled而不是all,是因为诊断工具的核心诉求恰恰是"哪怕失败了也要看到失败原因"。用all的话,任何一层抛异常整体就 reject,前面查到的信息全丢,等于把工具最有价值的部分扔掉了。
5.2 加一层薄日志,让排查从猜变成看
原型阶段没有监控,日志就是唯一眼睛。我加的东西非常薄,一个中间件把请求参数、耗时、结果状态打一行出来:
// server/middleware/log.js - 一行式请求日志 export default function requestLog(req, res, next) { const t0 = performance.now() res.on('finish', () => { console.log( `[api] ${req.method} ${req.originalUrl} ` + `${res.statusCode} ${Math.round(performance.now() - t0)}ms` ) }) next() }就这几行,价值比想象中大。因为探测类工具的失败往往发生在网络层,前端只能显示一个原因码,具体是超时还是连接被拒,得靠后端日志确认。没有这行日志,你只能在代码里到处插console.log再重跑一遍,一轮下来三五分钟就没了。
另一个小技巧:把每次探测的原始结果整条打印一次,别只打结论。因为很多问题是"看起来成功但数据不对",比如解析到的 IP 是内网地址、HTTP 状态是 200 但 server 字段为空,这类情况只有看原始对象才能发现。日志量在原型阶段完全不构成负担,一天几百行而已,别在这上面省。
5.3 什么时候该亲手重写,什么时候继续用生成的
这个问题我纠结过很久,后来总结出一条比较硬的判断线:如果一段生成代码你要改三个以上的地方才能理解它,就直接重写。因为理解成本一旦超过重写成本,后面的每次修改都在还这笔债。
具体到这个原型,我最后亲手重写了 TCP 探测那一段。生成版本用了一个第三方库,写法上把超时和错误处理绕在一起,我读了两遍才明白它到底什么时候 resolve,这种代码留着就是隐患。而 DNS 和 HTTP 两段我基本保留了生成结果,只改了字段名和超时值,因为它们的逻辑本身够直白。
还有一条经验:把错误码表单独抽出来。原型里错误码散落在各个文件里,前端没法统一映射成用户能看懂的话。抽成一个常量对象之后,界面文案和后端原因码就解耦了,加一个新原因码只改一处:
// shared/reasons.js - 原因码到可读文案的映射 export const REASONS = { DNS_TIMEOUT: '域名解析超时,检查本地解析配置', DNS_FAIL: '域名解析失败,确认域名拼写', TIMEOUT: '端口连接超时,检查地址与端口是否可达', ECONNREFUSED: '连接被拒绝,端口可能未开放', HTTP_TIMEOUT: '服务响应超时,可能负载过高', HTTP_FAIL: '请求未完成,检查协议与证书' }这个小文件是我认为原型里性价比最高的一块。它让一个"能跑的技术验证"变成"别人能看懂的界面",而诊断工具最终交付的其实不是技术结论,是让人看懂的解释。
最后分享我个人的节奏:这十分钟只用来验证一件事,就是"这套探测思路到底能不能给我有效结论"。它不需要好看,不需要测试,甚至不需要完整的错误码。跑通之后我会立刻用它去测几个已知状态的地址,看看结论跟预期是否一致——这一步才是真正验证原型价值的地方。生成得快不是目的,能尽快拿去验证一个判断,才是这十分钟最值钱的部分。