1. 为什么常用工具函数值得反复写如果你在前端岗位待过几年就会发现一个挺有意思的现象很多项目里真正跑得最久、被改得最少的代码往往不是业务组件也不是状态管理方案而是一批散落在 utils 目录下的小函数。我见过不少团队业务代码重构了三轮组件库换了两套但那几个工具函数还是老样子——因为大家每天都在用用着顺手就没人敢轻易动它们。反过来我也见过一些新人入职后写的第一个工具函数就被老员工打回去重写理由是边界情况没处理、可读性太差、别人没法维护。这一篇继续聊前端开发中的常用工具函数。前面几篇已经覆盖了类型判断、字符串处理、数组去重、深拷贝这一类基础操作这一篇我会把重点放在几类更贴近真实业务场景的函数上格式化输出、异步流程控制、DOM 操作封装、数据转换与聚合。每一类我都会给出可用的实现、使用场景、为什么这样写的理由以及我自己在实际项目里踩过的坑。不管你是刚入行不久的前端开发者还是已经在业务里写了很久代码但想整理自己工具库的人这篇内容都能直接拿来用。我不打算只丢一堆 lodash 风格的代码片段给你而是更想让你理解一个工具函数在什么情况下值得抽出来抽出来之后怎么设计参数和返回值才不会被后续的需求变化轻易推翻。2. 格式化输出类工具从能用到好用之间的距离格式化输出是前端工具函数里最容易被低估的一类。很多人觉得不就是 toString 一下、补个零、拼个字符串嘛但等你真正处理过金额显示、日期格式化、文件大小换算之后就会发现里面的细节比想象中多得多。2.1 数字格式化不仅仅是千分位先说一个最常见的需求数字千分位格式化。网上很多实现是这样的function formatThousands(num) { return num.toString().replace(/\B(?(\d{3})(?!\d))/g, ,); }这段正则很短处理正整数的确没问题。但只要你拿负数试一下就露馅了formatThousands(-1234567)会得到-,123,456因为正则往前找位置时把负号后面的位置也算进去了。我项目里实际在用的版本长这样function formatThousands(num, precision 0) { const [intPart, decPart] String(num).split(.); const sign intPart.startsWith(-) ? - : ; const digits intPart.replace(/^-/, ); const formattedInt digits.replace(/\B(?(\d{3})(?!\d))/g, ,); const result sign formattedInt; return precision 0 decPart ? ${result}.${decPart.padEnd(precision, 0).slice(0, precision)} : result; }这个版本拆分了整数部分和小数部分并且把符号单独拎出来处理。之所以这样设计是因为前端处理金额时数据源经常是后端返回的字符串比如-1234567.8如果不拆开处理直接在原字符串上做正则替换很危险。另外还有一个隐藏点num可能是个科学计数法表示的小数比如1e-7String(1e-7)得到的是1e-7拆出来的intPart是1decPart是e-7会直接出错。所以更稳妥的做法是先转成固定位数的小数function safeFormatThousands(num, precision 2) { const fixed Number(num).toFixed(precision); return formatThousands(fixed, precision); }这里用toFixed(precision)先把科学计数法变成普通字符串避免了解析问题。代价是toFixed本身有浮点精度问题比如(1.005).toFixed(2)得到的是1.00而不是1.01。如果对精度有严格要求建议用字符串小数展开的方式做四舍五入或者引入专门处理十进制的库。但如果业务上只是展示用途toFixed通常够用。2.2 日期格式化模板比一个个拼接更省心日期格式化也是每个前端项目里都会出现的函数。很多人在业务里直接写const date new Date(); const str ${date.getFullYear()}-${date.getMonth() 1}-${date.getDate()};这样写的问题在于月份和日期没有补零格式固定死想切换成2025/03/09或者带时间的版本就得复制一份再改时区问题完全没考虑。我比较推荐用一个支持模板的轻量函数function formatDate(dateInput, template YYYY-MM-DD HH:mm:ss) { const date dateInput instanceof Date ? dateInput : new Date(dateInput); if (Number.isNaN(date.getTime())) return ; const pad (n) String(n).padStart(2, 0); const map { YYYY: 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) map[match]); }使用方式很直接formatDate(2025-03-09T10:30:00, YYYY/MM/DD); // 2025/03/09 formatDate(Date.now(), MM-DD HH:mm); // 03-09 10:30这个函数有个设计细节值得说模板字符串的匹配顺序很重要。必须先匹配YYYY、MM、DD这些长的占位符再匹配HH、mm、ss因为如果用/YYYY|MM|DD|HH|mm|ss/g这个顺序写实际上YYYY和MM不可能同时匹配上没有歧义。但如果你把ss放在s前面就需要注意正则的长短顺序。如果碰到需要显示昨天、前天这种时间不要写在格式化函数里单独抽一个getRelativeTime函数更清晰。职责分离是工具函数设计里很重要的一条原则——一个函数只做一件事组合起来能满足更多需求。2.3 文件大小格式化单位进制也要可配置文件大小格式化常见的实现是 1024 进制也就是1KB 1024B。但实际业务里有些场景会用 1000 进制比如网络传输速率、硬盘厂商标注存储空间。所以函数要把进制设计成参数function formatFileSize(bytes, base 1024) { if (typeof bytes ! number || Number.isNaN(bytes) || bytes 0) { return -; } if (bytes base) return ${bytes} B; const units base 1024 ? [B, KB, MB, GB, TB] : [B, KB, MB, GB, TB]; let size bytes; let unitIndex 0; while (size base unitIndex units.length - 1) { size / base; unitIndex 1; } return ${size.toFixed(size 100 ? 1 : 2)} ${units[unitIndex]}; }这里有个细节size.toFixed(size 100 ? 1 : 2)是根据位数动态决定小数位。这样999 B不会显示成999.00 B1.5 GB会显示1.50 GB观感会好很多。实际项目中这个函数最坑的不是进制问题而是 bytes 参数可能来自各种来源有 number 类型有字符串还有 undefined。所以我在函数开头做了类型校验和非法值兜底返回-而不是抛错——因为文件大小的展示通常不是核心流程兜底显示比中断流程更合理。3. 异步流程控制把回调地狱变成清晰管道前端工具函数里异步相关的往往是团队里分歧最大的部分。有人喜欢用第三方库有人觉得原生 Promise 就够用。我的观点是工具函数应该只做一件事——把异步流程的某个共性痛点封装起来而不是试图取代 async/await。3.1 带超时的 Promise避免请求永远 pending前端开发中很常见的一个问题是请求发出去之后如果网络很慢或者后端没响应页面就一直在 loading。有时候用户等了一分钟还没结果体验非常差。解决办法是给 Promise 加一个超时控制。看起来很简单但如果你直接这样写function withTimeout(promise, ms) { return Promise.race([ promise, new Promise((_, reject) setTimeout(() reject(new Error(timeout)), ms)), ]); }这段代码确实会触发超时 reject但有一个严重问题原 promise 并没有被取消它还会继续执行如果它后面 resolve 了会进入未捕获的 Promise 状态在某些环境下会触发 unhandledrejection 警告甚至导致内存泄漏。实际项目中我用的版本会更谨慎一些function withTimeout(promise, ms, onTimeout) { let timer null; const timeoutPromise new Promise((_, reject) { timer setTimeout(() { const err new Error(Promise timed out after ${ms}ms); if (typeof onTimeout function) { onTimeout(err); } reject(err); }, ms); }); return Promise.race([promise, timeoutPromise]).finally(() { if (timer) clearTimeout(timer); }); }finally里清除定时器是为了防止 promise 先 resolve 后定时器还挂着白白占一个事件循环的任务。至于原 promise 的后续执行如果它是基于 XMLHttpRequest 或 fetch 的请求真正想要做到网络层面的取消需要传入 AbortController 信号工具函数层面只能做到结果层面的忽略。提示如果需要真正取消 fetch建议单独封装一个fetchWithTimeout利用AbortController在超时的时候主动断开连接。单纯的withTimeout只解决结果不等待不解决底层资源回收。3.2 重试机制加指数退避别把服务器打挂另一个高频需求是请求失败后自动重试。简单的重试函数长这样async function retry(fn, times 3, delay 1000) { let lastError; for (let i 0; i times; i 1) { try { return await fn(); } catch (err) { lastError err; if (i times - 1) { await sleep(delay); } } } throw lastError; }但真实场景里固定 delay 的重试有一个问题如果服务端已经过载了大家同时失败同时等待 1 秒然后又同时发起请求等于给服务器又来了一波压力波峰。我通常在重试函数里加入指数退避和抖动jitterfunction sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); } async function retryWithBackoff(fn, { retries 3, baseDelay 500, maxDelay 10000 } {}) { for (let attempt 0; attempt retries; attempt 1) { try { return await fn(); } catch (err) { if (attempt retries) throw err; const exponentialDelay Math.min(baseDelay * 2 ** attempt, maxDelay); const jitter Math.random() * 200; const actualDelay exponentialDelay jitter; await sleep(actualDelay); } } return undefined; }这里的关键是baseDelay * 2 ** attempt第一次重试等 500ms第二次等 1000ms第三次等 2000ms…… 加上随机 jitter可以让并发失败的重试请求不会整齐划一地打向服务器。实际业务中重试还要考虑一个点并不是所有错误都值得重试。比如 HTTP 400 参数错误重试一百次也是白搭而 502、503、网络超时这类错误才有重试价值。所以更完整的设计应该是让调用方传入shouldRetry判断函数async function retryIf(fn, { shouldRetry () false, retries 3 } {}) { for (let attempt 0; attempt retries; attempt 1) { try { return await fn(); } catch (err) { if (attempt retries || !shouldRetry(err)) throw err; const delay Math.min(500 * 2 ** attempt, 8000) Math.random() * 200; await sleep(delay); } } return undefined; }这个函数用起来就是请求失败时先判断错误类型shouldRetry返回 true 才继续重试。我一般在封装 AJAX 请求层的地方用它配合状态码判断比无脑重试可靠得多。3.3 并发控制限制 Promise.all 的并发数前端有时候需要在页面里批量拉取数据比如表格里每一行都要调一个接口拿详情。新手常见的写法是const results await Promise.all(rows.map(row fetchDetail(row.id)));这代码在行数少时没问题但如果 rows 有 200 个浏览器一下子发 200 个请求会出现连接阻塞、后端扛不住、部分请求直接失败的情况。可控的并发数是异步工具里很实用的一类。一个轻量实现如下async function mapLimit(items, limit, handler) { const results []; const executing []; for (const item of items) { const promise Promise.resolve(handler(item)).then((result) { results.push(result); }); executing.push(promise); if (executing.length limit) { await Promise.race(executing); } } await Promise.all(executing); return results; }这个实现的思路是维护一个 executing 数组每次达到 limit 数量时用Promise.race等其中一个完成然后从数组里移除它靠 push 新 Promise 时数组长度不会超限。不过上面的版本有个顺序问题结果顺序可能跟输入顺序不一致。如果你的业务要求结果必须保持顺序就需要改造一下把每个 item 的索引和值一起处理async function mapLimitOrdered(items, limit, handler) { const results new Array(items.length); let cursor 0; async function worker() { while (cursor items.length) { const currentIndex cursor; const currentItem items[currentIndex]; cursor 1; results[currentIndex] await handler(currentItem); } } const workers Array.from({ length: Math.min(limit, items.length) }, () worker()); await Promise.all(workers); return results; }这个版本更优雅启动 n 个 worker每个 worker 从游标位置拿一个任务执行完后继续拿下一个。因为每个任务在数组里的索引是固定好的结果天然就是有序的。我在真实项目里用的是后者。比如批量导出用户数据时每一页数据都要请求一次接口用mapLimitOrdered把并发数限制在 3整体时间比串行快很多也不会打挂后端。4. 数据处理与聚合业务代码里的隐形主角前端工具函数里数据处理类的函数数量往往是最多的但也是最零散的。这里挑三个我觉得复用率极高的场景来展开。4.1 树形数据转换扁平列表与嵌套树的互相转换后端接口经常返回扁平列表而前端组件却需要嵌套树结构比如省级联选择器、树形菜单、权限树。这个转换逻辑几乎每个中后台项目都会遇到。扁平转树的经典实现function buildTree(items, parentId null, options {}) { const { idKey id, parentKey parentId, childrenKey children, } options; const result []; const map new Map(); items.forEach((item, index) { map.set(item[idKey], { ...item, [childrenKey]: [] }); }); items.forEach((item) { const node map.get(item[idKey]); const parent map.get(item[parentKey]); if (parent) { parent[childrenKey].push(node); } else if (item[parentKey] parentId) { result.push(node); } }); return result; }这个实现用Map先把每个节点存起来然后二次遍历构建父子关系。时间复杂度是 O(n)比很多博客里写的递归查找快得多。但实际项目中树转换最大的坑是数据里的脏值parentId 指向一个不存在的节点、有循环引用、parentId 是字符串而 id 是数字等等。所以在转树之前我一般会先做一层数据清洗function sanitizeTreeItems(items, options {}) { const { idKey id, parentKey parentId } options; const ids new Set(items.map((item) String(item[idKey]))); return items .filter((item) { const parentId item[parentKey]; return parentId null || parentId undefined || parentId || ids.has(String(parentId)); }) .map((item) ({ ...item })); }清洗完再做树转换能少很多边界问题。注意这里我用了String()统一比较因为很多接口返回的 id 是数字但 parentId 因为某些历史原因变成了字符串。树转扁平列表就简单一些递归或者栈都能搞定function flattenTree(tree, options {}) { const { childrenKey children } options; const result []; const stack [...tree]; while (stack.length) { const node stack.shift(); const children node[childrenKey]; if (Array.isArray(children) children.length) { stack.push(...children); } result.push(node); } return result; }这里用栈代替递归可以避免树太深时爆调用栈。如果你的树结构可能有几万层用栈遍历更安全。4.2 对象与数组的分组聚合按字段归类「把一个数组按某个字段分组」这个需求出现频率高到我觉得应该成为每个前端的基础能力。原生的Array.prototype.reduce能实现但每次写{ [key]: [] }的初始化逻辑很烦人。封装之后是这样的function groupBy(items, keySelector) { return items.reduce((acc, item) { const key typeof keySelector function ? keySelector(item) : item[keySelector]; if (key undefined || key null) return acc; if (!acc[key]) acc[key] []; acc[key].push(item); return acc; }, {}); }使用示例const orders [ { id: 1, status: pending }, { id: 2, status: done }, { id: 3, status: pending }, ]; groupBy(orders, status); // { pending: [{id:1,...},{id:3,...}], done: [{id:2,...}] }很多人在分组时忽略了一个细节key如果是一个对象或者数组直接作为对象属性会被强制转成字符串[object Object]导致所有对象分到一组。所以 keySelector 返回的值最好是字符串或数字如果一定要用复合键在函数里 stringify 一下function groupBy(items, keySelector) { return items.reduce((acc, item) { const rawKey typeof keySelector function ? keySelector(item) : item[keySelector]; const key typeof rawKey object ? JSON.stringify(rawKey) : rawKey; if (!acc[key]) acc[key] []; acc[key].push(item); return acc; }, {}); }这个细节救过我很多次——尤其是多维分组比如按status和type两个字段分拼出一个复合 keyJSON.stringify 保证不会撞。4.3 数组去重进阶对象数组按字段去重数组去重前面几篇提过基础版这里补一个对象数组按字段去重的版本。因为业务里经常碰到接口数据里重复项很多但对象引用不同直接用new Set()去不掉。function uniqueBy(items, keySelector) { const seen new Set(); const result []; for (const item of items) { const key typeof keySelector function ? keySelector(item) : item[keySelector]; if (seen.has(key)) continue; seen.add(key); result.push(item); } return result; }有时候还需要统计重复项数量那就再扩展一个function countBy(items, keySelector) { const counter new Map(); for (const item of items) { const key typeof keySelector function ? keySelector(item) : item[keySelector]; counter.set(key, (counter.get(key) || 0) 1); } return Object.fromEntries(counter); }这两个函数在质量报表、统计数据、列表合并的场景里非常常用。Map和Set的查找复杂度是 O(1)比用数组includes每次 O(n) 扫描要快得多——尤其数据量几千上万时性能差距非常明显。5. DOM 操作封装把重复的浏览器操作打包对于大部分前端业务直接操作 DOM 的场景越来越少但表单输入校验、事件冒泡处理、元素可见性检测这些需求还是离不开 DOM API。DOM 操作封装的目标不是替代框架而是把跨浏览器差异和处理细节藏起来让业务代码更简单。5.1 防抖与节流性能和交互的平衡点防抖debounce和节流throttle绝对是前端工具函数里被问得最多的两个。很多人只记住了防抖是最后一次执行节流是每隔一段时间执行一次但实际项目里实现起来还有不少细节。先看防抖function debounce(fn, wait 300, immediate false) { let timer null; let invoked false; return function (...args) { if (immediate !invoked) { fn.apply(this, args); invoked true; } clearTimeout(timer); timer setTimeout(() { if (!immediate) fn.apply(this, args); invoked false; timer null; }, wait); }; }这个版本比常规版多了一个immediate参数是否在第一次触发时立即执行一次然后等待 wait 毫秒后的空闲。比如搜索框场景用户连续输入时我们只希望输入停顿后才请求用immediate false而按钮点击防重复提交希望第一次点击立即生效、后续点击被忽略用immediate true。防抖里有个容易被忽略的点this 绑定。在 setTimeout 里如果直接调用fnthis 会变成全局对象或 undefined严格模式下。所以要用fn.apply(this, args)保留调用方的上下文。节流的常规实现function throttle(fn, wait 300) { let lastTime 0; return function (...args) { const now Date.now(); if (now - lastTime wait) { lastTime now; fn.apply(this, args); } }; }这个实现是头节流时间段开始时立即执行wait 内的触发都会被忽略。还有一种尾节流在时间段结束时执行。真实场景里滚动加载到底部加载更多一般用头节流窗口 resize 后重新计算布局一般用尾节流。更完整的需求是实现两个方向的节流或者带 cancel 方法。不过不要在一个工具函数里塞太多选项不然调用方自己也一头雾水。我一般会把 debounce 和 throttle 各自写好再提供带 cancel 的版本function debounceWithCancel(fn, wait 300) { let timer null; const debounced function (...args) { clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); timer null; }, wait); }; debounced.cancel () { if (timer) { clearTimeout(timer); timer null; } }; return debounced; }cancel 方法在组件卸载时非常有用可以避免 setState 在卸载后执行导致的 React warning 或者 Vue 里类似的问题。5.2 元素判断是否在视口内判断元素是否在视口内是图片懒加载、滚动动画、埋点上报里经常用到的逻辑。老牌实现是监听 scroll 计算 getBoundingClientRect但现代浏览器提供了 IntersectionObserver性能更好。封装成工具函数的一种设计思路是返回一个具备 destroy 方法的清理器方便组件卸载时断开监听。function observeVisibility(element, callback, options {}) { if (typeof IntersectionObserver undefined) { const rect element.getBoundingClientRect(); const inView rect.top window.innerHeight rect.bottom 0; callback({ isIntersecting: inView, entry: null }); return { destroy() {} }; } const observer new IntersectionObserver(([entry]) { callback({ isIntersecting: entry.isIntersecting, entry, }); }, options); observer.observe(element); return { destroy() { observer.disconnect(); }, observer, }; }使用const { destroy } observeVisibility( imgElement, ({ isIntersecting }) { if (isIntersecting) { imgElement.src imgElement.dataset.src; destroy(); } }, { threshold: 0.1 } );这个封装的亮点是兼容了旧浏览器直接计算结果作为首帧反馈和新浏览器IntersectionObserver。在旧浏览器分支里callback 立即执行一次这样懒加载不会因为浏览器不支持而完全不工作。5.3 事件绑定带清理器的 addEventListener每次在组件里 addEventListener 都要记得 removeEventListener这几乎是前端开发的日常。为了防止忘记我常用一个绑定工具来返回解绑函数function on(element, eventName, handler, options) { element.addEventListener(eventName, handler, options); return () { element.removeEventListener(eventName, handler, options); }; }这个函数本身没什么难度但使用习惯很重要。在 Vue 组件里可以这样写// 组件返回一个 destroy 用于卸载 const off on(window, resize, onResize); // mounted 阶段执行 off 存入变量 // unmounted 阶段直接 off()这样代码里的事件监听生命周期就非常清晰不会出现挂载页面的监听器一直没清理、导致多个实例叠加的问题。5.4 获取 DOM 尺寸的稳定封装获取元素尺寸在浏览器里有好几种方式offsetWidth、clientWidth、getBoundingClientRect、getComputedStyle。不同方式在不同场景下意义不同很多新手容易选错。我提供的封装思路是这样根据需求返回不同类型的内容尺寸。function getElementSize(element) { if (!element) return { width: 0, height: 0 }; const rect element.getBoundingClientRect(); const styles window.getComputedStyle(element); return { // 布局尺寸包含 border 和 padding width: rect.width, height: rect.height, // 内容尺寸减去 padding 和 border contentWidth: element.clientWidth - parseFloat(styles.paddingLeft || 0) - parseFloat(styles.paddingRight || 0), contentHeight: element.clientHeight - parseFloat(styles.paddingTop || 0) - parseFloat(styles.paddingBottom || 0), }; }实际使用中判断一个元素是否溢出容器、滚动条到底没有需要知道的是scrollWidth和scrollHeight这个封装就没包括。所以工具函数不要试图覆盖所有情况按需取用最合适。6. 工具函数设计的一些共性问题写工具函数看起来简单但真正在项目里长期维护后你会发现有几个共性问题几乎每次都会遇到。这里统一说一下我的处理方式。6.1 参数默认值 vs 函数重载很多工具函数可以传多个参数但调用方经常只想传其中一个。我通常会用对象参数 解构默认值的方式来设计而不是一长串位置参数。因为位置参数在调用时很难看出含义而且新增参数时很容易错位。举个例子同样是防抖函数如果设计成debounce(fn, wait, immediate, leading, trailing, maxWait)调用方基本不可能记住每个参数的含义。而改成debounce(fn, { wait: 300, immediate: true })会清晰很多。要点两个固定参数的函数可以用位置参数重载参数超过 3 个时改成对象参数会更好维护。6.2 返回新数据 vs 修改原数据工具函数必须遵守一个约定是否修改传入的参数。像uniqueBy、groupBy、formatThousands这类纯函数一定不能改动输入的数据。这能避免很多隐蔽 bug。但有些场景下修改原对象是合理的比如树形数据转换时如果你确认业务逻辑就是要在原树上挂 children那可以直接改。不过我还是建议哪怕只改一个字段也显式 copy 一层比如{ ...item, children: [] }防止调用方后续复用时数据被污染。纯函数的好处是可以在项目里放心复用也不会因为某个调用方改了内部状态导致其他调用方出错。6.3 边界情况与用户输入工具函数处理的是各种来源的数据所以边界情况特别重要。常见的边界包括null、undefined、空字符串特殊数值NaN、Infinity、负数、极小值科学计数法超大数组几千甚至几万条数据要考虑遍历方式浏览器兼容IntersectionObserver、Map、Set、padStart 等在老浏览器里可能没有我一般会遵循一个原则工具函数不主动抛异常而是尽量返回一个安全值如空数组、空字符串、null。因为工具函数往往在业务逻辑的深处被调用一旦抛异常排查的链路很长。当然如果参数类型错得离谱也不妨抛个错让开发者尽早发现——这取决于函数的定位。比如formatThousands传入非数字时返回-是合理的展示兜底而buildTree如果传入一个根本不是数组的参数再往下执行也没意义可以直接返回[]或者抛错。6.4 测试优先级工具函数是前端代码里最适合做单元测试的部分。纯函数、输入输出明确、无副作用写测试的成本最低、收益最高。我给工具函数写测试时通常会覆盖这几类用例正常输入常规参数的预期结果边界输入空值、极值、临界值类型错误传入错误类型的参数观察是否兜底重复调用验证函数无副作用多次调用结果一致并发场景异步工具函数同时多个实例并行执行时结果是否正确比如groupBy的测试我至少会测空数组返回空对象、一维字段正确分组、复合 key 正确分组、key 为 null 时跳过、多次调用返回相同结果。工具函数测试不要追求 100% 覆盖率但要保证核心分支都被覆盖到。尤其是那些有分支逻辑的比如immediate参数、进制参数每个分支都测一遍会大大减少回归风险。7. 我在项目里实弹使用后的一些体会最后说一些比较零碎但我觉得很有价值的经验算是这些工具函数在真实项目中跑过之后沉淀下来的东西。第一工具函数库一定要有一个 team 公认的出口。不管是单独建一个包、还是挂在某个公共入口文件下团队成员都要能很容易地找到这个函数是不是已经存在了。我见过很多项目里同一个函数出现了三四份散落在不同目录大家各写各的最后改 bug 时还得一个个找。第二命名要直白。formatDate就是格式化日期debounce就是防抖不要搞sdkDate、smartFormat这种花名。工具函数是公共资产清晰命名能让别人少花时间揣测。第三工具函数不要追求大而全。每加一个参数就要多一组文档、多一组测试、多一种调用方的理解成本。宁可一个函数只解决一个明确问题组合使用比一个万能函数靠谱得多。第四注释里不要只写做了什么要写为什么这么做。比如 formatThousands 里对科学计数法的处理如果不是有人写注释后来接手的人很可能删掉那行代码因为看起来没用结果某些数据下就出 bug 了。如果你已经开始维护自己的工具函数库我建议每次在新项目里用到某个重复逻辑时都先想一下这个东西是只在这一个页面用还是以后其他项目也可能用如果是后者就抽出来放进公共库。这样积累大半年后你会发现业务代码里那些啰嗦的细节都被藏起来了剩下的逻辑读起来非常清爽。