自制XSF工具库实战:模块化配置、按需引入与示例
项目标题里的“自制”两个字,往往比库本身更能说明问题。但凡一个人动手写自己的库,通常不是市面上没有可用方案,而是现成方案在某几个具体场景里总是差一口气:要么体积太大,要么依赖链太深,要么某个边界行为怎么调都别扭。XSF 就是在这种背景下长出来的东西——一个体量克制、模块边界清晰、能按需取用的自制工具库。它不试图做成“全家桶”,而是把日常开发里反复出现的字符串清洗、时间格式化、表单校验、请求重试、内存缓存这几类琐碎活儿收拢到一处,让调用方少写点重复代码。这篇内容会把 XSF 从“它是什么”讲到“怎么下载”、再到“工程里怎么配置”、最后落到“五个能直接跑起来的简易示例”,中间穿插我自己在接入和维护过程中遇到的具体问题。适合手里正攒着自己的工具集、或者准备把一个散落在各项目里的 util 目录整理成正式库的人看,也适合刚接触模块化工程配置、想弄明白 exports 字段到底怎么回事的读者。
1. XSF 这个自制库到底解决了什么问题
1.1 三个重复劳动的瞬间
先说清楚动机,否则后面讲下载和配置都会显得没头没尾。XSF 的诞生不是因为有什么高深的技术追求,纯粹是被三件反复发生的小事磨出来的。
第一件事是字符串处理。几乎每个项目里都有一份叫utils.js或者helpers.ts的文件,里面躺着几个功能高度相似但实现略有差异的函数:有的负责去掉首尾空白,有的负责把全角字符转半角,有的负责把手机号中间四位打码。这些函数在不同项目里被复制粘贴了七八遍,改一个 bug 要改七八个地方,还经常漏掉。第二件事是时间格式化。原生日期对象的方法用起来啰嗦,社区方案又往往带着完整的时区数据库,一个只需要把时间戳转成“2024-06-01 12:30”的小需求,最后可能引入几百 KB 的依赖。第三件事是请求封装。超时、重试、取消,这三个需求在任何有网络请求的项目里都会出现,但每次都要从头写一遍状态判断,写完还得自己测。
这三件事有一个共同特征:逻辑不复杂,但细节多,容易写错,且高度可复用。XSF 的定位就是把这类“不复杂但容易写错”的东西沉淀下来,做成一个可以跨项目直接取用的库。它的名字是内部代号,取的是“小型、够用”的意思,没有特别的含义,读者完全可以在自己的项目里换成任何你喜欢的名字。
1.2 模块划分与能力边界
XSF 目前的模块划分是六个,每个模块单独导出一个入口,互不依赖。这个“互不依赖”是刻意设计的,后面讲打包体积的时候会看到它的价值。
| 模块 | 入口路径 | 主要能力 | 体积量级 |
|---|---|---|---|
| 字符串 | xsf/string | 清洗、脱敏、模板填充、全半角转换 | 约 3 KB |
| 日期 | xsf/date | 格式化、相对时间、时区偏移换算 | 约 4 KB |
| 校验 | xsf/validate | 组合式规则、异步校验、错误聚合 | 约 5 KB |
| 请求 | xsf/request | 超时、重试、取消、统一错误结构 | 约 6 KB |
| 缓存 | xsf/cache | TTL 过期、LRU 淘汰、容量上限 | 约 4 KB |
| 日志 | xsf/logger | 分级输出、环境开关、格式化 | 约 2 KB |
表里的体积是压缩后未再 gzip 的粗略量级,实际数字会随构建配置浮动,但相对关系是稳定的:请求模块最重,因为它要处理状态机;日志模块最轻,因为它本质上只做字符串拼接和条件判断。
能力边界同样要说清楚。XSF 不做 UI 组件,不做状态管理,不做路由,也不做任何与框架强绑定的东西。它只处理纯数据层面的通用逻辑,这意味着它可以同时用在浏览器、Node 服务端、甚至某些边缘运行环境里。这条边界是我在做过一次失败尝试之后才划定的:早期版本里我加过几个和特定框架生命周期绑定的辅助函数,结果每次框架升级大版本,库就得跟着改,维护成本远超收益。砍掉之后,整个库的升级频率从每月一次降到了半年一次。
1.3 为什么不做成“大而全”的框架
这个问题我被问过不止一次:既然都自己写库了,为什么不干脆做成一个完整的开发框架,把请求、状态、路由、组件全包了?
理由很实在:维护成本随功能数量呈超线性增长。一个只有六个纯函数模块的库,我需要关注的是输入输出契约和边界条件;一旦引入状态管理和路由,我需要关注的是生命周期、并发时序、内存泄漏、服务端渲染兼容性,每一项都是持续投入。以我一个人的精力,做成大而全的结果大概率是每个部分都半吊子,最后连最基础的字符串处理都没人敢用。
另一个理由是替换成本。纯函数模块的替换成本极低,调用方换掉一个函数只需要改一行引用;而框架级的东西一旦被业务代码深度绑定,想换就是伤筋动骨。把库做小,反而让它在项目里的存活时间更长。这个判断在后面讲版本号策略时还会再提一次,因为它直接决定了 XSF 的版本演进方式。
2. 把 XSF 拿到本地:四种下载路径与适用场景
2.1 包管理器直装:最省事也最容易埋雷
常规做法是在项目根目录执行安装命令,指定版本号而非使用默认的最新版本标签。
npm install xsf-toolkit@1.4.2 --save-exact这里的--save-exact是有意加的。默认安装会把版本写成带脱字符的形式(^1.4.2),意味着下次别人安装时可能拿到 1.5.x。对于一个还在快速迭代的自制库来说,次版本号里出现行为变更的概率不低,锁定精确版本能避免“我这里好好的,同事那里报错”这类最消耗时间的排查。
安装完成后先别急着写代码,做两件事。第一件是确认实际落地的版本:npm ls xsf-toolkit会打印出依赖树里的真实版本,如果显示的不是 1.4.2,说明有别的依赖把它提升了,或者 lock 文件没更新。第二件是看一眼安装体积:du -sh node_modules/xsf-toolkit。如果这个数字远超预期(比如超过 2 MB),大概率是包里误带了源码、测试文件或者 sourcemap,这类问题在自制库里非常常见,因为发布脚本往往没配好files字段。
包管理器安装的优势是版本管理和依赖解析自动化,劣势是你对包里到底有什么缺乏控制。所以在正式接入之前,我习惯先看一眼包的目录结构,确认里面只有构建产物和必要的声明文件,没有惊喜。
2.2 源码包手工落地:断网与内网环境的常规操作
有些环境不允许访问公共包仓库,比如隔离的构建机、某些客户现场的内网机器。这时候走源码包路线。
先在能联网的机器上打包:
npm pack这条命令会在当前目录生成一个xsf-toolkit-1.4.2.tgz文件,本质是一个 tar 归档。把它拷到目标机器,然后:
npm install ./xsf-toolkit-1.4.2.tgz安装本地 tgz 和装远程包在行为上几乎一致,区别只是来源。为了让这个过程可追溯,我会额外做一步校验:在打包机器上计算出文件的哈希值,和目标机器上的做比对。
# 打包机器 sha256sum xsf-toolkit-1.4.2.tgz # 目标机器 sha256sum xsf-toolkit-1.4.2.tgz两个值一致才能确认传输过程没有损坏。这一步听起来多余,但在跨网络传输大文件时,静默损坏导致的“安装成功但运行报错”极难排查,因为错误信息通常指向模块内部某个奇怪的位置,而不是指向文件损坏。
还有一种更轻的方式:直接把构建产物目录拷贝进项目,作为本地目录依赖引入。
{ "dependencies": { "xsf-toolkit": "file:./vendor/xsf-toolkit" } }这种方式适合项目数量少、更新频率低的情况。缺点很明显:库更新后需要手动同步每个项目的 vendor 目录,容易版本漂移。我一般只在一次性交付的项目里用,长期维护的项目还是走包管理器。
2.3 私有源与版本锁定
团队规模稍微大一点之后,公共源就不好用了:发布要走审批、包名可能被占、内部版本不想公开。这时候搭一个私有源是常规选择。
配置方式是在项目根目录建一个.npmrc:
registry=https://registry.example-internal.dev/ //registry.example-internal.dev/:_authToken=${XSF_TOKEN} always-auth=true这里把 token 写成环境变量引用而不是硬编码,是因为.npmrc经常会不小心被提交进版本库。硬编码的 token 一旦进了提交历史,清理起来非常麻烦,需要改 token、清历史、通知所有协作者重新拉取。
私有源的另一个好处是可以做版本冻结。对于交付类项目,我会在私有源上保留一个只读的稳定版本通道,所有线上项目只能从这个通道取包,新功能走另一个通道。这样即使新版本有问题,影响范围也可控。
2.4 下载完成后的完整性校验清单
不管走哪条路径,装完之后我都会跑一遍这个清单。它的作用是尽早暴露问题,避免等到写业务代码时才发现库根本没装对。
| 检查项 | 命令或方法 | 异常表现 |
|---|---|---|
| 版本是否正确 | npm ls xsf-toolkit | 版本与预期不符 |
| 入口文件是否存在 | 检查包内dist目录 | 目录为空或只有源码 |
| 类型声明是否齐全 | 查找.d.ts文件 | 只有.js没有声明 |
| 导出映射是否生效 | 尝试引用子路径入口 | 提示找不到模块 |
| 体积是否合理 | 查看目录大小 | 明显偏大,含测试文件 |
第三项和第四项是最容易出问题的。很多自制库在本地开发时一切正常,因为它走的是源码路径,而发布之后消费者拿到的是构建产物,导出映射一旦写错,就会出现“本地能跑、装完就崩”的情况。第 5 章会专门讲这类问题的排查链路。
3. 配置环节:让 XSF 在你的工程里“看起来像原生代码”
3.1 目录骨架与包描述文件的关键字段
配置的第一步是把包的描述文件写对。这是整个接入过程中最容易被敷衍、也最容易出问题的一环。下面是一个可用的最小配置,字段按重要性排列:
{ "name": "xsf-toolkit", "version": "1.4.2", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, "./string": { "types": "./dist/string/index.d.ts", "import": "./dist/string/index.mjs", "require": "./dist/string/index.cjs" }, "./date": { "types": "./dist/date/index.d.ts", "import": "./dist/date/index.mjs", "require": "./dist/date/index.cjs" } }, "files": ["dist"], "sideEffects": false }几个字段值得单独说明。type: module声明这个包默认按 ES 模块解析,配合exports里的条件映射,可以同时支持import和require两种引用方式。exports里每个条件块内,types必须放在第一位——解析器是按顺序匹配的,如果import写在前面,类型声明就可能被跳过,消费方会看到一堆隐式 any。
files字段决定发布时哪些文件进包。只写dist意味着源码、测试、配置文件都不会被发布。这个字段是控制包体积最有效的手段,比事后清理靠谱得多。
sideEffects: false是给打包工具的提示,告诉它这个包里的模块没有副作用,可以安全地做 tree-shaking。这条声明是有前提的:如果你的某个模块在导入时就执行了注册、打补丁之类的操作,就不能这么写。XSF 全部是纯函数导出,所以可以放心声明。
3.2 构建产物的三种形态与导出映射
导出映射写对之后,还有一件事要确认:构建工具真的产出了三种形态。
| 形态 | 文件后缀 | 使用方 | 关键要求 |
|---|---|---|---|
| ESM | .mjs | 现代打包器、浏览器原生模块 | 保留 import 语句 |
| CJS | .cjs | 旧版 Node、部分工具链 | 需要正确的 exports 赋值 |
| 类型声明 | .d.ts | 编辑器、类型检查 | 与源码结构一一对应 |
ESM 和 CJS 并存的意义在于兼容性。有些老项目还在用require,有些新项目已经全面 ESM,一个包如果只提供一种,总会有一半人用不了。代价是构建配置更复杂,需要在打包工具里配两个输出目标。
这里有个容易忽略的细节:CJS 产物的导出方式。ESM 里写export function parseDate() {},构建成 CJS 后应该变成exports.parseDate = ...,而不是把整个模块对象挂到module.exports上。前者支持具名导入,后者只能整体引入。如果构建配置没处理好,消费方写const { parseDate } = require('xsf-toolkit/date')会拿到 undefined。
类型声明文件的位置也要和导出映射对齐。上面的例子里,./string子路径对应的是dist/string/index.d.ts,如果实际构建产出的是dist/string.d.ts,映射就失效了。这种错位在本地开发时不容易发现,因为编辑器可能通过其他途径找到了声明文件,但换一台机器就暴露了。
3.3 路径别名与类型声明的对齐
项目里通常会配路径别名,让引用看起来更干净。别名配置有一个坑:打包工具认的别名和类型检查认的别名是两套配置,必须同时改。
// tsconfig.json { "compilerOptions": { "baseUrl": ".", "paths": { "@xsf/*": ["node_modules/xsf-toolkit/dist/*"] } } }// vite.config.js import { defineConfig } from 'vite'; import path from 'node:path'; export default defineConfig({ resolve: { alias: { '@xsf/string': path.resolve(__dirname, 'node_modules/xsf-toolkit/dist/string/index.mjs') } } });两边不一致时的典型症状是:编辑器里不报错,构建时报“找不到模块”;或者反过来,构建通过但编辑器满屏红波浪线。原因是 TS 只读paths,Vite 只读resolve.alias,两者各玩各的。
我的做法是尽量少配别名。XSF 的模块路径本身就很短,xsf-toolkit/string已经足够清晰,加一层@xsf/string的收益有限,反而多了一处需要同步维护的配置。只有当项目里引用频次非常高、路径又特别长的时候,别名才划算。
3.4 环境变量与运行时分流的配置细节
XSF 的日志模块和请求模块都有环境相关的行为:开发环境下日志级别更详细,生产环境下请求重试次数更保守。这些行为通过配置项传入,而不是库内部读环境变量。
import { createLogger } from 'xsf-toolkit/logger'; const logger = createLogger({ level: import.meta.env.DEV ? 'debug' : 'warn', prefix: '[app]' });这样设计的原因是可测试性。如果库内部直接读process.env.NODE_ENV,单元测试就没法覆盖不同环境下的分支,只能靠改全局变量来模拟,测试之间还会互相污染。把环境判断交给调用方,库本身保持纯粹,测试就好写得多。
请求模块的配置同理:
import { createClient } from 'xsf-toolkit/request'; const client = createClient({ timeout: 8000, retry: { times: 2, delay: 300, backoff: 2 }, onError: (err) => logger.error('request failed', err) });backoff: 2表示每次重试等待时间翻倍,即 300ms、600ms。这个参数在小规模重试时够用,重试次数超过三次就该考虑是不是接口本身有问题,而不是继续加大等待时间。
4. 跑起来才算数:五个从简到繁的示例
4.1 示例一:三行代码完成字符串清洗
先从最简单的开始,验证安装和引用链路是否通畅。
import { clean, mask } from 'xsf-toolkit/string'; const raw = ' Hello,WORLD '; console.log(clean(raw)); // "Hello,WORLD" console.log(mask('13800001234')); // "138****1234"clean做三件事:去首尾空白、全角转半角、中文标点转英文标点。这三步的顺序有讲究。如果先转标点再去空白,全角空格会被转成普通空格,但此时已经过了去空白阶段,结果里会残留空格。所以正确顺序是先处理字符宽度和标点,最后去空白。这个顺序在文档里不一定写,但用的时候如果发现结果多出空格,大概率就是顺序理解错了。
mask的第二个参数可以指定保留位数和掩码字符:
console.log(mask('13800001234', { keep: 3, char: '#' })); // "138####1234"默认保留前三位后四位,是因为这个组合覆盖了绝大多数手机号和证件号的展示需求。如果你的场景需要保留中间几位,直接传参即可,不用改库。
4.2 示例二:时间格式化与时区处理
时间模块是使用频率最高的部分,也是最容易出错的。
import { format, fromNow, toUTC } from 'xsf-toolkit/date'; const ts = 1717214400000; console.log(format(ts, 'YYYY-MM-DD HH:mm:ss')); console.log(format(ts, 'YYYY年M月D日')); console.log(fromNow(Date.now() - 3600 * 1000)); // "1小时前"格式化令牌用的是常见的大小写区分规则:MM是月份,mm是分钟,HH是 24 小时制,hh是 12 小时制。这个规则不是 XSF 独创的,而是沿用了社区里最广泛的那套约定,目的是降低切换成本。如果你之前用过别的格式化库,基本可以无缝迁移。
时区处理要特别小心。format默认按运行环境的本地时区输出,这在浏览器里通常没问题,但在服务端就有风险:同一份代码在开发机(东八区)和服务器(UTC)上会输出不同的字符串。
// 明确指定时区偏移,避免环境差异 console.log(format(ts, 'YYYY-MM-DD HH:mm', { offset: 8 })); // 或者先转成 UTC 再格式化 console.log(format(toUTC(ts), 'YYYY-MM-DD HH:mm'));我在一个报表项目里踩过这个坑:本地测试时日期都是对的,部署到服务器后所有报表的日期整体偏了一天。排查了半天才意识到服务器是 UTC 时区,凌晨的数据被算到了前一天。从那以后,凡是涉及日期边界的地方,我一律显式传offset,不依赖环境默认值。
4.3 示例三:表单校验的组合式写法
校验模块的设计思路是组合而非继承。规则是独立的函数,通过数组组合成校验链。
import { validate, rules } from 'xsf-toolkit/validate'; const schema = { username: [ rules.required('用户名不能为空'), rules.minLength(3, '至少 3 个字符'), rules.pattern(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字和下划线') ], phone: [ rules.required('手机号不能为空'), rules.pattern(/^1[3-9]\d{9}$/, '手机号格式不正确') ] }; const result = validate({ username: 'ab', phone: '123' }, schema); console.log(result.valid); // false console.log(result.errors); // { username: '至少 3 个字符', phone: '手机号格式不正确' }几个设计上的取舍值得说明。第一,校验遇到第一个错误就停,不会继续跑后面的规则。这样做的好处是错误信息干净,用户一次只需要改一个问题;坏处是没法一次性列出所有问题。如果你需要全量校验,可以在规则数组外面包一层配置:
const result = validate(data, schema, { all: true });第二,异步校验通过返回 Promise 的规则函数实现,validate会自动识别并等待。第三,错误信息的结构是“字段名 → 第一条错误”,而不是数组。这个选择是基于实际使用反馈做的:绝大多数表单在字段旁边只显示一条错误,返回数组反而每次都要取第一个元素,平白多一层代码。
4.4 示例四:带重试与超时的请求封装
请求模块封装的是一个带状态机的调用过程,不是简单的 fetch 包装。
import { createClient } from 'xsf-toolkit/request'; const client = createClient({ timeout: 5000, retry: { times: 2, delay: 200, backoff: 2 }, retryOn: (err) => err.status >= 500 || err.status === 429 }); const res = await client.get('/api/list', { params: { page: 1 } });retryOn这个配置是关键。默认的重试策略只对网络错误和 5xx 生效,不会对 4xx 重试——因为 4xx 是客户端问题,重试一百次结果也一样,只会加重服务端负担。429 是例外,它表示限流,等一会儿再试是有意义的。
取消请求的用法:
const controller = client.createAbort(); try { await client.get('/api/slow', { signal: controller.signal }); } catch (err) { if (err.name === 'AbortError') { console.log('已取消'); } } // 需要时取消 controller.abort();这里有个细节:重试过程中如果发生取消,正在等待的重试也要一并终止。实现上是在每次重试前检查 signal 状态,如果已取消就直接抛出 AbortError,不再进入下一轮。这个逻辑如果漏了,用户点了取消按钮之后请求还会在后台继续跑两轮,看起来像没生效。
4.5 示例五:内存缓存的过期与容量控制
缓存模块实现的是带 TTL 和容量上限的内存缓存,适合缓存接口结果、计算结果这类场景。
import { createCache } from 'xsf-toolkit/cache'; const cache = createCache({ ttl: 60 * 1000, max: 100 }); cache.set('user:1', { name: 'A' }); console.log(cache.get('user:1')); // { name: 'A' } // 60 秒后自动失效 setTimeout(() => { console.log(cache.get('user:1')); // undefined }, 61 * 1000);max: 100表示最多存 100 条,超出时按最近最少使用淘汰。这里有个实现细节容易出问题:TTL 到期和容量淘汰是两套逻辑,如果只在读取时检查过期,那么长期不读的键会一直占着位置,最终把缓存撑满,触发淘汰时淘汰的可能是刚写入的新键。XSF 的做法是在写入时顺带清理一批已过期的键,清理是惰性的、分批的,不会阻塞主流程。
还有一个常见误用:把大对象塞进缓存。内存缓存的所有数据都在堆上,缓存一个几 MB 的响应体,一百条就是几百 MB,很容易触发内存告警。我的经验是缓存值控制在几十 KB 以内,超过这个量级应该考虑用外部缓存,而不是进程内缓存。
5. 我踩过的七个坑与现场排查链路
5.1 安装成功但运行时报“找不到模块”
这是最高频的问题,报错信息通常是Cannot find module 'xsf-toolkit/string'。
排查链路是有顺序的,按这个顺序走基本能在五分钟内定位:
第一步,确认包真的存在。去node_modules/xsf-toolkit看一眼,如果目录不存在,说明安装根本没成功,可能是源配置错了或者网络问题,先解决安装。第二步,确认目标文件存在。按exports里./string对应的路径去找,比如dist/string/index.mjs,如果这个文件不在,说明构建产物不全,是发布环节的问题。第三步,检查exports字段是否包含这个子路径。有些库只写了根入口.,没写子路径,那么xsf-toolkit/string就会被拒绝,而xsf-toolkit本身能正常引用。第四步,检查大小写。文件系统在 Linux 上区分大小写,本地 macOS 不区分,所以本地跑得好好的代码在 CI 上会挂,这类问题占了相当比例。
还有一种情况是缓存导致的假象:包已经更新了,但构建工具的缓存还指向旧路径。
rm -rf node_modules/.vite node_modules/.cache清完之后重跑,问题常常就消失了。
5.2 类型声明存在却提示 any
症状是编辑器里所有来自 XSF 的函数都是any,但dist目录里明明有.d.ts文件。
根因通常有两个。第一个是exports里的types位置不对,放在了import后面。类型解析器按顺序匹配,遇到第一个符合条件的就停,如果import在前面且匹配成功,类型声明那一条就永远不会被读到。修复办法是把types提到每个条件块的第一行。
第二个是tsconfig里的moduleResolution设置太旧。设成node时不会读exports字段,只认main和types。改成bundler或node16才能正确解析条件导出。
{ "compilerOptions": { "moduleResolution": "bundler" } }改完之后如果还是 any,重启一下编辑器的语言服务。TS 的语言服务有缓存,配置变更后不一定立刻生效,这个细节坑过我不止一次。
5.3 打包体积翻倍的真正原因
现象是接入 XSF 之后,构建产物的体积涨了远超预期,明明只用了字符串模块的三个函数。
先确认sideEffects: false写了没有。没有这条声明,打包工具不敢做 tree-shaking,会把整个包都打进去。再确认引用方式是具名导入而不是整体导入:
// 可以摇掉未使用的导出 import { clean } from 'xsf-toolkit/string'; // 摇不掉,整个模块都会进包 import string from 'xsf-toolkit/string';第三点最隐蔽:检查构建产物里是不是同时打进了 ESM 和 CJS 两份。有些打包配置在解析时优先命中了main而不是module,导致 CJS 版本被引入,而 CJS 的静态分析能力弱,tree-shaking 效果大打折扣。解决办法是在构建配置里显式指定优先字段:
// 以 Vite 为例 export default { resolve: { mainFields: ['module', 'main'] } };我遇到过一次体积涨了三倍的情况,最后查出来是某个间接依赖同时引入了 ESM 和 CJS 两份 XSF,原因是两个库用了不同的解析配置。这类问题只能靠产物分析工具看出来,肉眼看依赖列表是发现不了的。
5.4 循环依赖引发的初始化顺序问题
这个坑最难受,因为报错信息完全不指向真正的原因。症状是某个函数在启动时报 undefined,但代码里明明导入了。
原因是模块之间存在循环引用:A 模块导入 B,B 又导入 A。在 ESM 里,循环引用不一定报错,但被循环引用的一方在初始化阶段可能还没完成赋值,拿到的是 undefined。
XSF 的模块设计上刻意避免了相互依赖,六个模块之间零耦合。但消费方的代码可能引入循环:比如业务模块 A 导入了xsf/request,而传给createClient的onError回调里又引用了业务模块 A 的某个方法。这种循环发生在业务层,库本身没法控制。
排查方法是看构建工具的警告。大多数打包器在遇到循环依赖时会打印警告,只是很容易被淹没在其他输出里。定位到具体的循环之后,常见的解法是把共享部分抽到第三个模块,或者把回调改成延迟引用:
onError: (err) => { // 延迟到调用时再解析,避免初始化阶段的循环 import('./handler.js').then((m) => m.handleError(err)); }这个改法的代价是错误处理变成异步的,如果你需要同步处理,那就只能老老实实拆模块。
6. 把 XSF 当作长期资产:版本、测试与扩展
6.1 版本号怎么定才不折腾自己
自制库最常见的死法不是没人用,而是版本号乱到自己都记不清哪个版本改了什么。
XSF 遵循的规则很简单:修 bug 升补丁位,加功能升次版本,改行为升主版本。关键在于什么叫“改行为”。我的判断标准是:如果调用方需要改代码才能保持原有输出,就算改行为。举个例子,把一个函数的默认参数从false改成true,虽然函数签名没变,但输出变了,这就得升主版本。
主版本升得勤不一定是坏事,但每次升主版本都要写迁移说明,说明里要写清楚“改了什么、为什么改、怎么改”。这份说明的价值在半年后体现得最明显——那时候你自己都忘了当初为什么那么改。
版本号还有一条实践:不要用0.x太久。0.x阶段意味着 API 随时可能变,消费方不敢用;一旦你决定让库进入可用状态,直接发1.0.0,把承诺明确下来。这个心理门槛对维护者自己是好事,它会逼着你在发布前把 API 想清楚。
6.2 最小可用的测试用例集
自制库的测试不需要追求覆盖率数字,但有几类必须覆盖。
第一类是边界值。字符串模块的clean要测空字符串、纯空白、超长字符串;日期模块的format要测闰年、月末、跨时区。这类测试写起来枯燥,但能拦住绝大多数线上问题。第二类是错误路径。校验失败时返回什么结构、请求超时时抛什么类型的错误,这些必须有断言,否则调用方没法可靠地处理异常。第三类是回归用例。每修一个 bug,就补一个能复现该 bug 的测试,这条规则执行下来,测试集自然会长成一份问题清单。
测试工具用什么不重要,重要的是能一条命令跑完:
npm testCI 里跑同一套。我见过不少自制库的测试只在本地手工跑,跑的时候还得改一堆配置,结果就是没人跑。
6.3 新增模块的扩展流程
想往 XSF 里加新模块,按这个顺序走,能少返工。
第一步,先在一个实际项目里用它,写成业务代码的一部分。用上两三个项目之后,如果这段代码几乎没改过,说明它的接口稳定了,可以提出来。第二步,把它整理成独立文件,确保不依赖业务上下文,所有外部依赖通过参数传入。第三步,补测试,覆盖边界和错误路径。第四步,加导出映射,exports里加一条子路径,构建配置里加一个入口。第五步,发一个次版本,在迁移说明里写清楚适用场景和典型用法。
这个流程的核心是“先验证再入库”。直接从想法出发写模块,写出来的接口大概率要返工,而返工的代价在库里比在业务代码里高得多——业务代码改了就改了,库的接口改了要通知所有使用方。
扩展时还要注意一件事:新模块不要引入新的运行时依赖。XSF 目前是零依赖的,这条约束让它的安装体积可控、安全审计简单、也不会因为某个间接依赖出问题而被迫发版。如果某个功能非依赖不可,优先考虑自己实现一小段,而不是引入一个包。
最后分享一个实际使用中的小技巧。我习惯在项目里建一个统一的出口文件,把 XSF 的引用集中在一处:
// src/lib/xsf.js export { clean, mask } from 'xsf-toolkit/string'; export { format, fromNow } from 'xsf-toolkit/date'; export { validate, rules } from 'xsf-toolkit/validate';业务代码只从这个文件引用,不直接引xsf-toolkit。这样做的好处是将来如果要换库、或者要包一层自己的默认配置,只需要改这一个文件,不用全项目搜索替换。这个习惯坚持了两年,中途换过一次时间格式化方案,改动量只有二十行。