接了这么个需求用户列表页要展示手机号但不能露全中间得打星号。第一反应是——这有什么难的一行replace搞定。但真正落地的时候才发现坑挺深要不要改源数据、Vue 响应式底下怎么处理、身份证号这种长度和规则完全不同的字段怎么统一、各种边界情况怎么兜底……我最近在一个运营后台项目里完整做了一遍从最朴素的字符串截取到组件化封装踩了不少坑也沉淀了一套方案。这篇把手机号、身份证号前端脱敏这件事掰开揉碎讲清楚。1. 前端脱敏的真实场景什么时候需要、边界在哪里1.1 一个常见需求的背后为什么显示层要打码用户列表、订单详情、员工花名册、运营后台……几乎所有涉及个人信息的页面都会遇到同一个需求把手机号、身份证号这些敏感字段在界面上做部分隐藏一般是保留首尾几位中间用星号代替。很多人第一反应是从接口层解决——让后端返回脱敏后的数据。但实际业务中你会发现后端并不总是方便这么干老接口被多个系统共用。改一个返回字段的脱敏规则其他调用方立刻受影响排期和沟通成本很高。需求只是临时运营页。运营要快速上线一个活动页面后端没有排期前端自己处理是最快路径。展示和操作要并存。表格里显示脱敏号点击拨打电话、进入详情页时又需要完整号码这种场景数据层最好不动。在这些情况下前端做脱敏就是最现实的方案。它不是要取代后端脱敏而是作为显示层的一层滤镜存在。1.2 不改变源数据到底是什么意思这条要求是这类需求里最容易翻车的地方。不改变源数据指的是三件事列表页显示脱敏后的字符串但当前列表数据源比如数组里的user.mobile仍然是完整的手机号。点击查看详情、拨打电话时仍然能拿到完整号码进行下一步操作。对源数据的任何循环、遍历、重新渲染都不会被脱敏操作污染。换句话说前端脱敏应该是显示层做的一层滤镜而不是对数据本身做修改。如果你写了一个函数直接对源数据项赋值那么一旦表格刷新或重新渲染脱敏就可能层层叠加出现138****5678被再次处理变成13*****678这类失控状态。这个坑我在第5节会详细展开。1.3 数据脱敏的常见方式梳理我梳理了一下最常见的脱敏方式分这么几类脱敏方式常见做法适用场景固定位置掩码判断字符长度后把中间某几位置为*手机号、银行卡号中段正则替换通过正则匹配中段字符并批量替换手机号、身份证号、邮箱前缀/后缀保留只保留前3后4中间用统一占位符填充手机号、座机号哈希脱敏对源数据做哈希显示哈希结果导出文件、日志记录前端场景用得最多的是前三种哈希脱敏一般是后端做的。而手机号和身份证号的脱敏规则差异非常大原因在于这两个字段的结构完全不同必须分别处理。2. 手机号脱敏的 JS 实现从字符串拼接讲到正则替换2.1 最朴素的实现substring 拼接手机号是11位数字常规脱敏规则是保留前3位和后4位中间4位打星号比如138****5678。最直白的写法function maskMobile(mobile) { if (!mobile) return ; return mobile.substring(0, 3) **** mobile.substring(7); }这种写法没有问题也很好理解。但它有一个隐含假设手机号一定是11位。如果传进来一个10位或者12位的号码substring(7)会把索引7之后的都保留下来那脱敏结果就很奇怪。所以从工程角度我建议在函数入口先做长度校验function maskMobile(mobile) { if (!mobile || mobile.length ! 11) { return mobile || ; } return mobile.substring(0, 3) **** mobile.substring(7); }长度不满足的情况我直接返回原值。原因很简单这不是一个正常手机号脱敏处理没有意义交给上层去判断更合理。返回原值也能避免在页面上出现一串莫名其妙的星号加乱码。2.2 正则替换更通用的写法用正则的好处是一次性匹配和替换代码更简洁const maskMobile (mobile) { if (!mobile || mobile.length ! 11) return mobile || ; return mobile.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); };这个正则是怎么工作的拆开看^(\d{3})从开头匹配3位数字这3位被捕获到分组1$1。\d{4}匹配中间4位数字不捕获。(\d{4})$匹配结尾4位数字捕获到分组2$2。替换成$1****$2就得到138****5678。这个写法的核心价值在于星号替换的是正则匹配到的完整中段不存在漏掉一位的问题。相比之下substring拼接方案虽然直观但一旦索引边界算错很容易把原字符混进脱敏结果里。2.3 处理带区号、带空格的非标准手机号实际场景里用户输入或者接口返回的手机号未必是纯数字11位。比如138-1234-5678、86 13812345678、138 1234 5678。如果直接走长度校验这些都会因为长度不等于11而被判定为非正常号码返回原值等于没脱敏——这显然不行。我的做法是在脱敏前先做一个数字提取预处理把非数字字符去掉再判断是否为11位纯数字手机号。function normalizeMobile(mobile) { if (!mobile) return ; return String(mobile).replace(/\D/g, ); } function maskMobile(mobile) { const digits normalizeMobile(mobile); if (digits.length ! 11) return mobile || ; return digits.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); }这里有个取舍问题如果原字符串本身带了分隔符脱敏后是返回纯数字的138****5678还是保留分隔符的138-****-5678我的经验是脱敏本身是为了展示纯数字就够了不必刻意保留分隔符展示更统一。但如果需求明确要求保留原格式那就要按原格式分段处理复杂度会高一个台阶。身份证号那节会遇到类似的取舍问题。2.4 为什么中间是4个星号而不是三个星号加一个数字这是很细节的地方。有的 UI 设计稿写138****5678也有写138***5678的。从数据保护角度来说关键不是星号个数而是被隐藏的字符绝对不能泄露。如果替换的时候里面混入了原字符比如正则只替换了一部分那脱敏就是失败的。这也是为什么我强烈建议用正则一次性完整替换中间4位而不是substring一位一位拼接。拼接方案在边界处理时漏字符的概率更高尤其当你需要处理不同长度号码的时候。脱敏这件事宁可多打一个星号也不能少遮一个数字。3. 身份证号脱敏18位结构决定了不能套用手机号方案3.1 先搞清楚身份证号各个部分身份证号是前端脱敏里的硬骨头因为它是18位字符串由6位地址码、8位出生日期、3位顺序码和1位校验码组成。无论隐藏哪一段都是在隐藏一部分语义信息。常见的脱敏规则有几类规则保留内容隐藏内容示例保留前6后4地址码 末4位出生日期 顺序码110101************1234保留前6后3地址码 末3位出生日期 顺序码 校验码110101***********123隐藏出生日期后4位前10位 末4位出生日期年段部分1101011990********全隐藏不保留任何信息全部******************看实际需求。大多数业务里身份证号脱敏是为了在后台列表展示时不暴露出生日期和完整号码一般用保留前6后4比较多前6位地址码在很多场景本来就会展示很多系统选省市区就能拼出来后4位用于做数据区分——同一个身份证前6位地址码相同的人太多后4位能拉开区分度又不至于暴露完整号码。3.2 基础实现按长度写分支function maskIdCard(idCard) { const id String(idCard || ).trim().toUpperCase(); if (!id) return ; if (id.length ! 18) return id; return id.substring(0, 6) ******** id.substring(14); }substring(14)是从索引14开始截取也就是保留第15位到第18位共4位。中间的8位出生日期用8个星号替代。这里我先把输入统一转成了大写字符串因为身份证号最后一位可能是X统一大写后再处理可以避免后续比对或展示时大小写不一致。转成大写这个细节很重要。很多人只处理了数字忽略了X最后页面上同一个身份证一会儿显示x一会儿显示X虽然不影响功能但看起来很糙。3.3 用正则一次性处理 18 位和 15 位身份证号脱敏同样可以用正则而且比字符串截取更严谨function maskIdCard(idCard) { const id String(idCard || ).trim().toUpperCase(); if (!id) return ; if (id.length 18) { return id.replace(/^(\d{6})\d{8}(\d{3}[\dX])$/i, $1********$2); } if (id.length 15) { return id.replace(/^(\d{6})\d{6}(\d{3})$/, $1******$2); } return id; }说明一下第二个分组(\d{3}[\dX])表示最后4位里前3位是数字最后一位是数字或X。这样能兼容最后一位是X的情况而且i标志让匹配不区分大小写。15位老身份证也要兼容。别以为现在办证都是18位存量数据里15位并不少用户可能是很早以前办的证件。如果只处理18位就会有一批人的身份证号在页面上完整暴露。3.4 保留前6后4真的足够安全吗做脱敏的时候要思考的是脱敏到什么程度算安全而不只是功能实现出来。前6位地址码公开信息很多地方能查到即使不隐藏影响也有限。8位出生日期属于核心隐私必须隐藏。后4位虽然看起来只暴露了4位但结合前6位可以在一定程度上缩小到很小的范围。如果业务对隐私要求很高可以改成只保留前6后2或者干脆全隐藏。所以保留前6后4不是唯一答案而是展示效果和隐私保护之间的平衡。作为前端不要觉得需求给我规则我就照做至少得理解规则背后的隐私含义这样需求要调整时你能给出准确建议而不是一脸懵。4. Vue 项目中的脱敏落地从过滤器到组件化封装4.1 Vue 2 时代的过滤器写法Vue 2 里做脱敏第一反应就是过滤器// main.js 或公共工具文件里 Vue.filter(mobileMask, function (value) { if (!value) return ; const digits String(value).replace(/\D/g, ); if (digits.length ! 11) return value; return digits.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); }); Vue.filter(idCardMask, function (value) { const id String(value || ).trim().toUpperCase(); if (!id) return ; if (id.length 18) { return id.replace(/^(\d{6})\d{8}(\d{3}[\dX])$/i, $1********$2); } return id; });模板里用起来很爽td{{ user.mobile | mobileMask }}/td td{{ user.idCard | idCardMask }}/td这种写法的优点是把脱敏逻辑从业务组件里抽出去了模板里一个管道符就搞定。缺点是过滤器在 Vue 3 里已经被移除了如果你现在新起项目更推荐下面这种方式。4.2 Vue 3 的组合式 API 方案Vue 3 取消了过滤器官方推荐用函数或者计算属性。我现在最常用的方式是在utils/mask.js里导出独立的脱敏函数// utils/mask.js export const maskMobile (mobile) { if (!mobile) return ; const digits String(mobile).replace(/\D/g, ); if (digits.length ! 11) return mobile; return digits.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); }; export const maskIdCard (idCard) { const id String(idCard || ).trim().toUpperCase(); if (!id) return ; if (id.length 18) { return id.replace(/^(\d{6})\d{8}(\d{3}[\dX])$/i, $1********$2); } if (id.length 15) { return id.replace(/^(\d{6})\d{6}(\d{3})$/, $1******$2); } return id; };模板里直接调用td{{ maskMobile(user.mobile) }}/td td{{ maskIdCard(user.idCard) }}/td这种方式的优点是类型简单、哪里需要哪里调用。缺点是如果同一个表格有大量行每次渲染都会执行正则。不过 Vue 的响应式系统决定了模板里引用的数据没有变化时函数不会无谓地反复执行所以大部分场景性能完全够用不必过早优化。4.3 计算属性与函数式封装怎么选在 Vue 3 里如果是一个详情页的少量字段脱敏计算属性更合适const displayUser computed(() ({ mobile: maskMobile(props.user.mobile), idCard: maskIdCard(props.user.idCard), }));但如果是列表页大量单元格props是循环产生的每个单元格都声明一个计算属性反而不自然直接用函数更干净。我的经验原则是场景推荐方式单个详情页的少量字段计算属性列表页大量行的单元格模板内函数调用多个页面复用相同规则抽成工具函数 组合式封装需要可配置脱敏规则的团队组件化方案4.4 自定义指令另一种封装思路如果你不想在模板里写函数调用Vue 3 还可以用自定义指令把显示脱敏这个逻辑和 DOM 直接绑定app.directive(mask, { mounted(el, binding) { const { type } binding.value || {}; if (type mobile) { el.textContent maskMobile(el.textContent); } else if (type idcard) { el.textContent maskIdCard(el.textContent); } } });用法td v-mask{ type: mobile }{{ user.mobile }}/td说实话自定义指令这种方案我用的不多。因为它改的是 DOM 内容和 Vue 的虚拟 DOM 更新机制有些割裂一旦依赖的响应式数据变化需要额外处理更新逻辑。除非你非常确定某个字段在生命周期内不会重新渲染否则我建议还是以函数计算为准少碰这种旁路操作。4.5 封装一个 MaskText 组件如果团队里有多个项目或者规则经常变我更推荐封装一个MaskText组件template span{{ displayText }}/span /template script setup import { computed } from vue; import { maskMobile, maskIdCard } from /utils/mask; const props defineProps({ type: { type: String, default: mobile }, value: { type: [String, Number], default: }, }); const displayText computed(() { if (!props.value) return ; if (props.type idcard) return maskIdCard(props.value); return maskMobile(props.value); }); /script组件的好处是规则收敛在一个地方多人协作时代码风格统一不至于这个人写replace、那个人写substring。缺点是多了一层封装对简单页面来说略重。做技术选型时看团队规模一个人维护的小后台项目函数就够了多人协作的中大型系统组件化收益更大。5. 不改变源数据Vue 响应式环境下的几个坑5.1 为什么脱敏函数改了源数据会连环翻车先说个真实踩坑经历。早期做一个用户管理表格我把脱敏函数写成了这样function maskMobileInPlace(user) { user.mobile user.mobile.replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2); return user.mobile; }模板里td{{ maskMobileInPlace(user) }}/td表面上看这能显示脱敏结果。但问题马上就来了函数修改了user.mobileVue 的响应式数据被修改于是触发了依赖该字段的更新重新渲染重新渲染又调用maskMobileInPlace这时候user.mobile已经是138****5678了原来的正则匹配不上于是返回一个没被处理的值同时user.mobile被再一次赋值……最后表格上看到的是一串变化不定的字符串严重时页面直接卡死或渲染错乱。根源就一句话显示逻辑和源数据耦合了。脱敏函数必须是纯函数——输入完整数据输出脱敏字符串过程中不碰外部状态不修改入参。5.2 纯函数 浅拷贝怎么做到不污染源数据要做到不修改源数据核心是两点脱敏函数内部不修改传入的参数。如果确实需要生成一个新对象用浅拷贝或深拷贝而不是直接改原对象。我前面写的maskMobile和maskIdCard都是纯函数入参在整个函数体里从未被赋值这就已经保证了不污染源数据。如果想生成脱敏后的新对象可以这样const toMaskedUser (user) ({ ...user, mobile: maskMobile(user.mobile), idCard: maskIdCard(user.idCard), });...user做的是浅拷贝对普通对象已经完全够用它只是把mobile和idCard替换成脱敏后的字符串其他字段引用保持不变。如果对象嵌套很深、又想深拷贝可以用structuredClone或JSON.parse(JSON.stringify(obj))但注意structuredClone对较老的浏览器兼容性一般用之前先查一下项目目标人群用的浏览器版本。5.3 表单回填和列表展示的冲突怎么处理一个高频场景用户管理表格里显示脱敏手机号点编辑弹窗时要回填完整手机号。如果你用v-model绑定的是同一个源数据字段那么表格一脱敏弹窗里也跟着变成脱敏后的值了——这不是想要的效果。我的做法是分两个字段源数据字段user.mobile始终存放完整手机号用于表单回填和提交。展示字段通过脱敏函数计算不落数据。如果后端返回的数据结构本身把展示值和真实值混在一起可以在获取数据后做一层映射const userList res.data.map((item) ({ ...item, mobileDisplay: maskMobile(item.mobile), mobile: item.mobile, }));表格里展示mobileDisplay编辑弹窗里用mobile两边互不干扰。虽然多了个字段但换来的是逻辑清晰、不会误操作源数据。5.4 表格长时间停留后重新渲染脱敏会叠加吗很多人担心表格某个字段被脱敏后如果用户停留很久Vue 重新渲染是不是会再脱敏一次变成1*****678答案是只要脱敏函数是纯函数、输入源数据保持不变就不会叠加。因为每次渲染读到的都是原始的完整手机号经过一次脱敏输出结果下一次渲染还是同样的流程结果恒等。真正会叠加的一定是有人把脱敏后的值写回了源数据——这又回到 5.1 节的问题了。所以排查脱敏越滚越离谱的问题第一步不是看脱敏函数本身而是去代码里搜一下有没有人对源数据的相关字段做过赋值操作。6. 通用化脱敏工具与边界情况盘点6.1 从手机号、身份证号延伸到其他类型做好了手机号和身份证号你会发现其他常见敏感字段的脱敏思路完全一样只是规则不同。我整理一个工具函数把多种类型都收进去export const mask (value, type) { if (!value) return ; switch (type) { case mobile: return maskMobile(value); case idcard: return maskIdCard(value); case email: return String(value).replace(/^(.{2}).*(.*)$/, $1***$2); case bankCard: return String(value).replace(/^(\d{4})\d(\d{4})$/, $1 **** **** $2); case name: return String(value).replace(/^(.?)(.*)$/, (_, first, rest) first *.repeat(rest.length) ); default: return value; } };拿邮箱举例testexample.com会变成te***example.com保留前两位和 后面的域名。银行卡号6222000012345678会变成6222 **** **** 5678。姓名脱敏有意思的地方在于长度不确定我的规则是保留第一个字后面的字用星号代替张三变成张*欧阳娜娜变成欧***。不同企业对姓名脱敏规则差异很大有些喜欢保留姓氏有些喜欢保留最后一个字需求怎么定就怎么调。6.2 边界情况清单脱敏函数虽然不长但边界情况非常多。我把实际开发中会遇到的情况列一下场景输入示例期望行为空值/null/undefined返回空字符串不报错数字类型13812345678先转字符串再处理长度不对12345、123456789012返回原值或空取决于需求带格式字符串138-1234-5678提取数字后处理大小写混合abc123ABC身份证处理时统一转大写15位老身份证110101880101123单独规则处理全星号数据******不再脱敏直接返回已经脱敏过的数据138****5678不二次处理最后两条容易被忽略。如果接口层本身已经做过脱敏前端再走一遍脱敏函数通常也不会出错因为正则匹配不上就返回原值但这种隐患存在所以团队里最好约定脱敏只做一次。6.3 一个小技巧正则先测试再上线写脱敏正则的时候我习惯先用test方法或者在线正则工具把几种典型输入跑一遍确认匹配结果符合预期再放进业务代码里。const mobileReg /^(\d{3})\d{4}(\d{4})$/; mobileReg.test(13812345678); // true mobileReg.test(1381234567); // 少一位false mobileReg.test(138abcd5678); // 有字母false mobileReg.test(8613812345678); // 有区号false这一步 10 秒钟的事但能省去很多在页面上调试的时间。正则的坑在于看着对和真得对是两回事尤其面对特殊输入的时候多测几种比什么都重要。6.4 什么时候该找后端而不是前端硬扛最后说一句工作边界。前端脱敏虽然能解燃眉之急但它不等于真正的数据安全。页面代码任何人通过浏览器开发者工具就能看到只要源数据在前端存在就一定可以被拿到——脱敏只是不让它显示在界面上不是加密保护。如果需求是某些角色根本没权限看完整手机号导出的文件不允许出现完整身份证号日志、埋点里不能有敏感信息这些必须靠后端做权限控制和脱敏前端不要硬扛。这个边界想清楚了你写代码的时候就不容易做出界面上打码、数据包里全是明文这种自欺欺人的方案。最后分享一个经验脱敏函数写完之后不要急着往业务代码里贴先单独写几行测试用例把空值、错误长度、带格式、特殊字符这些情况全部跑一遍。我见过太多线上问题都是因为某个接口返回了一个不按套路出牌的值脱敏函数直接返回原值敏感信息就这么漏出去了。这不是正则写得不够好而是边界情况没想全。把这几类输入测熟前端脱敏这件事基本就稳了。