ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

UniApp/Vue2项目沉淀一套工具函数库:校验、格式化与业务封装

2026/10/2 10:06:53 拓冰建站 浏览量
UniApp/Vue2项目沉淀一套工具函数库:校验、格式化与业务封装 做UniApp的都知道Vue2时代的项目有一个共性页面写多了之后最痛苦的其实不是业务逻辑而是到处重复的校验、格式化、存储、平台判断。今天这套工具函数库是我在多个HBuilderX实战项目里逐步沉淀出来的覆盖校验、格式、业务全场景适合正在做UniApp/Vue2项目、或者维护vue2老项目的朋友直接抄作业。它不依赖任何UI库纯JavaScript实现无论是搭配uview-plus还是原生uni-ui都能直接扔进utils目录使用。1. 为什么需要一套自研工具函数库而不是到处复制粘贴先讲点实在的。做过三个以上UniApp项目的开发应该都有体感同一个手机号校验A页面用的正则和B页面写的正则可能完全不一样A页面能通过的手机号换到B页面就被拦了同一份时间戳列表页显示成2024-01-01详情页又显示成2024年01月01日。这种不统一带来的不是功能bug而是沟通成本和测试成本的持续堆叠。1.1 复制粘贴带来的三个隐患第一个隐患是维护灾难。当你把校验逻辑复制到十几个页面之后一旦需要调整规则——比如运营商新增了192号段你得全局搜索替换漏掉一个页面就是一次线上问题。第二个隐患是跨端行为不一致。UniApp最核心的价值是跨端但plus.*只有App端有wx.*只有小程序端有很多人直接在业务代码里写// #ifdef结果页面逻辑越来越碎最后整个项目变成一团条件编译的纠缠体。工具函数可以把平台差异收敛在一个函数内部业务层拿到的永远是统一返回值。第三个隐患是错误处理随缘。同样一个uni.setStorageSync有人捕获异常有人不捕获有人存的时候不加前缀有人存的是undefined。时间久了光排查数据丢失就能耗掉半天。1.2 工具函数库的边界什么该收、什么不该收这里有个经验之谈工具函数库只放纯逻辑不放UI组件不放业务Api调用。我见过有人把uni.request二次封装也扔进工具库这本身没问题但如果你把某个项目的登录接口、订单接口直接写在工具函数里那这套库就废了——换个项目完全没法复用。我的划分标准很简单能通过参数判断结果的收进工具库。比如formatDate(timestamp, YYYY-MM-DD)。依赖具体业务表结构的一律不收。比如getOrderStatusText(status)这种宁可放在各自项目的constants或services里。涉及全局登录态、全局配置的单独封装成模块不和其他纯函数混在一起。好的工具函数库应该是无状态的输入输出清晰不偷偷读缓存、不做副作用。这样才能在Vue2和Vue3之间横跳在小程序端和App端保持一致性。2. 校验工具函数正则不是全部业务规则才是核心校验这块是工具库的重头戏也是大多数人最容易偷懒的地方。很多项目所谓的校验就是一行正则其实远远不够。真正能落地到生产环境的校验函数需要考虑返回值约定、边界场景、以及和表单框架的配合。2.1 基础格式校验的封装思路先上一段最常用的基础校验函数。注意我的统一约定所有校验函数返回boolean命名全部用isXxx这样在条件判断里读起来最自然。// utils/validate.js export function isPhone(value) { if (typeof value ! string) return false return /^1[3-9]\d{9}$/.test(value.trim()) } export function isEmail(value) { if (typeof value ! string) return false return /^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}$/.test(value.trim()) } export function isIdCard(value) { if (typeof value ! string) return false const reg /^\d{6}(18|19|20)?\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}(\d|X|x)$/ if (!reg.test(value)) return false // 身份证校验码最后一位的加权因子计算 const weights [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] const codes 10X98765432 const body value.substring(0, 17) let sum 0 for (let i 0; i 17; i) { sum Number(body[i]) * weights[i] } return codes[sum % 11] value[17].toUpperCase() } export function isAmount(value, { min 0, max Number.MAX_SAFE_INTEGER, decimal 2 }) { const num Number(value) if (Number.isNaN(num)) return false const decimalReg decimal 0 ? new RegExp(^-?\\d(\\.\\d{1,${decimal}})?$) : /^-?\d$/ return decimalReg.test(String(value).trim()) num min num max }身份证这个函数看起来比普通正则复杂一点但非常值得因为市面上大部分18位身份证正则只能校验格式校验不了生日和校验码。实际业务中我遇到过用户拿一个不存在的身份证号注册纯正则直接放行了后来改成带校验码的版本才拦住。2.2 表单校验rules的适配写法如果你用的是uni-ui的Forms组件或者uview-plus的Form组件它们都支持类似rules的配置。这里的坑在于很多组件库的validator回调签名不统一有的返回Boolean有的要求返回Promise还有的必须调用回调函数。uview-plus 2.xVue2版本的校验器支持返回Promise所以我在工具库里统一封装成异步校验器// utils/validateRules.js import { isPhone, isIdCard, isAmount } from ./validate export const rulesForPhone [ { required: true, message: 请输入手机号 }, { validator: async (rule, value) { if (!value) return true return isPhone(value) }, message: 手机号格式不正确 } ] export const rulesForAmount (label 金额) [ { required: true, message: 请输入${label} }, { validator: async (rule, value) isAmount(value, { min: 0.01, decimal: 2 }), message: ${label}必须大于0且最多保留两位小数 } ]写这套的时候有个经验不要把所有规则都塞在一个配置对象里。把手机号、邮箱、金额、身份证分别拆成可复用的规则数组页面里直接rules: rulesForPhone就行。以后新增校验场景只需要在validateRules.js里加一段全局通用。2.3 业务级校验联动校验和文件完整性校验正则和rules只能覆盖“格式”层面业务上的联动校验才是真正容易出bug的地方。我遇到过比较典型的场景请假表单里结束时间不能早于开始时间报名表单里选了“学生”身份必须有学校名称。这些规则如果写在页面里每写一个表单就要重抄一遍。所以我在工具库加了两个高阶函数// utils/businessValidate.js export function validateRange(start, end, { allowEqual true } {}) { if (!start || !end) return { valid: false, message: 请选择完整的时间范围 } if (allowEqual ? end start : end start) { return { valid: false, message: 结束时间必须晚于开始时间 } } return { valid: true } } export function validateRequiredWhen(condition, value, message) { if (condition (value undefined || value null || value )) { return { valid: false, message } } return { valid: true } }另外提醒一个容易被忽略的点文件完整性校验。在UniApp的App端做OTA更新或下载附件时经常要对文件做MD5或CRC校验。crypto模块在App端可以直接用plus.crypto调用小程序端则需要在后台计算好哈希再下发。工具库里放一个基于ArrayBuffer转十六进制字符串的工具在需要“防篡改”的场景下能省很多排查时间。这里不展开算法细节只说结论校验函数的范围不止是表单字段文件的完整性校验也值得放进工具库因为下载模块在多个端都有复用需求。3. 格式转换与数据标准化处理各种“不统一”校验解决了“数据对不对”格式化解决的是“数据怎么展示”。UniApp项目里最常见的格式问题集中在日期、金额、文件大小以及不同端返回的数据结构差异。3.1 日期、金额、文件大小的格式化我直接给出一套可落地的格式化函数注释里写清楚了使用场景// utils/format.js export function formatDate(input, pattern YYYY-MM-DD HH:mm:ss) { if (!input) return const date input instanceof Date ? input : new Date(input) if (Number.isNaN(date.getTime())) return String(input) 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 pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (key) map[key]) } export function formatAmount(value, { symbol , thousand true, decimal 2 } {}) { const num Number(value) if (Number.isNaN(num)) return ${symbol}0.00 let result num.toFixed(decimal) if (thousand) { const [intPart, decimalPart] result.split(.) result intPart.replace(/\B(?(\d{3})(?!\d))/g, ,) (decimalPart ? . decimalPart : ) } return symbol result } export function formatFileSize(bytes) { if (bytes 0) return 0 B if (!bytes || bytes 0) return 未知大小 const units [B, KB, MB, GB, TB] const i Math.floor(Math.log(bytes) / Math.log(1024)) const size bytes / Math.pow(1024, i) return ${size.toFixed(1)} ${units[i]} }你可能会问formatDate为什么不直接用Day.js或Moment.js理由很简单UniApp的打包体积在Vue2项目里非常敏感。为了一个日期格式化引入一个几十KB的库还要维护时区、locale一堆配置性价比太低。手写这几十行满足项目里90%的日期展示需求就够了。如果后续遇到“相对时间”、“周几”这类需求再在formatDate之外单独加fromNow函数即可。金额格式化有个容易被移动端输入法坑的点用户输入空格或全角数字。所以formatAmount内部做了Number(value)强转转出来的NaN会走兜底逻辑不会把undefined直接渲染到页面上。3.2 数据序列化JSON、FormData和提交前的结构整理接口联调时最大的坑往往是“前端看到的数据结构”和“后端期望的数据结构”不一致。比如后端要求startDate和endDate是YYYY-MM-DD字符串前端拿到的是时间戳再比如FormData格式的文件上传图片数据需要单独拼装。我封装了两个小函数专门处理这类脏活// utils/dataTransform.js export function jsonToFormData(params {}) { const formData new FormData() Object.keys(params).forEach((key) { const value params[key] if (value ! undefined value ! null) { // 数组字段统一用 key[] 的方式追加 if (Array.isArray(value)) { value.forEach((item) formData.append(${key}[], item)) } else if (value instanceof File || value instanceof Blob) { formData.append(key, value) } else if (typeof value object) { formData.append(key, JSON.stringify(value)) } else { formData.append(key, String(value)) } } }) return formData } export function parseDateRangeOut(range) { if (!range || !range[0] || !range[1]) return null return { startDate: formatDate(range[0], YYYY-MM-DD), endDate: formatDate(range[1], YYYY-MM-DD) } }这里有个细节很多项目的uni.uploadFile里的formData并不能直接接收File对象还得靠uni.chooseImage返回的临时文件路径来组装。所以jsonToFormData里的File/Blob分支更多是给H5端或微信小程序用的。App端的代码我一般会再包一层mergeUploadData把filePath和formData合并成uni.uploadFile需要的参数结构。3.3 跨端格式差异视频旋转、图片方向、网页通信这部分是我踩坑最多的。录视频时安卓和iOS对视频方向的处理完全不同。用户拿手机竖拍传上来的视频在部分安卓机上播放时变成横的这是很多UniApp项目被吐槽的点。// utils/media.js export function getVideoOrientation(videoFilePath) { return new Promise((resolve) { // App端通过plus.video获取视频信息 if (typeof plus ! undefined plus.video) { plus.video.getVideoInfo(videoFilePath, (info) { // info: { width, height, duration } const orientation info.height info.width ? portrait : landscape resolve({ orientation, width: info.width, height: info.height }) }, () resolve(null)) } else { // 小程序端通过uni.getVideoInfo获取 uni.getVideoInfo({ src: videoFilePath, success: (info) { const orientation info.height info.width ? portrait : landscape resolve({ orientation, width: info.width, height: info.height }) }, fail: () resolve(null) }) } }) }获取到方向后再决定上传前是否要旋转或让后台转码。这个函数看起来很轻但它把所有“获取视频元信息”的平台差异都吞掉了业务页只需要关心orientation。另外H5端嵌入web-view与UniApp通讯时数据格式也要统一。web-view通过uni.postMessage发送的消息在小程序端和App端的接收时机不一样——App端是plus.webview的onMessage小程序端是bindmessage。我的做法是封装一个WebViewBridge模块内部处理端的差异对外只暴露sendMessage(data)和onMessage(callback)业务层完全不用写条件编译。4. 业务场景的通用封装从登录到分享减少页面端差异工具库如果只做校验和格式化价值有限。真正让团队成员愿意用的是那些能直接解决业务痛点的模块级封装。这里分享四个我在实际项目里反复使用的场景封装。4.1 平台判断和系统信息封装很多人写平台判断都是直接在当前页面写// #ifdef MP-WEIXIN条件编译是UniApp的基础能力完全没问题。但当你在纯JavaScript工具模块里也需要判断平台时条件编译就不好使了所以我在运行时做一层封装// utils/platform.js const systemInfo uni.getSystemInfoSync() export const platform { isApp: typeof plus ! undefined, isH5: typeof window ! undefined !platform.isApp, isWeChatMiniProgram: typeof wx ! undefined, isAlipayMiniProgram: typeof my ! undefined, isIos: systemInfo.platform ios, isAndroid: systemInfo.platform android, safeAreaBottom: systemInfo.safeAreaInsets ? systemInfo.safeAreaInsets.bottom : 0 }这里有个小细节typeof plus ! undefined在H5端永远为false在小程序端也永false只有App端为true。而typeof wx ! undefined只有微信小程序为true。这两个判断在运行时完全可靠。把safeAreaBottom也一起缓存进来是因为iPhone的底部安全区在多个页面都要用与其每个页面重新计算不如在工具函数里统一暴露。需要注意别在模块顶层调用uni.getSystemInfoSync()后直接把所有信息都缓存。因为某些小程序端热启动后getSystemInfoSync拿到的数据有时是旧的特别是设备方向或横竖屏切换时。如果需要最新数据新增一个getFreshSystemInfo()方法不要硬复用旧缓存。4.2 Storage存储封装过期时间、JSON序列化和清理策略UniApp的uni.setStorageSync在H5端和App端都有大小限制而且存进去的值如果本身是对象取出来时类型可能不一致。我的封装思路是把“登录态”“用户信息”“临时配置”全部统一走一套带namespace和expire的存储。// utils/storage.js const PREFIX app_ function getPrefixedKey(key) { return ${PREFIX}${key} } export function setStore(key, data, expireSeconds) { const storageKey getPrefixedKey(key) const value { data, expireAt: expireSeconds ? Date.now() expireSeconds * 1000 : null } uni.setStorageSync(storageKey, JSON.stringify(value)) } export function getStore(key) { const storageKey getPrefixedKey(key) const raw uni.getStorageSync(storageKey) if (!raw) return null try { const parsed JSON.parse(raw) if (parsed.expireAt Date.now() parsed.expireAt) { uni.removeStorageSync(storageKey) return null } return parsed.data } catch (e) { return null } } export function removeStore(key) { uni.removeStorageSync(getPrefixedKey(key)) } export function clearAllStore(exceptKeys []) { const keysToRemove uni.getStorageInfoSync().keys || [] keysToRemove.forEach((storageKey) { if (storageKey.startsWith(PREFIX) !exceptKeys.includes(storageKey.replace(PREFIX, ))) { uni.removeStorageSync(storageKey) } }) }这套封装的直接收益是登录态过期判断不再散落在各个页面。以前每个页面在onShow里都要检查token是否存在还要处理token过期后跳转登录页。现在只要在请求拦截器里调用getStore(token)返回null就说明已经过期或不存在。4.3 登录、百度地图、自定义分享的模块化登录和地图的基础功能不同项目差异很大但有一个共同点每个页面都要判断“是否登录了”。我在工具库里单独放了一个userAuth模块对外暴露checkLogin()和logout()内部用getStore(token)做判断。这样页面里只需要if (!checkLogin()) { uni.navigateTo({ url: /pages/login/index }) return }百度地图的封装更强调“坐标系转换”。UniApp内置的定位返回的是GCJ-02坐标百度地图需要的是BD-09坐标直接拿去做标记点会偏移几十到几百米。工具库里的coordTransform函数做标准的火星坐标系转换这个算法本身不难但是一旦写错地图上所有点位全部飘移排查起来非常痛苦。自定义分享这块需要同时考虑App端和小程序端的差异。小程序端直接调uni.share或者使用按钮的open-typeshareApp端则是调用plus.share。我封装了一个shareManager只暴露// utils/shareManager.js export function share({ title, content, href, imageUrl }) { if (platform.isApp) { plus.share.sendWithSystem({ content, href, pictures: [imageUrl] }, () {}, () {}) } else if (platform.isWeChatMiniProgram) { uni.setClipboardData({ data: ${href} }) uni.showToast({ title: 请复制链接后分享 }) } else { // H5端直接打开系统分享 } }不要小看这几十行它在三个端的分享行为完全不一样。如果不收敛业务页面的share相关代码会被#ifdef塞满后期维护成本飙升。4.4 TabBar输入法顶起和键盘弹起的处理“tabbar输入法顶起”这个问题在App端比较常见尤其iOS的键盘处理机制和安卓不一致。用一种简单有效的方式在输入框所在页面设置adjust-positionfalse然后监听键盘高度变化手动把输入框推到键盘上方。// utils/keyboard.js export function watchKeyboardHeight(onHeightChange) { if (!platform.isApp) return uni.onKeyboardHeightChange((res) { const height res.height || 0 onHeightChange(height) }) } export function scrollIntoView(selector, offset 0, duration 300) { uni.createSelectorQuery().select(selector).boundingClientRect((rect) { if (rect) { uni.pageScrollTo({ scrollTop: rect.top - offset, duration }) } }).exec() }用法很简单页面在onLoad时注册watchKeyboardHeight拿到键盘高度后给底部输入区域动态设置padding-bottom键盘收起时归零。这套方案比直接靠windowSoftInputMode调整要可控得多也避免tabbar被键盘反复顶起盖住的问题。5. 集成与工程化HBuilderX插件、uview-plus和Vue2老项目的坑工具函数库本身写完了怎么让它顺畅地跑在项目里是另一件值得说的事。5.1 从插件市场导入uview-plus的正确姿势Vue2项目里如果要用UI库很多人图省事直接从插件市场导入整个uview-plus工程。这里我建议保持克制确认HBuilderX版本和项目类型。uview-plus 2.x是为Vue2准备的如果项目创建时选了Vue3版本直接导入uview-plus会有一堆编译报错。导入后的标准操作是在uni.scss里引入主题变量在main.js里注册组件库在pages.json里配置easycom规则。很多同学只做了前两步第三步漏了结果页面里写u-button时组件不生效查半天才发现easycom没配。这三个动作缺一不可而且顺序不能乱。工具库里如果用到UI组件相关的变量我建议不要和组件库耦合。比如uview-plus有$u对象但不该在纯工具函数里引用它。保持工具函数库“零依赖”才能在将来换成其他UI库时不做大规模返工。5.2 日志不打印、rules校验不触发、manifest配置这些疑难杂症日志不打印很多情况下不是代码问题而是HBuilderX运行模式的设置问题。查一下“运行”按钮旁边的模式选择如果是“发行模式”运行console.log默认被过滤另外部分安卓真机调试时日志输出被系统后台拦截需要在manifest.json里开启调试权限。rules校验不触发最常见的原因是form组件的model字段名和rules里的name对不上。我之前排查过一个case页面里写的是v-modelform.mobile但rules数组的name写成了phone结果校验函数压根没机会执行。这种坑肉眼很难发现因为页面表现是“没有任何提示”不是“提示错误”。manifest配置这块App端打包上架时最容易出问题的是权限描述和隐私协议。不管你在工具库还是业务层凡是涉及定位、相机、相册的API都要在manifest.json里补充对应的Permission描述。否则上架审核时被拒回来改配置又是一轮等待。5.3 Vue2老项目迁移Vue3前工具库可以先做的三件事最近很多团队在讨论vue2老项目转vue3“成熟项目 vue2 能转 vue3”这个问题的答案通常是“能但不要急着转”。如果非转不可我的建议是先把工具函数库从业务代码中剥离出来因为大部分纯函数在两个版本的Vue里都能直接跑。第一件事把工具库全部改成ES Module语法去掉this引用去掉对Vue原型链的依赖。很多老项目喜欢把工具函数挂到Vue.prototype上迁移时这一层会非常痛苦。第二件事统一函数命名和返回值不要混用return false和return { success: false }给迁移期做一个稳定契约。第三件事在工具库里增加一层平台适配器把uni.api调用收敛到适配层未来想替换成taro或原生框架时只需要改适配器。这三件事做完工具库就成为一个和框架“解耦”的独立资产。无论项目最终是否迁移、迁移到哪个框架它都能继续复用。最后分享一个我自己的工作流每次在业务代码里遇到需要重复三次以上的逻辑我会在当天结束前把它抽进工具库哪怕只有一行正则。这个习惯坚持半年之后你会发现写新页面的速度明显变快因为大部分校验、格式、平台判断都是直接调函数而不是去别的页面翻代码复制。一套好的工具函数库不是“别人开源了什么好用”而是你自己在项目里一点一点长出来的——它最懂你的业务形态和踩过的坑。