如果你是一个写了几年业务代码、回头却发现自己几乎没有“拿得出手”的公共代码库的开发者,那么 t3code 这个项目或许能给你一点启发。它是我日常维护的一个个人代码训练仓库:用 TypeScript 编写高频工具函数、类型工具和数据结构,每个模块都配上完整的单元测试,目录结构清晰到可以直接复制到生产项目里复用。t3code 这个名字本身就可以拆成 Train、Type、Test 三个词,正好对应这个仓库的三条主线——训练、类型、测试。这篇文章会把我的完整思路、模块设计、踩坑记录和搭建过程全部拆开来讲,适合想让代码更有沉淀、想练手数据结构或者想把吃灰的 GitHub 仓库真正用起来的开发者参考。
1. 为什么我会专门维护一个叫 t3code 的训练仓库
1.1 业务代码写得多,沉淀下来的却少
我先说一个很多开发者在同一个境遇里都会有的感受:业务需求一个接一个地做,GitLab 上的提交记录越来越长,但当你真想把自己写过的东西抽出来复用时,却发现自己连一个像样的工具函数都拿不出来。不是因为没写过,而是因为业务代码和具体场景绑得太死——接口返回结构不同、状态管理方式不同、配置项散落在各处,代码一脱离原来的项目就失去意义。
更麻烦的是,就算你曾经写了一个不错的深拷贝函数或者一个防抖工具,下次要用的时候大概率也找不到了。它藏在某个组件的 500 行代码里,和你根本不想再碰一次的旧逻辑纠缠在一起。时间一长,你唯一能做的就是在新项目里再写一遍,而且每次重写都靠记忆,质量参差不齐。这种“搬运工式”的写码状态,恰恰是成长停滞的信号。
所以我决定单独开一个仓库,把这些通用的、高频的、值得反复打磨的代码全部剥出来,和业务彻底解耦。这个仓库不挂在任何项目里,不依赖任何特定框架,只做一件事:让每一段代码都独立可用、有测试保护、有类型约束。我把这个仓库叫作 t3code。
1.2 t3code 这个名字怎么来的,以及三条执行原则
起名的时候我绕开了一堆花里胡哨的词,最后定在 t3code,是因为它刚好能概括我对这个仓库的三个核心期待。
- T = Train,训练。这里不是生产项目,而是刻意练习的地方。每次往仓库里加一个模块,我都会问自己:这个函数边界清楚吗?极端输入有没有处理?如果不清楚,就再改一轮。
- T = Type,类型。既然选了 TypeScript,就要吃透它的类型系统。仓库里专门放类型工具模块,写业务代码时你未必能接触到复杂的泛型体操,但在这里可以。
- T = Test,测试。没有测试保护的代码,本质上只是在赌自己写对了。t3code 的每个模块都要求有对应测试,哪怕是只有十几行的工具函数也一样。
三条原则对应一个硬性要求:新增模块时,必须同时提交实现代码、类型定义、测试用例三个文件。缺一个,这个模块就不算完成。
1.3 这个仓库到底适合谁来用
如果你和我一样处于“能写业务代码但想更进一步”的阶段,t3code 的用法参考价值非常大。具体来说有三类人可以直接受益:
- 准备面试的人。用这个仓库刷数据结构、手写工具函数,比在草稿纸上画更接近真实场景,而且测试会立刻告诉你答案对不对。
- 想把 TypeScript 类型水平提高的人。仓库里的类型工具模块难度是从入门到进阶排列的,每个文件都自包含,可以拿起来单独研究。
- 工作中频繁写通用逻辑的人。写完之后顺手拷一个经过测试的版本到项目里,比自己重写一遍稳得多。
仓库本身没有界面、没有依赖后端,任何人拿到目录就能跑通。这也是训练类仓库最理想的状态——零配置启动,专注在代码本身。
2. 技术选型分析:为什么是 TypeScript + Vitest + ESLint
2.1 用 TypeScript 写训练代码,到底值不值
很多人会觉得,既然是练手代码,用 JavaScript 不是更省事吗?类型体操反而是负担。但我用下来最大的感触是:类型系统不是一个需要被绕开的东西,而是一个能倒逼你思考的工具。
举个例子。当你要实现一个getValueByPath函数(通过路径字符串比如"user.address.city"去对象里取值),如果用 JavaScript 写,你会觉得只要循环split('.')然后一路取属性就行,三两行搞定。但如果用 TypeScript 写,你就会开始思考:路径字符串是字面量类型怎么推导?返回值的类型能不能推导到具体字段的类型?如果中间的某个 key 不存在,类型系统能不能在编译阶段就给出提示?这些问题逼着你把实现想得比需求深一层。
说白了,类型约束就是在开发早期把一部分测试成本前置。类型写对了,运行时出错的概率会明显下降,这份收益在训练阶段积累,在生产项目里兑现。t3code 里我把大部分模块都写成了“带有复杂类型签名”的版本,而不是最简实现,目的就在这里。
另外补充一个对新手友好的细节:仓库里所有模块我都尽量控制了依赖范围,不引第三方库。因为训练仓库的价值在于让你自己把原理写一遍,而不是调别人的 API。
2.2 Vitest 的优势:轻量、原生支持 ESM 和 TypeScript
测试框架我对比过 Jest 和 Vitest,最终选了 Vitest。原因很直观。
| 维度 | Jest | Vitest |
|---|---|---|
| 配置成本 | 需要配ts-jest或babel-jest | 零配置,原生支持 TS/ESM |
| 运行速度 | 较重,冷启动慢 | 基于 Vite,启动快,watch 模式体验好 |
| 依赖体积 | 安装包多、占空间 | 轻量,依赖更少 |
| 与 Vite 生态 | 需要用额外插件桥接 | 天然同源 |
对于训练仓库这种场景,我特别在意的是vitest run的执行速度。刷算法题的时候,改完代码想立刻看到测试结果,等待时间长会打断心流。Vitest 的 watch 模式几乎是实时的,保存文件后几百毫秒内就能给出反馈,这种短反馈循环对持续训练非常重要。
基础配置也很简单,在项目根目录创建vitest.config.ts:
import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'], globals: true, coverage: { provider: 'v8', reporter: ['text', 'html'], }, }, });这里有个小知识点:include用src/**/*.test.ts的模式,可以自动匹配src目录下所有以.test.ts结尾的文件,这样测试文件就在被测试模块旁边,不用另建tests目录,方便对照查看。
2.3 ESLint 不是为了管“格式”,而是为了统一“脑回路”
很多人嫌 ESLint 烦,觉得它管得宽。但在一个训练仓库里,ESLint 的价值恰恰不是帮你摆对齐,而是让代码风格保持统一,减少低级错误。比如no-unused-vars能抓住你写了一半忘记用的变量,@typescript-eslint/consistent-type-imports会强制你区分类型导入和值导入,这些习惯一旦养成,写生产代码时也会自动带上。
我用的规则集是@antfu/eslint-config的精简版,没有装太重的东西。关键是开了两个自定义项:
rules: { 'no-console': 'warn' },提醒我不要把调试语句留在模块里。@typescript-eslint/explicit-function-return-type视情况开启,因为训练仓库里每个函数我都希望有明确的返回类型,这本身就是一种练习。
配置不一定要多花哨,但一定要让 lint 结果保持绿色。跑 CI 时 lint、typecheck、test 三条流水线全部通过,代码才算合格。
3. 三组核心模块的实现与设计思路
3.1 类型工具模块:DeepReadonly 与按路径取值
t3code 里的类型工具模块,是我平时写业务代码时想用却不好意思在项目里折腾的一类代码。第一个案例是DeepReadonly,递归把对象的所有属性设为只读:
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? T[K] extends Function ? T[K] : DeepReadonly<T[K]> : T[K]; };这段代码的核心在于映射类型[K in keyof T]加上递归条件判断。为什么需要判断Function?因为函数也是对象,如果不对函数做特判,递归会把函数的所有属性都转成只读,这通常不是我们想要的。这个细节是我第一次写的时候漏掉的,后来测试给了我一记响亮的提醒。
第二个是路径取值函数,这个模块的类型推导比实现更有意思:
type Path<T> = T extends object ? { [K in keyof T]: K extends string ? T[K] extends object ? K | `${K}.${Path<T[K]>}` : K : never; }[keyof T] : never; type PathValue<T, P extends string> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? PathValue<T[K], Rest> : never : P extends keyof T ? T[P] : never; function getValueByPath<T extends object, P extends Path<T>>( obj: T, path: P, ): PathValue<T, P> { return path.split('.').reduce<any>((acc, key) => acc?.[key], obj); }Path<T>用模板字面量类型递归生成了所有可能的合法路径,PathValue再根据路径反推返回值类型。这样做最大的好处是:你写错了路径字符串,TypeScript 在编译时就能直接报错,而不是等你运行时拿到undefined再排查。这种体验一旦用过,就很难退回最简实现。
3.2 业务高频函数:带取消能力的一行防抖与节流
防抖和节流是面试题常客,但在生产环境里,很多人只记得最基础的实现,遇到“需要立即执行”“需要取消防抖”这类扩展需求就卡壳了。t3code 里的防抖实现,我特意把cancel和flush两个方法加上了:
interface DebouncedFn<F extends (...args: any[]) => void> { (...args: Parameters<F>): void; cancel: () => void; flush: () => void; } function debounce<F extends (...args: any[]) => void>( fn: F, wait: number, ): DebouncedFn<F> { let timer: ReturnType<typeof setTimeout> | null = null; let lastArgs: Parameters<F> | null = null; const debounced = (...args: Parameters<F>) => { lastArgs = args; if (timer) clearTimeout(timer); timer = setTimeout(() => { timer = null; fn(...lastArgs!); lastArgs = null; }, wait); }; debounced.cancel = () => { if (timer) clearTimeout(timer); timer = null; lastArgs = null; }; debounced.flush = () => { if (timer && lastArgs) { clearTimeout(timer); timer = null; fn(...lastArgs); lastArgs = null; } }; return debounced; }cancel的典型场景是组件卸载时清理定时器,避免内存泄漏;flush的典型场景是表单提交前要把最后一次内容立刻处理掉,而不是干等延迟结束。这两个方法加上后,这个防抖函数在业务里的实用性立刻提高一个档次。
对应测试要注意一点:用vi.useFakeTimers()时一定要记得在afterEach里调用vi.useRealTimers(),否则后一个测试文件会受到前一个文件假时钟的影响,出现莫名其妙的超时。
3.3 数据结构模块:用 Map 实现 LRU 缓存与前缀树
数据结构如果在 LeetCode 上刷,写完看一眼通过率就关了,但在 t3code 里我会要求自己把数据结构的完整 API 都实现出来。以 LRU 缓存为例,很多人会想到双向链表,但在 JavaScript 里有一个更巧妙的做法——直接用 Map:
class LRUCache<K, V> { private cache = new Map<K, V>(); constructor(private capacity: number) {} get(key: K): V | undefined { if (!this.cache.has(key)) return undefined; const value = this.cache.get(key)!; this.cache.delete(key); this.cache.set(key, value); return value; } put(key: K, value: V): void { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.capacity) { const oldestKey = this.cache.keys().next().value; if (oldestKey !== undefined) { this.cache.delete(oldestKey); } } this.cache.set(key, value); } }原理很简单:Map 会保持插入顺序,get时把对应的 key 删掉再重新插入,它就变成了最新使用的;要淘汰的时候只需要删掉第一个 key。整个实现只有 20 多行,但性能上和双向链表版本的复杂度是一样的,都是 O(1)。
前缀树 Trie 也是我强烈建议练手的数据结构:
class TrieNode { children = new Map<string, TrieNode>(); count = 0; endCount = 0; } class Trie { private root = new TrieNode(); insert(word: string): void { let node = this.root; for (const ch of word) { if (!node.children.has(ch)) { node.children.set(ch, new TrieNode()); } node = node.children.get(ch)!; node.count++; } node.endCount++; } search(word: string): boolean { let node = this.root; for (const ch of word) { if (!node.children.has(ch)) return false; node = node.children.get(ch)!; } return node.endCount > 0; } startsWith(prefix: string): boolean { let node = this.root; for (const ch of prefix) { if (!node.children.has(ch)) return false; node = node.children.get(ch)!; } return true; } }我在TrieNode里加了count字段,每次插入时经过的节点count加 1,这样就能支持“查询以某个前缀开头的单词总数”这类统计功能,搜索引擎的自动补全、词典联想,背后都是这个结构。
4. 实测踩坑记录:类型推导、异步测试与工程配置
4.1 路径字符串泛型的推导边界
给getValueByPath写类型的时候,我最早用的是递归 + 模板字符串,但测试了一个场景后发现推导不出来:
const path = 'user.address.city'; const value = getValueByPath(user, path);问题在于path被推断成了string,而不是字面量类型'user.address.city',于是P extends Path<T>直接被拒绝。解决方案两个:一是调用时用as const,二是把函数参数改成泛型加上约束。最终我选择更友好的方案——让path参数的类型是P extends Path<T>,并且把对象和路径分开推导,调用者传普通const变量也能被正确识别为字面量类型的一部分。这里的关键是不要用P extends string这种宽松约束,否则 TS 会把 P 拓宽成 string,路径校验就形同虚设。
如果未来想支持数组索引(比如"users.0.name"),模板字符串的递归逻辑又要再加一层判断。这个就先留给后续迭代了。
4.2 假时钟与真实异步混用的教训
我的测试代码里有一段时间经常出现“偶尔失败、重跑又通过”的现象,最后定位到是 Vitest 的 fake timers 使用不规范导致的。
一个典型的错误写法是:在某个测试里调用了vi.useFakeTimers(),然后下一个测试文件里的异步操作(比如真实的setTimeout、fs操作)被假时钟接管,导致事件循环卡住。解决方法是前面说的在afterEach里重置:
afterEach(() => { vi.useRealTimers(); });另一个细节是,如果被测代码内部使用了requestAnimationFrame或queueMicrotask,fake timers 默认不会接管,需要显式在配置里打开toFake的选项。对于训练仓库来说,我建议只在防抖、节流这类纯定时器模块里启用 fake timers,其他测试保持真实时钟,避免无谓的复杂度。
4.3 package.json 里的 module、main、types 字段
t3code 既是训练仓库,也是可以被打包成 npm 包的工具集。但当我想把src下的模块发布到 npm 时,遇到了一个格式化不匹配的问题:package.json里同时写了main和module字段,但忘了给 TypeScript 类型声明指定types字段,结果其他项目引用时报错找不到类型声明。
正确的发布配置应该这样:
{ "name": "t3code", "version": "0.1.0", "type": "module", "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } } }exports字段加上后,Node.js 的import解析会更严格,不会再出现因为同时存在main而导致歧义的问题。当然,对于纯训练用途,这一条其实可以延后,但如果你想把自己的代码库开源给别人用,这步还是值得做对的。
4.4 CI 里的版本管理与依赖缓存
GitHub Actions 跑起来之后,我发现一个隐藏的坑:仓库使用了engines字段指定 Node.js 版本,但 CI 镜像里的 Node 版本偶尔不匹配。更好的实践是在 workflow 里显式指定 node-version,并开启 npm 缓存:
- uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm'如果你使用了packageManager字段或者 pnpm/yarn,还需要在跑npm ci之前执行corepack enable,否则 CI 会忽略本地锁定文件。类似这种配置问题,本地永远不会暴露,只有 CI 才会教育你。
5. 从零搭建一个你专属的 t3code:一份可直接抄的清单
5.1 初始化项目与目录设计
新建仓库的准备工作,可以直接按下面的命令来:
mkdir t3code && cd t3code npm init -y npm install -D typescript vitest eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin npx tsc --init目录结构我强烈建议“模块平铺”,不要一层层嵌套太深:
t3code/ ├── src/ │ ├── types/ │ │ ├── deep-readonly.ts │ │ ├── deep-readonly.test.ts │ │ └── index.ts │ ├── functions/ │ │ ├── debounce.ts │ │ ├── debounce.test.ts │ │ └── index.ts │ └──>name: CI on: push: branches: [main] pull_request: jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: corepack enable - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm test这一套流程跑下来,任何一次改动如果没有通过 lint、typecheck 和 test,都不会被合并到主分支。这比“写完代码本地跑一遍就算完事”强多了,毕竟训练仓库的意义就在于建立可靠的反馈循环。
我自己维护 t3code 走了不少弯路,但最深的体会是:训练仓库的价值不在代码量,而在“每次提交都经得起推敲”。当你把src/types/deep-readonly.test.ts这类文件随手打开,既能当复习笔记又能当代码片段库用的时候,这个仓库才算真正活了过来。如果你也准备开始维护自己的代码训练仓库,我的建议是先放下“我要做一个大项目”的念头,从三个模块开始,跑通 CI,剩下的就是时间复利的问题了。