小程序开发里有个特别不起眼、但几乎每个项目都会撞上的问题从后端接口拿到的富文本内容直接塞进rich-text组件里页面上赫然出现一堆lt;、amp;、nbsp;这样的字符用户看到的是乱码一样的转义符而不是排版好的内容。更麻烦的是有时候反过来——后端存的是已经转义过的 HTML 字符串前端需要还原成正常标签再渲染或者需要把用户输入的纯文本做一次转义防止注入。这类转义符和标签之间来回倒腾的活儿散落在各个页面里写正则很快就会变成一坨没人敢动的祖传代码。wxDiscode这个工具类就是冲着这个痛点来的把小程序场景下常见的 HTML 标签转义、特殊字符还原、实体编码解码这些零碎操作收拢到一个统一的工具模块里让调用方只关心我要转成什么而不是我该写几条正则。这篇内容适合两类人看一类是刚接触小程序、被rich-text的转义问题卡住的新手另一类是手里已经有一堆散乱字符串处理函数、想整理成规范工具类的老手。我会从实际遇到的场景切入把转义这件事的来龙去脉、工具类的设计取舍、每个方法背后的正则逻辑、以及实测中踩过的坑一条条摊开讲。文中涉及的具体实现是基于常见实践补全的合理方案你可以直接照着改也可以只挑其中几个方法用。1. 先搞清楚小程序里到底在转义什么很多人一上来就搜小程序转义 html 标签拿到一段正则就贴进代码结果发现有的场景管用、有的场景不管用。问题出在没分清转义这个词在小程序里其实覆盖了三件不同的事它们的处理方向、使用时机、出错后果都不一样。先把这三件事拆开后面工具类的每个方法才有落脚点。1.1 三种容易混淆的转义场景第一种是HTML 实体编码encode。典型场景是用户在小程序里输入了一段带尖括号的文本比如用户反馈里写了script你要把这段内容存库或者回显到页面上就必须把变成lt;、变成gt;、变成amp;。这一步的目的是让特殊字符失去语法意义防止它们被当成标签解析。注意必须最先处理否则后面把转成lt;时新产生的会被二次转义成amp;lt;这是最经典的顺序坑。第二种是HTML 实体解码decode。后端数据库里存的富文本经常是经过一次转义的字符串形如lt;pgt;内容lt;/pgt;。你要把它还原成p内容/p才能交给rich-text的nodes属性去渲染。解码的方向和编码完全相反处理顺序也要反过来先处理lt;、gt;这类具名实体最后再处理amp;否则amp;lt;会被先解成lt;再解成虽然结果碰巧对但逻辑上是错的遇到amp;amp;这种多重转义就会翻车。第三种是标签剥离strip。有些场景你根本不需要保留任何标签比如列表页的摘要展示只要纯文字。这时候要把p、br、img这些标签整个删掉同时把nbsp;还原成空格、lt;还原成。这一步严格说不叫转义但实际开发中经常和转义混在一起做工具类里也得给它留个位置。下面这张表把三种场景的关键差异列清楚后面设计方法名的时候直接对照着来场景输入示例输出示例核心目的处理顺序要点实体编码 encodeb粗体/blt;bgt;粗体lt;/bgt;让字符失去标签语义必须最先替换实体解码 decodelt;pgt;内容lt;/pgt;p内容/p还原成可渲染标签amp;必须最后替换标签剥离 stripp你好nbsp;世界/p你好 世界只留纯文本先删标签再解实体提示判断该用哪个方法先问自己一句这段字符串最终要交给谁。交给rich-text渲染就 decode交给text组件或存库就 encode 或 strip交给input的 value 就什么都不用做。1.2 为什么小程序比普通 H5 更容易踩这个坑在普通浏览器里你把字符串塞进innerHTML浏览器会帮你做一部分容错解析转义符和标签混着来往往也能凑合显示。小程序的rich-text组件不一样它的nodes属性接受的是字符串或节点数组字符串模式下对 HTML 的解析相对严格实体编码没解干净的字符串会原样显示成lt;这样的字面量。而且小程序没有document、没有innerHTML你没法像 H5 那样借浏览器的解析能力兜底所有转义逻辑必须自己在 JS 层做干净。另一个差异是数据来源。小程序的富文本内容大多来自后端 CMSCMS 编辑器尤其是老版本的富文本编辑器在保存时习惯性做一次实体编码导致前端拿到的字符串是转义过的 HTML。如果你直接丢给rich-text看到的就是一堆lt;。这个链路问题在 H5 里也存在但 H5 开发者往往有现成的库比如某些富文本渲染库帮忙处理小程序生态里这类库要么体积大、要么不维护自己写一个轻量工具类反而是最稳的选择。1.3 工具类要覆盖的最小方法集基于上面三种场景wxDiscode这类工具类的方法集其实可以收敛得很清楚不需要贪多。我实际用下来下面这几个方法是真正会被反复调用的encode(str)把特殊字符转成 HTML 实体用于存库、回显、防注入。decode(str)把 HTML 实体还原成字符用于rich-text渲染前的预处理。stripTags(str)删掉所有标签保留纯文本。stripAndDecode(str)先删标签再解实体一步到位拿纯文本。escapeRegExp(str)转义正则元字符这个不是 HTML 转义但字符串处理工具类里几乎必备后面会讲为什么。方法名不要起得太花哨encode/decode这种直白的命名在团队协作里最省沟通成本。有人喜欢叫htmlEncode、htmlDecode也行关键是全项目统一别一半叫encode一半叫htmlEscape那还不如不封装。2. 每个方法背后的正则到底在干什么工具类的价值不在于把正则藏起来而在于把正则写对并且解释清楚。这一节把每个方法的核心实现拆开重点讲清楚每条正则为什么这么写、换个写法会出什么问题。所有实现都是普通 JS不依赖任何小程序特有 API所以你在 H5、Node 脚本里也能直接复用。2.1 encode顺序错了就是灾难先看编码方法。核心思路是把、、、、这几个字符替换成对应的实体。看起来简单但替换顺序是唯一的正确解function encode(str) { if (typeof str ! string) return ; return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }为什么必须第一个替换假设输入是如果你先替换得到lt;然后再替换这个新生成的会被替换成amp;最终变成amp;lt;解码回来是lt;而不是数据就坏了。把放最前面它只处理原始字符串里本来就有的后面替换产生的不会再被碰。这个顺序问题我在两个项目里都见过有人写反表现是转义一次正常转义两次内容就多出一层amp;排查起来很费时间。单引号用#39;而不是apos;是因为apos;在老版本浏览器和一些解析器里支持不好#39;是数字实体兼容性最广。双引号用quot;是标准写法。如果你的场景只关心标签安全、不关心属性值里的引号其实只转、、三个也够用但既然封装成工具类五个一起转更省心免得哪天字符串被拼进属性里出问题。2.2 decode具名实体和数字实体要分开处理解码比编码麻烦因为实体有两种形态具名实体lt;、nbsp;、quot;和数字实体#39;、#x27;。数字实体又分十进制和十六进制。一个完整的 decode 要能覆盖这几种function decode(str) { if (typeof str ! string) return ; return str .replace(/lt;/g, ) .replace(/gt;/g, ) .replace(/quot;/g, ) .replace(/#39;/g, ) .replace(/apos;/g, ) .replace(/nbsp;/g, ) .replace(/#(\d);/g, (m, code) String.fromCharCode(Number(code))) .replace(/#x([0-9a-fA-F]);/g, (m, hex) String.fromCharCode(parseInt(hex, 16))) .replace(/amp;/g, ); }这里有两个关键点。第一amp;必须放在最后替换。原因和 encode 对称如果先解amp;那么amp;lt;会先变成lt;再被后面的lt;规则解成看起来多解了一层遇到amp;amp;这种双重转义就会解过头。把amp;放最后保证它只还原最外层的那一层语义上更准确。第二数字实体的处理用回调函数而不是字符串替换。String.fromCharCode能把码点转回字符#20013;会还原成中#x4e2d;也一样。这里有个边界要注意fromCharCode只处理 BMP 范围内的码点0 到 65535遇到 emoji 这种超出范围的字符比如#128512;会得到乱码。如果你的内容里可能有 emoji 实体得换成String.fromCodePoint并且正则要能匹配多位数字。这个细节大多数网上抄来的 decode 都没处理实测中带 emoji 的富文本会显示成问号方块。2.3 stripTags别用正则硬啃嵌套标签剥离标签最直觉的写法是str.replace(/[^]/g, )这条正则能干掉绝大多数简单标签但它有两个已知缺陷一是遇到属性值里带的标签会截断错误比如img altab会被错误匹配二是遇到script里的内容不会被删掉只删了标签本身。对于小程序场景rich-text本身不支持script所以第二个问题影响不大但第一个问题在富文本里很常见——图片的alt或title属性里带尖括号的情况虽然少但不是没有。更稳的写法是分两步先把script和style整块含内容删掉再删剩余标签function stripTags(str) { if (typeof str ! string) return ; return str .replace(/script[\s\S]*?\/script/gi, ) .replace(/style[\s\S]*?\/style/gi, ) .replace(/[^]*/g, ); }[\s\S]*?里的?是非贪婪匹配保证匹配到最近的/script就停不会把后面正常内容一起吞掉。gi标志让匹配忽略大小写并全局替换。删完标签后字符串里可能还残留nbsp;这类实体所以实际使用中stripTags后面通常还要接一次decode这就是下一个方法存在的原因。注意正则剥离 HTML 永远不是 100% 可靠的方案遇到极端畸形 HTML 仍可能出错。如果你的场景对安全性要求高比如要防 XSS正确做法是白名单过滤而不是正则删除工具类里的 strip 只适合展示摘要这种低风险场景。2.4 stripAndDecode组合方法的顺序不能反摘要展示是最常见的组合场景既要删标签又要把nbsp;还原成空格。组合顺序必须是先 strip 再 decode反过来会出问题function stripAndDecode(str) { return decode(stripTags(str)); }如果先 decode 再 strip那么原本被转义的lt;scriptgt;会先还原成script然后被 stripTags 当成真标签删掉——这看起来更安全但实际上会把用户本来想展示的文本内容也删了。比如用户输入比较 ab 的情况先 decode 变成比较 ab 的情况再 strip 时b 的情况可能被误判成标签片段删掉内容就丢了。先 strip 再 decodelt;在 strip 阶段还是实体形态不会被当成标签decode 之后安全地还原成字符内容完整保留。这个顺序差异在实测中非常明显建议在方法注释里写死先 strip 后 decode。2.5 escapeRegExp字符串工具类的隐藏刚需这个方法严格说不属于 HTML 转义但凡是做字符串处理的工具类早晚会遇到用户输入的内容要当正则用的场景比如搜索高亮。如果不转义元字符用户搜a.b会把axb也匹配上function escapeRegExp(str) { if (typeof str ! string) return ; return str.replace(/[.*?^${}()|[\]\\]/g, \\$); }字符类里的\\]和\\顺序有讲究]要放在靠前位置避免被当成字符类结束符\\放最后。$是替换模式里的整个匹配内容前面加\\就是给每个元字符加反斜杠。这个方法在搜索框实时高亮的场景里几乎是标配顺手放进工具类不亏。3. 在小程序里怎么组织这个工具类方法写对了只是第一步怎么放进小程序项目、怎么导出、怎么在页面里引用决定了这个工具类是真能用还是写完就忘。小程序有自己的模块规范和 Node 的 CommonJS 基本一致但有几个细节容易踩。3.1 文件放哪、怎么导出推荐放在utils/目录下文件名就叫discode.js和标题里的wxDiscode对应。导出方式用module.exports这是小程序原生支持的写法不需要任何构建工具// utils/discode.js const discode { encode, decode, stripTags, stripAndDecode, escapeRegExp }; module.exports discode;有人喜欢用export default那需要项目开了 ES 模块转译纯原生小程序项目里直接用module.exports最省事。导入的时候const discode require(../../utils/discode.js);路径用相对路径别用绝对路径小程序对绝对路径的支持在不同基础库版本上表现不一致。如果你的项目用了 TypeScript 或者开了useExtendedLib导入方式可能要调整但module.exports这套在绝大多数项目里都能跑通。3.2 挂到全局还是按需引入一个常见的纠结是要不要把工具类挂到app.globalData或者getApp()上让所有页面直接取。我的建议是按需 require不要挂全局。原因有三一是小程序每个页面是独立模块require有缓存重复引入不会重复执行性能上没有负担二是挂全局会让依赖关系变模糊看代码时不知道某个方法从哪来三是全局对象在页面销毁时不会清理容易造成隐性内存占用。按需引入虽然多写一行require但换来的是清晰的依赖和更好的可维护性。如果确实嫌每个页面写require麻烦可以在app.js里做一次统一导出但本质上还是模块引用只是换了个入口不推荐把方法直接挂到Page原型上那会污染所有页面实例。3.3 在 rich-text 渲染链路里的接入位置工具类真正发挥作用的地方是数据流的关键节点。以富文本详情页为例典型链路是接口返回字符串 → 预处理 → 塞进rich-text。预处理这一步就是decode的接入点Page({ data: { content: }, onLoad(options) { wx.request({ url: https://example.com/api/detail, success: (res) { const raw res.data.content || ; this.setData({ content: discode.decode(raw) }); } }); } });对应的 WXMLrich-text nodes{{content}}/rich-text这里有个实测细节rich-text的nodes属性如果传字符串基础库会按 HTML 解析如果传数组则按节点对象解析。传字符串时decode后的内容里如果还有未闭合的标签渲染可能异常。所以decode之后最好再做一次简单的标签闭合检查或者干脆让后端保证 HTML 是闭合的。我在一个项目里遇到过后端返回的p没闭合rich-text把后面所有内容都吞进段落里样式全乱最后是在decode后加了一步补全自闭合标签才解决。3.4 和 wxs 的配合边界小程序有个wxsWXS 脚本可以在 WXML 里直接做简单运算有人会想能不能把转义逻辑写进 wxs省得在 JS 里处理。答案是简单替换可以复杂正则不建议。wxs支持的正则能力有限String.fromCharCode这类方法也不一定可用而且wxs里写复杂逻辑调试很痛苦。我的实践是数据预处理放在 JS 层用工具类做wxs 只做展示层的简单格式化比如截断、拼接。把decode这种带回调的正则替换塞进 wxs大概率会在某个基础库版本上翻车得不偿失。4. 实测中踩过的坑和排查链路工具类写完之后真正的考验才开始。下面这几个问题都是我在实际项目里遇到过的每个都附上完整的排查过程你可以对照自己的场景看有没有中招。4.1 转义两次导致内容多出一层 amp;现象详情页的富文本里本该显示价格 100的地方显示成了价格 100而且每次保存再打开amp;的层数还会增加。排查链路先看接口返回的原始字符串发现是amp;lt;。这说明数据在某个环节被 encode 了两次。顺着数据流往回找用户编辑时前端在提交前调了一次encode后端收到后又做了一次实体编码存库前端展示时decode只解了一层所以剩下lt;。层数递增是因为每次编辑保存都多 encode 一次。修复方案明确转义只在一处做的原则。要么前端提交前 encode、后端原样存要么前端提交原文、后端统一 encode。我们最终选的是后者前端提交前不做 encode后端存库时统一处理前端展示时decode一次。同时给decode加了个最多解两层的保护防止历史脏数据解不干净function decodeSafe(str, maxDepth 2) { let result str; for (let i 0; i maxDepth; i) { const next decode(result); if (next result) break; result next; } return result; }这个decodeSafe后来成了工具类里用得最多的方法因为历史数据里双重转义的情况太常见了。4.2 emoji 实体解码成乱码方块现象带 emoji 的评论内容在rich-text里显示成□。排查链路抓原始字符串发现 emoji 被存成了#128512;这种十进制实体。用String.fromCharCode(128512)测试返回的是乱码。查资料确认fromCharCode只支持 0 到 65535超出范围要用fromCodePoint。修复方案把数字实体的替换回调改成.replace(/#(\d);/g, (m, code) { const n Number(code); return n 0xffff ? String.fromCodePoint(n) : String.fromCharCode(n); })十六进制那条同理。改完之后 emoji 正常显示。这个坑的教训是网上抄来的 decode 实现大多没考虑 emoji如果你的产品有社交属性一定要测。4.3 stripTags 把正常内容误删现象用户反馈里写ab 且 bc摘要展示时变成了a。排查链路stripTags的正则[^]*把b 且 b当成了一个标签删掉了。因为[^]*会一直匹配到第一个中间的b 且 b被当成标签名和属性。修复方案这个问题的本质是纯文本里的尖括号和标签无法用正则区分。对于用户输入的纯文本正确做法是先 encode 再 strip或者干脆不 strip、直接 encode 后展示。我们最终在摘要场景改成如果内容来源是用户输入先encode再stripTags这样变成lt;就不会被误判如果来源是后端富文本才用stripAndDecode。这个区分后来写进了工具类的注释里避免新人乱用。4.4 rich-text 里样式丢失现象decode之后标签正常了但rich-text渲染出来的内容没有加粗、没有颜色。排查链路rich-text对标签和样式的支持是有限的白名单style标签、class属性、部分 CSS 属性都不支持。后端返回的富文本里用了class控制样式rich-text直接忽略。修复方案这个不是工具类能解决的属于rich-text的能力边界。要么让后端把样式内联成style属性rich-text支持部分内联样式要么用nodes数组模式手动构造节点。工具类这边能做的是在decode后加一个样式内联化的预处理但实现复杂我们最终是推动后端改输出格式。这个坑提醒我们工具类解决的是字符层面的问题渲染层面的限制得从数据源头解决。4.5 排查这类问题的通用思路踩了这么多坑我总结出一套排查转义问题的固定动作遇到类似问题按这个顺序走基本能定位打印原始字符串在decode前后各打一次console.log看清楚输入输出到底是什么别靠猜。数amp;的层数amp;lt;是两层lt;是一层层数直接告诉你被 encode 了几次。确认数据来源是用户输入、后端接口还是本地缓存不同来源的转义策略不同。隔离测试把出问题的字符串单独拿出来在控制台里跑一遍encode/decode看是否符合预期。检查渲染组件确认是rich-text还是text两者的转义要求完全不同。这套流程看起来笨但比漫无目的地改正则高效得多。转义问题的特点是现象在渲染层根因在数据层盯着渲染层改往往越改越乱。5. 几个让工具类更耐用的设计取舍工具类写出来容易用两年不烂很难。这一节聊几个我在维护过程中做的取舍都是踩过坑之后才想明白的。5.1 输入类型防御要不要做每个方法开头都有一句if (typeof str ! string) return ;有人觉得多余接口返回的肯定是字符串。但实际项目里undefined、null、数字、甚至对象被传进来的情况太常见了——后端字段缺失返回nulldecode(null)直接抛Cannot read property replace of null整个页面白屏。加一行类型判断的成本几乎为零收益是避免一类低级崩溃。返回空字符串而不是原值是因为调用方通常期望拿到字符串做后续拼接返回null反而要再判一次。5.2 要不要支持配置项有人会想给encode加个配置比如只转尖括号不转引号。我的建议是不加。工具类的价值在于行为确定加了配置项之后每个调用点都要想我该传什么配置反而增加了心智负担。如果真有特殊需求单独写一个方法比如encodeAngleOnly名字自解释比配置项清晰。小程序项目普遍不大方法数量多一点不是问题行为不确定才是问题。5.3 性能正则预编译有没有必要replace里的正则字面量在每次调用时会不会重新编译现代 JS 引擎对字面量正则有缓存优化同一个函数里反复调用的字面量正则不会每次重新编译。所以没必要把正则提取成模块级常量除非你在一个循环里调用几十万次。小程序场景下一次decode处理几 KB 的富文本性能完全不是瓶颈。我实测过一个 50KB 的富文本字符串decode耗时在 1ms 以内用户完全无感。与其纠结正则预编译不如关注别在setData里对大字符串做多次转义那才是真正的性能杀手。5.4 单元测试怎么写工具类最适合写单元测试因为输入输出明确。小程序项目里可以用miniprogram-simulate或者干脆在 Node 环境里跑因为工具类不依赖小程序 API。测试用例覆盖这几类就够用例类型输入期望输出基本编码blt;bgt;双重编码lt;amp;lt;基本解码lt;pgt;p数字实体#20013;中emoji 实体#128512;对应 emoji空值防御null空字符串标签剥离pa/pa组合场景panbsp;b/pa b把这些用例写成断言每次改工具类跑一遍能挡住 90% 的回归问题。特别是顺序、emoji、空值这三类最容易在重构时被改坏。5.5 文档注释比代码本身重要工具类的方法名再直白也挡不住半年后有人忘了decode 为什么要放最后解amp;。每个方法上面写清楚三件事这个方法解决什么问题、输入输出是什么、有什么顺序或边界上的坑。比如decode的注释里明确写amp;必须最后替换否则多重转义会解过头stripAndDecode的注释写必须先 strip 后 decode顺序反了会误删内容。这些注释看起来啰嗦但它们是工具类能长期被正确使用的关键。我见过太多项目工具类本身写得没问题但因为没人知道正确用法被调用方用错最后背锅的还是工具类。6. 从工具类延伸到更完整的字符串处理层wxDiscode这类工具类解决的是转义这一个点但小程序项目里字符串处理的坑远不止这些。如果你的项目已经积累了不少零散的字符串处理函数可以借这次封装的机会把它们整理成一个更完整的字符串处理层。这一节聊聊怎么扩展以及哪些东西不该往里塞。6.1 可以顺手收进来的方法除了转义相关的五个方法下面这些也是高频需求适合放进同一个工具文件或者相邻文件truncate(str, len, suffix)按字符数截断注意中文和 emoji 的字符数计算str.length对 emoji 不准要用Array.from(str).length。stripHtmlAndSpace(str)删标签、解实体、压缩连续空白摘要场景一步到位。safeJsonParse(str, fallback)JSON.parse的防御版解析失败返回兜底值接口返回脏数据时很有用。formatNumber(num)数字千分位展示价格、统计数时常用。这些方法和小程序无关纯 JS 实现放在utils/下按功能分文件即可。关键是别把所有东西塞进一个文件discode.js只管转义string.js管通用字符串处理职责清晰找起来快。6.2 哪些东西不该塞进来有两类东西我建议不要放进字符串工具类。第一类是业务相关的格式化比如把订单状态码转成中文描述这属于业务逻辑应该放在业务层或者单独的常量映射文件里塞进工具类会让工具类越来越臃肿最后变成什么都往里放的垃圾桶。第二类是依赖小程序 API 的方法比如获取剪贴板内容并转义这类方法依赖wx.getClipboardData放进纯函数工具类里会破坏它的可测试性。工具类保持纯函数、无副作用、不依赖运行环境这三条才能在任何地方复用、在任何环境测试。6.3 版本演进时的兼容策略工具类一旦被多个页面引用改方法签名就要谨慎。我的做法是只加方法不改已有方法的签名和默认行为。如果确实要改行为新增一个方法名旧方法标记废弃但保留至少一个版本周期。比如前面提到的decodeSafe就是新增的没有动原来的decode。这样老页面不受影响新页面用新方法迁移可以慢慢来。小程序项目通常没有严格的版本管理但工具类作为公共依赖兼容性意识必须有否则一次顺手优化可能让十几个页面同时出问题。6.4 一个真实的演进路径我参与过的一个项目字符串处理最初是散落在各个页面里的光删标签就有三种不同写法有的用正则、有的用split、有的调后端接口让后端返回纯文本。后来统一收拢成工具类第一步只做了encode/decode/stripTags三个方法覆盖 80% 的场景第二步加了stripAndDecode和decodeSafe解决组合场景和脏数据第三步把搜索高亮用的escapeRegExp也收进来。整个过程持续了大概两个月每次都是遇到新场景 → 确认是通用需求 → 加方法 → 补测试 → 替换旧代码。没有一次性大重构风险可控团队接受度也高。这个路径我觉得比一开始就设计一个大而全的工具库更实际因为需求是逐步暴露的过早设计容易过度工程。转义这件事说到底是个细节决定成败的活儿。一个的替换顺序、一个fromCharCode和fromCodePoint的选择、一次 strip 和 decode 的先后单独看都是小问题但组合在实际数据流里就能让页面白屏或者内容错乱。把这些问题收拢到一个行为确定、注释清晰、有测试兜底的工具类里比在每个页面里重复踩坑要划算得多。我现在接手新项目第一件事就是看utils/目录里有没有这样一个字符串处理工具有的话说明前人踩过坑并且总结过没有的话大概率过不了多久就得自己补一个。