把旧工具库迁到 es-toolkit:一次 sheng-tool 现代化改造
sheng-tool 是一个很早就写起来的小工具库。当时的背景很简单:项目里经常需要一些小函数,市面上的工具库要么太大,要么语义不完全贴合自己的项目,所以就把常用函数收在一起。
这个库里有一个我现在看仍然觉得值得保留的设计:函数注释里的 @example 同时是 TSDoc、测试和文档。也就是说,源码里写给读者看的例子,不只是说明文字,还会被脚本抽出来生成单测;如果例子和真实行为不一致,测试会直接报错。
几年以后再看,它的问题也很明显。依赖还停在 lodash-es 和 dayjs,测试是 Jest,构建是 Rollup 2,类型声明还靠 API Extractor 做一次额外处理,文档生成后直接放进 npm 包,再通过 unpkg 打开。工具库本身不大,工具链反而变成了最需要维护的部分。
这次改造的目标是先把库从「能用但老」调整成「发布边界清楚、测试链路可信、后续能继续演进」的状态。
先看旧状态
旧版 package.json 大概是这样:
{
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"typings": "dist/index.d.ts",
"scripts": {
"build-test": "ts-node ./script/build-test.ts",
"test": "jest --coverage",
"build-bundle": "rollup -c",
"build-typescript-declaration": "tsc -d && api-extractor run",
"build-document": "ts-node ./script/build-docs.ts",
"build": "npm run clean-before && npm run build-test && npm run test && npm run build-bundle && npm run build-typescript-declaration && npm run build-document && npm run clean-after"
},
"devDependencies": {
"dayjs": "^1.11.4",
"jest": "^28.1.3",
"lodash-es": "^4.17.21",
"rollup": "^2.77.2",
"typedoc": "^0.20.37",
"typescript": "~4.2.4"
},
"files": [
"dist/",
"docs/"
]
}这里至少有四个问题。
第一,TypeScript 太旧。我在本地先跑了一次 pnpm exec tsc --noEmit --pretty false,还没轮到业务源码,当前安装到的 @types/* 就已经让 TS 4.2 读不懂了。这类报错很容易被误判成「代码坏了」,实际是工具链年代差太大。
第二,依赖和构建边界不清楚。lodash-es 本身确实大,但更重要的是库没有把「依赖是公开契约还是实现细节」说清楚。工具库可以选择 external,也可以选择把依赖打进产物;关键是这个选择要和入口形态、CDN 使用方式、包体积检查一起验证,而不是让打包工具默认行为决定发布结果。
第三,文档不应该跟运行时代码一起进 npm tarball。旧 README 里写的是「文档部署到了 npm 上」,通过 unpkg 打开 docs/index.html。这个方案很省事,但用户安装包时并不需要 HTML、CSS、图片和搜索索引。更麻烦的是,当前 checkout 没有 dist/ 时,npm pack --dry-run 会只打进 docs/、README、LICENSE 和 package.json,发布前漏跑构建就可能得到一个没有运行时代码的包。
第四,「注释即测试」这条链路很有价值,但旧实现依赖 TypeDoc 旧版 comment 结构:
const { name, comment } = signatures;
const example = comment.tags.find(x => x.tagName === 'example');
const code = example.text.replace(/\n+```typescript\n|\n```\n+/g, '').split('\n');
const a = code.map(x => x.split(/\s+\/\/\s+/));它能处理 表达式 // 期望值 这种简单例子,但对 TypeDoc 升级、复杂示例、浏览器环境、异常测试都不够稳。
所以这次没有只做「把 lodash-es 换成 es-toolkit」。依赖、构建、测试、文档和发布边界要一起调整,否则只是把旧问题搬到新依赖上。
包边界重新定一次
新版先明确五件事:
- 运行时代码只发布
dist/。 - 文档由 TypeDoc 生成,但发布到 GitHub Pages,不再放进 npm 包。
- 包只暴露根入口,源码里的分类文件不变成公开 subpath。
- 入口改成
exports.import/exports.require,运行时产物用index.mjs和index.cjs。 es-toolkit作为实现细节打进产物,不再放进运行时dependencies。
现在的入口改成这样:
{
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"typings": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
},
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"unpkg": "./dist/index.umd.min.js",
"jsdelivr": "./dist/index.umd.min.js",
"sideEffects": false,
"files": [
"dist/"
]
}这里把入口一次改到现代形态。Node 判断 ESM/CJS 时,.mjs 始终按 ESM 处理,.cjs 始终按 CJS 处理;exports 又能把 import 'sheng-tool' 和 require('sheng-tool') 指到不同产物。Node Packages 文档对这套规则写得很明确。
类型声明看起来像是「一份就够」,实际多了一层模块格式问题。type: module 下的 .d.ts 会被 TypeScript 当成 ESM 声明;如果同一份 .d.ts 同时挂给 require 分支,publint 会提醒 CJS 消费者拿到的是 ESM 类型。反过来把包改成 type: commonjs,import 分支又会遇到同类问题。TypeScript 的 Node 模式也把 .d.mts / .d.cts 当成类型声明侧的模块格式标记;相关规则可以看 TypeScript Modules Reference。
这块不是一步到位想出来的,实际走了三轮。
第一轮先保留旧版文件名:index.esm.js、index.cjs.js、index.d.ts 和两个 UMD 文件都在,module 字段继续指向 ESM 产物。这个方案对旧 bundler 友好,但 publint 会提醒:在 type: commonjs 的包里,index.esm.js 这个名字不会让 Node 把它当成 ESM;Node 原生也不会读取 module 字段。
第二轮改成 .mjs / .cjs 和 exports。运行时入口清楚了,import 和 require 都能通过包名自引用验证,但类型入口又出现新 warning:同一份 index.d.ts 同时服务 ESM 和 CJS 分支时,TypeScript 在 Node 模式下会根据文件扩展名和 type 判断声明文件的模块格式,require 分支因此需要一个 CJS 形态的类型入口。
第三轮才定成现在的做法:类型内容只生成一份 index.d.ts,构建后复制成 index.d.cts 给 CJS 分支使用。这个妥协会让 npm 包多一个 51 KB 左右的未压缩声明文件,但它不增加维护成本;package smoke 会检查两份声明内容完全一致,publint 也能变成 All good!。
这个结果比「只保留一份类型文件」多了一个文件,但边界更诚实:维护源仍然只有一份,发布契约按 Node / TypeScript / publint 的规则给不同消费者不同入口。
版本号也从 0.0.23 提到 0.1.0。这次改的不只是内部实现,还改了工具链、文档分发方式和发布检查;如果继续用旧版本号,真正发布时也会撞上已经存在的 npm 版本。
es-toolkit 不应该机械替换
es-toolkit 有两个常见入口:
es-toolkit:更适合长期维护,API 更严格,也更利于 tree-shaking。es-toolkit/compat:更接近 Lodash 兼容层,适合迁移时先降低行为差异。
这次我没有把所有 lodash-es 调用机械换成 compat。更稳的拆法是三类:
| 类型 | 处理方式 | 例子 |
|---|---|---|
| 原生 JS 足够清楚 | 直接改成本地实现 | sum、initial、last、map、max、min |
| 复杂语义交给成熟库 | 使用 es-toolkit 严格入口 |
isEqual、pick、omit、camelCase、snakeCase |
| 兼容语义很小 | 写成本地内部函数 | Lodash eq 的 SameValueZero 语义 |
比如 eq 只需要处理 NaN 等于 NaN 这件事,没有必要为了一个函数保留兼容层:
export const sameValueZero = (value: unknown, other: unknown) => value === other || (value !== value && other !== other);dayjs 也被去掉了。它在这个库里只用于 SQL 模板转义里的固定日期格式,换成本地函数更直接:
const formatDateTime = (date: Date) => {
const pad = (value: number) => value.toString().padStart(2, '0');
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
].join('-') + ' ' + [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join(':');
};这样迁完以后,源码里只剩 es-toolkit 这一类第三方语义依赖。更重要的是,哪些地方真的需要成熟库,哪些地方只是数组和数字计算,代码里能看得很清楚。
补几个常见小函数
迁依赖时顺手翻了一遍公开 API,发现旧工具库里还缺几类很常见的小函数:按投影结果比较两个值、只挑几个字段比较两个对象、把外部输入归一成有限数字。这些逻辑属于许多前端状态同步、表单处理和接口归一都会遇到的基础动作,不该散落成项目里的近似实现。
所以这次没有把它们写成一段需要读者复制后再改的项目代码,而是直接补进 sheng-tool 的公开入口。读者需要时可以直接 import;如果项目边界更严格,也可以在这几个函数外面再包一层。
对象比较这两个函数放在 src/object.ts:
export const isEqualBy = <T, M>(
value: T,
other: T,
mapper: (value: T) => M,
) => isEqual(mapper(value), mapper(other));
export const isEqualByPick = <T extends object, K extends keyof T>(
value: T,
other: T,
keys: readonly K[],
) => isEqualBy(value, other, (item) => pick(item, keys));数值归一放在 src/number.ts:
export const toFiniteNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value.replace(/,/g, ''));
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
};这类函数看起来很小,但它们正适合进入工具库:业务项目不应该反复手写「按 id 比较两个对象」「把接口里的数值字符串归一成 number」这种逻辑。小函数不代表不需要测试,越小越容易被随手改坏。
顺手把公开语义定清楚
既然这次准备发一个大版本,就不能只把工具链换新,公开 API 里几处长期含糊的行为也要一起定下来。
第一类是格式校验函数。邮箱、手机号、URL、Base64、银行卡、MAC 地址和身份证号,都很容易在「严格符合规范」和「前端表单先挡一下明显错误」之间摇摆。最后没有把它们拆成两套函数,而是给第二个参数加模式:
isURL('www.qq.com'); // true
isURL('www.qq.com', 'strict'); // false
isBankCardCode('6212263602033054274'); // true
isBankCardCode('6212263602033054274', 'strict'); // false默认是 loose,适合输入层做基础筛查;显式传 strict 时才执行更硬的规则,比如 URL 必须能被 URL parser 解析成 HTTP(S) 地址,银行卡要继续跑 Luhn 校验,身份证要检查 18 位校验码。这样既能保留旧项目升级时的实用性,也给真正需要强校验的地方一个明确开关。
第二类是非法参数。everyNth(arr, 0) 过去会返回空数组,因为 i % 0 是 NaN;everyNth(arr, -1) 又会因为 i % -1 === 0 返回整个数组。这些结果都来自 JS 取余和隐式比较,不是工具函数主动设计过的语义。
这次先把 nth 在类型上定成正整数,让明显错误尽量在 tsc 阶段暴露;运行时遇到动态非法值时,不再沿用 JS 的怪结果,也不把筛选函数变成校验器,而是统一返回空数组:
export type PositiveInteger<N extends number> = number extends N
? N
: `${N}` extends '0' | `-${string}` | `${string}.${string}`
? never
: N;
export const everyNth = <T, N extends number>(arr: T[], nth: PositiveInteger<N>) => {
if (!Number.isInteger(nth) || nth <= 0) {
return [];
}
return arr.filter((v, i) => i % nth === 0);
};这里用了两层约束:数字字面量里的 0、负数和小数会在 tsc 阶段报错;运行时算出来的普通 number 仍然允许传入,再统一处理 0、负数、小数、NaN 和 Infinity。类型系统能提前挡掉明显错误,但不能证明用户输入、接口返回或 Number(input) 这类动态值一定是正整数,所以运行时分支仍然不能删。
第三类是 key 转换函数的类型。旧版 camelCaseObject、pascalCaseObject、snakeCaseObject 返回的是原始 T,但运行时 key 已经变了。也就是说,类型系统会让人以为 result.user_name 还在。新版用模板字面量类型表达转换后的 key:
const result = camelCaseObject({
user_id: 1,
nested_value: [{ child_id: 'a' }],
} as const);
result.userId; // 1
result.nestedValue[0].childId; // 'a'这里没有为了类型漂亮就改变运行时边界。函数仍然只递归 plain object 和 array,Date、Map、Set、类实例这些值会原样保留。
第四类是循环引用。key 转换函数已经用 WeakMap 处理循环,replaceValueFromObject 也应该一致。新版会克隆对象图,并保留原来的引用关系:
const source = { count: 1 };
source.self = source;
const result = replaceValueFromObject(source, 1, 2);
result.count; // 2
result.self === result; // true
source.count; // 1对工具库来说,这些决定比多加几个函数更重要。它们会告诉后面的维护者:哪些地方默认实用,哪些地方显式严格;哪些非法参数应该显式报错,哪些应该返回一个收敛的边界值;哪些工具承诺不会因为循环引用爆栈。
注释还是测试,但生成器换一套
新版继续保留 @example 作为测试来源,但不再依赖 TypeDoc 的旧内部结构,而是用 TypeScript compiler API 读取源码前导 JSDoc。
生成器现在支持三种输入:
表达式 // 期望值或表达式 // => 期望值:转成expect(表达式).toEqual(期望值)。表达式 // throws ErrorName:转成expect(() => 表达式).toThrow(ErrorName),适合说明不合理参数会报错。- 直接包含
expect(...)的代码块:作为兜底能力保留,用于少数确实需要放进文档的异常或复杂 setup。
源码里的 @example 默认仍然保持普通示例。如果异常是公开行为,就用 // throws RangeError 这种读者能直接理解的写法;类型推导、循环引用这类测试细节继续放在 tests/ 里。
核心解析大概是这样:
export function parseAssertionLine(line: string): ExampleAssertion | undefined {
const normalizedLine = line.replace(/^\s*\*\s?/, '');
const matched = /^(?<expression>.+?)\s{2,}\/\/(?:\s*=>)?\s*(?<expected>.+?)\s*$/.exec(normalizedLine);
if (!matched?.groups) {
return undefined;
}
const expression = matched.groups.expression.trim().replace(/;$/, '');
const expected = matched.groups.expected.trim();
const throwAssertion = parseThrowAssertion(expression, expected);
if (throwAssertion) {
return throwAssertion;
}
if (!expression || !isExpectedExpression(expected)) {
return undefined;
}
return {
kind: 'equal',
expression,
expected,
};
}无法解析的 @example 不会被悄悄忽略,而是写进 tests/generated/examples.report.json 并让生成命令失败。浏览器模块依然跳过自动生成,因为 DOM、剪贴板和视口判断更适合放在 tests/browser 的 happy-dom 用例里;但跳过原因也会写进 report。
当前 report 大概长这样:
{
"generated": {
"cases": 92,
"assertions": 225,
"executableBlocks": 0
},
"unparsed": []
}生成结果放在 tests/generated/examples.test.ts,并加了提示:
// @ts-nocheck
// 这个文件由 scripts/generate-example-tests.ts 生成,不要手写修改。这里选择 @ts-nocheck 是有意的。注释里的例子首先是运行时文档,不是类型测试;如果要测类型边界,应该单独写类型测试。把两件事混在一起,最后往往会让文档示例为了满足类型检查变得不自然。
手写测试补生成器管不到的地方
生成测试现在能覆盖大多数短示例,但运行时环境、类型推导和复杂对象引用关系仍然更适合手写测试。改造时我又补了几组用例:
replaceValueFromObject:确保替换不会把不匹配的 primitive 误删,也能按 predicate 转换嵌套值。replaceEmptyValueFromObject:确认只有存在默认值的 key 才会替换空值。camelCaseObject:确认循环引用不会爆栈,并且转换后的引用关系仍然闭合。camelCaseObject/pascalCaseObject/snakeCaseObject:用expectTypeOf和@ts-expect-error确认 key 转换类型是真的。everyNth:确认数字字面量里的0和小数会被类型拦住,动态传入的0、负数、小数、NaN、Infinity和非数字值都会返回空数组。format-validation:覆盖宽松 / 严格模式下 URL、Base64、MAC、身份证、银行卡等差异。generate-example-tests:确认简单断言、可执行expect代码块、显式跳过和无法解析的 report 都能工作。toFiniteNumber:确认千分位字符串、空字符串、Infinity和 fallback 行为。date/string/extra:补getWeeksInMonth的日历行数分支、SQL 模板转义分支、URL pattern 分支和中文数字递归补零。- 浏览器函数:用
happy-dom跑getQueryString、getFullUrl、动态 script 注入、滚动锁定、复制、等待、轮询和 viewport 判断。 - 浏览器类型判断:通过替换 UA 覆盖微信、小程序、移动端、桌面端、QQ 浏览器、爬虫、iOS 和 Android。
这次迁移里,测试确实暴露了旧实现的小问题。比如 replaceValueFromObject 原本递归到 primitive 时,如果没有命中替换条件,可能返回 undefined。这个在示例测试和手写测试一起跑时很容易看出来。
修完后还顺手加上了循环引用保护。核心结构是:先判断当前值是否需要替换,再用 WeakMap 记录已经克隆过的对象 / 数组,遇到回环时复用克隆对象。
export const replaceValueFromObject = (obj: any, from: any, to: any, autoExecuteFunction = true): any => {
const shouldReplace = (value: any) => autoExecuteFunction && isFunction(from) ? from(value) : isEqual(value, from);
const getReplacement = (value: any) => autoExecuteFunction && isFunction(to) ? to(value) : to;
const visit = (value: any, seen: WeakMap<object, any>): any => {
if (shouldReplace(value)) {
return getReplacement(value);
}
if (Array.isArray(value)) {
if (seen.has(value)) {
return seen.get(value);
}
const result: any[] = [];
seen.set(value, result);
for (const item of value) {
result.push(visit(item, seen));
}
return result;
}
if (!isPlainObject(value)) {
return value;
}
if (seen.has(value)) {
return seen.get(value);
}
const result: Record<string, unknown> = {};
seen.set(value, result);
for (const [key, nestedValue] of Object.entries(value)) {
result[key] = visit(nestedValue, seen);
}
return result;
};
return visit(obj, new WeakMap());
};这种 bug 不大,但它很适合作为「为什么要保留注释示例测试」的证据。工具库里的函数通常没有复杂业务流程,真正容易坏的是边界语义。
注释也要写成公共契约
把 @example 接进测试以后,注释就不再只是 README 的素材了。它至少有三层身份:
- 给人看的 API 文档。
- 给 TypeDoc 生成站点的结构化输入。
- 给
generate-example-tests提取运行时示例的测试来源。
这也意味着注释不能只写「函数做什么」。迁移后我又补了一轮 TSDoc,把已经由测试固定下来的边界写进公开文档。
第一类是导出的类型。PositiveInteger、ValidationMode、CamelCaseObject 这些类型会出现在 TypeDoc 里,读者会把它们当成公开 API 阅读。比如 PositiveInteger 需要明确说明它只能拦住数字字面量:
/**
* 正整数字面量类型。
*
* 数字字面量中的 `0`、负数和小数会被收窄为 `never`;普通 `number` 仍然会保留为 `number`,
* 因为 TypeScript 无法在静态阶段证明运行时数字一定是正整数。
*/
export type PositiveInteger<N extends number> = number extends N
? N
: `${N}` extends '0' | `-${string}` | `${string}.${string}`
? never
: N;这样读者不会误以为有了类型就能删掉运行时边界。everyNth 自己也要写清楚:类型只能拦住数字字面量,动态非法值会返回空数组;这属于公开行为,不是测试里的偶然细节。
第二类是模式参数。ValidationMode 如果只在每个函数里零散解释,读者很难知道这是不是统一设计。现在它在类型定义上先给总口径:loose 是默认模式,偏向业务表单里的实用形态判断;strict 才在形态之外增加更强约束,例如 URL parser、银行卡 Luhn、身份证校验码和 Base64 payload。
第三类是对象转换和浏览器 API 的运行时边界。camelCaseObject 这类 key 转换函数要说明只递归普通对象和数组,Date、RegExp、Map、Set、函数等内建对象会保持原引用;replaceValueFromObject 要说明它会克隆普通对象 / 数组、保留循环引用关系、不会修改输入对象;copyHTML 要说明它依赖 navigator.clipboard.write 和 ClipboardItem,不支持时会 reject,而且不能像纯文本复制那样用 textarea 兜底。
这里的判断标准很简单:凡是会影响调用方怎么接入、怎么处理失败、能不能依赖类型、会不会修改原对象、是否兼容某类运行环境的行为,都应该进入 TSDoc。单测负责证明这些行为没有退化,注释负责让读者在写代码前就知道这些行为存在。
这类注释会进入声明文件,也会让 npm tarball 稍微变大。这里我选择接受这点体积增长,因为 sheng-tool 本来就是给人直接 import 的工具库;调用方在编辑器里 hover 到 API 时能看到边界,比少几 KB 更重要。
构建换成 tsdown
旧链路是 Rollup 2 加 TypeScript 插件,再接 API Extractor。对这个库来说,它承担了太多维护成本。
一开始我考虑过 tsup,因为它过去是小型 TypeScript 库很常见的选择:配置短,能同时输出 ESM、CJS 和类型声明。但重新看官方说明后,这个选择应该收回。tsup 的 README 已经明确提示项目不再积极维护,并建议迁到 tsdown。
Vite 也能做 library mode,它更适合「应用构建工具顺便支持库模式」的场景。Vite 的 build.lib 可以打 ESM/CJS/UMD,也能通过底层 bundler options 控制依赖;TypeScript 这块官方定位仍然是 transpile only,完整 type checking 和 .d.ts 通常还要另外接 tsc --emitDeclarationOnly 或插件。sheng-tool 是纯工具库,核心诉求集中在稳定的 npm 包入口、声明文件、UMD CDN 包和发布文件检查。
所以最后换成 tsdown。它基于 Rolldown,定位就是库打包;ESM/CJS/UMD、声明文件、压缩产物和依赖内联都能放在一套配置里,不需要把 Vite library mode 和另一套 d.ts 生成链路拼起来。
新版用 tsdown 仍然只从 src/index.ts 进来,但输出改成现代入口:
import { defineConfig } from 'tsdown';
const baseConfig = {
entry: {
index: 'src/index.ts',
},
deps: {
alwaysBundle: [/.*/],
onlyBundle: ['es-toolkit'],
},
exports: false,
fixedExtension: false,
hash: false,
target: 'es2020',
};
export default defineConfig([
{
...baseConfig,
clean: true,
dts: true,
format: 'esm',
outputOptions: {
entryFileNames: chunk => chunk.name.endsWith('.d') ? 'index.d.ts' : 'index.mjs',
},
},
{
...baseConfig,
clean: false,
dts: false,
format: 'cjs',
outputOptions: {
entryFileNames: 'index.cjs',
},
},
{
...baseConfig,
clean: false,
dts: false,
format: 'umd',
globalName: 'shengTool',
outputOptions: {
entryFileNames: 'index.umd.js',
},
platform: 'neutral',
},
{
...baseConfig,
clean: false,
dts: false,
format: 'umd',
globalName: 'shengTool',
minify: true,
outputOptions: {
entryFileNames: 'index.umd.min.js',
},
platform: 'neutral',
},
]);这段配置仍然输出 index.umd.js 和 index.umd.min.js,给 unpkg / jsDelivr 直引使用。核心变化在 ESM/CJS 文件名:
outputOptions: {
entryFileNames: chunk => chunk.name.endsWith('.d') ? 'index.d.ts' : 'index.mjs',
}
// CJS 构建:
outputOptions: {
entryFileNames: 'index.cjs',
}这里有四处细节。
第一,入口只保留 src/index.ts。array、object、number 这些文件仍然可以作为源码组织方式存在,但不暴露成 sheng-tool/array 这类公开路径。这样以后内部移动文件,不会破坏用户 import。
第二,es-toolkit 被打进产物。sheng-tool 对外卖的是一组稳定函数,es-toolkit 只是内部实现依赖;把它放在 devDependencies 并在构建时内联,用户用 npm、unpkg 或 jsDelivr 都不需要额外处理第三方依赖。onlyBundle: ['es-toolkit'] 是一道护栏,后面如果又引入了别的依赖,构建会提醒它不能被悄悄打进包里。
第三,运行时入口改成 .mjs / .cjs。这比 index.esm.js 加 module 字段更明确:Node、bundler 和包检查工具都能直接读懂文件格式。module 字段仍然保留,给仍读取它的 bundler 一个兼容入口;真正的现代入口由 exports.import 和 exports.require 承担。
第四,CJS 类型文件由脚本复制出来:
import { copyFile } from 'node:fs/promises';
await copyFile(
new URL('../dist/index.d.ts', import.meta.url),
new URL('../dist/index.d.cts', import.meta.url)
);这一步不是生成第二套类型。它只是补一个 .d.cts 文件名,让 TypeScript 在解析 require 条件时知道这是 CJS 分支的声明文件。package smoke 会检查 index.d.ts 和 index.d.cts 内容完全一致,避免后续有人手改其中一份。
声明文件 map 这次没有打开。sheng-tool 的 npm 包只发布 dist/,不带 src/;如果生成 .d.ts.map,用户跳转类型时反而会指向安装包里不存在的源码路径。除非以后决定把源码也随包发布,或只在 monorepo 内部消费,否则声明文件本身就够了。
脚本也重新整理成一条发布前检查链:
{
"scripts": {
"clean": "rimraf dist coverage tests/generated docs",
"generate:examples": "tsx scripts/generate-example-tests.ts",
"typecheck": "tsc --noEmit",
"test:unit": "vitest run --coverage",
"test:package": "node scripts/package-smoke.mjs",
"test": "pnpm run generate:examples && pnpm run test:unit",
"build": "pnpm run clean && pnpm run generate:examples && pnpm run typecheck && pnpm run test:unit && tsdown && node scripts/sync-cjs-types.mjs",
"docs:build": "typedoc",
"pack:check": "pnpm run build && pnpm run test:package && pnpm exec publint && npm pack --dry-run",
"prepublishOnly": "pnpm run pack:check"
}
}prepublishOnly 只是一道兜底。真正开发时还是要主动跑 pnpm run pack:check,因为它能在发布前看到打包出来的文件清单。
包冒烟不要省
单测证明源码入口能跑,package smoke 负责检查 npm 包消费者实际拿到的发布产物。这次最需要守住的是发布契约:import / require 都要走 package exports,UMD 全局变量要能跑,包里不能混进 docs/,es-toolkit 也不能重新出现在运行时 dependencies 里。
所以我加了一个很小的 package smoke,核心检查大概如下:
import { createRequire } from 'node:module';
import { readFile, readdir } from 'node:fs/promises';
import vm from 'node:vm';
const require = createRequire(import.meta.url);
const cjs = require('sheng-tool');
const esm = await import('sheng-tool');
const packageJson = require('../package.json');
const distFiles = await readdir(new URL('../dist/', import.meta.url));
const esmTypes = await readFile(new URL('../dist/index.d.ts', import.meta.url), 'utf8');
const cjsTypes = await readFile(new URL('../dist/index.d.cts', import.meta.url), 'utf8');
const umdCode = await readFile(new URL('../dist/index.umd.js', import.meta.url), 'utf8');
const expectedDistFiles = [
'index.cjs',
'index.d.cts',
'index.d.ts',
'index.mjs',
'index.umd.js',
'index.umd.min.js',
];
const sandbox = {};
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
sandbox.window = sandbox;
vm.runInNewContext(umdCode, sandbox);
const checks = [
['cjs root', cjs.toFiniteNumber('1,000') === 1000],
['esm root', esm.toFiniteNumber('1,000') === 1000],
['umd root', sandbox.shengTool?.toFiniteNumber('1,000') === 1000],
['dist files', expectedDistFiles.every(file => distFiles.includes(file))],
['types synchronized', esmTypes === cjsTypes],
['bundled dependency', !packageJson.dependencies?.['es-toolkit']],
['docs not published', !packageJson.files.includes('docs/')],
];
const failed = checks.filter(([, passed]) => !passed).map(([name]) => name);
if (failed.length > 0) {
throw new Error(`Package smoke test failed: ${failed.join(', ')}`);
}这里覆盖的是发布契约:
- ESM / CJS 根入口,而且通过包名走
exports自引用。 - UMD 全局变量
shengTool。 - 运行时和类型入口文件都在
dist/。 .d.ts和.d.cts内容保持同步。es-toolkit不作为运行时依赖暴露给用户。docs/不随 npm 包发布。
publint 再补一层包结构检查。第一次尝试只保留 index.d.ts 时,publint 会提醒 require 分支拿到的是 ESM 类型;把 .d.cts 补上以后,检查结果变成 All good!。这个反馈说明类型入口也属于发布契约,不能只看运行时 JS 文件能不能执行。
文档改到 GitHub Pages
TypeDoc 继续保留,但它只负责生成 HTML 文档,不再把 docs/ 当源码提交进 npm 包。
配置也收敛成普通 typedoc.json:
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["src/index.ts"],
"out": "docs",
"readme": "readme.md",
"excludeInternal": true,
"excludePrivate": true,
"navigationLinks": {
"GitHub": "https://github.com/dev-itsheng/sheng-tool"
}
}CI 里分两条线:
CI:安装依赖、类型检查、测试、构建、package smoke、publint、npm pack --dry-run。Docs:安装依赖、pnpm run docs:build、上传 Pages artifact、部署 GitHub Pages。
本地 .gitignore 加了 docs/ 和 tests/generated/。这两个目录都应该可再生成,不应该让人 review HTML、搜索索引和生成测试文件。
迁移中 TypeDoc 还帮忙抓了一个小拼写:src/string.ts 里有一处写成了 @params str,新版会提示 unknown block tag。这个错误不影响运行时,但会污染文档结构;既然文档也进 CI,就顺手修掉。
TypeScript 没有追最新
这次把 TypeScript 锁到 ~6.0.3。主要原因是 TypeDoc 当前版本的 peer range 支持 5.x || 6.0.x,还没有覆盖 TS 7。
工具库升级时很容易犯一个错:所有包都升到最新,看起来「现代化」了,最后 peer dependency 全靠忽略。更稳的做法是按链路选择能共同工作的最高版本。本地用 pnpm peers check 确认没有 peer dependency 问题后,再把这组版本固定下来。
pnpm-workspace.yaml 里还记录了 pnpm 对构建脚本的允许项:
allowBuilds:
esbuild: true这是因为开发脚本里的 tsx 会用到 esbuild,pnpm 新版会对 install scripts 更谨慎。与其每台机器安装时弹交互,不如在仓库里明确记录。
最后验收
改完以后,本地跑的是这一组:
pnpm peers check
pnpm run pack:check
pnpm run docs:build
git diff --check结果是:
No peer dependency issues found
Test Files 11 passed (11)
Tests 167 passed (167)
Coverage statements / branches / functions / lines all 100%
Package smoke test passed.
publint All good!
typedoc html generated at ./docspublint 一开始还提醒过类型入口:同一份 .d.ts 在 type: module 下会按 ESM 解释,不能直接给 require 条件当 CJS 类型。最后通过复制出 index.d.cts 解决,声明内容仍然只维护一份。
真正发布后又补了一次 patch:0.1.0 里 everyNth 对非法 nth 直接抛 RangeError,这个语义比旧版严格太多,也不太像一个数组筛选工具。npm registry 的包版本是不可变的,npm Unpublish Policy 明确说已经使用过的 package@version 不能重新发布,即使 unpublish 也不能复用同一个版本号;同一份文档也建议在不想破坏依赖方构建时使用 deprecate 给出警告。
所以最后没有撤回重发 0.1.0,而是发布 0.1.1:everyNth 的运行时非法参数统一返回 [],0.1.0 用 npm deprecate sheng-tool@0.1.0 "0.1.0 中 everyNth 对非法 nth 直接抛错,建议升级到 0.1.1。" 标记成不推荐版本。
最终 0.1.1 的 npm pack --dry-run 输出也符合预期:
name: sheng-tool
version: 0.1.1
filename: sheng-tool-0.1.1.tgz
package size: 112.5 kB
unpacked size: 385.5 kB
total files: 10包里只剩 CHANGELOG.md、dist/index.mjs、dist/index.cjs、dist/index.d.ts、dist/index.d.cts、两个 UMD 文件、README.md、LICENSE 和 package.json。旧的 docs/、TypeDoc 静态资源、Jest、Rollup、API Extractor、Babel 配置都不再参与发布。
这次改造以后,sheng-tool 还不是「功能很多」的库,但它重新变成了一个能继续长的库:源码依赖更少,构建边界更清楚,注释示例会被测试约束,npm 包内容能在发布前看见,文档也可以独立部署。
对这种多年没动的小工具库来说,第一步是先让维护链路重新可信。链路可信以后,再补进那些小而通用的函数,就不会把临时经验继续留在各个项目里,也不会让它们变成下一轮迁移时的历史包袱。