ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

最近在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-text+nodes数组覆盖基础标签渲染,性能最优
富文本后台编辑独立富文本编辑器(如UEditor、wangEditor)生成HTML存储到服务端
复杂富文本渲染(视频/表格等)mp-html作为备选方案仅对特定页面启用
图片预览rich-text点击事件 +uni.previewImage组件本身不处理图片点击

这个矩阵的核心是:编辑器产生的数据是HTML字符串,落到数据库;前端请求到数据后,先做安全清洗,再转成rich-text能识别的nodes结构进行渲染。编辑和展示彻底解耦,安全性、可维护性都更好。

1.3 方案对比,为什么抛弃 iframe 和模板字符串渲染

群里讨论时也有人提过自己用<rich-text :nodes="htmlString">直接塞HTML字符串,这也是一种做法。但实测有几个问题:一是rich-text的nodes属性直接传HTML字符串时,对部分标签和属性的支持不稳定,尤其是小程序平台;二是字符串形式的HTML没有结构化校验,里面带上<script>标签也不会被过滤,存在XSS注入风险;三是样式控制困难,字符串里的style和class在一些端上会被吞掉,导致排版错乱。

所以我的方案是:服务端存储HTML字符串,前端通过htmlparser类工具解析成nodes数组再传给rich-text。这样既保留了HTML字符串的通用性,又通过解析层做了白名单过滤,结构清晰、渲染稳定。

2. 富文本的安全清洗,这项不做好迟早踩坑

2.1 XSS风险,富文本的隐形炸弹

富文本是前端最容易出安全问题的地方之一。原理很简单:如果你的后端直接接收编辑器提交的HTML原样存库,前端原样渲染,那攻击者在富文本里插入一段<script>alert(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允许href,img允许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 onclick="steal()" style="color:red; background:url(javascript:xss)">正常内容</p><img src="x.png" onerror="alert(1)"><script>alert(2)</script>

清洗后:

<p style="color:red">正常内容</p><img src="x.png" alt="">

2.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属性里可能带了固定宽度和高度,比如width="600" height="400",在小屏手机上就会溢出。处理思路是:在解析图片节点时,强制追加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-x(UniApp X)的兼容性

这阵子刚把一部分业务迁移到uni-app x(uniAppX)上,踩了一脚“看上去可以用,实际要重新测”的坑。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="...">,把空标签如<p><br></p>统一成<p><br></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,比如style="font-size: 24px!important"。这种样式在H5端正常,但在小程序端!important优先级高于微信开发者工具里定义的样式,导致你外面再怎么覆盖都改不动字号。目前没有特别干净的办法,只能在清洗阶段直接剥离!important关键字。

再补充一个view点击事件穿透的问题:如果你在rich-text外层绑定了@click,要注意rich-text内部如果有用户长按选择文本的行为,可能会触发点击事件导致误触。场景一般是:富文本里包含长文章,用户想复制某段文字,手指长按触发文本选择,结果页面先响应了点击事件跳转了链接。解决方案是给外层的点击事件增加一个判断,检测长按时间超过400ms则不响应点击逻辑。

如果你要处理的是不定高度的富文本在滚动列表中的性能问题,建议在scroll-view中做虚拟渲染或者延迟渲染,富文本组件尽量不要一次性全部渲染出来,否则列表滚动的FPS会掉得很难看。

最后说下日志排查的技巧。H5端调试富文本可以把解析出来的nodes数据直接打印在console里,格式化后逐个核对节点结构。微信小程序端不太方便打断点,可以在解析函数里加临时的日志开关,用uni.setStorageSync把解析日志存起来,在真机上复现问题时把日志读出来分析,比在开发者工具里看半天头绪来得快。

我个人在实际操作中的体会是:富文本组件本身不复杂,复杂的是所有外围问题——数据来源不可控、跨端支持有差异、样式污染难隔离、安全风险很高。如果你能在一开始就建立一套“编辑、存储、清洗、解析、渲染、事件处理”的流水线,把每个环节的职责边界划清楚,这个项目就成功了一大半。后续再遇到新问题,你已经有了排查它的上下文,就不再是老虎咬天无处下口了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进