☰
前端高频工具封装实战:从axios二次封装到事件总线
2026/9/30 11:53:30 网站建设 项目流程

前端这行干久了,你会发现一个真理:八成的重复代码,都死在了“当时偷懒,后面遭罪”这条路上。尤其是那些每天都在用的库——请求、存储、格式化、事件通知——今天写一遍,明天复制一遍,后天需求一变更,全项目搜索替换,改得头皮发麻。我就是从前几年那次“一个接口地址改了四处”的线上事故之后,才下定决心,把手边高频使用的前端能力系统性地封装成一套内部库。这一弹先交作业,把这些年沉淀下来、日常开发中使用频率最高的五类封装,连同设计思路和踩过的坑一起整理出来。

这篇内容适合谁?刚工作一两年、每天被重复代码拖慢节奏的前端开发,可以直接拿走方案;做了三五年、想提升代码复用率和团队协作效率的进阶开发,可以对照检查自己封装里的细节有没有遗漏。文章不会只贴代码,每个封装背后的取舍、每个参数为什么这么定、哪些场景特别容易翻车,我都会讲清楚。看完你至少能少走我踩过的几段弯路。

1. 为什么我劝你把手边这几个库“封装”起来

1.1 封装的本质不是炫技,是止损

很多同学一听到“封装”两个字,第一反应是设计模式,是继承多态,是面试题里的理论概念。但在实际前端项目里,封装是把“频繁变化的细节”和“稳定不变的使用方式”分离。举个例子:你的项目里存储用户信息用的是 localStorage,接口用的 JSON.stringify 序列化。今天这么写没问题,哪天产品说“用户量太大,某些大对象存不进 localStorage 了,换 IndexedDB”,你怎么办?如果每个页面都在直接操作 localStorage,你至少得改十几个文件;如果当初封装了一个 storage 对象,你只需要把内部实现换掉,调用方代码一行都不用动。

我再给你一个更扎心的场景。axios 大家都会用,但如果你在每个页面里直接axios.get()、直接处理错误,一旦后端调整了错误码结构,或者你需要在请求头里统一加一个 token,你能想象自己要在多少个文件里找那行headers配置吗?封装的意义就在这里——把“会变的”关进笼子里,把“不变的”留给业务。

提示:判断一个东西要不要封装,有一个很实用的标准——如果一行代码你复制粘贴超过三次,并且每次粘贴都要带一点参数调整,它就值得封装。

1.2 哪几类日常库最值得先封

我按自己项目的实际使用频次,梳理了一个优先级排序。这个顺序不是拍脑袋定的,而是按“改动成本高不高、复用面宽不宽、出错后影响大不大”三个维度排的。

  • 网络请求层:所有数据交互的入口,后端接口变动、鉴权失效、超时处理、错误提示,全在这一层。排第一没有悬念。
  • 本地存储层:token、用户信息、缓存数据、主题设置,全局到处都在用。直接裸用 localStorage 的散落写法,是后期重构的重灾区。
  • 通用的格式化与工具函数:日期、金额、手机号、千分位,每个模块都离不开。这类函数单个看逻辑不多,但最容易被复制得走样,比如日期格式化,十个页面能写出九个版本。
  • 事件通信机制:非父子组件之间传参、刷新列表、弹窗联动。用了框架的全局状态管理会觉得没必要,但当你遇到“组件A 修改数据后,远在另外一个兄弟节点里的组件B 需要同步刷新”的场景,一个干净的事件总线能省掉大量 props 穿透。
  • DOM 与浏览器增强工具:防抖、节流、复制到剪贴板、生成唯一 ID、URL 参解析。这些属于提效利器,封装后每次用的时候都像是开了外挂。

这五类封装完之后,你会发现业务代码的体量肉眼可见地瘦了一圈,代码审查的时候也不再为了那些低级重复去拉锯了。

2. 第一弹的五类封装:选型与整体设计

2.1 请求层封装:为什么一定要“再包一层 axios”

如果你问我,前端日常库里最应该先动手的,我一定投票给请求层。很多人会说:axios 本身就挺好用的,拦截器都有了,为什么还要再封?这个问题我太有发言权了。axios 的拦截器是“能力”,不是“约定”。它确实提供了请求拦截和响应拦截,但你的团队里有多少人知道响应拦截里应该处理什么?多少人会把业务错误码和 HTTP 状态码混在一起?

