把旧工具库迁到 es-toolkit:一次 sheng-tool 现代化改造

sheng-tool 是一个很早就写起来的小工具库。当时的背景很简单:项目里经常需要一些小函数,市面上的工具库要么太大,要么语义不完全贴合自己的项目,所以就把常用函数收在一起。

这个库里有一个我现在看仍然觉得值得保留的设计:函数注释里的 @example 同时是 TSDoc、测试和文档。也就是说,源码里写给读者看的例子,不只是说明文字,还会被脚本抽出来生成单测;如果例子和真实行为不一致,测试会直接报错。

几年以后再看,它的问题也很明显。依赖还停在 lodash-esdayjs,测试是 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.mjsindex.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: commonjsimport 分支又会遇到同类问题。TypeScript 的 Node 模式也把 .d.mts / .d.cts 当成类型声明侧的模块格式标记;相关规则可以看 TypeScript Modules Reference

这块不是一步到位想出来的,实际走了三轮。

第一轮先保留旧版文件名:index.esm.jsindex.cjs.jsindex.d.ts 和两个 UMD 文件都在,module 字段继续指向 ESM 产物。这个方案对旧 bundler 友好,但 publint 会提醒:在 type: commonjs 的包里,index.esm.js 这个名字不会让 Node 把它当成 ESM;Node 原生也不会读取 module 字段。

第二轮改成 .mjs / .cjsexports。运行时入口清楚了,importrequire 都能通过包名自引用验证,但类型入口又出现新 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 足够清楚 直接改成本地实现 suminitiallastmapmaxmin
复杂语义交给成熟库 使用 es-toolkit 严格入口 isEqualpickomitcamelCasesnakeCase
兼容语义很小 写成本地内部函数 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 % 0NaNeveryNth(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、负数、小数、NaNInfinity。类型系统能提前挡掉明显错误,但不能证明用户输入、接口返回或 Number(input) 这类动态值一定是正整数,所以运行时分支仍然不能删。

第三类是 key 转换函数的类型。旧版 camelCaseObjectpascalCaseObjectsnakeCaseObject 返回的是原始 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、负数、小数、NaNInfinity 和非数字值都会返回空数组。
  • format-validation:覆盖宽松 / 严格模式下 URL、Base64、MAC、身份证、银行卡等差异。
  • generate-example-tests:确认简单断言、可执行 expect 代码块、显式跳过和无法解析的 report 都能工作。
  • toFiniteNumber:确认千分位字符串、空字符串、Infinity 和 fallback 行为。
  • date / string / extra:补 getWeeksInMonth 的日历行数分支、SQL 模板转义分支、URL pattern 分支和中文数字递归补零。
  • 浏览器函数:用 happy-domgetQueryStringgetFullUrl、动态 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,把已经由测试固定下来的边界写进公开文档。

第一类是导出的类型。PositiveIntegerValidationModeCamelCaseObject 这些类型会出现在 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 转换函数要说明只递归普通对象和数组,DateRegExpMapSet、函数等内建对象会保持原引用;replaceValueFromObject 要说明它会克隆普通对象 / 数组、保留循环引用关系、不会修改输入对象;copyHTML 要说明它依赖 navigator.clipboard.writeClipboardItem,不支持时会 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.jsindex.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.tsarrayobjectnumber 这些文件仍然可以作为源码组织方式存在,但不暴露成 sheng-tool/array 这类公开路径。这样以后内部移动文件,不会破坏用户 import。

第二,es-toolkit 被打进产物。sheng-tool 对外卖的是一组稳定函数,es-toolkit 只是内部实现依赖;把它放在 devDependencies 并在构建时内联,用户用 npm、unpkg 或 jsDelivr 都不需要额外处理第三方依赖。onlyBundle: ['es-toolkit'] 是一道护栏,后面如果又引入了别的依赖,构建会提醒它不能被悄悄打进包里。

第三,运行时入口改成 .mjs / .cjs。这比 index.esm.jsmodule 字段更明确:Node、bundler 和包检查工具都能直接读懂文件格式。module 字段仍然保留,给仍读取它的 bundler 一个兼容入口;真正的现代入口由 exports.importexports.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.tsindex.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 ./docs

publint 一开始还提醒过类型入口:同一份 .d.tstype: module 下会按 ESM 解释,不能直接给 require 条件当 CJS 类型。最后通过复制出 index.d.cts 解决,声明内容仍然只维护一份。

真正发布后又补了一次 patch:0.1.0everyNth 对非法 nth 直接抛 RangeError,这个语义比旧版严格太多,也不太像一个数组筛选工具。npm registry 的包版本是不可变的,npm Unpublish Policy 明确说已经使用过的 package@version 不能重新发布,即使 unpublish 也不能复用同一个版本号;同一份文档也建议在不想破坏依赖方构建时使用 deprecate 给出警告。

所以最后没有撤回重发 0.1.0,而是发布 0.1.1everyNth 的运行时非法参数统一返回 []0.1.0npm deprecate sheng-tool@0.1.0 "0.1.0 中 everyNth 对非法 nth 直接抛错,建议升级到 0.1.1。" 标记成不推荐版本。

最终 0.1.1npm 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.mddist/index.mjsdist/index.cjsdist/index.d.tsdist/index.d.cts、两个 UMD 文件、README.mdLICENSEpackage.json。旧的 docs/、TypeDoc 静态资源、Jest、Rollup、API Extractor、Babel 配置都不再参与发布。

这次改造以后,sheng-tool 还不是「功能很多」的库,但它重新变成了一个能继续长的库:源码依赖更少,构建边界更清楚,注释示例会被测试约束,npm 包内容能在发布前看见,文档也可以独立部署。

对这种多年没动的小工具库来说,第一步是先让维护链路重新可信。链路可信以后,再补进那些小而通用的函数,就不会把临时经验继续留在各个项目里,也不会让它们变成下一轮迁移时的历史包袱。