把旧工具库迁到 es-toolkit:一次 sheng-tool 现代化改造
sheng-tool 是一个很早就写起来的小工具库。当时的背景很简单:项目里经常需要一些小函数,市面上的工具库要么太大,要么语义不完全贴合自己的项目,所以就把常用函数收在一起。
这个库里有一个我现在看仍然觉得值得保留的设计:函数注释里的 @example 同时是 TSDoc、测试和文档。源码里写给读者看的例子,不只是说明文字,还会被脚本抽出来生成单测;如果例子和真实行为不一致,测试会直接报错。
几年以后再看,它的问题也很明显。依赖还停在 lodash-es 和 dayjs,测试是 Jest,构建是 Rollup 2,类型声明还靠 API Extractor 做一次额外处理,文档生成后直接放进 npm 包,再通过 unpkg 打开。工具库本身不大,工具链反而变成了最需要维护的部分。
这次改造要在保留工具库可用性的基础上,明确发布边界、补齐测试链路,让后续加函数和升级依赖都有可靠的检查依据。
下文的 API 行为以最终发布的 0.1.1 为准;0.1.0 曾对非法 nth 抛错,发布验收部分记录了为什么改成返回空数组,以及如何处理已发布版本。
先看旧状态
旧版 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 升级、复杂示例、浏览器环境、异常测试都不够稳。
因此,迁移除了替换依赖,还要调整构建和类型入口、更新示例测试生成器,并将文档分发与运行时发布分开。
包边界重新定一次
新版先明确五件事:
- 运行时代码只发布
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(':');
};
这个日期格式使用进程的本地时区,不包含时区标记;如果 SQL 协议要求 UTC,需要另定转换契约。toFiniteNumber 则会直接去掉逗号后转数字,并不校验千分位分组:1,2 也会变成 12,不能当作严格数值输入校验。
迁移后,源码里的第三方语义依赖只剩 es-toolkit。复杂比较和对象操作由它实现,简单的数组与数字计算则保留在本地,依赖范围也随之明确。
迁移也补齐了按投影比较、按字段比较与有限数值归一等小函数,避免业务项目继续维护近似实现。完整内容见迁移中补充的工具函数弹窗。
顺手把公开语义定清楚
既然要发布 0.1.0,我也把公开 API 里几处含糊的行为重新检查了一遍。
第一类是格式校验函数。邮箱、手机号、URL、Base64、银行卡、MAC 地址和身份证号,都很容易在「严格符合规范」和「前端表单先挡一下明显错误」之间摇摆。最后没有把它们拆成两套函数,而是给第二个参数加模式:
isURL('www.qq.com'); // true
isURL('www.qq.com', 'strict'); // false
isBankCardCode('6212263602033054274'); // true
isBankCardCode('6212263602033054274', 'strict'); // false
默认是 loose,用于输入层基础筛查;strict 增加更具体的规则,比如 HTTP(S) URL 解析、银行卡 Luhn 校验和身份证校验码。这里的「严格」仍只是格式规则:校验码正确不证明卡号或身份真实,HTTP(S) URL 也不等于可信地址。涉及身份、支付或服务端请求时,仍要做相应的业务与安全校验。
第二类是非法参数。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 阶段报错;科学计数法等边界仍要靠运行时检查,例如 ${1e-7} 不含小数点,会绕过这里的模板判断。运行时算出来的普通 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 也应该一致。新版会克隆对象图,并保留原来的引用关系:
type CyclicValue = { count: number; self?: CyclicValue };
const source: CyclicValue = { count: 1 };
source.self = source;
const result = replaceValueFromObject(source, 1, 2);
result.count; // 2
result.self === result; // true
source.count; // 1
这些调整明确了调用方可以依赖的行为:格式筛查默认宽松、按需严格,everyNth 的动态非法参数返回空数组,对象转换保留约定的递归范围和循环引用关系。
注释还是测试,但生成器换一套
新版继续保留 @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 是有意的。注释里的例子首先用于说明并验证运行行为,类型边界另写类型测试。若要求每个文档示例同时承担类型测试,就可能为了构造非法输入、标记预期错误而打断原本的用法说明。
生成测试之外,运行环境、类型推导和循环引用等边界仍需手写用例;修复要保持函数原有的边界语义。完整内容见生成测试之外的手写用例弹窗。
注释也要写成公共契约
把 @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 更重要。
旧 Rollup、TypeScript 插件与 API Extractor 链路维护成本较高,比较工具状态后改用 tsdown。ESM/CJS 类型入口、UMD 与声明文件仍按发布契约分别检查。完整内容见tsdown 构建与导出配置弹窗。
包冒烟通过 package exports 验证 import 与 require,检查 UMD、类型入口和实际文件清单;单测通过不能代替消费者拿到产物后的检查。完整内容见打包后的消费端冒烟弹窗。
TypeDoc 继续生成文档,GitHub Pages 负责分发;生成的 docs 与测试文件不作为源码或 npm 包内容提交。完整内容见文档部署与路径配置弹窗。
TypeScript 没有追最新
这次把 TypeScript 锁到 ~6.0.3。当时安装的 TypeDoc 版本声明支持 5.x || 6.0.x,没有覆盖 TS 7;这是这轮迁移的版本组合,不是长期升级上限。
版本选择要受整条工具链的兼容范围约束。这里按 TypeDoc 等依赖的支持范围选择能共同工作的最高版本,再用 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 ./docs
真正发布后又补了一次 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 这次迁移同时调整了实现依赖和发布契约:简单语义用本地实现,复杂语义交给 es-toolkit 严格入口并内联进产物;tsdown 生成 ESM、CJS 和 UMD,根 exports 为运行时与声明分别匹配模块格式。npm 包只保留运行所需文件,TypeDoc 文档改由 GitHub Pages 分发,类型维护源仍只有一份。
原有「注释即测试」的设计得以保留,生成器改用 TypeScript compiler API,无法解析的示例会报告并失败;手写测试补充类型、浏览器环境、循环引用和边界语义。公开注释也同步说明默认模式、非法输入和副作用。最终 0.1.1 对非法 nth 返回空数组,已发布的 0.1.0 则通过 deprecate 提醒升级,没有复用版本号重发。
本地检查已经通过示例与单元测试、构建、包名自引用 smoke、publint 和文档生成。后续增加函数或升级依赖,可以继续沿这条链验证源码行为、入口与文件清单;它尚未覆盖真实 tarball 在独立项目中的安装及 ESM / CJS 运行与类型消费,这部分仍要另做测试。
迁移中补充的工具函数
补几个常见小函数
迁依赖时顺手翻了一遍公开 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;
};生成测试之外的手写用例
手写测试补生成器管不到的地方
生成测试现在能覆盖大多数短示例,但运行时环境、类型推导和复杂对象引用关系仍然更适合手写测试。改造时我又补了几组用例:
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());
};tsdown 构建与导出配置
构建换成 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 分支的声明文件。这只适用于当前可在两种模块格式下使用的声明内容;包含 ESM 专属依赖或模块格式敏感导入时,不能机械复制。package smoke 检查两份内容一致,消费端类型测试还需分别采用 ESM / CJS 解析模式。
声明文件 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 则通过发布入口检查本地构建产物。这次最需要守住的是发布契约: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(', ')}`);
}
这里检查的是本地构建目录和包名自引用,还没有把真实 tarball 安装到独立项目。npm pack --dry-run 用来复核文件清单;要验证完整消费过程,还需在临时项目安装打出的 .tgz,分别跑 ESM / CJS 运行与类型检查。
当前 smoke 覆盖这些本地发布约定:
- 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 文档,GitHub Pages 负责分发,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,就顺手修掉。