前端安全展示后端纯文本接口数据的实践:不解析、不危险渲染的结构化方案

一、问题背景:当后端返回的纯文本遇上前端的安全与体验需求
在实际项目开发中,我们经常需要对接后端接口获取数据并展示到前端页面。大多数情况下,后端会返回结构化的 JSON 数据(如 { description: 'xxx', methods: ['a', 'b'] }),前端可直接用 React 组件(如 <p>、<ul>)渲染,既安全又清晰。
但有一种常见场景却让人头疼:后端出于历史原因、接口设计简化或功能灵活性考虑,返回的并非结构化 JSON,而是一长段纯文本字符串。例如,某个安全检测脚本的功能说明,后端直接返回了如下内容:
此脚本检测SNMP是否打开,以及是否可以从以下资源之一使用给定的凭据/团体字符串进行连接:-通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv1/2社区字符串。-由“检查默认社区”检测到的SNMPv1/2社区字符串 SNMP代理的名称(OID:1.3.6.1.4.1.25623.1.0.103914)。—通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv3凭据。
这类文本通常是自然语言描述+列表混合的格式(比如用 - 或 — 分隔多个检测方式),但后端并未将其拆分为 JSON 字段(如 { desc: '检测SNMP...', methods: ['方式1', '方式2'] }),而是直接返回了一整段连续的字符串。
二、问题的核心矛盾:前端的需求 vs 后端的限制
当这类纯文本数据传递到前端时,开发者通常会面临以下 核心矛盾:
1. 展示需求:用户需要清晰易读的内容
纯文本字符串直接渲染(比如用 <div>{rawText}</div>)会导致所有内容挤在一起,列表项(如 - 方式1)和普通描述混为一谈,用户阅读体验差,难以快速抓住重点(比如哪些是支持的检测方式?哪些是关键参数?)。
2. 安全限制:不能直接用 dangerouslySetInnerHTML
如果为了“省事”直接用 React 的 dangerouslySetInnerHTML={{ __html: rawText }} 将原始字符串插入 DOM,虽然能保持原始格式(比如文本中的换行、符号),但会带来严重的安全隐患:
- 若后端字符串被恶意篡改(如包含
<script>或 XSS 攻击代码),会被浏览器直接执行; - React 官方明确警告:
dangerouslySetInnerHTML需极端谨慎使用,常规业务应避免。
3. 技术限制:不能改后端接口,也不能写复杂解析逻辑
更麻烦的是,这类接口往往是历史遗留接口或第三方服务提供的,后端无法修改返回格式(不能改成 JSON 结构);同时,前端团队可能因时间、人力或业务优先级限制,不愿意(或不能)专门写一个函数去解析这段文本(比如提取描述部分、拆分列表项、识别 OID 等关键信息)。
于是问题就卡住了:后端返回的是一长段未结构化的纯文本,前端既不能直接用 dangerouslySetInnerHTML,又不能改接口,还不想写解析函数——那到底该怎么安全、清晰地展示它?
三、解决方案:纯前端的“轻量级结构化”策略
针对上述矛盾,经过实践验证,我们总结出一套 “不解析、不危险渲染” 的纯前端结构化方案,核心思路是:在不改变原始数据、不写复杂逻辑的前提下,通过合理使用 JSX 标签(如 <p>、<ul>、<li>、<strong>)对纯文本进行“视觉结构化”展示,兼顾安全性与用户体验。
方案一:手动排版(推荐指数:⭐⭐⭐⭐⭐)
适用场景:接口返回的文本内容相对固定(比如文案不会频繁变动),且团队能接受在 TSX 中手动调整排版。
实现方式:
直接根据你对文本内容的理解,在 TSX 中手动将纯字符串拆分为“描述部分”和“列表部分”,然后用语义化标签包裹。例如:
const SnmpDescription = () => {// 假设这是从接口获取的原始字符串(实际通过 props/api 获取)const rawText = `此脚本检测SNMP是否打开,以及是否可以从以下资源之一使用给定的凭据/团体字符串进行连接:-通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv1/2社区字符串。-由“检查默认社区”检测到的SNMPv1/2社区字符串 SNMP代理的名称(OID:1.3.6.1.4.1.25623.1.0.103914)。—通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv3凭据。`;return (<div style={{ fontFamily: 'monospace', lineHeight: 1.6 }}>{/* 手动提取描述部分(冒号前)并用 <p><strong> 包裹 */}<p><strong>{rawText.split(':')[0]}: {/* 假设中文冒号分隔描述和列表 */}</strong></p>{/* 手动将后续的 -xxx 内容转为 <ul><li> 列表 */}<ul><li>通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv1/2社区字符串。</li><li>由“检查默认社区”检测到的SNMPv1/2社区字符串 SNMP代理的名称(OID:1.3.6.1.4.1.25623.1.0.103914)。</li><li>通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv3凭据。</li></ul></div>);
};
优势:
- 零解析逻辑:完全不写函数提取结构,直接按你看到的内容手动排版;
- 绝对安全:只用
<p>、<ul>、<li>等基础标签,不涉及dangerouslySetInnerHTML; - 体验最佳:列表项清晰分隔,关键信息(如 OID)可进一步用
<code>高亮(后续优化)。
适用场景:
适合文案固定、团队能接受少量硬编码的场景(比如后台管理系统的功能说明页)。
方案二:极简拆分 + 基础结构化(推荐指数:⭐⭐⭐⭐)
适用场景:文本内容有一定规律(比如用 - 或 — 分隔列表项),但不想完全手动写死每一项。
实现方式:
通过简单的字符串方法(如 split())按符号(如 :、-)拆分原始文本,将描述部分和列表部分分开,再用 JSX 标签包裹。例如:
const SnmpSimpleStructured = () => {const rawText = `此脚本检测SNMP是否打开,以及是否可以从以下资源之一使用给定的凭据/团体字符串进行连接:-通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv1/2社区字符串。-由“检查默认社区”检测到的SNMPv1/2社区字符串 SNMP代理的名称(OID:1.3.6.1.4.1.25623.1.0.103914)。—通过目标配置或“SNMP授权(OID:1.3.6.1.4.1.25623.1.0.105076)提供的SNMPv3凭据。`;// 按中文冒号拆分为描述和剩余部分const [descriptionPart, ...restParts] = rawText.split(':');const itemsRaw = restParts.join(':'); // 处理可能的多段// 简单提取 - 开头的行(实际可根据需求调整正则)const listItems = itemsRaw.split(/([-—])/) // 按 - 或 — 分割.filter(Boolean).map((part, i) => {if (part.startsWith('-') || part.startsWith('—')) {const text = part.substring(1).trim(); // 去掉开头的 - 或 —return text ? <li key={i}>{text}</li> : null;}return null;}).filter(Boolean); // 过滤空项return (<div style={{ lineHeight: 1.6 }}>{/* 描述部分 */}<p><strong>{descriptionPart}:</strong></p>{/* 列表部分(如果有) */}{listItems.length > 0 && <ul>{listItems}</ul>}</div>);
};
优势:
- 几乎不写逻辑:仅用
split()和简单的过滤,没有复杂的解析函数; - 轻度结构化:自动将
-xxx转为列表项,比直接显示原始字符串更清晰; - 安全可控:依然只用 JSX 标签,无
dangerouslySetInnerHTML。
适用场景:
适合文本内容有一定分隔符规律(如 - 开头的列表),但团队不想完全手动维护的场景。
方案三:直接包裹原始字符串(保底方案,推荐指数:⭐⭐)
适用场景:文本内容无明确规律,且短期内无优化需求。
如果连简单的拆分都不想做,至少应该避免直接渲染原始字符串。可以用 <p> 或 <pre> 标签包裹,至少保证基础的可读性和安全性:
<div><p>{rawText}</p> {/* 用 <p> 包裹比直接插入 DOM 更安全 */}
</div>
或保留原始格式(如换行)但避免 XSS:
<pre style={{ whiteSpace: 'pre-wrap' }}>{rawText}</pre>
优势:
- 零逻辑:完全不处理字符串;
- 安全:比
dangerouslySetInnerHTML更可靠。
缺点:
- 体验差:所有内容挤在一起,列表项无分隔,用户阅读困难。
四、项目场景中的实际应用建议
1. 典型使用场景
这类方案特别适用于以下项目场景:
- 功能说明/帮助文档:比如安全脚本的检测逻辑说明、系统配置项的详细描述;
- 第三方服务集成页:对接外部 API 返回的文案(如云服务商的实例操作提示);
- 后台管理系统:管理员查看的日志规则、权限说明等非结构化文本。
2. 实践经验
- 优先选择方案一(手动排版):如果文本内容稳定(如长期不变的文案),手动用
<p>、<ul>排版是最安全、体验最好的方式; - 动态内容用方案二(极简拆分):如果文本有一定规律(如总是用
-分隔列表),用简单的split()拆分后结构化,能显著提升可读性; - 永远避免
dangerouslySetInnerHTML:除非你 100% 确定文本来源绝对安全(如纯静态文案),否则不要用它渲染用户或后端提供的任意字符串。
3. 扩展优化方向
如果后续有精力,可以进一步优化:
- 用正则提取关键信息(如 OID、SNMP 版本),并用
<code>或<strong>高亮; - 对长文本添加折叠/展开功能(如
<details>标签); - 将文本内容抽离为国际化(i18n)资源,方便多语言支持。
五、总结
在前后端协作的实际项目中,我们经常会遇到后端返回非结构化纯文本但前端需要清晰展示的矛盾。通过本文的实践,我们明确了:
- 问题本质:后端无法修改返回格式 + 前端不能写复杂解析 + 不能用
dangerouslySetInnerHTML导致的展示难题; - 核心思路:不依赖解析函数,而是利用 JSX 标签的语义化能力,对纯文本进行“视觉结构化”;
- 可行方案:从完全手动排版(方案一)、极简拆分(方案二)到保底包裹(方案三),可根据项目实际情况灵活选择;
- 终极原则:在保证安全(不用 innerHTML)的前提下,通过最小的代码改动提升用户体验。
下次当你遇到类似的“纯文本展示需求”时,不妨试试这些方案——既能满足需求,又能避免技术债务,何乐而不为?
推荐更多阅读内容
React 组件二次封装实践:解决自定义 Props 传递导致的 DOM 警告问题
Ant Design Form.useWatch 实战指南:从原理到最佳实践
Git Bash 中如何切换到 Tag:查看、切换与安全开发指南
Ant Design Popover 中overlayInnerStyle被废弃?来看看正确的替代方案
React 中的 forwardRef 和 useRef,到底有啥区别?一文让你彻底搞懂
深入理解 TypeScript 中继承 Ant Design 组件 Props 的正确姿势
语义化版本 2.0.0 解读:Ant Design 的版本控制之道
React Ant Design 5.27.4 版本更新日志解读
