写 t3code 这个工具,纯粹是被三个重复劳动逼出来的。日常开发里我同时维护前端项目和几个三维展示页面,还要时不时代管一些文本预处理脚本,时间长了就发现三件事特别烦:手写 TypeScript 接口定义、反复调 Three.js 的场景初始化模板、提交代码前估算 token 消耗。t3code 就是我把这三件事揉到一起做的一个本地命令行工具,核心能力是类型生成、Three.js 代码补全和 token 统计。它不会替代你的工程化体系,但能把那些“谈不上难、就是费时间”的环节压缩到一条命令以内。如果你也经常跟 TS 类型、WebGL 场景或文本 token 打交道,这篇文章值得读完。
1. t3code 要解决的真实痛点与设计取舍
1.1 三个让我想写工具的日常场景
先说 TypeScript 类型生成。我接手过一个数据中台项目,后端接口返回几十个字段的 JSON,手写 interface 不算难,但架不住字段多、嵌套深,而且后端经常改字段名。每次联调都要对着接口文档敲一遍类型定义,改一处就要顺着引用链改一串,那段时间我一度怀疑自己是个“类型打字员”。后来我意识到,大部分这类工作完全可以交给程序自动推断,只要给它一份真实的接口返回样例。
再说 Three.js。我经常要快速搭一个三维演示页面,灯光、相机、渲染器、动画循环这些代码其实高度模板化,但每次都要重新翻文档回忆参数。比如 PerspectiveCamera 的视野角度、近远裁剪面,PointLight 的颜色、强度、衰减距离,这些参数不查一下容易记错。更麻烦的是不同的性能面板、不同的环境光方案,模板差异不算大,却总得手打一遍。
最后是 token 统计。我在给一些本地模型整理训练语料,也在做 RAG 检索的文本切分,需要提前知道一批文本大概会消耗多少 token。OpenAI 提供了 tiktoken,但它是 Python 库,命令行调用要包一层;而且我们的文本里有大量中文注释和代码片段,直接拿官方统计跟实际需求对不上。既然都要封装一版工具,不如干脆做成一个通用 CLI。
1.2 为什么是命令行而不是 IDE 插件
我一开始想过做成 VS Code 插件,但很快就否了。这种工具的使用场景很杂:生成类型可能是在终端里跑的,也可能是在 CI 流程里跑的;Three.js 模板可能要在其他编辑器里用;token 统计则几乎总是出现在 shell 脚本里。插件形态绑死了编辑器环境,而命令行工具可以自由组合,比如把 t3code 的生成结果通过管道交给 prettier 格式化,再写进文件,这在插件里不容易做到。
另外命令行工具的测试和发布也更轻量。我没有精力维护多端插件市场,CLI 只需要一个 npm 包名,一条 install 命令,用户环境里有 Node.js 就能跑,不需要依赖具体 IDE 的 API 变化。
1.3 项目架构:一个入口,三种能力
t3code 整体结构并不复杂,我把三个功能模块做成三个子命令,主入口只负责参数解析和配置加载。仓库目录大概是这样的:
t3code/ ├── bin/ │ └── t3code.js # 入口,解析子命令 ├── src/ │ ├── commands/ │ │ ├── type.js # 类型生成 │ │ ├── three.js # Three.js 辅助 │ │ └── token.js # token 统计 │ ├── core/ │ │ ├── config.js # 配置文件加载 │ │ └── logger.js # 输出格式化 │ └── utils/ ├── templates/ │ └── three/ # Three.js 代码模板 ├── t3code.config.json └── package.json架构上我坚持一个原则:三个模块之间不共享复杂状态,只复用最底层的配置和输出工具。这样做的原因是避免过度设计。以前我写工具容易犯一个毛病,就是试图把所有功能抽象成一套“引擎”,结果改一个功能要动全局。t3code 明确走“多个小工具、一个壳”的路线,每个模块可以独立升级,单独测试,出问题也不至于互相拖累。
2. 三大核心模块的实现原理与关键参数
2.1 TypeScript 类型生成:从样例到 interface
类型生成的核心逻辑是:读入一个 JSON 或 JS 对象样例,递归遍历每个字段,根据值的运行类型推断出对应的 TypeScript 类型节点。具体步骤如下:
- 解析输入文件,支持 .json 和 .js 两种格式,JS 文件会先通过 AST 解析找出默认导出或指定的对象。
- 遍历对象属性,判断值类型:字符串映射为 string,数字映射为 number,布尔映射为 boolean,数组则递归推断元素类型,对象则继续深入。
- 特殊值单独处理:null 会被映射为 null 类型并登记为“可选字段候选”,空数组被映射为
unknown[]并给出提示,日期字符串默认保留为 string,除非开启--detect-date。 - 组装成
interface或type,按缩进和排序规则输出。
举个最简单的例子,假设后端返回的用户信息长这样:
{ "id": 1001, "name": "北极", "tags": ["前端", "工具"], "profile": { "age": 18, "vip": true } }直接跑t3code type gen user.json --name ApiUser,生成结果就是:
export interface ApiUser { id: number; name: string; tags: string[]; profile: { age: number; vip: boolean; }; }这里有个关键参数值得展开说。默认情况下,单个数字、字符串会被推断成字面量类型还是基础类型,取决于--literal-threshold这个参数。阈值的意思是:当一个字段在多个样例中出现的不同值数量小于等于该阈值时,推断为字面量联合类型;超过阈值则退化为基础类型。我默认设成 3,原因是阈值太小无法表达联盟类型,阈值太大又容易把真实业务数据里的枚举值误当成固定常量。
还要处理“可空字段”。接口返回里经常出现null,比如一个用户可能没有手机号,字段值为 null。t3code 提供了三种可选模式:
| 可选模式 | 生成结果 | 适用场景 |
|---|---|---|
--optional-mode question | phone?: string | 字段可能不存在时 |
--optional-mode union | phone: string | null | 字段存在但值为空时 |
--optional-mode nullable | phone: string | null,同时生成type而不是interface | 需要严格空值语义时 |
接入后端时我几乎总是选union模式,因为接口契约里如果明确返回了null,说明这个字段“在响应里出现过”,用可选符号反而掩盖了真实结构。这个坑我一开始踩过,生成的类型看起来挺干净,但真正解析数据时空值判断逻辑全乱了。
2.2 Three.js 辅助:场景描述到可运行代码
Three.js 模块不是什么“人工智能生成代码”,而是一套把场景描述关键词映射到模板的匹配引擎。实现思路很简单:我把最常见的三维场景初始化和常用元素拆成模板片段,每个模板片段带有若干标签,比如cube、rotate、point-light、orbit-controls。当你输入自然语言描述时,t3code 会做关键词切分和权重匹配,把命中的模板组装起来。
比如输入:
t3code three scene "旋转立方体 + 点光源 + 背景色"匹配到的模板组合会生成这样的代码:
import * as THREE from 'three'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x20232a); const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100); camera.position.set(3, 2, 5); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); document.body.appendChild(renderer.domElement); const cube = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0xffffff }) ); scene.add(cube); const light = new THREE.PointLight(0xffffff, 1, 10); light.position.set(2, 3, 4); scene.add(light); function animate() { requestAnimationFrame(animate); cube.rotation.x += 0.01; cube.rotation.y += 0.01; renderer.render(scene, camera); } animate();注意这里有几个参数不是随便给的。视野角度默认 45 度,是因为这个值最接近人眼自然视角,不容易产生畸变;near 和 far 裁剪面设成 0.1 和 100,覆盖了绝大多数演示场景的尺寸范围;点光源的强度给到 1、距离给到 10,是配合默认尺寸的立方体来调的,太暗或太远都会让物体看起来发灰。这些默认值不是死规矩,它们只是为了让你第一次运行就能看到东西,想微调再改参数也不迟。
模板匹配最有意思的问题是权重。一个场景描述里可能同时出现“红色立方体”和“旋转动画”,模板库里的 cube 模板和 rotation 模板都能命中。t3code 会给每个关键词算一个相关度得分,命中次数多且标签权重高的模板排在前面。如果描述太复杂导致匹配结果不理想,可以直接指定模板名:
t3code three scene "红色茶壶" --template teapot开发过程中我最怕模板库无限膨胀。目前 templates/three 目录下有 40 多个模板,覆盖了初始化、基础几何体、灯光、相机控制、粒子、后处理这几大类。超过 50 个以后,模板解析启动时间会明显变长,而且匹配时产生歧义的概率也变大。这个数量级是我测试下来比较舒服的平衡点。
2.3 Token 统计:为什么自己造轮子
Token 统计算是最有“复用”价值的功能,因为 tiktoken 本身就是 OpenAI 开源的成熟库。但直接拿来用有几个问题:第一,tiktoken 官方是 Python 库,在 Node 生态里要借助绑定包,安装步骤多了好几层;第二,很多项目并不只用 OpenAI 的模型,本地模型用的词表可能是其他 BPE 实现,统计口径不一致;第三,我需要的是对目录级别做批量的 token 估算,而不是在 Python 脚本里手动循环调用。
所以 t3code 的 token 模块做了一层兼容层:底层词表可以加载多种 BPE 编码,默认是cl100k_base(也就是 GPT-4 系列用的词表),也支持通过--model参数切换其他编码。核心统计流程是:
- 遍历目标目录,按扩展名过滤代码、Markdown、纯文本等类型。
- 读取文件内容,按配置决定是否剥离注释。默认剥离
//、/* */、<!-- -->和#开头的注释行。 - 对文本做预分词,中文按字符切分后合并,英文和数字按空格和标点粗分。
- 用 BPE 词表对粗分结果做合并,累加得到 token 总数。
- 输出统计表,包括文件数、总 token 数、代码 token 占比、注释 token 占比。
跑一下:
t3code token count src/ --model cl100k_base --detail输出大概是:
文件数 12 总 token 18,432 代码 token 11,205 注释 token 5,217 中文字符占比 31.2%这里最需要注意的是统计口径的对齐。同样一段代码,开不开注释剥离,token 数可能差出一大截;不同模型的 BPE 词表不同,同一个句子的 token 数也可能不同。我踩过比较深的一个坑是:用cl100k_base统计的数据,去预估某个本地模型的训练成本,结果偏差接近 15%。后来所有统计都显式传--model参数,并把模型名一并写进输出结果里,才彻底解决这个“数字对不上”的困惑。
3. 从安装到写进工作流:t3code 实操全记录
3.1 三分钟装好并初始化
安装条件只有一个:本机有 Node.js 18 以上版本。直接全局安装:
npm install -g t3code装完先初始化配置文件,这样不用每次敲一堆参数:
t3code init运行后会在当前目录生成一个t3code.config.json,我的推荐配置是这样:
{ "type": { "optionalMode": "union", "literalThreshold": 3, "indent": 2, "detectDate": false }, "three": { "templateDir": "./templates/three", "defaultBackground": "#20232a", "preferModule": true }, "token": { "defaultModel": "cl100k_base", "includeComments": false, "chunkSize": "512KB" } }indent控制输出缩进,接进前端项目时建议跟 ESLint 的缩进规则统一;preferModule让三模块输出 ES module 风格的导入语句;chunkSize是 token 统计时按块读取文件的大小,目录特别大时可以调小避免内存暴涨。
3.2 高频命令实战:type、three、token
类型生成最常用的命令是这样的:
t3code type gen ./mock/api-user.json --name ApiUser --optional-mode union --out ./src/types这条命令会根据样例文件生成ApiUser接口,并写到src/types目录下。如果不想输出到文件,也可以去掉--out,结果会直接打到标准输出,方便你 pipe 给别的工具,比如:
t3code type gen sample.json | prettier --stdin-filepath sample.tsThree.js 辅助命令的完整用法:
t3code three scene "带轨道控制的地球模型,有环境光" --template orbit-earth --out ./src/three/scene.ts场景描述匹配不到合适模板时,先看看t3code three list里有哪些可用模板,再决定是换关键词还是指定模板名。模板列表我按功能做了分组:
- 初始化类:basic-scene、full-scene、ssr-scene
- 几何体类:cube、sphere、plane、torus-knot、text-geometry
- 灯光类:ambient-light、point-light、directional-light、spot-light
- 控制类:orbit-controls、pointer-lock
- 特效类:particles、post-processing、glow
token 统计最实用的命令:
t3code token count ./docs --model cl100k_base --include-comments=true --detail如果你只想快速算一段文本,也可以直接从标准输入读:
echo "hello world" | t3code token count --stdin3.3 把 t3code 接进脚本、编辑器和提交流程
真正让 t3code 发挥价值的是跟现有工作流串起来。我在package.json里加了这么几个 script:
{ "scripts": { "gen:types": "t3code type gen ./mock/*.json --out ./src/types", "scene:init": "t3code three scene \"rotation cube & point light\" --out ./src/three/init.ts", "tokens": "t3code token count ./src --include-comments=false" } }这样团队里其他成员不用记 t3code 的参数,直接npm run gen:types就行。编辑器里我把它配成了 VS Code 的 task,按快捷键就能生成类型定义并自动格式化,省得来回切换终端。
提交前流程我只建议加 token 统计这一步,别把类型生成设成提交钩子。原因后面会讲。
4. 我踩过的坑:t3code 常见问题排查速查表
4.1 类型生成最常见的“过度推断”问题
我最早版本的类型生成器有个毛病,会把样例里的单个值直接推断成字面量类型。比如样例里status: 1,生成的是status: 1而不是status: number,看起来精确,实际害死人,因为后端只要多返回一个 2,这个类型就崩了。后来加了--literal-threshold参数,默认 3,意思是同一个字段在多个样例中出现不同值的数量不超过 3 时才推断为字面量联合类型。如果你手上只有一条样例,建议干脆设成 0,彻底关掉字面量推断,全部用基础类型。
4.2 Three.js 匹配不准:模板与权重调整
“旋转立方体”这种描述匹配率一直不错,但碰上“一个发光的红色球体在转”这种口语化描述,就会在球体、灯光、旋转三个模板之间摇摆。我的解决方法是两层:第一层是给模板加同义词标签,比如“球”和“ball”都映射到 sphere 模板;第二层是支持手动覆盖,匹配结果不满意就用--template直接指定。这不算优雅,但在实际使用里够用,毕竟三维场景的初始化代码就那么几种变体。
4.3 Token 口径不一致:如何对齐官方统计
如果你拿 t3code 统计出来的数字跟 OpenAI 接口返回的usage.prompt_tokens对不上,先检查两件事。第一,注释有没有被剥离,官方接口统计的是完整输入内容,包括注释,所以对比时要不就两边都算注释,要不就两边都不算。第二,词表是否一致,cl100k_base和p50k_base对同一段文本的结果不同,必须显式指定模型。我在输出里加了一行模型标识,就是为了避免隔几天回来忘了这组数据是用哪个词表算的。
4.4 问题排查速查表
| 现象 | 原因 | 解决方法 |
|---|---|---|
类型生成把0推出0而不是number | 字面量阈值太低 | --literal-threshold 0或用多条样例 |
JSON 样例里有空数组,生成unknown[] | 无法推断元素类型 | 换一条更完整的样例,或手动给该字段加类型注释 |
| Three.js 模板匹配到无关模板 | 描述里关键词权重过低 | t3code three list查模板名后--template指定 |
| token 统计跟官方对不上 | 注释统计口径或词表不同 | 对齐--include-comments并--model指定词表 |
| 大目录统计时内存飙升 | 一次性读入全部文件 | 设置--chunk-size,按块读取 |
| 中文长文本 token 数偏高 | 中文按字切分后再 BPE 合并,与真实分词有差距 | 有自定义词表时挂载--vocab,没有则接受近似结果 |
最后分享两个我实际用下来的小经验。
第一,别把 t3code 类型生成接进提交钩子。自动生成类型后如果直接提交,很容易产生大量无意义的 diff,尤其是接口字段顺序一变,整个文件都跟着重排,评审的人会疯掉。我现在的做法是生成到临时目录,人工 diff 之后再合并。
第二,token 统计除了算成本,还能当“代码可读性探测器”。如果一段代码注释占比超过 40%,说明注释多到可能影响整洁度;如果低于 5%,说明关键逻辑缺少说明,该补文档了。这个指标不严谨,但用来提醒自己挺有效。
t3code 算是我个人工具列表里“小但高频”的那一类,它不解决架构问题,也不替代任何重型框架,只是把三件琐碎事压成了三条命令。如果你也想复制这套思路,记住一点就够了:命令行工具最怕的不是功能少,而是边界失控。t3code 从第一天就限定自己只做类型、三维辅助、token 这三件事,其他需求一概不进主仓库,这种“克制”反而是它到现在还没被我丢掉的原因。