1. 整体设计与思路拆解
1.1 先搞清楚脱敏到底在解决什么问题
手机号、身份证号这类个人敏感信息,在真实项目中几乎天天碰到。管理后台的用户列表、客服平台的工单详情、运营看板里的用户画像,只要界面上会出现用户的真实手机号和身份证号,就有数据泄露的风险。测试人员截个图发到群里、前端同事开着DevTools调接口、甚至只是领导从你工位旁边路过瞄了一眼屏幕,敏感信息就这么不经意地流出去了。
脱敏就是干这个事的:把关键字段的中间几位用星号替换掉,让数据看起来像真的,但已经不是完整的真实数据。比如手机号138****5678,身份证号110***********1234,既保留了必要的辨识度(比如知道是哪个号段、哪个人),又不会暴露完整的个人隐私。
这里要重点强调一个容易被忽略的需求点:不改变源数据。也就是说,你的脱敏逻辑只能作用在展示层,不能把接口返回的原始数据对象给改掉。这个约束看起来简单,但实际操作中很多人会踩坑,比如直接对数组里的某个字段做替换,结果污染了源数据,后面提交表单、二次操作时拿到的是已经脱敏过的值,排查半天找不到原因。
1.2 为什么选择在前端做而不是交给后端
有一种声音认为脱敏应该在后端做,后端返回给前端的数据就应该已经是脱敏后的。这话对,也不全对。对于需要严格合规的场景(涉及刑法规定的“侵犯公民个人信息罪”那种),后端脱敏并且控制权限,确实更安全。但现实项目里,有很多场景后端没办法替你处理:
- 旧接口历史遗留问题,返回的就是完整数据,你改不动后端;
- 后端返回同一份数据,但用户在详情页需要看完整手机号(比如客服工单),列表页只需要脱敏展示,同一字段不同场景要求不同;
- 联调阶段后端还没适配脱敏逻辑,前端需要先行处理;
- 甚至有些项目压根没有后端,纯前端模拟数据或对接第三方服务。
所以我的建议是:前端展示层脱敏必须做,而且要做得规范、做得统一。前端脱敏的价值在于兜底——不依赖后端,任何时候拿到数据都能安全展示。至于更极致的合规要求,那是后端和架构层面的问题,两者不冲突。
在动手写代码之前,先明确三个设计原则:
第一,脱敏函数必须是纯函数。输入一个原始字符串,输出脱敏后的字符串,不修改入参,不产生副作用。这样可以保证同一条数据在任何地方调用都得到一致结果,也方便单元测试。
第二,脱敏逻辑要集中管理。不要每个页面复制粘贴一套正则,最好抽成一个工具模块,通过导入方式复用。后续如果要调整脱敏规则(比如星号数量、保留位数),只改一处,全局生效。
第三,区分“脱敏”和“隐藏”。脱敏是中间打星号,隐藏是整个字段都不显示。有些页面需求可能是“用户无权限看手机号,统一显示——”,这种情况不要用脱敏函数硬套,单独处理就行。
1.3 方案选型对比:正则替换 VS 字符串截取
实现脱敏的核心思路就两种:正则替换和字符串截取拼接。我列个对比表,方便你根据项目情况选:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 字符串截取(substring/slice + 拼接) | 逻辑直观,性能极好 | 对长度敏感,号码格式变化时容易出错 | 规范定长的手机号(11位)、身份证号(18位) |
| 正则替换(replace + 捕获组) | 灵活强大,能处理变长和复杂格式 | 正则写错容易误替换,可读性稍差 | 带区号、带空格、格式不稳定的场景 |
| 两种方案结合 | 先做格式规整再脱敏,兼容性强 | 代码量稍多,需要处理边界 | 生产环境建议,兼容脏数据 |
先看字符串截取的思路。手机号是定长的11位数字,截取前3位和后4位,中间用4个星号连接:
function maskMobile(phone) { return phone.slice(0, 3) + '****' + phone.slice(7); }这个方案简单粗暴,但有个隐形bug:如果传入的手机号不是11位(比如有人存了带区号+86),slice(7)拿到的就不是后4位,结果完全不对。所以在生产环境,我更推荐用正则来做。
手机号的正则脱敏:
function maskMobile(phone) { return String(phone).replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); }这里的核心逻辑是:用捕获组把前3位和后4位先“框”出来,中间的\d{4}匹配要脱敏的4位,替换时用$1和$2引用两个捕获组,中间拼上4个星号。注意正则里的^和$锚定符,它强制要求整个字符串必须刚好是3位数字+4位数字+4位数字,多一位少一位都不匹配,不匹配就原样返回,这样反而起到了“格式校验”的作用。
这个正则方案还有个优势:即便以后运营商手机号段增加(比如新增了某些罕见号段),正则的规则依然适用。因为手机号的前3位决定了运营商和号段归属,中间4位是地区编码+随机位,后4位是用户编号,只要前3+后4的保留逻辑不变,脱敏规则就可以一直用。
2. 核心细节解析与实操要点
2.1 手机号脱敏的几种写法与坑点
从最简单的开始,我们一行一行拆解。
写法一:直接对数字字符串操作
const maskMobile = (mobile) => { if (!mobile) return ''; const str = String(mobile); return str.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); };这个版本适用于标准的11位手机号。有一个很容易踩的坑:如果数据源里的手机号是number类型(比如JSON里没加引号),直接用replace会报错,因为number没有replace方法。所以我在函数第一行先把入参转成String再做处理,这是前端脱敏函数必须具备的健壮性。
写法二:兼容“带区号”和“带空格”
很多用户在填手机号的时候会手滑输入空格,比如“138 1234 5678”;或者某些业务场景存了“+86 13812345678”这种带国际区号的值。这种脏数据直接套上面的正则,匹配不上就原样返回,等于脱了个寂寞。
所以在脱敏之前,可以先做一次数据清洗,把非数字字符统一剔除,只保留纯数字,再走脱敏逻辑:
const maskMobileSmart = (mobile) => { if (!mobile) return ''; const digits = String(mobile).replace(/\D/g, ''); if (digits.length !== 11) return String(mobile); return digits.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); };注意这里的策略:先去掉所有非数字字符(\D等价于[^0-9]),如果去掉之后恰好是11位,才认为它是个有效的手机号并做脱敏;如果长度不对,说明这个数据有问题,那就“老实”地把原始值原样返回。这样设计有个好处:你不会因为格式不合法就把用户数据误解成脱敏后的数据,出了问题好排查。
写法三:处理“非11位”数据的兜底策略
有些业务场景手机号可能是座机号“010-88886666”或者短号“10086”,此时上面两个版本都会原样返回,但这会造成一个问题:明明该脱敏的字段却没脱敏,数据泄露风险依然在。
正确的做法是:无论什么格式,只要不是合法的11位手机号,就统一只保留前3位和后3位,中间全部星号覆盖:
const maskMobileFallback = (mobile) => { if (!mobile) return ''; const str = String(mobile); const digits = str.replace(/\D/g, ''); if (digits.length === 11) { return digits.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); } if (str.length <= 7) { return str.slice(0, 3) + '****'; } return str.slice(0, 3) + '****' + str.slice(-3); };这种兜底策略可以在“不能完全脱敏”和“完全不能脱敏”之间做一个折中,至少保证敏感信息的核心部分不完整暴露。
2.2 身份证号脱敏的正则细节
身份证号是18位(最后一位可能是X),脱敏的常见策略是把出生年月日那8位(第7-14位)用星号代替,这样既能保留省份和性别信息,又能起到基本的保护作用。另一种更严格的策略是只保留前1位(代表地区)和后1位(校验位),中间16位全部打星号。具体用哪种,取决于你业务对“辨识度”的需求。
先看最常见的“保留前6后4”方案:
const maskIdCard = (idCard) => { if (!idCard) return ''; return String(idCard).replace(/^(\d{6})\d{8}(\d{4})$/, '$1********$2'); };这里用\d{8}匹配了8位出生日期(YYYYMMDD),替换成8个星号,所以结果是类似110101********1234的格式。
这里有一个新手很容易搞错的地方:捕获组和替换字符串中星号的数量必须严格对应。如果你把\d{6}和\d{8}都“框”进捕获组,但替换时只写3个星号,那么脱敏后的长度就和原来不一致了,看起来会很奇怪。另外,脱敏后的数据长度必须和原数据一致,这是一个隐含的校验——如果长度变了,说明你的正则写错了。
再提一个容易被忽视的细节:身份证号末位可能是X(大小写不确定)。如果直接用上面的正则,\d{4}匹配后4位时会要求这4位全是数字,一旦末位是X,整个正则就匹配不上了。这就需要单独处理:
const maskIdCardSafe = (idCard) => { if (!idCard) return ''; const str = String(idCard).toUpperCase(); return str.replace(/^(\d{6})\d{8}(\d{3}[\dX])$/, '$1********$2'); };把最后的\d{4}改成\d{3}[\dX],意思是后4位中的最后一位允许是0-9或X。同时把输入统一toUpperCase,防止小写x影响匹配。
2.3 “不改变源数据”的落地策略
这是整个需求里最容易被忽略、但最容易出bug的点。直接对源数据做替换是最典型的错误:
// 错误示例:直接修改了源数据 userList.forEach(item => { item.phone = item.phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); });这行代码执行完,userList里的phone就变成脱敏后的了。如果后续要把这个userList传给下一个页面、提交给后端、或者做一个搜索匹配,那么拿到的全都是带星号的数据,整个链路就崩了。
正确的做法是什么?两个方向:
方向一:展示时用函数处理,不修改原值。
<!-- Vue 2 中 --> <span>{{ maskPhone(user.phone) }}</span> <!-- Vue 3 中同样适用 --> <span>{{ maskIdCard(user.idCard) }}</span>每次渲染都调用脱敏函数,但user对象里的phone和idCard字段永远是原始值。Vue的响应式系统会保证在user.phone变化时重新渲染,脱敏结果也会同步更新。这个方案最无脑也最安全,适合绝大多数场景。
方向二:创建一个新字段存脱敏值。
const displayList = userList.map(item => ({ ...item, phoneDisplay: maskPhone(item.phone), idCardDisplay: maskIdCard(item.idCard) }));通过展开运算符浅拷贝出一个新对象,把脱敏结果挂在新字段上,原对象的字段不受影响。页面渲染时只用phoneDisplay和idCardDisplay,提交操作时用phone和idCard。这种方案的好处是:计算一次,后续渲染不需要重复调用函数,性能更好;坏处是:如果原数据更新了,你必须同步刷新display字段,否则会显示旧值。
成年人全都要,我一般两种方案结合:列表页用“显示字段”方案(一次性计算,渲染快),详情页用“函数调用”方案(每次实时计算,不会出现脏缓存)。
3. 实操过程与核心环节实现
3.1 封装一个健壮的脱敏工具模块
不建议在每个组件里写正则,太散且难维护。我习惯新建一个utils/mask.js,把所有脱敏逻辑集中管理。
/** * 手机号脱敏:保留前3后4,中间用*替换 * @param {string|number} phone - 手机号 * @returns {string} 脱敏后的手机号 */ export function maskPhone(phone) { if (phone === null || phone === undefined || phone === '') return ''; const str = String(phone); const digits = str.replace(/\D/g, ''); if (digits.length === 11) { return digits.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); } // 非11位数字时,保留首尾各3位,中间用*覆盖 if (str.length <= 7) { return str.slice(0, 3) + '****'; } return str.slice(0, 3) + '****' + str.slice(-3); } /** * 身份证号脱敏:保留前6后4,出生日期8位用*替换 * @param {string} idCard - 身份证号 * @returns {string} 脱敏后的身份证号 */ export function maskIdCard(idCard) { if (idCard === null || idCard === undefined || idCard === '') return ''; const str = String(idCard).toUpperCase(); const match = str.match(/^(\d{6})\d{8}(\d{3}[\dX])$/); if (match) { return match[1] + '********' + match[2]; } // 不规则数据,保留前1后1,其余全部打码 if (str.length > 2) { return str[0] + '*'.repeat(str.length - 2) + str[str.length - 1]; } return str; } /** * 通用脱敏工具:根据类型自动选择规则 * @param {string} value - 原始值 * @param {string} type - 'phone' | 'idCard' | 'name' | 'email' */ export function maskValue(value, type) { switch (type) { case 'phone': return maskPhone(value); case 'idCard': return maskIdCard(value); case 'name': return maskName(value); case 'email': return maskEmail(value); default: return value; } }这个模块还顺带加了姓名脱敏和邮箱脱敏两个示例。姓名脱敏一般保留姓氏,后面的字用*代替;邮箱脱敏一般是@前面只保留第一个字符:
export function maskName(name) { if (!name) return ''; if (name.length === 1) return name; if (name.length === 2) return name[0] + '*'; return name[0] + '*'.repeat(name.length - 2) + name[name.length - 1]; } export function maskEmail(email) { if (!email) return ''; const atIndex = email.indexOf('@'); if (atIndex <= 0) return email; const prefix = email[0] + '***'; return prefix + email.substring(atIndex); }3.2 Vue 2 与 Vue 3 中的接入实践
环境不同,接入方式差异还挺大。先看Vue 2,传统Options API写法。
在Vue 2的组件中使用:
import { maskPhone, maskIdCard } from '@/utils/mask'; export default { name: 'UserList', data() { return { userList: [] }; }, methods: { maskPhone, maskIdCard } };模板里直接用:
<el-table :data="userList"> <el-table-column label="手机号" prop="phone"> <template slot-scope="scope"> <span>{{ maskPhone(scope.row.phone) }}</span> </template> </el-table-column> <el-table-column label="身份证号" prop="idCard"> <template slot-scope="scope"> <span>{{ maskIdCard(scope.row.idCard) }}</span> </template> </el-table-column> </el-table>在Vue 2中注册为全局过滤器:
如果多个页面都要用,建议注册成Vue过滤器,这样模板里可以少写一行方法调用:
// main.js import Vue from 'vue'; import { maskPhone, maskIdCard } from '@/utils/mask'; Vue.filter('maskPhone', maskPhone); Vue.filter('maskIdCard', maskIdCard);模板里写:
<span>{{ user.phone | maskPhone }}</span> <span>{{ user.idCard | maskIdCard }}</span>Vue 2的过滤器很好用,但一个坑是:过滤器里的this不是组件实例,所以不要试图在过滤器内部访问this.$route或者this.someData。虽然脱敏函数一般不会用到组件上下文,但如果你有类似需求,就得改成方法调用。
Vue 3 + Composition API 的方案:
Vue 3 的setup函数里封装一个组合式函数是最舒服的。我把脱敏逻辑封装成一个useMask hook,组件里引入就能用:
// hooks/useMask.js import { maskPhone, maskIdCard, maskValue } from '@/utils/mask'; export function useMask() { const maskPhoneValue = (value) => maskPhone(value); const maskIdCardValue = (value) => maskIdCard(value); const maskField = (value, type) => maskValue(value, type); return { maskPhoneValue, maskIdCardValue, maskField }; }组件里这样用:
<template> <div> <p>手机号:{{ maskPhoneValue(user.phone) }}</p> <p>身份证号:{{ maskIdCardValue(user.idCard) }}</p> </div> </template> <script setup> import { reactive } from 'vue'; import { useMask } from '@/hooks/useMask'; const { maskPhoneValue, maskIdCardValue } = useMask(); const user = reactive({ phone: '13812345678', idCard: '110101199001011234' }); </script>另外,在表格组件里(Element Plus / Ant Design Vue)有一种更优雅的玩法——利用列配置的customRender或者scopedSlots:
// Element Plus 的列配置式写法 const columns = [ { prop: 'phone', label: '手机号', formatter: (row) => maskPhone(row.phone) }, { prop: 'idCard', label: '身份证号', formatter: (row) => maskIdCard(row.idCard) } ];这样表格列渲染时自动脱敏,组件模板里不需要写多余的插槽代码,更清爽。
3.3 列表多个字段批量脱敏的优雅循环
实际项目中经常是一个表格里同时有手机号、身份证号、邮箱等多个敏感字段,如果每列都写一遍方法调用,模板会显得很啰嗦。我一般会在拿到接口数据后,循环处理一次,生成一个“展示用副本”:
const sensitiveFieldsMap = { phone: 'phone', idCard: 'idCard', email: 'email' }; const getDisplayList = (rawList) => { return rawList.map(item => { const displayItem = { ...item }; for (const field in sensitiveFieldsMap) { if (item[field] !== undefined && item[field] !== null) { displayItem[`${field}Display`] = maskValue(item[field], sensitiveFieldsMap[field]); } } return displayItem; }); };模板直接用phoneDisplay这种带后缀的字段名即可:
<el-table-column label="手机号" prop="phoneDisplay"></el-table-column>这么做的好处有三点:源数据完全不动;展示字段是独立的新变量,不存在脏数据;每个字段只计算一次,没有重复的运行时开销。坏处是如果表格行数据发生更新,需要重新跑一遍getDisplayList,否则展示字段不会变。不过通常表格数据都是从接口统一拉取的,全量替换即可,不存在这个问题。
3.4 脱敏后的操作限制与旁路处理
脱敏做完不代表万事大吉。一个经典问题:列表页对手机号脱敏了,那么“复制”操作怎么办?用户看到138****5678,想复制这个号码去加微信,结果复制了一串星号。
针对这种场景,合理的做法是:列表页默认脱敏展示,但提供“点击显隐”的能力。点击眼睛图标或复制按钮时,用源数据(非脱敏字段)做复制操作。在Element Plus里可以用Popover做一个“悬停显示完整信息+点击复制”的交互:
<el-popover trigger="click" width="200"> <div> <p class="full-info">{{ row.phone }}</p> <el-button size="mini" @click="copyText(row.phone)">复制</el-button> </div> <span slot="reference">{{ maskPhone(row.phone) }}</span> </el-popover>这里的关键点是:只有用户主动触发(点击、悬停)时才显示完整信息,这个交互过程算是一次“临时授权”,比直接把完整数据铺在页面上安全得多。同时产品层面要加上权限控制,不是所有人都能看到完整信息,那就需要和后端配合,让有权限的接口返回源数据,无权限的接口直接返回脱敏数据,前端不需要操心这个。
4. 常见问题与排查技巧实录
4.1 为什么脱敏后数据长度对不上?
之前有同事反馈,身份证脱敏后长度变成了17位,排查了半天发现他写的替换字符串里星号数量少了。这是一个非常典型的低级错误,但发生频率极高。
我的排查习惯是:先对脱敏前后的字符串长度做一次断言校验。写单元测试时这行代码能救你一命:
// 单元测试示例(Jest) test('掩码后长度与原始长度一致', () => { const raw = '110101199001011234'; const masked = maskIdCard(raw); expect(masked).toHaveLength(raw.length); });只要长度校验不通过,赶紧去数正则里的星号个数和\d{n}里的n值是不是对得上。
4.2 正则没匹配上,数据原样返回?
这种情况多见于“数据源带格式”的场景。比如手机号存的是“138-1234-5678”这种带横杠的,或者身份证号有前导空格。正则严格匹配会直接失败,返回原样数据,表面上看是“没脱敏成功”,实际上是“格式校验失败按原值返回”了。
解决办法就是我在3.1节中写的方案:先剔除所有非数字字符,用纯数字做匹配。但是要注意,这种“清洗后脱敏”的策略也有风险:如果数据源是“11010119900101123X”这种合法身份证号,清洗后反而会误删末位X,导致匹配失败。所以清洗逻辑要区分场景,手机号可以大胆剔除所有\D,身份证号只能剔除空格和中划线,不能剔除字母X。
4.3 列表页卡顿,脱敏函数性能是不是拖后腿?
脱敏函数的逻辑本身是O(n)的字符串正则匹配,性能损耗微乎其微。真正导致卡顿的原因是:在Vue模板里写方法调用,每次渲染都会执行一次函数。如果列表有10000行,每一行有2个脱敏字段,那一次渲染就要执行20000次正则匹配。
实测下来,这个量级的匹配在普通pc上耗时大概几十毫秒,单个操作不卡,但如果是大列表滚动、频繁排序、筛选操作时,累积起来确实会感知到卡顿。
我推荐的优化手段就是上面提到的“先批量算好display字段再渲染”,把重复计算变成一次计算,之后渲染全是读缓存字段。这一步做完,哪怕几万行的表格也不会因为脱敏逻辑卡顿。
4.4 表格排序和筛选时,应该用原始值还是脱敏值?
这个坑特别隐蔽。如果你按脱敏后的字段排序,比如按手机号排序,敏感的号段信息本来在源数据里是连续的,脱敏后排序顺序就完全乱了。原因很简单:星号的ASCII码是42,数字的ASCII码是48-57,两者混在一起排序,结果不可预测。
所以排序和筛选的逻辑必须基于源数据字段,脱敏计算只影响展示字段。在Element Plus里实现时,表格的sortable应该绑定原始字段,或者你对数据做一次“先排序再脱敏”的预处理:
const sortedList = rawList .sort((a, b) => a.phone.localeCompare(b.phone)) .map(item => ({ ...item, phoneDisplay: maskPhone(item.phone) }));先排完序,再打入脱敏展示字段,这样就同时兼顾了排序正确性和展示安全性。
4.5 后端接口返回的数据本身就是脱敏后的,前端再脱敏一次会怎样?
这个问题经常出现在联调阶段。如果后端已经做了脱敏,返回的就是“1385678”,前端拿到后再次调用脱敏函数,正则根本匹配不上(因为它中间是星号不是数字),最终原样返回。所以“双重脱敏”并不会报错,也不会变成“138**68”这种奇怪结果,整体是安全的。
但这里隐含的风险是:如果后端把脱敏后的数据直接存到了缓存或者库里,下次查询出来再给前端时,数据就永远“脏”了。所以必须明确前后端的分工:后端负责“存储安全”,前端负责“展示安全”。后端的接口如果已经脱敏了,前端就不要重复调用脱敏函数,不然以后后端调整脱敏规则(比如从保留前3后4改成保留前3后3),前端这边风格就对不上了。
4.6 一个容易被忽视的边界:空字符串和null
脱敏函数最基础但也最容易被忽视的边界就是空值处理。在真实接口数据中,有些用户的手机号是null,有些是空字符串,有些干脆没这个字段。如果脱敏函数不处理这些情况,页面就会显示[object Object]、undefined这类内容,测试看板瞬间变车祸现场。
我的习惯是:函数入口一句if (!value) return '',把null、undefined、空字符串统一输出为空字符串。注意这里不能用value == null做判断,因为空字符串也是有效输入,要原样返回空串而不是undefined。
另外,如果读取字段时直接用了user.phone.mask()这种链式写法,一旦phone为null就会抛TypeError,整个组件都崩了。这种场景建议改用可选链user.phone?.slice(0, 3)或者解构时给默认值const { phone = '' } = user。
4.7 面试官常问:字符串包含、正则替换、响应式数据修改
这些脱敏相关技术点也经常出现在前端面试题里,比如面试官可能问“JS中如何判断字符串是否包含某个子串”——这里可以用String.prototype.includes方法。再比如“从一个比价项目中如何把脱敏功能复用”,答案就是抽离成纯工具函数。
如果面试官让你手写一个正则脱敏,一定要展示你对边界条件的思考,比如:
- 非11位手机号怎么处理?
- 身份证末位是X匹配不上怎么办?
- 是否修改了源数据?
- 是否考虑过大写X与小写x的区别?
这些点基本就是考察一个前端工程师的“工程思维”——你写代码不只是为了跑通,还要考虑全面、考虑健壮性。把这些边角聊出来,面试表现基本就稳了。
5. 从脱敏到数据安全的扩展思考
5.1 脱敏逻辑也适用于姓名、邮箱、地址等字段
脱敏并不只局限于手机号和身份证号。姓名、邮箱、家庭住址、银行卡号都可能是敏感信息。设计师和产品经理有时候只提了手机号和身份证号的脱敏需求,但作为前端,我们可以在工具模块里把常用脱敏函数都准备好,后续其他字段要加脱敏,直接调用即可。
我之前在项目里还遇到过“银行卡号脱敏”的需求:银行卡号一般是16-19位,脱敏规则一般是保留前4位和后4位,中间全部用星号:
export function maskBankCard(cardNo) { if (!cardNo) return ''; const str = String(cardNo); if (str.length > 8) { return str.slice(0, 4) + '*'.repeat(str.length - 8) + str.slice(-4); } return str; }这类“中间打星号”的函数写多了之后,你会发现它们内核都是同一个模式:保留头部N位,保留尾部M位,中间用星号填充。可以抽象一个通用的maskMiddle(str, headCount, tailCount, maskChar = '*')函数出来,一行代码搞定各种变体。不过实用性上,单独的命名函数可读性更好,我不太推荐过度抽象。
5.2 前端脱敏不是终点,尽量和后端统一规则
前端脱敏本质上属于“展示层安全”,只能防君子不能防黑客。因为任何认真看网络请求的人都能在DevTools里看到源数据——前端拿到的本来就是完整数据。
真正要保护数据安全,必须靠后端:接口层面控制权限、日志层面不记录完整身份证号、返回数据默认脱敏、内部系统才返回源数据。前端脱敏,是“即使后端没控制好,也要在界面上把好关”的最后一道防线。
所以项目中最好不要出现“后端不脱敏、全靠前端脱敏”的情况。理想状态是:后端在接口层根据用户权限决定返回“源数据”还是“脱敏数据”,前端根据返回字段标记做展示。如果后端还没支持,前端用工具函数兜底,同时跟进后端同事尽快把这个逻辑加上去。我在实际项目中是这样推进的:先在前端把脱敏工具写好铺到所有页面上,然后和后端约定好规则,后端再逐步改造接口。两层都做了才敢说数据展示环节是安全的。
5.3 脱敏后的内容禁止回传
最后一个特别容易犯的错误:把脱敏后的数据提交给后端。比如表单里有个“确认手机号”的输入框,用户没有重新填写,前端读的是脱敏后的展示值,直接提交了。后端收到的就是“138****5678”这种带有星号的脏数据,后续短信发送、号码校验全都会出问题。
怎么避免?核心原则:提交的数据永远用源数据,脱敏只用于展示。如果某个表单需要“默认带入手机号”再允许用户修改,那么初始值应该设置成源数据(内部字段),展示时用脱敏值;用户不修改则提交源数据,修改了则提交用户输入的新值。我一般这样处理:
// 组件内 const formData = reactive({ phone: user.phone, // 源数据,只用于提交 phoneDisplay: maskPhone(user.phone) // 展示数据,只用于页面回显 }); // 用户修改了输入框时 watch(() => formData.phoneDisplay, (newVal) => { // 展示值不等于源数据脱敏结果,说明用户改了内容 if (newVal !== maskPhone(formData.phone)) { formData.phone = newVal.replace(/\D/g, ''); } });这样写下来,用户看到的是脱敏后的手机号,但提交给后端的永远是完整的真实数据。用户不改,源数据原样提交;用户改了,用新的输入值覆盖。
6. 实操心得:写脱敏函数时我养成的几个习惯
脱敏这个功能技术含量不算高,但特别容易写得毛糙。几次踩坑之后,我给自己定了几条规矩,分享给你参考。
第一,入口先做类型归一化。任何脱敏函数第一行永远是判空 + 转字符串。因为接口数据经常不按套路出牌,number、undefined、null都可能出现,先把类型统一了再谈后面的匹配逻辑。
第二,严格区分“展示值”和“源值”。后端返回的数据结构尽量保持原封不动,要脱敏展示就单独加display字段,或者模板里调用函数。永远不要让脱敏逻辑去修改后端给的数据对象。
第三,每个脱敏函数都必须有兜底分支。正则匹配不上时的处理逻辑,不是简单地return原值,而是要判定“这个值是不是合法数据”,如果合法但不匹配规则,说明规则不兼容;如果不合法,则返回空或按通用规则脱敏。宁可展示不太美观的兜底结果,也不要让敏感信息原样裸奔。
第四,写单元测试,重点测边界。脱敏函数是纯函数,最好测了。把空值、11位、非11位、带横杠、带空格、身份证末位X、大写X、小写x全测一遍。这些测试代码不需要很多,但能保证你重构时不改坏逻辑。
第五,在代码里留注释说明规则来源。比如手机号脱敏为什么保留前3后4?身份证号为什么保留前6后4?这些规则通常来自产品需求,不写注释的话,三个月后你自己都忘了当初为什么这么设计。写一行注释,后面的人维护起来会轻松很多。
回到最初的问题——手机号、身份证号脱敏“中间显示星号,不改变源数据”,这件事的本质是:前端在展示层给敏感信息加了一道滤网,这道滤网不阻碍业务正常流转,但能让不该看到信息的人看到“打码版本”。应对面试也好、应对真实需求也好,真正重要的不是你背了多少行代码,而是你清不清楚边界在哪里、数据流怎么走、安全性怎么保障。把这些想明白,任何字段的脱敏需求到你手上,都能快速落地。
最后再分享一个小技巧:兼容性检查时不妨把脱敏函数扔进浏览器控制台跑一把,看输入各种脏数据的结果是否符合预期。前端这种跟正则、字符串打交道的小工具,最快的验证方式就是打开DevTools直接玩,比写一堆mock再跑测试要直观得多。