
前端项目里手机号、身份证号脱敏几乎是每个信息管理系统的标配需求。后台列表要展示用户手机号运营人员扫一眼就能知道是哪个客户的订单但页面不能把完整号码明文展示出来身份证号更是高敏字段线上环境通常只保留前几位和后四位中间打星号。这类需求听起来很简单真正写起来细节很多正则表达式怎么写才不会误伤带 X 的身份证号脱敏之后源数据会不会被改掉Vue 项目里应该用过滤器、指令还是工具函数这篇文章不聊理论直接讲我在实际项目中沉淀下来的脱敏方案包括通用脱敏函数、Vue 封装方式、边界测试和几个只在线上才暴露的问题。1. 内容整体设计与思路拆解脱敏的本质是“展示层降级”。数据从后端拿回来数据库里存的是完整值但页面显示要满足隐私保护要求。前端要做的是在渲染那一刻把字符串替换成带星号的形式同时不动原数组、原对象里的值。这样分页、搜索、重新渲染时源数据依然完整不会因为一次格式化操作把 raw 数据污染了。1.1 脱敏需求从哪里来最常见的场景是运营后台、客服工作台、订单列表、用户管理列表。这些页面天然会展示手机号、身份证号、银行卡号等信息。业务方提需求时经常只有一句“号码中间打码”但实现的时候会遇到几个选择是后端直接返回脱敏字段还是前端自己处理如果后端返回脱敏后的字段那么详情页、编辑页、导出功能就会出现问题——编辑框内如果要回显完整号码就需要再调一次接口拿原数据增加了请求次数和接口设计成本。所以我更偏好前端做展示层脱敏源数据保持完整。这个思路有一个前提数据本身已经经过接口权限校验前端脱敏只是“防止看了一眼屏幕的人顺手拍照泄露”并不是安全边界。换句话说后端该做的权限控制、字段过滤一样也不能少前端脱敏是在这之上的额外一档保护。实际业务中同一个手机号在列表页、详情页、编辑页的展示规则往往不同列表页打星号详情页保留前三位后四位编辑页回显完整号码导出 Excel 时又要根据当前账号权限决定是否脱敏。如果全部依赖后端定制接口后端要写很多分支逻辑前端反而失去了灵活性。1.2 为什么选择前端脱敏而不是只靠后端有些人会问既然数据敏感为什么不在后端把接口直接返回脱敏后的数据这个说法部分正确。如果前端根本不需要原数据比如列表只展示手机号那么后端返回脱敏字段是最省事的。但实际业务通常不是这样同一个字段在不同页面有不同展示规则列表页打码详情页部分打码编辑页显示完整导出时又不能脱敏。如果全部依赖后端定制接口后端要写很多分支逻辑前端反而失去了灵活性。最终我的方案是后端返回完整数据受权限控制前端统一封装脱敏工具在展示层按需调用。这样接口保持通用前端可以自由控制哪些页面脱敏、哪些页面不脱敏也方便应对临时改成“中间显示4个星号”这种产品需求的频繁变化。前端脱敏的另一个好处是可以用在同一份接口数据的不同字段上比如手机号和身份证号走同一个工具函数只是传入的类型参数不同。代码统一收口之后就算产品要求“手机号只保留前三位”改成“保留前三位和后四位”也只需要改一个正则表达式。1.3 设计目标不改源数据只改展示“不改变源数据”是这个需求里最关键的一句话。很多新手写脱敏时会直接在原数组上循环 replace结果页面翻页、筛选时源数据已经被改过再次渲染就得到一堆星号或者不完整的号码。正确做法是写一个纯函数输入字符串返回脱敏后的字符串不在原数据上做任何赋值。这里要强调的是函数的“纯”性同样的输入永远得到同样的输出没有任何副作用。基于这个目标我做了三层封装最底层是一个兼容手机号、身份证号、银行卡号等常见号码的通用脱敏函数往上一层是 Vue 的全局方法或组合式函数再往上是可复用的指令。这样既保证了源数据不被修改又让业务组件代码保持干净。真实项目中我还会在请求返回数据的 map 阶段就生成脱敏后的派生字段渲染时直接用派生字段这样连 computed 都不用写性能也更好。2. 核心细节解析与实操要点2.1 手机号脱敏的正则怎么写最稳手机号脱敏最常见写法是正则替换中间四位function desensitizePhone(phone) { return String(phone).replace(/(\d{3})\d{4}(\d{4})/, $1****$2); }这里(\d{3})是前三位\d{4}是中间被替换掉的四位(\d{4})是后四位替换成$1****$2就是把前三位和后四位保留中间打星号。要注意 replace 方法默认替换第一个匹配由于手机号一般只有一段连续数字这个用例没问题。但如果是“手机号旁边还有座机号”的字段就得加边界条件比如/(\D|^)(\d{3})\d{4}(\d{4})(\D|$)/来避免误伤。实战中还有一个细节手机号可能被写成带空格的“138 1234 5678”前端拿到这种数据后要先去掉空格再脱敏。否则正则最前面的(\d{3})会匹配到前三位但空格会打断后续的连续数字匹配最终导致替换失败。比较稳妥的写法是在入口统一处理function desensitizePhone(phone) { const value String(phone).replace(/\s/g, ); if (!/^\d{11}$/.test(value)) return String(phone); return value.replace(/(\d{3})\d{4}(\d{4})/, $1****$2); }加上^\d{11}$的校验之后长度不对的数据会原样返回而不是被正则部分匹配后改得面目全非。2.2 身份证号脱敏的坑身份证号是 18 位由 17 位数字和一位数字或 X 组成。常见的脱敏要求是保留前三位和后四位或者保留前六位和后四位。我用的正则function desensitizeIdCard(idCard) { return String(idCard).replace(/(\d{3})\d{11}([\dXx])/, $1***********$2); }这里$1保留前三位\d{11}吃掉中间的 11 位([\dXx])保留最后一位。实际项目中身份证号尾数有概率是 X所以最后一位必须用字符类去匹配。如果直接写\d那么类似“11010119900307123X”这种带 X 的身份证号就不会匹配replace 失效整个号码原样返回等同没有脱敏这是线上很容易踩的坑。有些需求只显示前四位和后四位保留位数不同时正则也要跟着调整String(idCard).replace(/(\d{4})\d{10}(\d{4})/, $1**********$2);还有的旧版身份证是 15 位如果统一用 18 位规则去脱敏长度不够时整个号码原样显示也要做兼容处理。我的建议是身份证脱敏前先判断长度15 位和 18 位用不同的正则这样即便数据库里存在历史脏数据页面也不会出现完整明文。2.3 星号数量与格式的取舍星号数量不是固定的产品文档经常写“中间打码”没说几位。常见的做法是“保留前3后4中间4个星号”用于手机号但身份证号有人保留前6后4中间8个星号也有人保留前3后4中间11个星号。星号数量不需要和真实位数一致因为脱敏的目的就是让观察者无法回推原数据。从信息量角度看身份证号前面6位是地址码生日信息可能就隐含在中间所以保留前6位再加4个星号已经足够甚至应该尽量少暴露前几位。这里我建议跟后端团队、法务口径同步确认公司隐私规范到底要求保留几位。有些公司的安全规范会明确写“手机号保留前3后4身份证号保留前1后1”那就必须按规范来。另外一个容易忽略的地方是有些组件库的表格列里有 copy 按钮复制出去的是脱敏值还是会原值如果整行绑定了 click 事件去取 row.phone那么复制和导出时拿到的还是源数据脱敏形同虚设。3. 实操过程与核心环节实现3.1 通用脱敏函数编写为了不重复造轮子我写了一个同时支持手机号、身份证号的函数。判断逻辑很简单先清洗字符串再去掉非数字字符然后根据长度走不同分支。字段类型传进来之后用对应的正则处理。有两点必须注意入参可能是 number 类型也可能是 null/undefined如果直接调用 String(undefined).replace 不会报错但会返回 undefined所以要在入口处做非空判断拿不到数据时返回空字符串。下面这段代码是基础版export function desensitize(str, type phone) { if (str null || str undefined) return ; const value String(str).trim(); if (!value) return ; if (type phone) { return value.replace(/\s/g, ).replace(/(\d{3})\d{4}(\d{4})/, $1****$2); } if (type idCard) { return value.replace(/(\d{3})\d{11}([\dXx])/, $1***********$2); } return value; }这个函数覆盖了 90% 的场景。但项目里往往还有其他字段比如银行卡号。银行卡号的脱敏规则一般是保留后四位前边全部打星号可以再加一个分支if (type bankCard) { return value.replace(/(\d{4})(\d{4})(\d{4})(\d{4})/, **** **** **** $4); }如果还要支持邮箱脱敏也可以加一个分支保留首字母和后面的域名部分中间用星号替代。但要注意脱敏工具函数不是越复杂越好每多一个分支就多一组测试用例。我的建议是只保留业务里真实用到的类型其他字段等需求出现了再加避免写一堆看似通用实则没人调用的代码。3.2 Vue 3 组合式封装Vue 3 里过滤器没了比较优雅的方式是提供一个全局的脱敏方法或者封装成一个可用的组合式函数。我习惯在 utils 里导出函数在 main.js 里挂到全局// main.js import { createApp } from vue; import App from ./App.vue; import { desensitize } from ./utils/desensitize; const app createApp(App); app.config.globalProperties.$desensitize desensitize; app.mount(#app);在组件里可以这样用const phone computed(() props.row.phone); const displayPhone computed(() desensitize(phone.value, phone));不过全局挂载在script setup里拿this比较麻烦我需要配合getCurrentInstance()或者直接用具名导入。所以我更推荐在需要脱敏的页面直接 import 对应的工具函数类型提示和 Tree Shaking 都更好。模板里需要直接用的话可以写一个简单指令// v-desensitize app.directive(desensitize, { mounted(el, binding) { const type binding.arg || phone; el.textContent desensitize(el.textContent.trim(), type); } });用法span v-desensitize:phone{{ row.phone }}/span。这种指令方式适合后端返回的字段本身是完整值、且只需要展示的场景能省去在每个组件里写 computed。但要注意指令初始化之后如果row.phone异步变化需要增加一个 updated 钩子去更新 textContent否则数据加载完还是一堆空内容。完整一点的指令写法要同时处理 mounted 和 updated。3.3 测试用例与边界验证写完脱敏函数后我通常先跑到浏览器控制台或 node 里把边界数据测一遍。至少要有这几个用例入参类型预期输出13812345678phone138****5678138 1234 5678phone138****567811010119900307123XidCard110***********23X110101199003071230idCard110***********230nullphone空字符串undefinedidCard空字符串12345phone12345不匹配时原样返回138123456789phone138****6789后四位匹配但要多观察这里最后一条测试容易踩坑当手机号是 12 位时正则会从倒数第 4 位往前匹配结果可能不是预期的“前3后4”。实际项目中如果每条数据长度都不标准建议先校验长度长度不等于 11 位时返回原始值或者打上默认的脱敏样式。否则脱敏函数会“悄悄”把不完整数据替换成更奇怪的样子。测试用例不要只跑 happy path还要把 null、空字符串、纯空格、全角数字、带横杠的号码都跑一遍这样才能覆盖真实接口的脏数据。4. 常见问题与排查技巧实录4.1 正则匹配不到导致原样输出这是最常见的现象同一个函数在手机号上正常在身份证号上却不生效整串号码原样显示。排查思路分三步先在控制台里打印 value 字符串确认有没有不可见字符、空格再检查正则里的最后一位是否用了[\dXx]最后用replace之后的返回值对比原值如果完全一致说明正则没有匹配。身份证号这种 18 位长串如果中间夹杂了全角空格或者\n正则的\d匹配会失败。所以脱敏函数入口统一做 trim 是底线。如果还不行就用更严格的格式校验/^\d{6}(18|19|20)?\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/先判断是不是合法身份证再脱敏这样也能过滤出脏数据。我曾经在一个老项目里遇到过数据是从 Excel 导入的身份证号展示成科学计数法比如1.10101e17这种数据进到正则里根本不会匹配。这种问题不是脱敏函数能解决的需要在数据导入阶段就统一格式。4.2 身份证末尾 X 的匹配之前提到身份证尾号可能是大写 X也可能是小写 x。正规代称最好保留原样所以正则里用了[\dXx]而不是\d。这里还有一个容易忽略的点在 SQL 导出场景中有些系统会把身份证统一转成大写前端脱敏后显示的还是小写 x下单场景再拿去匹配就可能出问题。稳妥做法是脱敏前判断value.toUpperCase().endsWith(X)脱敏后手动替换回大写 Xfunction desensitizeIdCard(idCard) { const str String(idCard).trim(); const upper str.toUpperCase(); const result upper.replace(/(\d{3})\d{11}([\dX])/, $1***********$2); return str.endsWith(x) ? result.toLowerCase() : result; }这个细节看着小真遇到 X 的身份证测试用例时能省不少排查时间。如果你的系统要求所有身份证统一显示为大写 X那在脱敏函数里直接返回result.toUpperCase()也行关键是要和业务方确认大小写规则。4.3 前端脱敏的性能问题一个列表页如果一次渲染几百条数据每条数据在模板里调用一次正则替换性能其实可以接受。真正影响性能的是两种情况一是列表数据量上千且脱敏函数在 computed 或模板表达式中重复执行二是把脱敏后的结果重新赋值给了源数据导致 watch 或依赖收集频繁触发更新。解决办法有两个方向把脱敏提前到数据层请求接口成功后先遍历一次给每条数据挂一个phoneDesensitized字段渲染时直接读这个字段或者用记忆化方案同一输入值只计算一次。考虑到现在业务列表分页后很少超过几百条第一种方法最简单而且可以在打印日志、下载时复用脱敏字段。还有一个性能瓶颈容易被忽视如果表格同时展示手机号、身份证号、银行卡号三个字段每行数据就要调用三次正则。页面滚动时 dom 复用、排序、筛选都会触发重新渲染正则调用次数会被放大。提前 map 生成派生字段之后这些重复计算就全部消除了。5. 工程化落地与进阶建议5.1 列表页批量渲染时的性能优化我处理后台列表数据时一般会在请求返回的 map 阶段就把脱敏字段生成好const list res.data.list.map((item) ({ ...item, phoneMasked: desensitize(item.phone, phone), idCardMasked: desensitize(item.idCard, idCard) }));然后表格列直接渲染phoneMasked。这样模板里不再调用函数Vue 的响应式更新只依赖这个新字段列表滚动、排序时也不会因为重复正则计算卡顿。代价是多了一层字段冗余但对展示型列表来说非常划算。如果担心字段冗余也可以用 computed 返回新数组但 computed 每次依赖变化都会全量重算数量大的列表反而更慢所以 map 一遍生成派生字段是更稳妥的方式。如果是实时搜索、筛选的场景前端拿到筛选结果后同样做一次 map 即可。需要注意的是map 生成的是浅拷贝对象如果原 item 里嵌套了对象或数组展开运算符不会深拷贝。但脱敏字段是我们自己加的不会改动嵌套结构所以通常不会有问题。万一原数据结构复杂建议用解构时只提取需要的字段避免把整对象拷贝一遍。5.2 只做前端脱敏够不够这个问题我觉得必须聊清楚。前端脱敏解决的是显示层隐私问题它不能让接口数据传输过程更安全也不能替代后端对敏感字段做权限控制。如果接口返回的就是完整明文那么抓包、控制台都能看到原始数据这是前端防不住的。所以稳妥的架构是后端根据角色权限决定是否返回完整字段前端负责展示层的二次脱敏保证即便接口返回了完整数据页面也不会把明文暴露给不小心看到屏幕的人。线上项目里我会同时做三层接口层脱敏配置、前端展示层脱敏、页面离开时清空敏感数据。这样才能把隐私泄露风险压到最低。另外说一个和表格相关的点使用 Element Plus 或 Ant Design Vue 时我通常会在 el-table-column 的默认插槽里写一个span放脱敏值同时给整行绑定 click 事件去读 row 对象。click 事件里如果直接用了row.phone其实拿到的还是原数据后面做日志记录、跳转详情时要注意“展示脱敏、逻辑用原值”这一层关系不能因为列表打码了就认为全流程都脱敏了。还有人不小心在:data上直接操作row.phone desensitize(...)把响应式数据改了这样排序、筛选后列表数据变成“二次脱敏”最后整列全是星号排查起来特别头疼。5.3 结合业务扩展导出、复制、详情页脱敏需求一般不会只停留在列表展示。常见的扩展场景有三个导出文件里要不要脱敏复制按钮复制出去的是脱敏值还是原值详情页的字段要不要做分级脱敏导出和复制这一块很多系统会遗漏导致列表页打码导出文件却是明文。简单做法是导出的数据也走一遍脱敏函数但这时候要结合权限判断如果当前用户有“查看明文”权限那就导出原值否则导出脱敏值。详情页做分级脱敏时我倾向于维护一个字段配置表约定哪些字段、哪些角色展示明文、部分明文或者星号。这样比在页面里散落调 desensitize 函数更可控产品改规则时也只改配置。不过这个约定不一定要做成复杂的配置中心初期可以先在 utils 里维护一个SENSITIVE_FIELD_RULES常量后续量大了再抽出来。举个例子详情页经常是弹窗打开用户可以点击“查看完整身份证号”按钮此时前端拦一道弹窗提示“查看后将记录日志”再把明文展示出来。这种交互虽然简单但能显著提升合规性。实现时对应字段从idCardMasked切换到idCard切换逻辑里塞一个埋点事件即可。如果你要把脱敏规则做成全局配置可以定义一个对象const SENSITIVE_RULES { phone: { type: phone, show: partial }, idCard: { type: idCard, show: masked }, bankCard: { type: bankCard, show: last4 } };页面遍历字段配置来做渲染后期新增字段只用加一条配置比到处复制正则要干净得多。当然对小型项目来说直接调用desensitize函数就够用了过度设计反而拖慢开发进度。我在实际项目里最早踩过的坑就是直接在原数组上循环 replace结果翻页之后数据彻底变样排查了很久才发现是“源数据被改了”。后来强制自己写纯函数、不在调用处修改源数据并且所有脱敏字段统一在数据层生成派生字段这个坑就再也没出现过。最后再分享一个小技巧开发时可以在控制台临时跑一下脱敏函数把公司内部测试号、测试身份证输入进去连续对比几轮结果确认正则边界没写错再提交代码。这样客户验收时就不会出现“为什么列表里有个人的号码只打了 4 个星号其他人打了 6 个”这种尴尬问题。