我给内部封装定了三个设计目标:

  1. 调用方拿到的数据直接就是业务数据。页面里不需要每次res.data.data,那些壳子都在封装内部剥掉了。
  2. 错误处理是半自动的。HTTP 网络错误和业务逻辑错误分开走,UI 层只管用户提示。
  3. 业务逻辑的“挂载点”足够清晰。比如需要携带 token、需要处理登录失效、需要上传文件、需要取消请求,这些都应该有明确的位置,而不是依赖大家“自觉”。

再补充一个选型细节:我是直接基于 axios 做二次封装,而不是自己写一个 fetch 的封装。原因很简单,axios 在浏览器兼容性、请求取消、上传进度、JSON 序列化这些复杂边界上已经非常成熟,自己重新实现这些去踩坑,属于给团队找不痛快。质量的三重保障是:成熟的底层 + 统一的业务约定 + 清晰的对外接口。

2.2 存储层封装:从“能用”到“好用”

localStorage 和 sessionStorage 是浏览器自带的“仓库”,但直接使用它们的问题非常多。第一,它只能存字符串,存对象必须自己 JSON.stringify,取的时候还得 JSON.parse,一旦忘了解析,页面直接显示[object Object]。第二,它没有过期机制,缓存的数据一旦写进去就永久存在,除非用户手动清浏览器。第三,** key 的命名散落**,今天叫userInfo,明天叫user_info,后天叫userInfo.v2,查 bug 的时候你会怀疑人生。

所以我在设计存储封装时,重点解决四件事:序列化和反序列化的透明处理、缓存过期时间的支持、统一的前缀管理、以及一套轻量的内存回退方案(某些隐私模式下行 localStorage 会抛异常)。这个封装我觉得是“门槛最低、收益最直接”的,新手下午就能写完,但细节决定了它到底好不好用。

2.3 格式化工具函数:把散装的判断聚成一处

日期、金额、手机号脱敏、千分位、百分比、字节数转可读文本、银行卡号四位分隔——这些函数单个都不难,但它们有一个共同的特点:边界条件多。比如日期格式化,你以为yyyy-MM-dd HH:mm:ss就完了?用户可能传时间戳(秒级和毫秒级都有人传)、传 Date 对象、传 ISO 字符串,你不得统一处理?还有时区问题,如果后端传的是 UTC 时间字符串,你要不要转本地时间?这些逻辑如果在业务代码里散落处理,每个人的写法都不一样,测试用例也覆盖不到。

我把这些工具函数放进一个utils模块里,遵循一个原则:输入尽量宽容,输出尽量严格。函数内部把各种可能的输入格式都兼容一遍,统一输出标准格式。这看着笨,但用起来是真舒服,写业务的时候你根本不需要去想“这里会不会出问题”。

2.4 事件总线与状态共享:小场景下的轻量“广播塔”

前端框架发展到现在,组件通信的方案已经很多了:props 逐层传、provide/inject、全局状态管理(Vuex、Pinia、Redux 等)。那我还保留一个事件总线,是不是过时了?我的答案是:大材小用不如杀鸡用牛刀。像“登录成功后通知多个模块刷新用户信息”“筛选条件变化后,右侧面板和底部列表同时更新”这类同级、跨层但逻辑简单的场景,为它去引入一整套全局状态管理,学习和维护成本反而偏高。事件总线把这类“通知-监听”的动作收敛起来,不占全局 store 的空间,用完即毁,非常轻量。

我选的实现方案是基于mitt的思路做二次封装。为什么不直接用 node 的 EventEmitter?因为浏览器端没有那个包。为什么不用自己造轮子从零写?因为事件订阅发布的边界细节,比如重复绑定、监听器报错隔离、事件类型常量统一管理,这些成熟小库已经处理得很好了,我只需要在它上面加一层项目语境的东西:统一的模块命名、全局事件列表、防止内存泄漏的 off 约束约定。这个设计我后面细讲。

2.5 DOM 与浏览器增强工具:高频操作的“外挂合集”

防抖、节流、复制文本、生成唯一标识、URL 参数解析、元素滚动到可视区域。这类函数的特点是没有太深的技术含量,但每次用到都要现场写一次,还总是写不完整。比如复制文本,你用document.execCommand('copy'),这个 API 在部分浏览器已经标记废弃了,应该优先用navigator.clipboard,但后者在非安全上下文(非 https 或非 localhost)下不存在,你得做降级。再比如防抖节流,很多人只知道“大概是要 delay 一下”,但首拍是否立即执行、尾拍是否会补一次,这两个参数在真实交互场景(比如搜索建议、按钮防重复提交)里有非常大的区别。

