news 2026/10/27 6:50:38

t3code:用工具函数、模板与规范自动化终结重复劳动

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
t3code:用工具函数、模板与规范自动化终结重复劳动

如果你跟我一样,每年要起两三个新项目,大概会懂这种烦躁:每个项目开头都要重新写一遍 debounce、重新配置 ESLint 和 Prettier、重新搭一个空的 README。代码本身不难,但很烦,而且每次复制粘贴都会把旧项目的残余痕迹带进来。我后来干脆把这些零零碎碎的东西整理成一个叫 t3code 的项目,用一套命令就能把工具函数、工程模板和规范自动化全部带进新仓库。项目名是我自己起的,t3 不是某个框架缩写,而是 Three-tier 的代号:底层是纯函数工具库,中间是脚手架模板,上层是工程规范和自动化。这篇文章就来聊聊我把日常代码沉淀成 t3code 的完整过程,包括仓库怎么布局、工具函数怎么写才能长期复用、CLI 脚手架是怎么做的,以及最容易被忽略的那些坑。

1. 为什么要把"随手写的代码"升级成一个叫 t3code 的沉淀库

1.1 重复劳动的真实成本

先算一笔时间账。过去我在不同项目里反复写过:文件转 Blob 下载、金额分转元、日期范围格式化、数组按字段分组、带超时的 fetch 封装……每个实现大同小异,但每次都要打开旧项目翻找、再删掉不相关的业务逻辑,怎么也要半小时。别小看这半小时,项目越多越频繁,积累起来就非常可观。

更麻烦的是工程配置这一块。新仓库要配 ESLint、Prettier、husky、lint-staged,TS 的 tsconfig 还要按场景区分严格程度。不同项目配置五花八门,新人进来跟着一个老项目抄,抄出问题来都不知道错在哪。这种"隐性时间"很难量化,但是每次都会消耗一群人。

所以我做 t3code 的初衷很简单:把"重复的代码"和"重复的配置"变成一套可以被版本管理、被测试、被文档化的资产。它的最小单位是函数和模板,整体则是一个独立维护的项目。大概整理完第一个版本之后,我体会最深的一点是:之前复制粘贴是在"搬运代码",现在整理沉淀是在"设计资产",心态完全不同。

1.2 t3 三个层面的职责划分

t3code 内部严格分三个层面,这也是名字里 t3 的由来:

  • 第一层 tools(工具层):纯函数工具库,不发请求、不碰 UI、不依赖业务环境,只管输入和输出。这样脱离业务之后才能单独测试。
  • 第二层 templates(模板层):不同技术栈的项目初始模板,包含基础目录、构建配置、示例代码,作为新项目的起点。
  • 第三层 tooling(规范层):ESLint、Prettier、Husky、CI 脚本等工程规范,保证生成的项目从第一天起就是一致的。

三个层面各自独立,又通过 CLI 串起来。这是我刻意做的分层:工具层负责能力,模板层负责结构,规范层负责约束。任何一层出问题,都能定位到具体包去修,不用把整个项目翻个底朝天。

后来同事打趣说这个名字应该解释成 "Times Three"(效率翻三倍),听着好听,但正式文档里我还是只保留 Three-tier 的说法,免得产生歧义。

1.3 适合谁参考和借鉴

t3code 不是一个大而全的平台,撑死算一个"个人或小团队的代码资产管理方案"。如果你满足下面几个条件,会比较有参考价值:

  • 经常从零起项目,尤其是一个人维护多个小项目
  • 手头已经有大量复制粘贴来的代码,但没认真整理过
  • 希望团队里的新项目都能有一致的工程规范,而不是靠某个人记得

如果你的场景是一个巨大的单体仓库,或者已经有一套成熟的内部脚手架平台,那 t3code 的设计就需要重新考虑。我的建议很直接:先看思路,再决定自己到底需要哪几块。不是所有人都需要 CLI,但几乎所有人都会从"整理自己的工具函数集"这件事里受益。

2. 仓库骨架:用 pnpm monorepo 组织三个层面

