如果你维护过一个迭代了两年以上的 Vue 项目,大概率见过这种场景:想找一个时间格式化函数,搜索栏里瞬间冒出七八个名字相似的候选,分布在不同的组件、不同的 mixin,甚至某个早已没人维护的旧模块里。想改一个公共函数又不敢动手,因为根本不知道哪些地方在引用。项目里的工具函数散落各处,是代码维护里最隐蔽也最消耗耐心的坑之一。
我负责的某后台管理系统,在维持了三年的业务迭代之后,终于被这个问题反噬了。UI 同事指着一个列表页问“为什么这一列日期有的带斜杠、有的带横杠、有的连时分秒都有”,那一刻我知道不能再拖了。于是在一次版本迭代间隙,我主导做了一轮vue工程代码优化:在src下新建utils文件夹,把散落在各处的工具函数统一收编、集中管理。这篇文章就是那次重构从设计到落地、再到半年后复盘的真实记录。
1. 这次重构解决的三个真实痛点
1.1 同一份日期格式化,项目里至少有三种写法
第一个触发我动手的,就是日期格式化。一个组件里是这样写的:
const target = new Date(time) return `${target.getFullYear()}-${target.getMonth() + 1}-${target.getDate()}`另一个组件改用了 dayjs:
import dayjs from 'dayjs' return dayjs(time).format('YYYY-MM-DD')还有个老页面的写法更野,直接toLocaleDateString(),输出的是2025/4/15这种带斜杠的格式。单看三段代码都不算错,但放在同一个项目里就是“同一件事有三种口径”。消息列表显示时分秒,数据报表却不带时分秒,连基本的一致性都保证不了。想要统一格式,就得同时改三个组件,漏一个就继续乱。
1.2 校验逻辑靠复制粘贴撑了三年
手机号校验这种需求,几乎每个表单页都会遇到。但我在代码搜索里发现了同一个“校验”有三份完全不同的实现:一个页面用的是/^1[3-9]\d{9}$/,另一个页面用/^1[3456789]\d{9}$/,还有一个页面干脆用str.length === 11这种原始方式。后面新号段逐步开放之后,我甚至说不清哪份正则漏了、哪份是过时的副本。这就是没有集中管理工具函数的代价:你以为只是复制了一行正则,实际上是复制了一份不确定对错的逻辑副本,后续排查和升级都要为每一份副本单独买单。
1.3 新同事入职根本不知道去哪找通用函数
新人问项目里有没有现成的“把对象转 query 参数”的函数,老同事凭记忆说“好像某个组件里写过”,于是整个团队翻遍代码仓库。工具函数散落在各家组件里,等于把项目的公共知识库打碎塞进了几十个容器里,出了问题只能靠搜索碰运气。建一个统一utils目录,最重要的其实不是好看,而是把项目的公共知识从“口头相传”的隐性状态,变成“可见可查”的显性状态。这三件事加起来足够说明一个结论:工具函数集中管理不是整理癖,是代码可维护性的刚需。
2. utils目录的三层结构:先设计再动手
2.1 不要把一百个函数平铺进一个utils.js
我第一版重构图省事,把所有函数都塞进src/utils/index.js,结果三个月后那个文件变成了三千多行的垃圾场。每个函数之间毫无关联,想改一个函数得滚半天屏幕。后来我才意识到:文件夹的价值在于边界,而不只是容器。方便的组织方式不是“把东西聚在一起”,而是“按类别分开,同时保留统一入口”。
我最终采用的结构是:
src/utils/ ├── index.js # 统一出口 ├── format/ # 格式化类 │ ├── date.js │ ├── number.js │ └── string.js ├── validate/ # 校验类 │ └── index.js ├── storage/ # 本地存储类 │ └── index.js ├── browser/ # 浏览器相关 │ ├── index.js │ └── platform.js ├── object/ # 对象与数组处理 │ ├── index.js │ ├── object.js │ └── array.js ├── business/ # 项目业务相关 │ └── order.js └── helpers/ # 内部私有辅助 └── ...这个结构最核心的特点是:通用工具按领域分模块,业务工具单独隔离。format、validate、storage放的是纯函数,谁都能用;business放的是和当前项目上下文强相关的逻辑,比如“根据订单状态码返回对应文案”“根据角色判断是否可操作”,这类函数不通用,但多个页面会共用,单独放在一块,避免污染通用区。
2.2 子目录和文件边界怎么定
分目录的依据不是“看起来整洁”,而是函数的使用方式和使用者。我总结过几个判断问题:
- 这个函数和什么领域强相关?时间、数字、字符串、存储、校验各归各的域。
- 它是通用纯函数,还是依赖项目上下文?依赖上下文的进
business。 - 它的输入输出来自浏览器环境,还是纯 JS 对象?UA、滚动、剪贴板这类进
browser。
更实用的做法是:先问“如果没有这个项目,这个工具函数是否还有意义”。答案是有,那它大概率是通用函数,放 format/validate/object 这类目录;答案是没有,那它大概率属于业务函数,放 business。这个标准简单,组里同学基本不会选错。
2.3 层级为什么不能太深
有人喜欢按“数组-对象-字符串-转义”继续往下拆,比如src/utils/object/array/inner/xxx.js。我不建议超过三层。太深的嵌套会让“找文件”的成本重新超过“找函数”的成本,import 路径变得又长又碎,编码体验明显下降。模块边界定义的初衷是降低搜索成本,不是增加导航屏障。我见过深到五层的目录,打开文件树感觉像在迷宫里走,反而比平铺一百个文件更糟。
3. 手把手搭建utils目录:从零到可用的全部步骤
3.1 初始化目录与路径别名
我以 Vue 3 + Vite 工程为例。第一步是在src下新建utils文件夹,同时创建src/utils/index.js作为统一入口。然后配置路径别名,让项目里可以方便地用@/utils引用,避免到处写../../../utils。
在vite.config.js中:
import { fileURLToPath, URL } from 'node:url' export default { resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } } }如果你用的是 Vue CLI / Webpack 工程,则需要在vue.config.js里通过configureWebpack.resolve.alias配置,同时在jsconfig.json里同步一份,让编辑器也能识别@:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }别名配置本身不算 utils 的专属内容,但没有它,“统一管理工具函数”很难真正跑起来——写相对路径一不方便,大家又会把函数直接定义在组件内部,又回到最初的问题。
3.2 创建第一个模块:format/date.js
我先拿最通用的日期格式化当示范,目标是提供一个统一的formatDate,替代组件里各种手写拼接:
// src/utils/format/date.js /** * 格式化日期为指定格式 * @param {Date|string|number} input - 日期对象、时间戳或可解析的字符串 * @param {string} pattern - 格式模板,默认 YYYY-MM-DD * @returns {string} 格式化后的日期字符串 * @example * formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss') // 2025-04-15 09:30:00 */ export function formatDate(input, pattern = 'YYYY-MM-DD') { const date = input instanceof Date ? input : new Date(input) if (Number.isNaN(date.getTime())) { return '' } const map = { YYYY: date.getFullYear(), MM: String(date.getMonth() + 1).padStart(2, '0'), DD: String(date.getDate()).padStart(2, '0'), HH: String(date.getHours()).padStart(2, '0'), mm: String(date.getMinutes()).padStart(2, '0'), ss: String(date.getSeconds()).padStart(2, '0') } return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (key) => map[key]) }这段代码有三个设计点值得说明。一是输入统一处理成new Date(input),调用方传时间戳、ISO 字符串还是 Date 对象都能兼容,不强迫业务层统一格式。二是对无效日期做兜底,返回空字符串而不是'Invalid Date',展示型逻辑最怕到处提着一堆 NaN。三是用String.padStart补零,比month < 10 ? '0' + month : String(month)更简洁,也不容易写错。这个文件后续继续收纳formatNumber、formatRelativeTime等函数,职责非常清晰。
3.3 创建校验模块和存储模块
format 模块完成后,我接着处理项目里另一个高发痛点——校验逻辑:
// src/utils/validate/index.js export const regex = { mobile: /^1[3-9]\d{9}$/, email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, url: /^https?:\/\/[^\s/$.?#].[^\s]*$/i, idCard: /^\d{17}[\dXx]$/ } export const isMobile = (value) => regex.mobile.test(String(value).trim()) export const isEmail = (value) => regex.email.test(String(value).trim()) export const isEmpty = (value) => value === null || value === undefined || value === ''再把 localStorage 的常见操作封装成存储模块,统一处理 JSON 序列化:
// src/utils/storage/index.js const PREFIX = 'my_project_' export function setStorage(key, value) { // 用对象包一层,避免字符串被 JSON.parse 误转成数字或布尔 localStorage.setItem(PREFIX + key, JSON.stringify({ value })) } export function getStorage(key) { const raw = localStorage.getItem(PREFIX + key) if (!raw) return null try { return JSON.parse(raw).value } catch { return null } } export function removeStorage(key) { localStorage.removeItem(PREFIX + key) }这里设置PREFIX是为了避免多个项目部署在同一域名下互相覆盖数据。只要项目统一走这个模块,key 冲突问题就能被挡在入口外。这就是“统一管理”相比“各写各的”最直观的好处:公共细节可以在一个地方一次性约定。
3.4 统一出口 index.js 怎么写
最后是src/utils/index.js,它只做一件事:把模块里的导出聚合成一个统一入口。
// src/utils/index.js export * from './format/date' export * from './format/number' export * from './validate' export * from './storage' export * from './browser' export * from './object' export * from './business'这段入口代码有两点需要注意。一是使用具名重导出(export *)而不是默认导出。默认导出会让调用方自由起名字,比如import myString from '@/utils',同一个功能在不同组件里叫法不一样,搜索起来又是一片混乱。具名导出则要求调用方必须写import { formatDate } from '@/utils',全项目搜索formatDate就能找到所有使用点。二是保持入口文件只重导出、不写实现,不要把函数直接写在index.js里。入口一旦开始堆代码,就又会变成垃圾场。
4. 工具函数的分类、命名与设计规范
4.1 函数命名的三个动词前缀约定
统一命名对工具函数尤其重要。我把项目里常用的命名习惯整理成三个前缀:
| 前缀 | 用途 | 例子 |
|---|---|---|
is | 判断型,返回布尔值 | isMobile、isEmpty |
to/format | 转换或格式化,返回新值 | toQueryString、formatNumber |
get | 获取值 | getQueryParam、getPlatform |
文件命名统一用 kebab-case(date.js、url.js),函数命名统一用 camelCase。前缀约定不需要多完美,但必须有。有了它,调用方在代码里一眼就能看出函数大概在做什么,不用点进定义。
4.2 参数设计:一两个入参是极限,复杂配置用对象
工具函数最怕一上来就是五六个参数:
// 不要这样设计 export function formatTime(time, pattern, timezone, locale, withSuffix) { ... } // 推荐这样设计 export function formatTime(time, { pattern = 'YYYY-MM-DD', timezone, locale, withSuffix } = {}) { ... }超过两个参数就建议收敛成 options 对象。对象的好处是可以省略中间参数、默认值清晰、调用处是键值对语义,三个月后自己回来看也秒懂。但 options 里的字段也要控制,最好不超过五个,不然又是一团乱麻。
4.3 工具函数必须保持“纯”
所谓纯函数,就是同样的输入永远得到同样的输出,且不产生外部副作用。这个原则对 utils 里的函数要当成硬性要求:不要直接修改入参对象;不要在普通工具函数里偷偷读写 localStorage 或 window;不要依赖组件状态、当前时间等隐式信息。
我强调这一点是有切身体会的。业务代码里曾有个formatPayload函数,内部会偷偷修改传入对象,往里面塞一个 timestamp 字段。结果某个页面渲染数据异常,排查了两小时才定位到这个函数改动了数据源对象。这种函数一旦放进 utils,只会把“统一管理”带来的秩序重新破坏掉。
4.4 JSDoc注释与示例:把工具文档写在代码里
统一管理工具函数之后,这个文件夹自然变成了团队的公共库。为了让公共库好用,我在每个函数上方写 JSDoc:
/** * 将对象转换为 URL 查询字符串 * @param {Record<string, any>} params - 待转换对象 * @param {Object} [options] * @param {boolean} [options.encode=true] - 是否对值做 encodeURIComponent * @returns {string} 例如 'a=1&b=2' * @example * toQueryString({ a: 1, b: 'hello' }) // 'a=1&b=hello' */这个习惯投入很小,收益却很大:编辑器悬停能直接看到说明和示例,新同事读 utils 目录就像读文档,review 成本骤降。我甚至把“工具函数必须有 JSDoc 示例”写进了团队的默认约定——不强制,但新增函数基本都带。
5. 组件中引用的正确姿势与tree-shaking那些事
5.1 推荐从统一入口具名引入
实际开发中最常见的方式是:
<script setup> import { formatDate, formatNumber, isMobile } from '@/utils' </script>从@/utils具名引入的好处是:调用代码统一、模块边界清晰,后续调整某个函数实现时只需改工具模块内部,调用方零感知。搜索formatDate也能准确找到所有使用位置。这套做法在 Vue 2 的 Options API 和 Vue 3 的 Composition API 下都成立,区别只是引入后的挂载方式。
有人担心“从入口引了没用到的函数会不会让打包体积爆炸”。这里要澄清 tree-shaking 的机制:只要工程是 ES Module 构架,入口文件用的是export *,Vite / webpack 在生产构建时都会做静态分析,把未使用的具名导出摇掉。前提是工具模块内不要写副作用代码(顶层的 console.log、顶层 fetch、顶层修改 window 等),也不要用动态导入或变量名动态导出这种无法静态分析的写法。满足这两点后,“统一入口引用”和“每个函数单独引文件”的最终产物差别很小,不构成性能风险。
5.2 什么时候可以直接引子模块路径
在某些特殊场景我会选择直接从子模块引:
import { formatDate } from '@/utils/format/date'这种写法更适合一个工具模块内部的互相调用。比如format/date.js里的formatDate想调用formatRelativeTime时,直接./date内部引用比绕道@/utils更内聚。但对外部业务代码,我还是推荐全部走统一入口@/utils。如果业务层到处直接戳子模块深路径,以后重构子模块的文件名或目录结构,改动面会非常大。
5.3 自动按需导入需谨慎
有些团队会引入自动导入插件,让formatDate不用写 import 语句就能直接在组件里用。确实能减少样板代码,但我不建议在工具函数上做全局自动导入。工具函数一旦变成“隐式存在”,新同事看到formatDate不知道它从哪里来,想查定义得先看配置文件;代码 review 时也增加了一层隐式依赖。它牺牲了可搜索性,换来一点省字便利,对几十个工具函数来说并不划算。
6. 踩过的坑与几条实在经验
6.1 不要为了“打算复用”而提前抽象
我这次重构里犯过一个典型错误:看到一个场景像以后会复用,就把函数提前搬进 utils,结果这个函数半年里只被一个组件调用,又因为接口字段变化改了两版。后来我把这个使用点的函数挪回组件内,utils 的改动面反而变小了。现在的原则很明确:一个函数至少出现两次真实复用需求,才考虑提升到 utils。第一次复用时再从组件里抽出来,比提前抽象稳妥得多。提前抽象的最大问题是,你并不知道真实的复用形态,很可能设计出的入参和返回值并不符合第二次使用场景,到时候还得回头改接口,白白增加成本。
6.2 业务状态码和通用工具要物理隔离
项目里有个团队习惯把所有“状态码到文案”的映射塞进 utils,比如getStatusText(status)根据 1/2/3 返回不同文案。这种函数依赖业务上下文,和formatDate这类通用函数放在一起并不合适。我的处理是单独建business子目录,让它明确和通用工具隔开。这样业务代码搜索路径清晰;哪天后端换了状态码字典,只需要动utils/business,不会误伤通用模块。
6.3 新函数上线的“三问”审查法
在新增工具函数时,我会让提函数的人回答三个问题:
- 这个函数在项目里目前有一处以上真实使用吗(判断是不是真需求)?
- 它是否依赖具体业务上下文或接口字段(判断放通用区还是 business)?
- 给它取的名字和 JSDoc 示例,新同事不点进代码能否猜出用途(判断命名质量)?
这三个问题基本能拦住八成无效抽象。回答不过关就先让函数留在组件里,等真正有第二次复用时再来提。
6.4 给核心工具函数写单测,性价比比业务逻辑更高
业务页面逻辑变化频繁,测试维护成本高;工具函数是被多处复用的稳定逻辑,一旦出错影响面很大,值得配单测。核心的日期格式化、校验、query 参数转换我都配了 Vitest 用例:
import { describe, it, expect } from 'vitest' import { formatDate } from './date' describe('formatDate', () => { it('should format local date', () => { expect(formatDate(new Date(2023, 10, 14, 9, 30, 0), 'YYYY-MM-DD HH:mm:ss')).toBe('2023-11-14 09:30:00') }) it('should return empty string for invalid input', () => { expect(formatDate('abc')).toBe('') }) })测试的作用不只在回归,它同时也是工具函数的行为说明书。一个复杂的工具函数,别人看到单测里的同名用例,比看十行注释理解得还快。
6.5 localStorage 封装里的类型丢失陷阱
存储封装里有一个特别隐蔽的坑:如果直接对字符串JSON.parse,存进去的'123'读出来是数字123,存进去的'false'读出来是布尔false,数据类型悄悄变了。所以我的setStorage里用{ value }包一层再序列化,读取时先JSON.parse(raw).value,这样字符串、数字、布尔、对象都能保持原类型。这类细节如果不写注释,后来维护的人根本不知道为什么要多包一层。另外,typeof null === 'object'、isNaN会对字符串做强制转换这类 JS 固有坑,写校验类工具函数时特别容易踩。先把语言特性弄明白,比修补一百行注释管用。
收尾的几点实在体会
工具函数统一管理这件事,难点从来不是“建文件夹”这个动作,而是后续的存量迁移和新增约定。我当时切的顺序是:先建好结构和入口,把新产生的公共函数直接放到 utils,再分批从高频组件里把重复逻辑抽出来,每周抽出一两个通用函数,团队接受度会高很多。一口气搬完全部旧逻辑既不现实,也容易让 review 压力爆炸。
按这个节奏推进了三个月的重构之后,我最明显的感觉不是代码变少了,而是团队协作的摩擦变少了。新同事入职不用再到处问“某个函数到底在哪定义的”,直接在src/utils目录下按领域找就行。工具函数从散落各处,变成了项目的公共知识库,这种结构化带来的收益,比少写几十行代码要实在得多。如果你正在维护一个自己折腾了很久的 Vue 项目,现在就可以打开文件树,看看那些散落在组件组件里的“临时函数”——它们可能就是你下一轮重构最好的起点。