我把这些浏览器能力整合成一个browser模块,统一导出。每个函数保持单一职责,并且把容易踩的兼容性细节在函数内部消化掉,调用方永远用最简洁的参数。

3. 实操过程:五段核心代码与参数说明

3.1 Http 封装:从拦截器到业务层

我先讲讲请求层封装最核心的一个骨架。语言我用 TypeScript,因为类型本身就是最好的文档,这一点对团队协作特别重要。

// request.ts import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000, }); // 请求拦截:自动携带 token http.interceptors.request.use((config: InternalAxiosRequestConfig) => { const token = storage.get('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error)); // 响应拦截:统一剥离外壳、处理错误 http.interceptors.response.use((response: AxiosResponse) => { const code = response.data.code; // 约定:后端返回 code 为 0 或 200 表示成功 if (code === 0 || code === 200) { return response.data.data; // 直接返回业务数据 } // 业务错误:401 登录失效,403 无权限 if (code === 401) { storage.remove('token'); // 跳转登录页,并保留当前地址以便回跳 window.location.href = `/login?redirect=${encodeURIComponent(location.href)}`; return Promise.reject(new Error('登录状态已过期,请重新登录')); } if (code === 403) { message.error('抱歉,您没有权限执行该操作'); return Promise.reject(new Error('FORBIDDEN')); } message.error(response.data.message || '请求失败,请稍后重试'); return Promise.reject(new Error(response.data.message || 'Error')); }, (error) => { // 网络错误、超时、HTTP 5xx 等 if (error.code === 'ECONNABORTED') { message.error('请求超时,请检查网络'); } else if (error.response) { message.error(`服务异常:${error.response.status}`); } else { message.error('网络连接异常'); } return Promise.reject(error); }); // 对外暴露的泛型请求方法 export const request = { get<T>(url: string, params?: object): Promise<T> { return http.get(url, { params }) as Promise<T>; }, post<T>(url: string, data?: object): Promise<T> { return http.post(url, data) as Promise<T>; }, put<T>(url: string, data?: object): Promise<T> { return http.put(url, data) as Promise<T>; }, delete<T>(url: string, params?: object): Promise<T> { return http.delete(url, { params }) as Promise<T>; }, };

有几个细节我需要单独强调一下。

第一,baseURL 不要写死在代码里。我见过太多项目直接把http://xxx.com/api写在封装文件里,结果换环境、换代理配置时,改一个文件倒还好,问题是这种情况说明你根本没有区分“构建环境”。上面写的import.meta.env.VITE_API_BASE_URL是 Vite 的环境变量方式,如果你用 webpack,就换成process.env.VUE_APP_API_BASE_URL。这个变量的意义是让开发环境走代理、测试环境走测试域名、生产环境走正式域名,互不干扰。

第二,业务数据的“外壳”一定要在这里剥掉。绝大多数后端包装格式都是{ code, message, data }这样,如果你不在这里剥,业务代码里会到处出现res.data.data.list这种丑陋的链式取值。剥掉之后,调用方拿到的直接就是data,类型也更干净。

第三,401 的全局处理。很多项目里,登录失效的判断散落在各个页面,容易出现“明明已经退出了,某个请求还在带旧 token 发”的问题。在上面这个封装里,401 一旦发生,我会清掉 storage 里的 token,再跳转登录页。注意这里必须用storage.remove('token'),而不是localStorage.removeItem('token'),原因我放到存储封装里讲。

第四,错误提示的“半自动”。为什么说半自动?因为封装里默认弹了一条错误消息,但调用方仍然可以在 catch 里做更细的处理。我不建议封装做得太死,把所有错误都静默吞掉或者反过来所有错误都弹窗,灵活度太低。尤其是业务上的“错误”有时候不是错误,比如“列表没有更多数据了”,这种情况应该在业务层 catch 里判断,而不是封装层误报。

再说一个我强烈建议加的功能:统一取消过期请求。比如用户切换筛选条件时,上一次的请求还没返回,如果不处理,旧响应可能覆盖新数据。这可以用 axios 的 CancelToken 实现,但实现起来会稍微复杂一点,我一般是在封装里维护一个AbortController列表:

