Web前端脱敏实战:从工具函数到框架集成的防泄露指南
先说一个我去年遇到的真实事故。我们公司后台的客户列表页一个客服同事要把一张订单截图发给客户核对信息结果手滑把截图发到了客户群里。那张截图里客户的手机号、家庭住址、身份证号全部是明文清清楚楚。当天下午就有客户打电话来投诉说接到了诈骗电话。复盘的时候后端同事说“接口是按规范返回的明文脱敏不是后端的事”前端同事说“框架是统一模板没接入脱敏组件压根没想过”。两边都有道理但问题就卡在中间Web前端脱敏这件事理论上该做实际却常常没人做。这事儿之后我花了两周时间把自己的前端脱敏方案、工具函数和框架集成方式彻底做了一遍梳理和封装。这篇文章就是那套方案的完整记录覆盖场景判断、方法选型、代码实现、踩坑排查主要面向日常做Web前端开发的同学也适合全栈工程师和关注数据隐私的产品经理参考。1. 先搞清楚脱敏这件事到底该谁做、在哪儿做很多项目里脱敏是“大家都觉得该做但没人认领”的灰色地带。不把这个问题先说透后面的代码写得再漂亮也落不了地。1.1 什么时候必须在前端脱敏后端接口返回明文数据前端拿到后直接渲染这是大多数后台管理系统最原始的形态。问题在于前端页面是数据流向用户的最后一公里——只要页面里出现了明文它就可能在浏览器开发者工具、截图、录屏、客服聊天记录里“二次泄露”。我做了一个相对武断但有效的判断标准只要页面会被非开发人员客服、运营、管理层、客户本人看到就必须做前端脱敏。原因是后端脱敏往往针对的是“接口级”的防护它保证的是传输链路和存储环节的合规。但前端页面一旦渲染出明文数据就在用户的浏览器里“落地”了。这时候即使后端接口脱敏做得再好也防不住一张截图外传。举个极端例子就算后端把手机号改成138****8000前端如果从 localStorage 里读到一份缓存数据重新渲染照样能把明文展示出来——前端脱敏解决的就是这类“展示层”的泄露。1.2 前端脱敏和后端脱敏的分工边界很多人问既然后端能做脱敏为什么还要前端做这不是重复建设而是两道不同关卡。后端脱敏解决的是“数据离开服务端时能不能少暴露”。适合做持久化存储前的加密、接口返回前的规则化处理、日志脱敏。前端脱敏解决的是“数据到达浏览器后怎么展示才不泄露”。适合做列表字段掩码、详情页敏感信息收起、导出文件前二次打码、错误日志脱敏。我的建议是后端把接口层的基础脱敏做好比如手机号自动打星号前端把展示层的兜底脱敏做好比如针对后端漏配的字段、缓存数据、联调环境数据。后端为主、前端兜底谁也别替谁背锅。有一个典型的反面案例某个系统要求后端对列表接口的身份证号直接脱敏但是“详情页”为了后续编辑功能后端返回的是完整身份证号。前端列表页跳详情页时直接把列表里的脱敏字段替换成详情接口的明文数据导致列表页也变成明文。这就是前后端职责没有对齐造成的——后端以为列表页不展示明文前端以为详情页可以复用明文结果中间漏了一道。1.3 前端脱敏必须守住的3条原则原则一脱敏不是加密。脱敏产生的138****8000是不可逆的展示层结果不是用来还原的。如果业务上需要“查看完整手机号”应该调用权限受控的详情接口而不是在前端对脱敏结果做反向解析。我自己见过有同事写了个restoreMask函数企图把脱敏数据还原成明文这种设计本质上是把脱敏当摆设。原则二脱敏要统一规则不能一组件一写法。同一个手机号列表页显示138****8000详情页显示138****8000导出文件却显示138-****-8000用户和客服都会蒙圈。所有端包括管理后台、H5、小程序的脱敏规则必须由一份公共配置维护。原则三面向场景脱敏不面向字段脱敏。同一个字段在不同场景展示规则可以不同。比如订单详情页机主本人查看时手机号可以明文因为这就是他本人的手机号但客服查看时必须脱敏。所以脱敏组件最好支持“权限模式”和“场景模式”两种配置而不是写死某个字段必须脱敏。2. 最常见的脱敏场景和对应的处理方案前端脱敏不是简单“打星号”三个字能概括的。不同场景的脱敏点、保留位数、掩码符号都有讲究而且很多脱敏点的位置很隐蔽容易被忽略。2.1 列表页与详情页的字段级脱敏列表页是最典型的脱敏场景。几类高频字段的规则我做成了固定配置字段类型示例明文推荐脱敏结果规则说明手机号13812348000138****8000前3后4保留中间4位掩码身份证号110101199003073333110***********3333前6后4保留其余掩码银行卡号62220212345678901236222 **** **** 0123前4后4保留中间分段掩码邮箱zhangsanexample.comz****nexample.com保留首字符和前末字符或统一掩码姓名张三丰张**复姓做单独处理地址北京市朝阳区望京街道xx小区xx号楼北京市朝阳区********保留省市区街道后掩码详情页和列表页略有不同。详情页数据量小、展示空间大我更喜欢采用“点击可见、离开隐藏”的交互式脱敏。用户点击“眼睛”图标时调用权限接口拿到明文并记录操作日志3秒后自动隐藏同时触发浏览器的自动截图防泄露策略部分内网环境可以启用。这种方案比单纯打星号体验好又比一直展示明文安全。身份证号脱敏特别容易出错因为它有两种常见情况。18位身份证前6位是地址码所以我强烈建议保留前6后4展示110101********3333。如果只保留前3后4等于把省市区的前缀信息也暴露了一部分如果保留前4后4地址码泄露更多。实测过很多库/^(.{6}).*(.{4})$/搭配$1********$2是最稳妥的写法。2.2 操作类文案与第三方集成的脱敏点这是最容易被忽略的脱敏盲区。第一大类是“页面文案模板拼接”的泄露。比如订单状态栏里写“您的手机号 13812348000 已通过实名认证”这种字符串在服务端模板拼接好后接口直接以notice字段下发前端根本没机会对手机号单独处理。所以我在前端加了一道“敏感信息扫描器”可以简单理解为一个正则匹配 字符串替换的工具在渲染前对接口返回的字符串字段做一次全量扫描命中手机号、身份证号等模式时自动打码。第二大类是第三方脚本的埋点上报。很多项目接入了行为埋点、错误监控、客服系统。我踩过的最大的坑是列表页明明做了脱敏但埋点 SDK 自动采集 DOM 文本明文数据被带到了埋点服务器。所以前端接入第三方组件时要么关闭 DOM 自动采集要么在埋点上报前先对 payload 里的text字段做过滤脱敏。第三大类是分享和复制功能。比如用户“复制订单信息”后剪贴板里是全量明文因为navigator.clipboard.writeText拿到的是完整文本。这类功能如果非做不可至少要把复制内容的敏感字段打码否则前端脱敏做得再好复制一下就全破功了。2.3 打印、导出、分享前的脱敏处理后台系统常遇到“打印快递单”“导出客户表单”的需求。如果打印走的是window.print()浏览器渲染的是当前 DOM打印预览里出现明文就说明你的 DOM 节点里有明文数据。处理方式是打印前遍历打印区域内的敏感 DOM 节点把文本内容批量替换成脱敏值打印完成后恢复。导出 Excel 的场景更麻烦。多数前端导出方案是“读取表格组件的当前数据”也就是说表格 columns 里如果是脱敏后的138****8000导出文件自然是脱敏的。但如果你用“从接口重新拉全量数据再导出”的方案就可能把明文写进文件里。我自己处理时统一在导出方法里套了一个脱敏过滤函数导出的字段全部走一遍desensitizeField(columns, row)。分享链接和海报是另一个高频泄露路径。比如活动页的分享海报里嵌入了用户邀请码邀请码虽然不是严格意义的 PII个人隐私信息但结合用户手机号就能反查身份。安全起见我认为任何分享类文案里的“身份标识符”都应当做“短态化”脱敏——比如只展示前2位 星号 后1位或者干脆只展示一个随机生成的短码而不是直接分享完整 ID。3. 手写一套可复用的脱敏工具函数上面讲了半天“怎么做”接下来直接上代码。我提供了一套适合直接抄作业的脱敏工具函数覆盖常用字段附带通用规则配置同时也谈一下我在性能和组件化上的取舍。3.1 工具函数设计纯函数、单一职责、可测试我封装的核心理念是“正则优先、纯函数优先”。每个脱敏函数接收value和options返回脱敏后的字符串不依赖任何框架和 DOM这样单元测试写起来非常舒服也可以直接移植到 Node.js 端做服务端兜底。项目里我建立了一个mask.js工具文件const DEFAULT_MASK_CHAR *; function maskValue(value, start, end, maskChar DEFAULT_MASK_CHAR, replaceCount) { if (value null || value undefined) return ; const str String(value); if (str.length start end) { // 字符串太短时退化为全掩码 return str.replace(/./g, maskChar); } const head str.slice(0, start); const tail str.slice(-end); const len replaceCount || (str.length - start - end); const masked maskChar.repeat(len); return ${head}${masked}${tail}; } function maskPhone(phone, { maskChar DEFAULT_MASK_CHAR } {}) { if (!phone) return ; const str String(phone); if (str.length ! 11) return maskValue(str, 3, 4, maskChar); return ${str.slice(0, 3)}${maskChar.repeat(4)}${str.slice(7)}; } function maskIdCard(idCard, { maskChar DEFAULT_MASK_CHAR } {}) { if (!idCard) return ; const str String(idCard); if (str.length ! 18) return maskValue(str, 6, 4, maskChar); return ${str.slice(0, 6)}${maskChar.repeat(8)}${str.slice(14)}; } function maskBankCard(cardNo, { maskChar DEFAULT_MASK_CHAR } {}) { if (!cardNo) return ; const str String(cardNo).replace(/\s/g, ); if (str.length 9) return maskValue(str, 4, 4, maskChar); return ${str.slice(0, 4)} ${maskChar.repeat(4)} ${maskChar.repeat(4)} ${str.slice(-4)}; } function maskEmail(email, { maskChar DEFAULT_MASK_CHAR, visibleHead 1, visibleTail 1 } {}) { if (!email || !email.includes()) return email || ; const [name, domain] email.split(); if (name.length visibleHead visibleTail) { return ${maskChar.repeat(name.length)}${domain}; } const head name.slice(0, visibleHead); const tail name.slice(-visibleTail); return ${head}${maskChar.repeat(name.length - visibleHead - visibleTail)}${tail}${domain}; } function maskName(name, { maskChar DEFAULT_MASK_CHAR } {}) { if (!name) return ; const str String(name); if (str.length 1) return maskChar; if (str.length 2) return ${str[0]}${maskChar}; return ${str[0]}${maskChar.repeat(str.length - 1)}; } function maskAddress(address, { maskChar DEFAULT_MASK_CHAR, keepLevels 2 } {}) { if (!address) return ; const str String(address); const match str.match(/^(.*?[省市区县])(.*)$/); if (!match) return maskValue(str, Math.ceil(str.length / 2), 0, maskChar); if (keepLevels 2) { // 保留到区县后面全部掩码 return ${match[1]}${maskChar.repeat(match[2].length)}; } return ${maskChar.repeat(match[1].length)}${match[2] ? maskChar.repeat(match[2].length) : }; } module.exports { maskValue, maskPhone, maskIdCard, maskBankCard, maskEmail, maskName, maskAddress, };有两点使用心得说下。一是银行卡号我保留了空格和4位分段这是为了可读性。如果不做分段622202****5678用户很容易看错对账时很麻烦。二是邮箱脱敏参数里visibleHead和visibleTail我默认都是1。有隐私洁癖的团队也可以设置成visibleHead: 2, visibleTail: 0用户名的第2个字符也掩码泄露风险更低。但用户体验会略差毕竟用户需要确认“这是我的邮箱”保留太多反而不好认。3.2 敏感信息通用扫描器除了给字段逐个脱敏实际项目还需要处理“接口返回了一段包含了手机号和身份证号的文本”的情况比如“您预留的手机号13812348000与证件号110101199003073333不一致”。我的方案是维护一个“敏感正则列表”在文本渲染前做一次全局替换const sensitivePatterns [ { name: phone, pattern: /(1[3-9]\d)\d{4}(\d{4})/g, replace: $1****$2 }, { name: idCard, pattern: /(\d{6})\d{8}(\d{4})/g, replace: $1********$2 }, { name: bankCard, pattern: /(\d{4})\d{10,12}(\d{4})/g, replace: $1 **** **** $2 }, ]; function desensitizeText(input) { if (typeof input ! string) return input; let result input; for (const item of sensitivePatterns) { result result.replace(item.pattern, item.replace); } return result; }这个扫描器我放在所有接口响应拦截器的出口处。前提是配置里打开autoDesensitizeText: true全局对res.data做一次遍历遇到字符串字段就执行desensitizeText。平时它不会把用户的自定义文本比如备注误伤因为它只匹配手机号和身份证号样式的字符串。性能方面全量遍历接口响应对大列表有影响但实测下来 1000 条数据、每条 20 个字段耗时约 3~5ms在可接受范围。如果列表数据特别大可以只对description、notice、remark这类字符串字段做扫描不做全字段扫描。3.3 组件化与框架接入Vue自定义指令和React Hook工具函数只是“弹药”关键还得接入组件库。Vue 项目里我用自定义指令v-mask来做避免每次手写maskPhone(user.phone)。实现思路是指令的binding.value接收{ type: phone | idCard | email | name | address, options? }在mounted和updated钩子里对el.textContent做替换// v-mask.js import { maskPhone, maskIdCard, maskEmail, maskName, maskAddress } from ./mask; const maskers { phone: maskPhone, idCard: maskIdCard, email: maskEmail, name: maskName, address: maskAddress, }; const vMask { mounted(el, binding) { if (!binding.value) return; const { type, options } binding.value; const masker maskers[type]; if (!masker) return; // 只处理文本节点避免误伤子节点里的按钮等文本 const originalText el.textContent; const maskText masker(originalText, options); el.textContent maskText; el.dataset.originText originalText; el.dataset.masked true; }, updated(el, binding) { if (!binding.value) return; const { type, options } binding.value; const masker maskers[type]; if (!masker) return; const currentText el.textContent; // 如果当前文本已经脱敏了不能二次脱敏 if (el.dataset.masked true) return; const maskText masker(currentText, options); el.textContent maskText; el.dataset.originText currentText; el.dataset.masked true; }, unmounted(el) { // 组件卸载时恢复原始文本避免复用节点时污染 if (el.dataset.originText) { el.textContent el.dataset.originText; } }, }; export default vMask;这里有个细节很容易踩坑如果渲染函数在指令更新前已经把数据换掉了updated里就不能直接拿currentText当原文本再脱敏一次否则138****8000会变成1**80**。所以我设计了一个dataset.masked标记只对未脱敏的原始文本做一次处理。React 项目我通常写一个useDesensitizedData的 Hook对列表数据做一层映射再传入组件渲染import { useMemo } from react; import { maskPhone, maskIdCard, maskEmail, maskName } from ./mask; function useDesensitizedData(data, config {}) { return useMemo(() { if (!Array.isArray(data)) return data; return data.map((item) { const result { ...item }; Object.keys(config).forEach((field) { const type config[field]; const value result[field]; if (value null || value undefined || value ) return; switch (type) { case phone: result[field] maskPhone(value); break; case idCard: result[field] maskIdCard(value); break; case email: result[field] maskEmail(value); break; case name: result[field] maskName(value); break; default: break; } }); return result; }); }, [data, config]); } export default useDesensitizedData;使用示例const maskedList useDesensitizedData(userList, { phone: phone, idCard: idCard, email: email, name: name, });选型建议是Vue 项目优先用自定义指令因为指令是声明式的模板里一眼能看出“这个字段做了脱敏”React 项目优先用 Hook 或高阶组件因为 React 的数据流是单向的在数据层做映射比在渲染层做遍历更可控。如果你用的是组件库的 Table 组件还有一种方案是统一在columns的render里套一层脱敏函数但这种方式维护成本高表格改了列配置还要检查脱敏逻辑有没有跟上。3.4 动态数据的脱敏时机与性能优化脱敏的“时机”很关键。有人喜欢在接口then里做脱敏有人喜欢在渲染时做脱敏。我推荐“数据进入业务层之前脱敏”也就是组件拿到原始数据后、setState/reactive之前就处理好这样渲染层拿到的一定是脱敏后的数据杜绝“渲染一半又跳成明文”的闪烁。性能方面大列表脱敏最大的瓶颈在于String.replace在长字符串上的匹配。比如地址脱敏我见过有的实现用正则/.{2}$/匹配城市名在大列表 10000 条记录时耗时上百毫秒。优化方案有两个对脱敏函数做记忆化memoize同一个原始值只脱敏一次缓存结果把脱敏时机挪到虚拟滚动渲染的“可视区内”用户滚到哪一屏只处理哪一屏的数据用户感知不到处理耗时。我自己的经验是列表超过 500 条就用虚拟滚动 可视区脱敏不要全量脱敏。前端脱敏的目的是“防泄露”不是“防后端”可视区外的数据不渲染也谈不上泄露等滚动到可视区时再脱敏即可性能和安全能取得一个比较好的平衡。4. 实操中的常见问题与排查技巧实录脱敏方案上线后问题不会比功能上线前少。这一节我把碰到的典型问题、排查思路和解决办法整理成一个速查表再挑几个高发问题展开讲。4.1 常见问题速查表问题现象根因解决方案脱敏后的数据在页面刷新后变回明文缓存/状态管理里存的是明文在接口响应拦截器统一处理并检查 localStorage/sessionStorage某个字段脱敏后搜索/筛选失效前端用脱敏后的字段值请求后端脱敏只做展示层请求参数必须用原始数据两套数据分开存分享出去的链接重新打开出现明文分享页用了同一套代码但没启用脱敏开关增加场景配置分享页强制开启脱敏复制表格数据剪贴板里是明文剪贴板写入的是 DOM 原始文本复制事件里拦截并替换成脱敏文本打印预览出现明文打印用的 DOM 节点和展示用 DOM 节点不同打印前遍历目标区域做文本替换脱敏数据被二次脱敏变成1**80**指令的 updated 钩子对已脱敏文本重复处理检查 dataset.masked 标记或改用保障函数email 中后也被打码正则没排除域名部分先 split() 再处理用户名域名不脱敏日志里出现明文手机号前端 utils 日志打印了入参对象日志打印前调用 desensitizeText 过滤或在 console 封装层统一过滤4.2 脱敏后数据不可逆导致的线上问题最典型也最头疼的详情页的“编辑”功能。用户点“编辑”后表单里回显的是脱敏后的138****8000用户不改这个字段直接提交前端把脱敏值传给了后端后端就把客户手机号真的改成了138****8000酿成事故。解决方案是“展示脱敏 提交原始”分开双轨。表单详情页面用脱敏后的数据展示但提交时从详情接口的原始数据快照里取值不依赖输入框的展示文本。具体做法是打开编辑弹窗的同时请求详情接口拿到明文后存到ref/state中表单里的手机号字段展示脱敏值且禁填提交时优先使用快照里的明文如果用户必须修改手机号则走单独的验证码校验流程而不是直接传 input 值。这个坑我至少见三个项目踩过。前端脱敏不能只考虑“怎么把数据藏起来”还得考虑“藏起来之后业务怎么继续”否则脱敏会成为数据更新链路上的定时炸弹。4.3 脱敏规则和后端不一致前端该怎么兜底后端做了一套脱敏前端也做了一套脱敏两边规则一旦不一致前端拿到的就是“已经脱敏过”的数据再用自己的规则去脱敏就会出现1**80**这种恶性结果。这个问题在前后端并行开发时特别容易出现。我给团队定的规矩是前后端各自维护一份脱敏规则声明接口文档里明确标注每个字段的脱敏级别L1明文、L2中间脱敏、L3完全脱敏前端优先信任接口返回的数据如果发现数据已经脱敏通过正则检测是否含*或其他掩码字符就不再做二次处理。关键代码实现function isMasked(value) { return typeof value string /[*#xX]/.test(value); } function safeMask(value, masker) { if (!value || isMasked(value)) return value; return masker(value); }这样即使前后端规则不一致前端也能通过“识别已脱敏”来兜底不会破坏数据。另一个兜底方案是前端给后端传递原始数据时约定所有展示字段用xxxDisplay和xxxOrigin双字段展示字段可能来自前端脱敏或后端脱敏原始字段仅用于提交和操作日志前端永不展示xxxOrigin。4.4 日志、错误上报和第三方的“隐性泄露”前面提过埋点 SDK 自动采集 DOM 文本的问题这里再展开一次因为这是我最想让同行们重视的隐性通道。很多前端团队脱敏做得不错列表页、详情页都很干净但打开浏览器 Console 一看接口响应的 log 里全是明文错误监控平台上报的componentStack、props、state里全是明文客服系统接入的网页里客服看得到当前用户的所有页面数据。排查建议我从三方面展开console 层面不推荐全局重写console.log影响调试但可以在axios拦截器里对“开发环境下的打印日志”做一次脱敏。生产环境的调试日志应直接关闭或者白名单化。错误上报层面接入 Sentry 这类平台时在beforeSend回调里把error对象序列化后走一遍desensitizeText再把脱敏后的内容上报。不要为了方便排查 bug 就把整个 state 原样塞进上报 payload。第三方客服/在线聊天层面如果第三方脚本要采集当前页面信息尽量关闭“全页面自动采集”只上报主动埋点的点击事件。这个属于供应商配置问题需要在接入时确认好隐私条款和采集范围。有个反面教训是我们曾上线一套新的错误监控 SDK本意是捕获前端异常结果它把 VNode 的 props 全都序列化上报了包含列表页所有用户的明文手机号。等发现时已经上报了几天导致客户的隐私数据在企业外部服务器留存。前端做脱敏时这类“非页面直接展示但被第三方间接采集”的泄露路径必须同步排查。5. 落地推动怎么让团队真正执行脱敏规范工具和函数都写了如果团队不执行、不维护这套方案很快就会变成一次性交付的垃圾代码。我总结了几个在团队里推动落地有效的做法分享给大家。第一个做法是把脱敏规则收口成“配置中心化”而不是散落在各个业务代码里。我在项目里维护一个desensitize.config.js配置文件团队的脱敏规则、字段映射、场景开关全部集中在这里。业务组件直接引配置不写死规则后续规则变更只改这一个文件。这样评审代码时看到有人直接replace(/^(\d{3})\d{4}(\d{4})$/, $1****$2)在业务代码里硬编码就能快速揪出来。第二个做法是在 ESLint 或 Code Review 清单里加一条“展示层敏感字段必须走脱敏工具”。具体到实操我给团队加了一个简单脚本扫描代码库中 “手机号”“身份证”“银行卡”相关的column定义如果没有包在desensitize相关函数中就在 MR 时提示告警。规则不复杂但能在代码合并前发现问题比事后线上事故好处理太多。第三个做法是建立“线上泄露自查”机制。前端脱敏做得再好也不代表页面 100% 没有明文。我的自查方法是在后台系统里设置一个只读的“隐私测试账号”和一批测试数据每天定时用 Puppeteer 自动化打开关键页面抓取页面 DOM 文本正则扫描是否出现明文手机号、身份证号、银行卡号。一旦命中立即告警并定位到页面路由。这个方法成本不算高比人工检查可靠得多强烈建议在用户量较大的系统里上。如果你所在项目还没有任何脱敏措施我的建议是先别一上来就搞全套工具库和自动化脚本。先挑一个最高频的数据出口——通常是列表页和详情页的手机号加姓名——用最朴素的replace函数做掉观察一周再逐步扩展字段和场景。脱敏这件事最怕的不是做不完善而是宁可等到事故发生也不愿意开始。根据我自己的经验Web前端脱敏做得好不好本质上是团队有没有把“隐私保护”当成前端工程的一部分来对待。技术难度不算高真正难的是让每个前端同学在写每一行渲染代码时都下意识地问一句这个字段脱敏了吗这篇文章里的工具函数和方案可以随时抄但只有把脱敏意识渗透进开发习惯里数据泄露这种事才能真正远离你。