资讯动态

uni-app 富文本渲染实战:rich-text 安全清洗与跨端适配方案

发布时间:2026/10/14 14:39:36 来源:尧图企业网站定制
最近在uni-app项目里处理富文本展示和rich-text组件死磕了几天里面涉及编辑器选型、数据清洗、跨端适配、图片预览、XSS防护这些一连串问题。这篇就把我从调研到落地的完整过程、踩过的坑、最终跑通的方案都整理出来给正在做uniapp富文本需求的朋友一份可直接参考的操作手册。先说结论uni-app的rich-text是个轻量级富文本渲染组件适合做“展示型”富文本不建议拿它做编辑。如果你的业务只是把服务端返回的富文本内容渲染到页面上比如公告、协议、资讯正文、商品详情它够用而且性能好、跨端一致性强。但如果你需要富文本编辑能力那要另配编辑器如果富文本里有视频、表格、复杂交互你大概率需要换mp-html。1. 内容整体设计与思路拆解1.1 为什么选 rich-text 而不是 web-view 或 mp-html接手这个需求时组里对方案是有过分歧的。有同事提出直接用web-view加载一个H5页面理由是H5生态成熟什么都能做图片点击放大、视频播放、样式控制都很方便。我当时第一个否掉这个方案原因很实际web-view在App端和微信小程序端的体验割裂严重Android上web-view和原生页面之间的跳转、传参、分享都受限小程序里web-view要求业务域名备案很多客户没有这个东西而且web-view的页面加载速度比原生渲染慢得多。打个比方你要是只需要在墙上挂一幅画不必为此建一个展厅。再看mp-html功能确实强大支持视频、表格、自定义标签、代码高亮背后还有一套插件机制。但引入它也意味着要处理它的依赖体积、样式侵入和版本兼容问题。对于纯展示型富文本rich-text已经覆盖了80%的场景而且它是uni-app内置组件不需要额外安装依赖编译到微信小程序、App、H5三端行为一致渲染性能接近原生Text层级远比web-view强。1.2 核心决策两端分离展示是展示编辑是编辑需求分析阶段有个关键判断——富文本展示和富文本编辑必须拆开做。很多产品经理会理所当然认为“你既然能显示就应该能编辑”但技术上这是两码事。rich-text只负责渲染没有光标控制、没有选区、没有内容生成能力编辑器则需要维护一个独立的文档模型。强行在rich-text上做编辑相当于拿一个投影仪去当笔记本电脑用方向就错了。我们项目最终的技术矩阵是业务场景技术选型说明富文本详情展示rich-textnodes数组覆盖基础标签渲染性能最优富文本后台编辑独立富文本编辑器如UEditor、wangEditor生成HTML存储到服务端复杂富文本渲染视频/表格等mp-html作为备选方案仅对特定页面启用图片预览rich-text点击事件 uni.previewImage组件本身不处理图片点击这个矩阵的核心是编辑器产生的数据是HTML字符串落到数据库前端请求到数据后先做安全清洗再转成rich-text能识别的nodes结构进行渲染。编辑和展示彻底解耦安全性、可维护性都更好。1.3 方案对比为什么抛弃 iframe 和模板字符串渲染群里讨论时也有人提过自己用rich-text :nodeshtmlString直接塞HTML字符串这也是一种做法。但实测有几个问题一是rich-text的nodes属性直接传HTML字符串时对部分标签和属性的支持不稳定尤其是小程序平台二是字符串形式的HTML没有结构化校验里面带上script标签也不会被过滤存在XSS注入风险三是样式控制困难字符串里的style和class在一些端上会被吞掉导致排版错乱。所以我的方案是服务端存储HTML字符串前端通过htmlparser类工具解析成nodes数组再传给rich-text。这样既保留了HTML字符串的通用性又通过解析层做了白名单过滤结构清晰、渲染稳定。2. 富文本的安全清洗这项不做好迟早踩坑2.1 XSS风险富文本的隐形炸弹富文本是前端最容易出安全问题的地方之一。原理很简单如果你的后端直接接收编辑器提交的HTML原样存库前端原样渲染那攻击者在富文本里插入一段scriptalert(document.cookie)/script提交后所有看到这条内容的用户都会执行这段脚本轻则弹窗骚扰重则窃取cookie、跳转钓鱼页面。在uniapp的rich-text里script标签默认不执行但img onerror...这类事件属性在某些H5端的处理上就不是那么保险了。而且更重要的是你还要考虑数据的二次消费——同一份富文本数据可能用在App端、小程序端、H5端甚至可能是后台管理系统里的预览。安全策略必须前置到数据进入存储之前。2.2 服务端白名单清洗的具体实操我们的做法分为两层缺一不可第一层是服务端清洗。服务端接收到编辑器提交的HTML后用Java/PHP/Python的HTML解析库做白名单过滤。核心逻辑是只保留白名单内的标签和属性其余全部剥离。白名单长这样允许的标签p, br, strong, b, em, i, u, a, img, ul, ol, li, blockquote, h1, h2, h3, h4, h5, h6, span, div, table, tbody, tr, td, th, hr 允许的属性a允许hrefimg允许src和alt其余标签只允许style和class同时要对href做协议过滤只允许http://、https://、mailto:开头的链接否则一律丢弃。style属性要做二次校验只放行CSS白名单内的属性比如color、font-size、text-align、background-color、width、height。第二层是前端兜底。即使服务端做了白名单前端也不能完全信任数据源。我们前端在拿到HTML字符串后用了一个基于htmlparser2轻量化封装的自研解析函数把HTML转成rich-text的nodes数组转换过程中同样过一遍白名单。这一步主要起到兜底作用防止服务端清洗规则有遗漏或者数据被绕过服务端直接写入。清洗前后对比一个真实数据片段清洗前p onclicksteal() stylecolor:red; background:url(javascript:xss)正常内容/pimg srcx.png onerroralert(1)scriptalert(2)/script清洗后p stylecolor:red正常内容/pimg srcx.png alt2.3 CSP与安全响应头别只盯着数据层在H5端部署时除了数据清洗还应该在nginx层增加CSP响应头。业界建议的配置是Content-Security-Policy: default-src self; script-src self; img-src self data: https:; style-src self unsafe-inline其中script-src self的意思是只允许同源脚本执行内联脚本和外域脚本一律拒绝。这个策略能有效缓解富文本被注入后script标签如果是字符串渲染到H5的WebView中带来的风险。注意unsafe-inline要谨慎使用如果你对样式比较在意可以放行内联样式但不要放行内联脚本。2.4 为什么必须前后端双重校验说说我的理解有些初学者会疑惑既然后端已经清洗了前端为什么还要再做一次我的答案是你永远无法保证数据从后端到前端的链路是完全可信的。比如后台运营人员通过某个接口直接写入了一条数据走了另一个没有清洗逻辑的上游系统再比如数据被第三方接口同步来源不可控。前端兜底清洗的意义不是重复劳动而是让前端展示永远运行在一条安全的渲染路径上。这就好比银行转账风控系统查一遍柜台人工再核一遍不会有人嫌多。3. rich-text 核心配置与三端适配实战3.1 nodes 参数数组比字符串更可靠rich-text的nodes参数可以传HTML字符串也可以传一个数组。实测下来我更推荐传数组。原因有两点第一数组是结构化的type为node时内部用tag、attrs、children描述节点type为text时用text字段描述文本。结构清晰不存在字符串解析歧义。第二数组形态天然支持递归方便你对节点做遍历处理。比如遇到img标签时统一加上lazy-load属性或者统计富文本中图片数量用于后续的点击预览。一个典型的nodes结构如下[ { name: div, attrs: { class: content-box, style: font-size:16px; }, children: [ { type: text, text: 这是段落文字 }, { name: img, attrs: { src: https://example.com/1.png, style: width:100%;height:auto; } } ] } ]注意rich-text内部用的是name属性部分旧文档中写作tag这是个小坑。如果直接照搬某些博客里的示例用tag写在部分端上会出现渲染空白实际查半天才发现是字段名不对。3.2 标签支持范围提前理清避免返工官方文档列出的支持标签包括a、abbr、b、blockquote、br、code、col、colgroup、dd、del、div、dl、dt、em、fieldset、h1-h6、hr、i、img、ins、label、legend、li、ol、p、q、span、strong、sub、sup、table、tbody、td、tfoot、th、thead、tr、ul。关于标签支持实际中还遇到这些边界情况table标签在App端微信小程序端渲染正常但在某些Android WebView里会出现边框丢失需要自己补border样式或者用mp-html方案替换。video标签rich-text在微信小程序端不直接支持需要自定义cover-view方案或者用mp-html。这是我这个项目展示正文里没有视频需求的原因有需求的谨慎选型。svg标签支持极差如果编辑器后台需要粘贴svg图形建议在服务端清洗时自动转换成img标签。class属性在跨端时不会自动引入对应的css你需要自己在页面的style中定义这些类否则可能出现“电脑上好的手机上排版乱”的情况。我在项目里维护了一个测试页把所有支持的标签按表渲染一遍截图存档。每次升级HBuilderX版本或改编译配置都先跑一次这个测试页。强烈建议你也这样做别信“文档说支持就支持”这种话富文本的坑大多藏在环境差异里。3.3 图片适配与点击预览两个必须解决的问题rich-text渲染出来的img默认行为是等比例缩放宽度撑满容器高度自适应。但有两个现实问题第一个是图片的style属性里可能带了固定宽度和高度比如width600 height400在小屏手机上就会溢出。处理思路是在解析图片节点时强制追加max-width:100%;height:auto;样式。如果图片本身有height属性解析时直接移除height避免被锁定。具体代码如下function formatImgAttrs(attrs) { const style attrs.style || const newStyle style.includes(width) ? style : ${style};max-width:100%; return { ...attrs, style: ${newStyle};height:auto; } }第二个问题是图片点击预览。rich-text本身没有暴露图片点击事件小程序端你可以用rich-text外面套一层cover-view实现但不够通用。更通用的做法是解析nodes时把所有img的src提取到一个数组同时给每个图片节点外层包一层view如果本身是块级图片或者利用click事件对富文本整体进行点击区域判断根据点击的坐标和当前nodes里的图片位置做hit-test。实际操作中效率最高的做法是在rich-text外层包裹view绑定点击事件在事件回调中做坐标判断onTap(e) { const { pageX, pageY } e.detail const index this.hitTestImage(pageX, pageY) if (index -1) { uni.previewImage({ current: this.imgList[index], urls: this.imgList }) } }hitTestImage可以用uni.createSelectorQuery()拿到图片节点的实际渲染坐标和大小但这个方案在某些端上遍历节点较多时性能一般。另一个更偷懒的方案解析nodes时给所有图片的src收集起来同时用一个独立的透明浮层盖在图片上通过浮层的点击事件来触发预览。这个方案不优雅但胜在稳定而且不用计算坐标。各有利弊我在项目里最终用的是createSelectorQuery()遍历的方案实测在30张以内的图片列表体验没有明显卡顿。3.4 样式统一的三板斧富文本在不同端里样式不一致几乎是必然的。原因很简单H5端走浏览器样式小程序端走小程序组件样式App端可能是WebView渲染也可能是原生渲染。三板斧分别是第一板斧容器级别统一基准样式。在rich-text的外层view上加上font-size、line-height、color让整体基调先统一。第二板斧对style属性做规范处理。解析时不直接信任编辑器传过来的style字符串而是叠加我们的基础样式。例如编辑器传font-size: 20px在小屏幕手机上会被原样呈现我们选择在解析层做样式覆盖比如统一限制最大字号为22px超过就压回。第三板斧利用CSS变量。在启用了scoped的组件里rich-text内部的类名可能被组件样式隔离影响不到这时候需要用到::v-deep或:deep选择器来深度穿透:deep(.rich-text-container img) { max-width: 100%; }注意微信小程序端对:deep的支持有限所以尽量让图片样式直接写在内联style里不要依赖外部样式覆盖。3.5 与 uniapp-xUniApp X的兼容性这阵子刚把一部分业务迁移到uni-app xuniAppX上踩了一脚“看上去可以用实际要重新测”的坑。rich-text在uni-app x里不是原生的rich-text标签而是需要通过rich-text映射到原生Text组件支持范围有变化。最明显的是在uni-app x中nodes只支持数组不再支持HTML字符串table标签渲染直接失效。所以如果你的项目有向uni-app x迁移的规划建议从一开始就把富文本组件抽成一个独立模块内部封装rich-text或者mp-html后续迁移时只需要改模块内部实现不需要改业务页面。迁移前把富文本测试页在uni-app x编译环境里重新跑一遍能省下大量线上排查时间。4. 富文本编辑器与数据链路打通4.1 编辑和展示分离数据格式怎么定前端的展示组件选定了后台编辑器的产出格式也需要统一。我们用的是后台管理系统里的富文本编辑器默认输出HTML字符串存储字段是TEXT类型。前后端约定编辑器提交的HTML必须经过两步处理后再入库。第一步是标签规范化把font、center这类废弃标签转换成span style...把空标签如pbr/p统一成pbr/p再存储避免后续解析出现空节点。第二步是内容截断风险控制如果富文本会同步到小程序端建议服务端限制HTML字符串的最大长度比如不超过50000字符否则小程序端setData时会有性能问题表现为页面卡顿或者白屏。4.2 解析HTML到nodes一个够用的轻量实现我一开始是想直接引入mp-html的解析器但后来发现只为了展示功能引入一整个解析渲染框架有点重。后来基于htmlparser2写了一个轻量解析核心逻辑不到100行import htmlparser2 from htmlparser2 const ALLOW_TAGS [p, div, br, strong, b, em, i, u, a, img, ul, ol, li, blockquote, h1, h2, h3, h4, h5, h6, span, table, tbody, tr, td, th, hr] const ALLOW_ATTRS [href, src, alt, style, class] export function htmlToNodes(html) { const nodes [] const stack [] const current { children: nodes } const parser new htmlparser2.Parser({ onopentag(name, attrs) { if (!ALLOW_TAGS.includes(name)) return const node { name, attrs: filterAttrs(attrs), children: [] } stack.push(node) const parent stack.length 1 ? stack[stack.length - 2] : current parent.children.push(node) }, ontext(text) { const parent stack.length ? stack[stack.length - 1] : current parent.children.push({ type: text, text }) }, onclosetag(name) { if (ALLOW_TAGS.includes(name) stack.length) stack.pop() } }, { decodeEntities: true }) parser.write(html) parser.end() return nodes }简单的HTML字符串经过这个函数处理输出就是rich-text可以直接渲染的nodes数组。如果你用mp-html它也有自己的html2nodes方法原理类似。需要注意decodeEntities一定要开启否则amp;这类实体字符不会被正确解码成渲染出来是乱码。4.3 图片列表提取的一并处理在htmlToNodes过程中可以顺手收集图片数组const imgList [] const walkNodes (nodes) { nodes.forEach(node { if (node.name img) { imgList.push(node.attrs.src) } if (node.children) walkNodes(node.children) }) }这个imgList配合uni.previewImage就实现了富文本图片的点击全屏预览。如果在解析时就把图片列表提取出来后面做点击预览就不需要再遍历一遍页面节点性能要好不少。4.4 小程序端和App端的数据传输注意点uniapp打包成App后富文本数据可能来自云端接口也可能来自本地SQLite数据库如果你用了uniapp sqlite这样的本地存储方案。从SQLite读取富文本字符串时注意编码往往会出现中文乱码需要在存储时统一用UTF-8编码读取后用decodeURIComponent处理一遍。另外在微信小程序端操作富文本数据时要特别注意setData的数据大小限制。单个setData数据超过1MB会被直接拒绝导致渲染失败。解决方案是分页渲染或者懒加载首次只截取富文本的前1000个字符渲染后续通过IntersectionObserver或者滚动事件动态追加。5. 常见问题与排查技巧实录5.1 富文本渲染空白这个问题遇到最多。通常有三类原因第一类nodes参数传了字符串但字符串包含的标签在rich-text不支持列表中。比如在uni-app x中传了table页面直接空白。排查方法先打印nodes数组确认结构是否正常再把HTML字符串截断成最小片段逐步二分定位问题标签。第二类解析函数出了问题children层级结构错误。比如parser的onclosetag逻辑写得不对标签没有正确出栈导致整个节点嵌套错乱。排查方法换官方HTML字符串的方式渲染一次如果正常问题就在解析器。第三类样式问题。某些端上style属性里的单位不识别比如你把宽度设成了100%但外层容器没有给宽度这在小程序端偶尔会成为渲染空白的原因。排查方法去掉style属性中的百分比换成固定像素测试。5.2 图片不显示或只显示一半图片不显示大概率是src协议问题。后台编辑器粘贴图片时有些编辑器会把图片存成data:image/png;base64,...这种base64格式。rich-text在小程序端对base64图片的支持不完整长图会直接黑屏或者只渲染首帧。我们的解决方案是在服务端清洗时把base64图片转存为CDN文件将src替换为CDN地址。还有一种情况图片的src是相对路径比如/uploads/2024/01/1.png这种路径在H5端没问题但小程序端相对路径会变成pages/index//uploads/...自然404。需要在存储数据时统一存绝对URL或者在清洗时做协议补齐。5.3 富文本点击链接无法跳转微信小程序端的rich-text对a标签的点击跳转支持有限实测部分基础库版本点击链接后无反应。如果你希望富文本里的链接可以被点击跳转一般做法是在解析nodes时对a标签做特殊标记外层容器监听点击根据点击区域判断命中的是哪个链接然后手动调用uni.navigateTo或复制链接。同样的思路也适用于电话、邮件等特殊协议。5.4 富文本中嵌入H5页面时的跨域问题如果你的富文本内容是从一个H5 WebView页面里拿到的比如把H5链接嵌入uni-app的web-view那你需要考虑前端调用uni-app能力时需要的桥接方法。这里最常见的坑是H5页面里通过URL Scheme调用uni-app能力时如果配置了多个域名被嵌入的H5域名和接口域名不一致会触发跨域拦截表现就是“功能时好时坏、部分Android机白屏”。我的建议是富文本内容尽量走数据接口返回不要依赖web-view承载H5页面。必须用web-view的场景要提前跟后端约定跨域方案或者让H5页面通过uni.webview.js桥接调用uni-app能力而不是直接发起跨域请求。5.5 富文本与自定义分享日常业务中富文本详情页往往会加一个“分享好友”的按钮。这里有个容易被忽略的点微信小程序的分享卡片title和imageUrl需要单独配置不能提取富文本中的第一张图直接用否则分享卡片会因为你拿到的图片是CDN地址而无法展示。正确做法是从富文本图片列表里取第一张图上传到微信素材库拿到临时素材ID再作为imageUrl传入分享参数。App端的分享则要看你接的分享SDK如个推、友盟分享图片需要本地路径CDN地址也要先下载到本地再分享。5.6 富文本渲染性能调优一个富文本详情页如果nodes数组特别长比如超过500个节点在小程序初次渲染时会明显感觉到卡顿。优化手段有三个第一首屏只渲染屏幕内可见部分用IntersectionObserver监听容器进入视口后再替换完整nodes。第二避免在rich-text外层使用过多的动态style绑定因为任何样式变化都会触发整个组件的重新渲染。第三合理使用v-if和v-show。如果你在同一个页面里同时存在编辑预览和展示两个富文本用v-if只保留当前需要的那个避免两棵树同时渲染。5.7 常见问题速查表现象可能原因排查顺序渲染空白nodes结构错误 / 标签不支持 / 样式属性异常先打印nodes再二分HTML再查样式图片不显示src协议问题 / 相对路径 / base64不支持看src值换CDN绝对路径排版错乱style属性被吞 / class未引入检查style有没有被解析检查css是否全局可用链接点不动小程序端a标签支持差自己解析链接外层点击命中中文乱码存储编码问题统一UTF-8读出后解码视频无法播放rich-text不支持video换mp-html或原生video组件偶发白屏setData超限 / 节点过多分页渲染控制节点数量5.8 一些安全提醒特别是H5打包部署如果富文本内容最终会被编译成H5包部署建议你对富文本中的iframe做拦截不要信任任何富文本来源的iframe因为iframe里加载的内容完全不受你控制而且很容易被嵌入恶意广告或钓鱼页面。部署时记得给H5包所在的Nginx配置安全响应头add_header X-Content-Type-Options nosniff; add_header X-Frame-Options SAMEORIGIN;再加上前面提到的CSP算是给自己加了几道保险。6. 富文本相关的几个细节经验再补充说来也巧这次需求里还顺带解决了一个“富文本里图片在微信小程序里加载失败但在App端正常”的老大难问题。最后发现是微信小程序对图片域名有白名单要求downloadFile合法域名你CDN域名没在小程序后台配置图片就会加载失败。这个问题提醒我们富文本里的图片资源域名必须提前梳理好能配进小程序后台的就尽量配不能配就用中转接口下载转存。还有一个经验是关于富文本中嵌入视频的。如果你确实需要在富文本里放视频我的建议是不要试图让rich-text直接渲染video。最稳的方案是在解析nodes时把video标签识别出来转换成自定义的封面图片节点点击后跳转到原生视频播放页面或者用uni.createVideoContext播放。这样既不影响富文本的渲染节奏又能获得完整的视频播放控制权。关于样式优先级我在项目中还踩过一个很隐蔽的坑后台编辑器生成的style属性里带了!important比如stylefont-size: 24px!important。这种样式在H5端正常但在小程序端!important优先级高于微信开发者工具里定义的样式导致你外面再怎么覆盖都改不动字号。目前没有特别干净的办法只能在清洗阶段直接剥离!important关键字。再补充一个view点击事件穿透的问题如果你在rich-text外层绑定了click要注意rich-text内部如果有用户长按选择文本的行为可能会触发点击事件导致误触。场景一般是富文本里包含长文章用户想复制某段文字手指长按触发文本选择结果页面先响应了点击事件跳转了链接。解决方案是给外层的点击事件增加一个判断检测长按时间超过400ms则不响应点击逻辑。如果你要处理的是不定高度的富文本在滚动列表中的性能问题建议在scroll-view中做虚拟渲染或者延迟渲染富文本组件尽量不要一次性全部渲染出来否则列表滚动的FPS会掉得很难看。最后说下日志排查的技巧。H5端调试富文本可以把解析出来的nodes数据直接打印在console里格式化后逐个核对节点结构。微信小程序端不太方便打断点可以在解析函数里加临时的日志开关用uni.setStorageSync把解析日志存起来在真机上复现问题时把日志读出来分析比在开发者工具里看半天头绪来得快。我个人在实际操作中的体会是富文本组件本身不复杂复杂的是所有外围问题——数据来源不可控、跨端支持有差异、样式污染难隔离、安全风险很高。如果你能在一开始就建立一套“编辑、存储、清洗、解析、渲染、事件处理”的流水线把每个环节的职责边界划清楚这个项目就成功了一大半。后续再遇到新问题你已经有了排查它的上下文就不再是老虎咬天无处下口了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