// 简单示例:新增请求时取消上一个相同标识的请求 const requestMap = new Map<string, AbortController>(); export const cancellableGet = <T>(url: string, key: string, params?: object): Promise<T> => { if (requestMap.has(key)) { requestMap.get(key)?.abort(); } const controller = new AbortController(); requestMap.set(key, controller); return http.get(url, { params, signal: controller.signal }) as Promise<T>; };

这里用 Map 管理多个 key,每个 key 的业务含义由调用方决定,比如搜索关键词、列表的 tab 类型。这个能力对于搜索输入类的页面特别重要,实测下来能避免掉很多“竞态 bug”。

3.2 Storage 封装:把“存取删”做成一件小事

存储层封装我自己写得最简单,但使用率最高。看一下代码:

// storage.ts interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } class StorageHandler { private prefix: string; private engine: StorageLike; constructor(prefix: string, engine: StorageLike = window.localStorage) { this.prefix = prefix; this.engine = engine; } private buildKey(key: string): string { return `${this.prefix}:${key}`; } get<T>(key: string): T | null { const raw = this.engine.getItem(this.buildKey(key)); if (raw === null || raw === undefined) return null; try { const parsed = JSON.parse(raw); // 支持过期时间:{ __expires, value } if (parsed && typeof parsed === 'object' && parsed.__expires) { if (Date.now() > parsed.__expires) { this.remove(key); return null; } return parsed.value as T; } return parsed as T; } catch (e) { // 如果解析失败,说明存的是普通字符串,原样返回 return raw as unknown as T; } } set<T>(key: string, value: T, expiresIn?: number): void { const payload = expiresIn ? { value, __expires: Date.now() + expiresIn * 1000 } : value; try { this.engine.setItem(this.buildKey(key), JSON.stringify(payload)); } catch (e) { console.warn('[storage] setItem failed:', e); } } remove(key: string): void { this.engine.removeItem(this.buildKey(key)); } } export const storage = new StorageHandler('myapp');

这段代码有几个细节值得揣摩。

第一个是“前缀”。我这边定为myapp,那么存的 key 最终是myapp:token、myapp:userInfo。为什么要有前缀?因为同一个域名下可能部署多套系统,或者同一个系统有多个环境共用同一域名。没有前缀,A 系统的token会覆盖 B 系统的token,查起来非常头疼。前缀相当于给项目加了一个命名空间。

第二个是“解析失败回退字符串”。如果你在旧代码里直接存过'hello',它不是一个合法的 JSON,JSON.parse 会抛异常。旧版很多人的做法是直接裸存,所以封装里要兼容这批历史数据,否则线上会出现“读取缓存突然崩溃”的诡异 bug。我这边 catch 到解析失败就按原字符串返回,这个设计看起来不优雅,但是在真实项目里极其实用。

第三个是“过期时间”。注意我的过期时间单位是秒,为expiresIn,在内部换算成毫秒。为什么用秒?因为业务语义上都是“缓存 10 分钟”“记住我 7 天”,秒比较直观。过期的数据读取时就会返回 null 并且顺带清除,不会越积越多。

再补一个 sessionStorage 的封装。直接复用 StorageHandler 类,把第二个参数传window.sessionStorage即可:

export const session = new StorageHandler('myapp:session', window.sessionStorage);

这里还要提一个很多新人容易漏掉的点:现代 web 应用里,你还要考虑 SSR / 环境变量。比如你在服务端渲染时,window 对象不存在,直接new StorageHandler('myapp', window.localStorage)就会崩。所以我一般会加一个环境判断,非浏览器环境就用一个内存 Map 充当 StorageLike。这个降级方案在单元测试和 SSR 里都是保命的一手:

class MemoryStorage implements StorageLike { private map = new Map<string, string>(); getItem(key: string) { return this.map.get(key) ?? null; } setItem(key: string, value: string) { this.map.set(key, value); } removeItem(key: string) { this.map.delete(key); } }

3.3 格式化工具函数:宽进严出

工具函数这块我整理一个最常用的日期格式化,这个可以说是“前端面试题”级别的基础,但真要写出一个经得起考验的版本,还是有很多讲究。