2.1 为什么选 monorepo 而不是拆成多个仓库

t3code 内部有三个明显不同的包:工具库、CLI、模板。很多人第一反应是拆成三个 Git 仓库,觉得代码干净、职责清晰。但如果你真的这么做过就会发现,小项目拆仓库的维护成本远高于收益:改动一个工具函数,要提交、发布、再更新 CLI 依赖、测试新版有没有问题,链条非常长。

我选了 pnpm workspace 的单仓库方案,把三个包放在 packages 下,公共配置放在 configs 下:

t3code/ ├── packages/ │ ├── core/ # 纯函数工具库(包名 @t3code/core) │ ├── cli/ # 脚手架和初始化命令(包名 @t3code/cli) │ └── templates/ # 项目模板文件(不发布 npm,仅供 CLI 读取) ├── configs/ │ ├── eslint/ # 共享的 ESLint 配置 │ ├── prettier/ # 共享的 Prettier 配置 │ └── tsconfig/ # 共享的 TypeScript 基础配置 └── pnpm-workspace.yaml

pnpm-workspace.yaml 里只需要几行:

packages: - packages/* - configs/*

这样做的直接好处:包之间互相引用走 workspace 协议,比如 templates 的结构变化,CLI 立刻能在本地感知;配置统一之后,CLI、core、templates 自身也共用同一套工程规范,谁来改都知道该按什么标准写。

2.2 工具函数库的分类逻辑

core 目录里的函数最多,但分类要克制。我不赞成把那几百个工具函数一股脑塞进一个大对象,那样查找成本很高。我按使用场景分四个模块:

  • guards:类型守卫函数,如 isPlainObject、isBrowser、asNum
  • async:异步控制,如 withRetry、asyncPool、sleep
  • data:数据处理,如 groupBy、uniqueBy、deepGet、deepSet
  • ui:浏览器侧小工具,如 downloadBlob、copyToClipboard、parseQueryString

只按"函数做什么"分,不按"业务"分。因为业务会变,而工具的本质是稳定的。这个规则特别重要:一旦按业务分模块,订单相关的函数会开始依赖订单的类型定义,工具库就被污染了。

2.3 模板必须和工具库分开的原因

我一开始图省事,把模板目录放在 core 包里。结果发布新版本时发现一个严重问题:core 是纯函数包,用户 import 的是编译产物;而模板是纯文本文件,根本不参与编译。把它们放在一个包里,模板细节的更新会污染 core 的版本面,哪怕只是改了一处注释,也要发一个新版本,造成无谓的版本震动。

后来我把 templates 单独拎出来,不发布 npm,CLI 通过相对路径读取。CLI 发布时,把模板作为资源一起带上。这样模板的改动只影响 CLI 的 minor 版本,语义上更清晰。

为什么不把模板发布成 npm 包?因为模板不是"依赖",而是一份"可以随时改动的起点"。如果你把模板发布成包,开发者安装它之后往往就冻结了,想调整目录结构还要再发一版;而本地路径读取配合版本管理,已经足够满足"模板可演进"的需求。这一点到后面讲接入流程时还会展开。

3. 工具函数库从零到一:配置、实现与测试

3.1 初始化工程和 TypeScript 配置

包名 @t3code/core,构建工具我选了 tsup。原因很简单:它能同时输出 ESM 和 CJS 两种格式,外部使用者无论什么环境都能直接 require 或 import。内部开发时我们用 vitest 跑测试,两者搭配比较顺手,不用维护两套构建链。

tsconfig 的关键配置值得展开讲讲:

{ "compilerOptions": { "target": "ES2020", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "declaration": true, "outDir": "dist" } }

strict 必须开,工具函数最怕在类型上含混。target 选 ES2020 是因为我只接受现代运行时的接口,比如 Promise.allSettled(ES2020)和 String.prototype.matchAll(ES2020)。ES2021 之后的一些东西(比如 replaceAll)我不会直接用,而是要垫一层自己的实现,或者明确要求使用者运行在较新的 Node 版本上。这个取舍要写进项目文档,不然别人接手时不知道为什么老代码在旧环境跑不起来。

3.2 几个高频函数的实现细节

这里用三个最有代表性的函数来说明写代码时的取舍。

第一个是 withRetry,做请求重试:

export async function withRetry<T>( task: () => Promise<T>, options: { retries?: number; delayMs?: number; backoff?: 'fixed' | 'exponential' } = {} ): Promise<T> { const { retries = 3, delayMs = 200, backoff = 'exponential' } = options; let lastError: unknown; for (let attempt = 0; attempt <= retries; attempt++) { try { return await task(); } catch (error) { lastError = error; if (attempt === retries) break; const wait = backoff === 'exponential' ? delayMs * 2 ** attempt : delayMs; await sleep(wait); } } throw lastError; }

设计上注意两点。第一,它接受一个返回 Promise 的 task 而不是直接接受请求函数,这样调用方自己决定怎么发起请求,重试逻辑和请求逻辑就解耦了。第二,重试次数从 0 数到 retries 一共是 retries + 1 次尝试,使用时注意别被循环边界绕晕。实际业务里说"最多重试三次",传 retries: 3 正好符合直觉,不会多一次也不会少一次。

第二个是 asyncPool,控制并发数量。这段代码略微绕,直接贴核心:

export async function asyncPool<T>( tasks: Array<() => Promise<T>>, limit: number ): Promise<T[]> { const results: T[] = new Array(tasks.length); let index = 0; const worker = async () => { while (index < tasks.length) { const current = index++; results[current] = await tasks[current](); } }; const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker() ); await Promise.all(workers); return results; }

这里没有用复杂的队列库,就是用index共享指针配合固定数量的 worker 循环取任务。实现简单,性能也不差。需要特别注意的是返回结果顺序:这里用预先分配的数组而不是 push,所以即使任务完成先后不同,返回数组的每一项仍和传入顺序一一对应。如果你的业务不在乎结果顺序,改成 push 也完全没问题,但文档里必须写清楚行为差异。

第三个是 deepGet,最容易写但最容易踩坑的函数。我用 split 解析路径,然后逐层取值,同时处理 null 值和路径不存在的情况:

export function deepGet<T>(obj: unknown, path: string, fallback?: T): T | undefined { const keys = path.split('.').filter(Boolean); let current: any = obj; for (const key of keys) { if (current == null) return fallback; if (!(key in Object(current))) return fallback; current = current[key]; } return current as T; }

有个细节容易被忽略:路径里含数字索引(如list.1)也能正常处理,因为对象属性访问本来就可以走字符串数字键。但若某个 key 本身带点号,就无能为力了。对于工具库来说,文档里要写清楚"只支持点号分隔的简单路径",避免使用者把它当成万能方案硬塞奇怪路径。

3.3 测试先行的沉淀方式

工具函数库更新频繁,最怕新函数把旧函数搞坏。我定的规矩是:新增功能必须带测试,用 vitest 覆盖正常值、边界值、类型异常三种情况。比如 withRetry 的测试至少包括:task 第一次就失败、连续失败到重试次数耗尽、指数退避的等待时间是否随 attempt 增长。一旦这些用例稳定下来,后面改代码会非常有底气。

deepGet 的边界测试就更直接了:

it('should return fallback when path is not found', () => { expect(deepGet({ a: { b: 1 } }, 'a.c')).toBe(undefined); }); it('should handle array index', () => { expect(deepGet({ list: [10, 20] }, 'list.1')).toBe(20); });

把这些测试沉淀在仓库里,比任何文档都有说服力。新成员改工具函数之前先跑一遍测试,就能理解函数行为,再决定要不要动。这也是"测试即文档"这句话在个人项目里最实在的体现。

4. CLI 脚手架:用对话驱动的方式初始化项目

4.1 命令入口与参数解析

t3code 的命令设计非常克制,就三条:

  • t3code new启动交互问答,创建新项目
  • t3code list列出可用模板
  • t3code update把生成项目里的公共配置同步到最新模板(目前只同步配置文件)

为什么不多做?因为命令越多,文档就要越厚,维护负担越大。CLI 工具的生死线是"第一次用的人三分钟内能不能跑通",所以交互流程要优先于功能堆叠。

参数解析我用的是 Node 内置的 process.argv 配合简单解析,没有上 commander。原因很实际:我们没有子命令嵌套、没有复杂参数覆盖,那么小的工具引入一个第三方包,反而让构建和调试多一层负担。如果你喜欢 commander 的声明式写法也不用较真,各有利弊。

4.2 模板变量替换的实现

模板不是死文件,需要动态替换项目名、包名、作者等变量。我采用的方案是约定{{ varName }}占位符,在 CLI 里扫描文本文件做简单替换:

function renderTemplate(content: string, vars: Record<string, string>): string { return content.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, name) => { return Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : `{{ ${name} }}`; }); }

这里有个很重要的细节:对于未识别变量要保留原样,而不是替换成空字符串。如果直接 replace 成空,模板里某个漏配的变量会悄无声息变成空值,排查起来非常痛苦。保留原声明,生成后一眼就能看出哪里漏配了。

4.3 复制模板时的文件过滤

CLI 复制模板时最容易出的问题:把模板里的 node_modules、dist、.git 也复制进去。我用一个忽略集合来过滤:

const IGNORED = new Set(['node_modules', 'dist', '.git', '.DS_Store']); function shouldInclude(relativePath: string): boolean { return !IGNORED.has(relativePath) && !relativePath.startsWith('.git'); }

注意这里不能简单用 startsWith('.git') 过滤所有以 .git 开头的文件,因为像.gitignore这种文件必须保留。上面代码里做了两个不同条件:目录名等于 .git 的丢掉,普通文件里只丢.git本身。这个坑我踩过,有人把.gitignore也丢了,新仓库从一开始就没有忽略规则,后面提交代码各种心惊胆战。

4.4 交互问答的默认值策略

交互环节不能问太多问题,问多了用户就烦了。我保留的必问项只有两个:项目名、选择模板。其余像包管理器、是否初始化 Git、是否安装依赖,全部提供默认值(pnpm、是、否),回车直接通过。

这样设计有一个额外好处:可测试性。自动化测试跑 CLI 时,只需要喂入固定的答复序列就能完成一次初始化,不会因为交互太长而超时。日常使用我甚至可以全程回车快速生成项目,再回去补改配置。

5. 一次真实的初始化实操:从命令到跑起来

5.1 完整操作过程

假设我现在要新建一个 Node 服务,执行:

t3code new

终端会依次提问:项目名(输入my-fake-service)、选择模板(列表里选 node-service)、包管理器(默认 pnpm,回车)、是否初始化 Git(默认是,回车)、是否安装依赖(这次输入 y)。

命令结束后生成目录:

my-fake-service/ ├── src/ │ ├── index.ts │ └── app.ts ├── tests/ │ └── smoke.spec.ts ├── .gitignore ├── .prettierrc.json ├── eslint.config.mjs ├── package.json ├── tsconfig.json └── README.md

整个流程不到 30 秒。对比之前手动搭仓库动辄一两个小时,差距是肉眼可见的。生成的 package.json 会把项目名替换到位:

{ "name": "my-fake-service", "scripts": { "dev": "tsx watch src/index.ts", "build": "tsup src/index.ts", "test": "vitest run", "lint": "eslint ." } }

5.2 工具函数接入的三种方式

新项目拿到骨架后,接下来就是把 @t3code/core 的工具函数用起来。有三种接入方式,按项目情况选:

第一种是本地 workspace 引用。适用于 monorepo 内的子项目,直接把 core 的workspace:*版本写进依赖,走 pnpm 本地链接。改 core 代码立刻生效,无需发版。缺点是项目跟着仓库走,不适合对外独立发布。

第二种是发布 npm 私有包。core 打包发布,新项目里正常 install。适合多团队、跨仓库复用的场景。工具库发版要遵循 semver,新增函数走 minor,行为变更走 major。

第三种是直接把需要的函数源码整段拷进项目。有些代码依赖太重,为一个函数拉一个包确实不划算。我实践上会为这类函数加一行分隔注释:// copied from @t3code/core - do not manually edit,将来升级时好定位。

给个参考数字:我维护的四个小项目里,两个用第一种、一个用第二种、一个混合用第三种。没有一种方案是普适的,关键是每个项目选一种规则,别一会儿 workspace 一会儿 npm 包,混着来最乱。

5.3 接入后的工程规范落地

新项目生成之后,规范层的配置也已经就位。eslint.config.mjs 里会引用 t3code 的共享配置,并带一批内置规则。比如我在共享配置里加了两个容易忽略但很实用的规则:

'no-unused-vars': 'warn', '@typescript-eslint/no-floating-promises': 'error',

第一条很好理解,第二条是很多人会忽略的:async 函数调用后如果没 await,既不报错也不警告,常常成为线上隐患的根源。把它设为 error 之后,团队里每个人写异步代码都会下意识地把 Promise 处理干净。

husky 和 lint-staged 也同样配好了。commit 前自动跑 lint 和格式化,避免垃圾进版本库。这些配置看起来不起眼,但它们才是"每个项目从第一天就一致"这句话的真正载体。

5.4 实测下来的数字

我把 t3code 投入使用后做了一次粗统计,以一个季度为周期:

  • 新起项目时间从平均 1.5 小时降到 10 分钟内(包含安装依赖)
  • 四个项目的 ESLint 初检报错数量从 50+ 降到 5 以内,基本是代码风格小问题
  • 新增工具函数不再重复造轮子,四个项目共享 core 里的约 40 个函数

这些数字没有水分,但也不是什么惊人的成绩。重点是稳定:同样的起点,掩盖了"每个项目一套野生规范"的混乱。

6. 沉淀库的长期维护:踩过的坑与演进方向

6.1 最容易踩的三个坑

第一个坑是"工具库成了垃圾桶"。工具函数从一开始的分类克制,慢慢演变成什么东西都往里塞。某个业务逻辑也写成通用函数放进 core,导致 core 依赖了业务环境,测试跑不起来。我的解决办法是立一条不成文规则:函数里出现业务关键词(比如订单、用户、库存)就禁止进入 core;core 的依赖必须为零。后来每次 review 代码,我都会拿这条规则筛一遍,宁可让某些函数留在各自项目里,也不污染公共库。

第二个坑是"新项目的模板改了,旧项目不知道"。模板只作用于新建项目,已存在的项目如果不主动升级配置,过半年它的 ESLint 规则和模板就完全脱节。t3code 的 update 命令能解决一部分问题,但也只能同步配置文件,做不到自动改代码。所以我的策略是:配置类文件认同步,业务代码保留各自演进,不要追求所有项目永远一致。强一致本身也是一种负债。

第三个坑是"共享配置里的隐式依赖"。比如 eslint 配置引用了某个插件版本,但 package.json 里没有显式声明。别觉得这是小事,pnpm 对 peerDependencies 的处理和 npm 不太一样,经常出现"本地好好的,新环境装完报缺包"。我的做法是:configs 包里的 package.json 把所有依赖和 peerDependencies 都写全,宁可看起来啰嗦,也不能靠运气。

6.2 版本管理与发布节奏

工具库和 CLI 是两个独立的包,版本必须分开管理。我给 @t3code/core 定的节奏是:新增函数走 patch,任何函数行为变化走 minor,删除或破坏性变更走 major。CLI 的模板内容更新走 minor,纯修复走 patch。各自遵循语义化版本,谁依赖谁就很清楚。

发布流程我用 changesets 管理,每次改动在 PR 里带上 changeset 描述,合并后 CI 自动发版。这在个人项目里看着有点重,但正因为流程轻,我才能长期坚持。产物发布前,我会在 CI 里跑一遍构建和全部测试,再检查发布包的文件列表。

一个容易忽略的点:CLI 发布时要把 templates 目录一起打进去,因为 CLI 依赖模板的物理文件。package.json 的 files 字段要明确包含:

"files": ["dist", "templates"]

否则发布出去的包缺模板,用户第一次 new 就直接报错,这是很伤口碑的问题。我第一版就栽在这上面,排查半天才发现是 files 字段漏了目录。

6.3 接下来可以扩展的方向

t3code 目前还只是个人级的代码资产库,但它留出了几个很自然的扩展口子:

  • 插件机制:让团队里的不同角色注册自己的模板,而不用改 CLI 核心代码
  • 版本漂移检测:生成的项目里记录模板版本,下次 t3code 可以提示"你的项目落后了 X 个模板版本"
  • 更完整的模板体系:增加 monorepo 子包模板、CLI 插件模板,覆盖更多新的起点

这些方向什么时候做,取决于实际使用频率。我个人原则是:不预造需求,等第一个人提真实需求再动手。工具库也好、脚手架也好,最怕的从来不是功能少,而是没人真正用。

把 t3code 从"我的个人目录"升级成"有版本、有测试、有文档的项目"之后,最大的变化不是省了多少时间,而是我开始认真对待每一次代码沉淀。以前复制粘贴时从没想过"这段代码为什么没有测试",现在每个进入 core 的函数都先有测试再谈复用。如果你也有一堆散落在各个项目里的常用代码,我建议从最小的工具函数集开始,花一个周末把它整理成包,配上测试,再想想哪些配置可以模板化。一开始可能慢,但后面每次起新项目,都会感谢当时做下的这个决定。

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

数字化工厂物料齐套高效模型:口径、算法与工程落地

1. 先把“齐套”这件事说透&#xff1a;它到底在算什么做数字化工厂这几年&#xff0c;我被问得最多的问题不是MES怎么上、看板怎么做&#xff0c;而是“为什么计划排得好好的&#xff0c;一到上线就缺件停线”。追到根子上&#xff0c;几乎都指向同一个词——物料齐套。齐套率…

作者头像 李华
网站建设 2026/10/27 6:50:37

从触达范围到协作优化:Agent-Reach如何重构团队绩效评估

团队协作时&#xff0c;绩效评估总是最让人头疼的事。当我把这个日常困惑抛给团队里几个资深工程师&#xff0c;大家的第一反应都是想做一个能自动评估成员贡献的系统。但聊着聊着&#xff0c;我们发现真正难的不是“怎么评估”&#xff0c;而是“评估维度怎么定”——如果只盯…

作者头像 李华
网站建设 2026/10/27 6:49:55

牛津词典数据落地实战:从Excel清洗到SQL建库与查询优化

简介&#xff1a;这份资源面向英语学习者与开发者&#xff0c;提供《牛津英语词典》的Excel与SQL两种结构化数据版本&#xff0c;替代传统PDF阅读方式&#xff0c;便于检索、编辑与二次开发。压缩包共2个文件&#xff0c;包含1个xls表格与1个sql数据库脚本&#xff0c;整体约2.…

作者头像 李华
网站建设 2026/10/27 6:49:35

基于Spring Boot与Redis的股票门户网站系统设计与实现

1. 项目整体定位与方案选型思路拆解1.1 股票门户网站到底在解决什么问题很多人看到“股票门户网站系统”这几个字&#xff0c;第一反应是“不就是把行情数据展示出来吗”。真上手做过的人都知道&#xff0c;这类项目的难点从来不是“把数字显示在页面上”&#xff0c;而是在数据…

作者头像 李华
网站建设 2026/10/27 6:49:10

AI终端如何通过OpenClaw实现秒级故障自动修复

1. 项目概述&#xff1a;AI终端如何重塑故障修复流程去年双十一大促期间&#xff0c;我负责的电商系统突然出现订单支付成功率暴跌的紧急故障。当运维团队还在手忙脚乱查日志时&#xff0c;部署在边缘节点的OpenClaw已经自动识别出是第三方支付接口超时导致&#xff0c;并在30秒…

作者头像 李华