news 2026/10/28 6:14:25

TypeScript代码训练仓库:类型、测试与数据结构实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TypeScript代码训练仓库:类型、测试与数据结构实战

如果你是一个写了几年业务代码、回头却发现自己几乎没有“拿得出手”的公共代码库的开发者,那么 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。原因很直观。

维度JestVitest
配置成本需要配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,剩下的就是时间复利的问题了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 6:12:50

YOLOv8工业缺陷检测全流程解决方案

1. 项目概述&#xff1a;基于YOLOv8的工业缺陷检测全流程解决方案这个项目是一套完整的工业表面缺陷检测系统&#xff0c;从数据集标注到模型训练再到Web端部署的全套解决方案。核心在于利用YOLOv8这一当前最先进的目标检测框架&#xff0c;结合70个改进创新点&#xff0c;打造…

作者头像 李华
网站建设 2026/10/28 6:12:20

IIS 500与503错误排查指南:快速区分应用层与服务层故障

1. 先搞清楚 500 和 503 到底在说什么做运维和后台开发的人&#xff0c;对 IIS 报错页面不会陌生。白底黑字一个“500 - Internal Server Error”&#xff0c;或者“503 - Service Unavailable”&#xff0c;看起来都是“服务器挂了”&#xff0c;但这两者背后的排查路径完全不…

作者头像 李华
网站建设 2026/10/28 6:12:18

t3code实战:基于T3 Stack的类型安全全栈开发

如果你最近在折腾全栈项目&#xff0c;大概率已经瞄到过t3code这个名字。它不是某个框架的代号&#xff0c;也不是公司内部工具&#xff0c;而是一套非常完整的项目模板与最佳实践集合&#xff0c;围绕前端圈这两年讨论度很高的 t3 stack 搭建。t3 stack 四个字母对应四样东西&…

作者头像 李华
网站建设 2026/10/28 6:12:17

Angular HTTP请求封装与数据共享服务实践

1. 为什么需要封装HTTP请求与数据共享服务在Angular项目中&#xff0c;直接在每个组件中调用HttpClient进行数据请求会导致大量重复代码&#xff0c;这种"散弹式修改"会让项目变得难以维护。我曾接手过一个电商后台项目&#xff0c;发现同样的商品列表请求逻辑竟然在…

作者头像 李华
网站建设 2026/10/28 6:12:17

t3code:基于T3 Stack打造全栈类型安全的工程化实践

t3code 这个名字乍看起来像某个第三方库的随手命名&#xff0c;但和我一起维护这个项目的人都知道&#xff0c;它其实是一整套面向现代全栈 Web 应用的代码骨架和工程实践。整个项目围绕 T3 Stack 展开&#xff0c;也就是 TypeScript Tailwind CSS tRPC 三个核心技术的组合&a…

作者头像 李华
网站建设 2026/10/28 6:12:16

PHP安全:如何检测和修复auto_prepend_file恶意注入

1. 问题现象与初步排查那天早上&#xff0c;我像往常一样打开网站后台检查&#xff0c;突然发现所有页面底部都多出了一串奇怪的链接。这些链接指向一些赌博和色情网站&#xff0c;明显是被恶意挂上了黑链。更诡异的是&#xff0c;我在网站源码和模板文件中反复检查&#xff0c…

作者头像 李华