// format.ts type DateInput = Date | number | string; function normalizeDate(input: DateInput): Date { if (input instanceof Date) return input; if (typeof input === 'number') { // 兼容秒级时间戳(10 位)和毫秒级时间戳(13 位) const len = String(Math.abs(input)).length; return new Date(len === 10 ? input * 1000 : input); } // 兼容 ISO 字符串和带时区的字符串 const date = new Date(input); if (isNaN(date.getTime())) { throw new Error(`[format] invalid date: ${input}`); } return date; } export function formatDate(input: DateInput, template = 'YYYY-MM-DD HH:mm:ss'): string { const date = normalizeDate(input); const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); const tokens: Record<string, string> = { YYYY: String(date.getFullYear()), MM: pad(date.getMonth() + 1), DD: pad(date.getDate()), HH: pad(date.getHours()), mm: pad(date.getMinutes()), ss: pad(date.getSeconds()), }; return template.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => tokens[match]); }

这个实现就是用正则替换去匹配模板。为什么不用字符串拼接去兼容?因为模板是动态的,业务方可能传YYYY-MM-DD,可能传HH:mm:ss,还可能传YYYY/MM/DD HH:mm,只有模板解析的方式能一套代码全部搞定。

兼容秒级时间戳这一条,是我强烈建议你抄下来的。后端接口返回“时间戳”,有的返回 10 位(秒级),有的返回 13 位(毫秒级),前端如果不统一处理,同一个formatDate函数在不同数据源之间会出现“日期差了十几年”的灵异事件。你可能会觉得后端不会这么不靠谱,但要真有这么一个不靠谱的字段,排查起来能让你怀疑人生。

再补充一个金额格式化。平时金额展示要千分位,还要保留两位小数,最省事的写法是toLocaleString('zh-CN', { style: 'currency', currency: 'CNY' }),但这个会带一个“¥”符号,有时候你只是想要纯数字的千分位,不需要符号。所以更加可控的做法是用Intl.NumberFormat:

const numberFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2, }); export function formatMoney(value: number | string): string { const num = typeof value === 'string' ? parseFloat(value) : value; if (isNaN(num)) return '0.00'; return numberFormatter.format(num); }

这里特意缓存了Intl.NumberFormat实例。不要每次都 new 一个,因为这个构造器的成本其实不低,连续渲染几百条金额数据时,实例复用有肉眼可见的收益。工具函数封装时,“能省则省”的原则要落实到细节里。

如果更极端一点,你还可能遇到“金额超过 JS 安全整数”的问题,比如支付系统的分转元。这时候我建议直接用字符串处理,或者引入decimal.js这类库,不要用浮点数直接计算,否则你的账会变成0.1 + 0.2 = 0.30000000000000004。

3.4 事件总线:轻量版 mitt 封装与内存泄漏防护

事件总线我在团队里用的是mitt作为底层,然后加一层项目约定:

// eventBus.ts import mitt from 'mitt'; type AppEvents = { 'user:login': { userId: string; name: string }; 'user:logout': void; 'table:refresh': { tableId: string }; 'message:update': { total: number }; }; const emitter = mitt<AppEvents>(); export const eventBus = { on: emitter.on, off: emitter.off, emit: emitter.emit, /** * 安全移除一个监听器:不存在的类型直接忽略 * 避免多次 off 导致报错 */ offSafe<Key extends keyof AppEvents>(type: Key, handler: (event: AppEvents[Key]) => void) { try { this.off(type, handler); } catch (e) { // mitt 对未注册的事件会抛错,这里统一吞掉 } }, };

你可能觉得这层封装太薄了,没什么存在感。但我要说的是,薄封装恰恰是合理的。事件总线的本质需求就是“订阅”和“发布”,如果你在它上面加一堆 middleware、加优先级、加通配符,那反而复杂化了,出了问题还不好排查。

真正的关键不在 eventBus 本身,而在于一套约束:所有事件名必须写在AppEvents这个类型里。这样在 TS 里写eventBus.emit('user:login', ...)时,参数类型是自动校验的;写一个没定义过的事件名,编译器直接报错。这个约定比任何运行时的防御都更有价值,因为它把错误提前到了开发阶段。

用的时候还要注意一个非常现实的问题:组件销毁时必须 off。如果只 on 不 off,你可能遇到的问题不是“内存泄漏”(现代框架对这种问题没那么敏感),而是“监听器重复执行”,组件重建十次,同一个事件触发时回调执行十次。我见过一个贼离谱的 bug:一个弹窗在关闭后,内部的请求还是会被之前的事件触发重新发一遍,就是因为开着弹窗的时候 on 了一次,关闭时没有 off,再打开时又 on 了一次,越积越多。

所以我在封装上做了一个“保命”的辅助函数——让组件在挂载时绑定,卸载时自动解绑:

