简介:ztools是一个面向JavaScript开发者的轻量前端工具集,围绕异步编程、模板渲染与依赖管理三个方向提供实用能力:内置兼容IE旧版本的ES6 Promise方案,便于在老旧浏览器中编写现代异步代码;Plato模板引擎以简洁的方式完成数据与DOM的绑定,适合快速搭建视图层;Eidos依赖注入封装则有助于降低模块耦合,提升代码可测试性与复用性。压缩包共17个文件,以js源码为主,同时包含html示例、README说明、package.json配置等,结构清晰,便于按模块阅读与调试,整体仅13KB,适合学习或直接引入项目。目前已有308人学习下载。通过阅读源码与示例,读者可以了解Promise polyfill的实现思路、简易模板引擎的解析过程以及依赖注入容器的设计方式,对于想深入前端工程化与工具封装的开发者是份不错的参考资料。 接手团队那会儿,我翻了一遍现有代码库,发现一个很真实的现象:deepClone至少有三个版本在两个模块里各写各的,防抖函数有四个人用自己的实现,日期格式化更是五花八门,有的返回字符串、有的返回数组,还有的干脆直接报错。这些代码本身没问题,但维护的人换了一茬又一茬,风格已经割裂到没法看了。
所以就有了ztools这个前端工具集项目。它不是要做一个“什么都有”的大杂烩包,而是把团队里反复出现、已经验证过的工具函数和逻辑沉淀成一套统一、可测试、可按需引入的基础库。如果你也需要把散落的公共代码整合起来,或者是想搭建自己的第一个前端工具库,这篇内容应该能给你一些可以直接抄作业的思路。
1. 为什么需要一套前端工具集
1.1 团队代码里那些“复制粘贴”之痛
一个中大型前端项目跑两三年之后,公共逻辑的重复率会高得吓人。最典型的症状就是:每个新同学入职,第一个任务大概率是“把这里的请求封装改成统一的”,然后你会发现项目里已经有四套request封装,三份localStorage读写工具,还有两个行为互相矛盾的 cookie 操作函数。
重复代码的问题不只是浪费几行字节,真正可怕的是“改不动”和“不敢删”。当你发现线上有个日期格式化的 bug,你要在所有用到格式化的地方逐个排查,因为每一个实现的行为都可能略有不同。而当你试图删掉其中一个工具函数时,又怕某个隐晦的调用点突然报错。这种状态下,任何重构都是在走钢丝。
我建ztools的初衷就是把这些重复逻辑捞出来,给它们一个统一的归宿。它解决的问题不是“代码少写几行”,而是让团队对“公共能力”只有一个认知来源、一份测试用例、一个维护入口。
1.2 工具集的设计目标与边界
工具集不是框架,它的定位要非常克制。我在项目规划阶段就定下了几条设计原则:
- 只做基础能力,不做业务逻辑。通用的函数、hooks、类型定义可以收进来,但跟具体业务绑定的数据解析、权限判断、接口封装一律不进。
- 按需引入,不能拖累主包体积。用户引一个
debounce,不能被迫加载整个工具集。 - 类型完整,用法统一。所有函数都要有精确的 TypeScript 类型,所有命名都要符合一套规范,调用方式保持一致。
- 必须经过测试。工具函数是最容易被大家依赖的底层代码,没有测试覆盖,出了问题就是全线崩溃。
这些边界约束了工具集的发展方向,也帮我在后续无数次“要不要把这个也放进来”的讨论中快速做出判断。工具集的价值不在于大,而在于清晰。
2. 技术选型与整体架构
2.1 为什么用 TypeScript 加双格式构建
ztools的技术栈选择不算激进,但都是经过实际验证的。整个工具集用 TypeScript 编写,构建产物同时输出 ESM(ES Module)和 CJS(CommonJS)两种格式,部分工具还附带浏览器直接可用的 IIFE 版本。
TypeScript 的核心收益不是“有类型”,而是“让使用方在编译期就拿到提示”。工具函数一旦在团队内广泛使用,类型定义就是隐形的文档。比如debounce函数,如果没有类型约束,调用方很容易搞混wait和immediate参数的顺序,而有了类型提示,这种问题几乎不可能发生。
双格式构建的原因更务实:现在前端项目基本都跑在 Vite 这类现代构建工具上,ESM 是主流;但还有一些老项目用的是 webpack 4 甚至直接是 Node 端的 CommonJS 引用,如果只有 ESM 产物,它们在require('ztools')的时候会直接报错。所以我在package.json里用exports字段做了条件导出:
{ "name": "ztools", "version": "0.3.2", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./utils/*": { "types": "./dist/utils/*.d.ts", "import": "./dist/utils/*.js", "require": "./dist/utils/*.cjs" } } }同样地,很多场景下按需引入也依赖 ESM 的tree-shaking。如果你只用了ztools里的formatDate,构建工具应该有能力把其他函数全部摇掉,让最终的包体积增加量几乎可以忽略。这一点我在第 5 节会展开讲。
2.2 目录结构与模块划分
ztools的目录结构从一开始就是按“领域”划分的,而不是按“类型”堆在一起。这样做的好处是,使用方一看到路径就能猜到功能归属,维护的人也知道该往哪里加代码。
ztools/ ├── src/ │ ├── utils/ # 基础函数工具 │ │ ├── debounce.ts │ │ ├── throttle.ts │ │ ├── deepClone.ts │ │ ├── formatDate.ts │ │ ├── formatNumber.ts │ │ ├── getUrlParam.ts │ │ └── storage.ts │ ├── hooks/ # React Hooks │ │ ├── useDebounce.ts │ │ ├── useThrottle.ts │ │ ├── useLocalStorage.ts │ │ └── usePrevious.ts │ ├── dom/ # 浏览器 DOM 操作 │ │ ├── scrollToBottom.ts │ │ └── copyToClipboard.ts │ ├── types/ # 公共类型定义 │ │ └── index.ts │ └── index.ts # 入口统一导出 ├── tests/ ├── docs/ ├── package.json └── tsup.config.ts入口文件index.ts会统一导出所有公共 API,但每个子目录也支持单独路径引用,这样既能兼顾“一次性引入全部”的方便,也能满足“只引一个函数”的精准诉求。子路径导出在package.json的exports里已经做了映射,不需要额外配置。
2.3 tree-shaking 与按需引入的设计
很多工具库明明功能很少,但打出来的包却有几百 KB,核心原因就是没有做按需设计。ztools在这个问题上做了几个层级的控制:
- 内部模块拆分:除入口文件外,每个工具函数一个文件,互不依赖。这样任何构建工具在分析依赖图时都能把未用到的模块隔离掉。
- 保持较少的内部依赖:每个函数尽可能不依赖工具集内其他函数,避免“引一个函数拖进来一串”的连锁效应。
- 声明
sideEffects: false:在package.json中明确告知构建工具,这个包里的文件不会在 import 时产生副作用,可以放心删除未使用的导出。这一点经常有人漏掉,但少了它 tree-shaking 可能就失效了。
{ "sideEffects": false }3. 核心实现与渐进搭建过程
3.1 已实现的工具类目与典型实现
ztools目前积累了几十种工具,按使用频率分为三类。第一类是高频基础函数,比如debounce、throttle、deepClone、formatDate、getUrlParam;第二类是 React Hooks,比如useDebounce、useThrottle、useLocalStorage;第三类是浏览器环境下的辅助函数,比如copyToClipboard、scrollToBottom。
以debounce为例,抛开各种边界情况不谈,它的核心实现其实只有十几行。但真正的难点在于类型定义、参数兼容、以及this上下文的保持。我参考了业界常见的实现,最后写出来是这个样子:
export function debounce<A extends unknown[], R>( fn: (...args: A) => R, wait = 300, immediate = false ) { let timer: ReturnType<typeof setTimeout> | null = null; let result: R | undefined; const debounced = function(this: unknown, ...args: A) { const later = () => { timer = null; if (!immediate) { result = fn.apply(this, args); } }; const callNow = immediate && timer === null; if (timer !== null) { clearTimeout(timer); } timer = setTimeout(later, wait); if (callNow) { result = fn.apply(this, args); } return result as R; }; debounced.cancel = function() { if (timer !== null) { clearTimeout(timer); timer = null; } }; return debounced; }实现完之后还有两件事必须做:一个是让copyToClipboard这类函数兼容浏览器对剪贴板权限的限制——在不支持navigator.clipboard的环境下自动降级到document.execCommand('copy');另一个是给所有函数补充 JSDoc 注释,明确参数含义、返回值、使用示例和注意事项。后面这一件事,当时觉得耽误时间,后来发现文档的价值比代码本身还大。
3.2 工具函数的单元测试与质量保障
一个工具函数如果没有测试,那它和临时脚本没有本质区别。ztools的测试选的是 Vitest,理由很直接:它跟 Vite 的配置天然打通,跑起来快,而且对 TypeScript 的支持不需要额外配置。
测试用例的覆盖范围,我一般会遵循“正常值 + 边界值 + 异常值”的思路。拿formatDate来说,正常值就是传一个时间戳或 Date 对象,期望返回格式化的字符串;边界值要覆盖0时间戳、跨年的日期、闰年 2 月 29 日;异常值要覆盖undefined、null、非法字符串等,这个时候最好能让函数抛出一个明确的错误,而不是静默返回一个诡异结果。
import { describe, expect, it } from 'vitest'; import { formatDate } from '../src/utils/formatDate'; describe('formatDate', () => { it('formats timestamp correctly', () => { const timestamp = new Date('2024-03-15T08:30:00').getTime(); expect(formatDate(timestamp, 'YYYY-MM-DD HH:mm')).toBe('2024-03-15 08:30'); }); it('handles invalid input by throwing', () => { expect(() => formatDate('not-a-date', 'YYYY-MM-DD')).toThrow(); }); });刚开始补测试的时候,我会觉得进度变慢了,但后来发现,测试真正发挥作用的时刻是“别人来改你的函数”。没有测试罩着,别人动代码你心里是悬的;有测试罩着,他改坏了 CI 第一个跳出来,比你在代码 review 里耳提面命一百遍都管用。
3.3 文档站点与 npm 发布
工具集的另一半价值在于“让人愿意用、用得明白”。我一开始只在 README 里写了几个示例,后来被同事反复问“这个函数怎么用、参数是什么”,才意识到文档必须跟上。
ztools的文档方案没有搞得很重。我选了一个轻量的静态文档生成器,把函数说明、示例代码、参数表、变更记录集中在一个站点上。每个函数都配一个可折叠的示例区块,方便读者直接复制。文档的源码放在docs/目录下,和代码库同步维护,提交代码时如果改了公共 API,CI 会检查对应文档是否更新,避免出现“代码改了文档没改”的脱节。
npm 发布流程则完全交给 GitHub Actions。每次打v*标签自动触发构建、跑测试、生成类型声明,然后发布到配置好的 registry。发布之后还会同步生成一份最新版 CHANGELOG,日志里的版本号、feature、fix 全部从 Git 提交记录里提取,不需要手写。这个流程一开始搭的时候花了半天,但之后每次发版都是推个 tag 的事,省心很多。
4. 使用场景与接入方式
4.1 在业务项目中接入
业务项目接入ztools的方式取决于它使用的模块体系。新项目基本走 ESM 按需引入:
import { debounce } from 'ztools'; import { useDebounce } from 'ztools/hooks'; const onSearch = debounce((keyword: string) => { // 搜索请求 }, 500);老项目如果还在用 CommonJS,也可以直接const { debounce } = require('ztools'),因为我前面提到的双格式构建已经做了兼容。这样团队在做技术栈升级迁移期间,不需要等所有项目都切到 ESM 才能开始复用工具集。
4.2 团队协作与版本管理
工具集既然是给团队用的,版本管理和发布策略就得有章法。我的做法是采用语义化版本(SemVer):新增工具函数加minor版本,修复 bug 或优化实现加patch版本,发生 breaking change 才升major版本。breaking change尽量少出,如果非要出,必须提前一个版本在文档和 CHANGELOG 里标注弃用信息,给使用方留出迁移时间。
比较重要的是要建立“工具集不是某个人的私有物”的共识。任何人想往里面加东西,都要发起 MR,说明用途、实现方案、调研过哪些已有方案,并附上测试用例。我自己作为维护者,一开始会花比较多精力在 review 这些 MR 上,但等大家习惯了这套流程,工具集会越滚越健康。
4.3 后续扩展与生态方向
ztools目前的规划是继续往更细分的场景做扩展。一个方向是增加更多 React Hooks,比如useEventListener、useMediaQuery、useAsync,这些都是业务里反复出现的需求。另一个方向是提供一些轻量的“配置化”能力,比如统一的错误捕获上报入口、统一的日志格式,但这类能力要谨慎,因为它容易滑向业务逻辑。
还有一个想法是把工具集按领域拆成独立包,比如ztools-utils、ztools-hooks、ztools-dom,由同一个 monorepo 管理、统一发布。这样团队里某个项目如果只需要 Hooks,可以只装ztools-hooks。不过这个分解动作会带来不小的维护成本,目前看必要性不高,先记在规划里。
5. 常见问题与排查技巧实录
5.1 tree-shaking 失效,打包体积没降下来
这是我被问过最多的问题。症状是业务项目明明只引了一个函数,打包产物体积却增大了几百 KB。绝大多数情况下,原因有三个:
package.json缺sideEffects: false声明,构建工具不敢动任何模块。- 入口文件把所有工具都
export出去了,虽然理论上 ESM 可以 tree-shaking,但如果构建工具配置不当或产物格式不理想,还是会失效。 - 使用方可能用了
import * as ztools from 'ztools'。这种写法会保留整个模块对象,导致所有函数都被打包进去。
排查思路是先用vite --debug或webpack-bundle-analyzer看产物结构,确认哪些模块被打进去了,再一个个排除原因。我实际处理过的一个案例,就是某业务项目把import * as ztools改成具名导入后,体积直接少了近 200 KB。
5.2 类型声明丢失或和实际 API 不匹配
发布之后发现使用方在 TypeScript 里拿不到类型提示,或者提示的老类型和实际函数不匹配。这个问题的根源通常是我在发版时没有成功生成最新的.d.ts文件,或者exports字段里的types路径指向不对。
我的处理方式是:构建脚本里显式用tsc --emitDeclarationOnly生成类型声明,而不是依赖打包工具的附带产物;发布前在本地用npm pack打一次 tarball,检查里面的文件结构是否符合预期;再用一个模拟业务项目通过npm link做一次真实引用测试。这套检查做完,基本就不会再出现“发出去之后发现类型不对”的尴尬了。
5.3 浏览器兼容性问题集中爆发
部分工具函数在不同浏览器里表现不一致,比如Intl.DateTimeFormat在部分旧浏览器里对中文 locale 支持不完整,structuredClone在更早期环境里根本不存在。工具集必须在代码里做兼容降级,而不能默认使用方浏览器都是最新版。
我给的策略是:在函数实现层面做能力检测,如果环境不支持基准 API,就降级为简单的模拟实现或抛出明确的警告。同时一定要在文档里写清楚每个函数的浏览器支持范围,避免业务在低版本浏览器上排查问题到头来发现是工具集的问题。
5.4 发布到内部 registry 后安装失败
这个坑我踩过一次。当时配置了私有 npm registry,但发布流程里没有正确处理 registry 的认证信息,结果 CI 构建能过,业务项目却怎么都拉不到包。后来我在发布脚本里显式指定了 registry 地址和认证环境变量,并在 CI 里加了“安装验证”这一步骤:发布完成后立刻在一个临时目录里执行一次npm install ztools,确保安装链路完全畅通再通知团队使用。
经验就是:凡是自动化发布的流程,都要在流程末尾加一个“自检”环节,机器不会“觉得没问题”,只有验证过才是真的没问题。
5.5 工具函数行为不统一导致线上问题
有时候不同函数对同一类参数的解析方式不一致,比如formatDate会用本地时区解析时间字符串,而getUrlParam里的时间处理用了 UTC 时区,两个函数联动时就会出现几小时的偏差。所以我后来在ztools里立了一条规矩:所有时间相关的函数,必须在文档里明确写清楚默认时区,并且提供统一的时区参数入口。这类隐性约定,靠代码 review 很难发现,靠测试用例才能把行为固定下来。
最后再说一点维护心得
工具集这件事,做起来容易,坚持维护下去难。我见过不少团队的工具库,热度过了之后没人维护,新需求各写各的,慢慢又退化成“历史遗留代码”。我的经验是:工具集的生命力不在于代码多炫,而在于边界清晰、文档完整、测试覆盖到位。每次有人提“再加一个函数”的时候,先问三个问题——这个逻辑真的通用吗?团队里有没有已经在写的重复实现?它能不能配齐测试和文档?如果答案都是肯定的,再收进来;如果有一个是否定的,就先缓一缓。
如果你也在规划自己的前端工具集,建议不用一上来就追求大而全,先从业务项目里捞两个高频复用的函数,配好测试、写好文档、发布一版,让团队先“用起来”。跑顺了流程,再慢慢迭代,你会发现工具集这东西,真的是越早做越划算。
本文还有配套的精品资源,点击获取