import { onBeforeUnmount } from 'vue'; export function useEventBus() { const handlers: Array<() => void> = []; const register = <Key extends keyof AppEvents>( type: Key, handler: (event: AppEvents[Key]) => void ) => { emitter.on(type, handler as never); handlers.push(() => emitter.off(type, handler as never)); }; onBeforeUnmount(() => { handlers.forEach((off) => off()); }); return { register, emit: emitter.emit }; }

这个组合式函数在 Vue 里用起来就非常干净了,不需要每次手动 off。React 那边对应的做法是useEffect里返回清理函数。

3.5 浏览器增强工具:防抖、节流、复制、UUID

浏览器增强工具这一组里,最常被问到的就是防抖和节流的区别。我用一句话给你讲明白:防抖是“你停下来之后我再做”,节流是“我做,但最多每 N 秒一次”。搜索输入框适合防抖,因为用户停下来之后才发请求,避免每次都请求;滚动加载适合节流,因为滚动过程中需要定期执行,但不能每次滚动都触发。

封装时我建议把两个“行为开关”参数暴露出来:

// browser.ts export function debounce<T extends (...args: any[]) => void>( fn: T, delay = 300, immediate = false ) { let timer: ReturnType<typeof setTimeout> | null = null; let isInvoked = false; return function (this: any, ...args: Parameters<T>) { if (immediate && !isInvoked) { fn.apply(this, args); isInvoked = true; } if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); isInvoked = false; timer = null; }, delay); }; } export function throttle<T extends (...args: any[]) => void>( fn: T, interval = 500, options: { leading?: boolean; trailing?: boolean } = { leading: true, trailing: true } ) { let lastTime = 0; let timer: ReturnType<typeof setTimeout> | null = null; return function (this: any, ...args: Parameters<T>) { const now = Date.now(); const { leading = true, trailing = true } = options; if (lastTime === 0 && !leading) { lastTime = now; } const remaining = interval - (now - lastTime); if (remaining <= 0) { if (timer) { clearTimeout(timer); timer = null; } fn.apply(this, args); lastTime = now; } else if (trailing && !timer) { timer = setTimeout(() => { fn.apply(this, args); timer = null; lastTime = Date.now(); }, remaining); } }; }

这里有一个我个人经验总结成的忠告:不要以为网上抄一个防抖函数就能直接用,边界条件往往埋在“第一次调用”和“最后一次调用”里。比如你防的是一个“提交按钮”,如果尾部没有补一次执行,用户快速双击时第二下可能漏掉;但如果你把尾部补上,用户拖拽滚动时又会在停止滚动后额外执行一次,可能不是你期望的。这就要根据业务场景去调leading和trailing,所以我把它们作为参数暴露出来,而不是写死死。

复制文本的功能,我封装成下面这样:

export async function copyText(text: string): Promise<boolean> { // 首选 Clipboard API if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { // 用户拒绝权限等情况,继续走降级 } } // 降级:textarea + execCommand try { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); return true; } catch (e) { console.error('[copyText] failed:', e); return false; } }

我没有用new Promise去把整个函数包成同步风格,而是直接让异步流程自然展开。navigator.clipboard.writeText在部分浏览器上会返回一个 Promise,你需要 await;但不支持时立刻走execCommand降级,这个分支是同步的,所以整个函数设计成 async 最合适。

UUID 生成也很容易被低估。网上有很多版本Math.random().toString(36).slice(2)之类,但之前有个安全圈的热搜词“crypto.randomUUID”值得记住:现代浏览器有了原生的crypto.randomUUID(),比任何自己拼的随机串都可靠,而且不存在Math.random()被预测的风险。封装时我做兼容:

export function uuid(): string { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } // 降级实现 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); }

4. 这些写在封装里的坑,我一条条踩过

4.1 this 丢失与回调乱入

封装防抖、节流这类工具时,最容易出的坑就是this 指向。多数人从网上抄代码时,只关注参数和返回值,完全忽略了在setTimeout回调里执行函数时,this已经丢失了。如果你把工具函数应用在 Vue 组件里,this.value会变成 undefined,或者应用在 class 里,this.method直接报错。

我上面写的版本里每次都做了fn.apply(this, args),这个this是包装后函数被调用时的调用方。一定要保留,否则封装“可用”,但不“可靠”。

4.2 类型“过度封装”反而难用

我在早期封装请求方法时,把返回类型直接写成了Promise<T>,然后业务方调用时要用泛型传参数。看着很优雅,但实际使用中经常有人偷懒不传泛型,导致拿到的数据一直是unknown,接着用起来全是断言。后来我调整了思路:基础方法保持灵活泛型,再给业务层定义更具体的“接口函数”。

比如:

// api/user.ts import { request } from '@/utils/request'; export const getUserInfo = () => request.get<UserInfo>('/user/info'); export const updateUserInfo = (data: Partial<UserInfo>) => request.put<UserInfo>('/user/info', data);

这个接口函数层把泛型“固化”下来,业务页面里直接getUserInfo()拿到的就是带类型的UserInfo。封装层级清楚,比在页面里反复传泛型舒服得多。

4.3 事件名成了团队里的“暗语”

事件总线用了一段时间后,有同事反馈“我不知道有哪些事件可以订阅”。因为我只把事件名写在了类型定义里,新同事压根不知道去翻那个类型文件。后来我在AppEvents类型旁边加了一份 Markdown 表格,列出每个事件名的触发时机、参数含义、触发方示例。然后我用一个EventList.ts文件专门维护,并在代码审查时规定:新增事件必须同步更新文档。这件事看起来是“工程管理”,但实际上比代码本身更重要。封装里藏的“隐藏约定”越多,不是越高级,而是越危险。

注意:事件名和常量名建议不要直接裸写字符串。哪怕你用 TS 类型约束了,我还是建议抽常量或者枚举,避免字符串手滑打错时排查半天。

4.4 请求封装把“业务弹窗”锁死

最早我封装的请求层里,错误提示全部统一弹,不管什么错误都message.error一把梭。结果电商后台的人反馈:“有些接口是校验类错误,我们需要把错误字段标红在表单里,而不是弹窗”。我意识到我把错误处理做得太“自动化”了,自动化到业务方没有插手的空间。

后来我在封装里加了一个开关,像这样:

interface RequestOptions extends AxiosRequestConfig { silent?: boolean; // 静默模式,不自动弹错误提示 }

业务代码里如果传入silent: true,响应拦截器发现业务错误时不弹窗,直接 reject,交给业务自己 catch 处理。这个开关虽然只有一行,但把一个“一刀切”的封装变成了“可商量”的封装,团队接受度立刻不一样。

4.5 单元测试会暴露你的封装底裤

封装库如果没有单元测试,基本等于裸奔。我踩过最疼的一坑是:date formatter 里只测试了毫秒级时间戳,上线后接口给了秒级时间戳,日期偏了老远;后来这个 bug 是在自动化测试里抓出来的。

我现在给封装库的每个纯函数都配了测试,用的就是 vitest。举个例子:

import { describe, it, expect } from 'vitest'; import { formatDate } from '../src/format'; describe('formatDate', () => { it('handles millisecond timestamp', () => { const date = new Date(2026, 0, 15, 10, 30, 45).getTime(); expect(formatDate(date, 'YYYY-MM-DD')).toBe('2026-01-15'); }); it('handles second timestamp', () => { const date = Math.floor(new Date(2026, 0, 15, 10, 30, 45).getTime() / 1000); expect(formatDate(date, 'YYYY-MM-DD HH:mm:ss')).toBe('2026-01-15 10:30:45'); }); it('handles ISO string', () => { expect(formatDate('2026-01-15T10:30:45.000Z', 'YYYY-MM-DD HH:mm:ss')).toMatch(/2026-01-15/); }); });

封装的库越是给全项目用,测试的优先级越高。别相信“这么简单还要测试”的说法,线上那个问题往往就出在你觉得“不可能出错”的地方。

5. 让团队愿意用你的库:从封装到工程化的最后一公里

5.1 类型定义:TypeScript 才是隐性文档

很多封装在 JS 环境里写着写着就失控了。比如storage.get('userInfo')返回的到底是什么?新人得去翻源码、看 set 的调用处,效率极低。我给 storage 也加上了泛型约束,不过要真正做到好用,还是要业务方在初始化时就定义好“存储实体”:

export interface AppStorageSchema { token: string; userInfo: UserInfo; searchHistory: string[]; theme: 'light' | 'dark'; settings: { fontSize: number; compact: boolean }; } export function getStoreKey<K extends keyof AppStorageSchema>(key: K) { return storage.get<AppStorageSchema[K]>(key); }

这样每个存储 key 的取值类型都是明确的,写业务代码时 IDE 能直接提示出token是什么类型、searchHistory是一个字符串数组。这种“类型地图”是所有封装库长期维护的根基。

5.2 示例页:胜过一百行注释

封装完成之后,我强烈建议写一个examples目录,每个封装模块配一个最小可运行的 demo 页面,不需要花里胡哨,但要覆盖最常见的用法和参数。

举个例子,storage 封装的 demo 里可以写:

页面初始化时读取 token → 页面里修改 token → 点击“设置过期时间”写入一个 60 秒后过期的缓存 → 刷新页面,验证过期缓存已被清除 → 点击“清空所有缓存”验证前缀下的所有 key 被移除。

示例页面能从用户视角验证封装好不好用。我个人的判断标准是:如果有同事用示例页面完成了操作,没来问我任何问题,那这个封装初步算是过关了。如果他在示例页面里都磕磕绊绊,说明 API 设计还不够“顺”。封装的前端库,拼的不是代码复杂度,是接口舒适度。

5.3 内部 npm 包:别只在项目里复制目录

很多团队封装完库之后,就放一个src/utils文件夹,让项目之间复制。这在一两个项目时倒还好,一旦到了五六个项目,每个项目里的 utils 都会长出不同的分叉,慢慢就没人知道哪个是对的。

我后来把这些库统一整理成一个私有 npm 包,发布到公司内部 registry。发布前需要注意这几件事:

  • package.json 里的main和module字段指到编译后的产物;
  • 用 rollup 或 vite 打包成es和lib两种格式,支持 tree-shaking;
  • 配置files字段,只发布构建产物和类型声明,不要带上源码里的 demo 页面;
  • 版本号遵循 semver,破坏性改动记得发 major 版本,避免下游项目悄悄崩掉。

发布私有 npm 包之后也有新问题:版本更新迭代了,下游项目不主动升级,又退回到“各自为政”的状态。我的做法是提供一个 changelog,每次发版前写清楚:新增了什么、废弃了什么、有没有 breaking change。封装库的长期维护,核心是“信任”——同事信任你每次升级是稳妥的、可预期的。这份信任不是靠代码写得多漂亮赢来的,而是靠版本纪律和文档质量慢慢攒起来的。

5.4 命名规范:一致性比正确性更稀缺

封装库的 API 命名如果东一榔头西一棒,用起来会非常痛苦。比如同样是“设置存储”,你都叫set,就不要有哪一天突然冒出一个putItem;同样是“获取”,都叫get,就不要一会儿fetch、一会儿query。我建议在封装前的设计阶段就把这两个问题确定下来:

  • 动词统一:get / set / remove / on / off / emit;
  • 参数顺序统一:优先“主体在前,选项在后”(set(key, value, expiresIn)而不是把 key 放最后);
  • 返回类型统一:查询类函数要么返回T | null,要么返回T | undefined,不要今天 null 明天 undefined。

这些看似微不足道的规划,在真实使用中节省的时间非常可观。团队新人写代码时甚至不用翻文档,光看函数名就能猜出八九成用法,这本身就是效率提升。

6. 第二弹规划与个人经验

第一弹这五类封装,是我认为“投入产出比”最高的部分。它们覆盖了绝大多数前端日常开发中高频接触的基础模块,也是我实际项目里反复迭代过、已经稳定运行了很长一段时间的方案。

后面我再打算整理第二弹,方向大概会往这些靠:通用 UI 组件库(表格、弹窗、表单联动这类业务向组件的封装思路)、hooks 层的封装模式(特别是涉及异步状态、竞态处理、轮询管理的逻辑),以及微前端场景下公共模块怎么抽离才不容易互相污染。这些相比第一弹会牵扯到更多框架层面的内容,也更考验封装边界的划分能力。

最后再分享一点点个人体会。前端封装库这件事,不要把它想成是一次性的“大招”,它更像是一把需要持续打磨的刀。最开始我从封装storage和formatDate起步,那时候也不完美,业务里还是会直接写localStorage。后来每遇到一次因为重复代码导致的 bug,我就往封装里补一个能力,每补一个能力就顺手补一条测试、更新一行文档。它不是在某个周末一口气写完的,而是跟着项目一起生长出来的。你刚开始封装的时候,可能也会觉得费时间、不值当,但只要坚持下来,最直接的变化就是:新需求开发时,你不再纠结工具函数在哪里复制,真正需要思考的只有业务本身。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询