简介:面向iOS开发者的HTML字符串与富文本互转demo源码,聚焦服务端HTML内容在UILabel、UITextView等原生控件中的富文本呈现。资源围绕NSAttributedString的初始化转换方法,演示如何加载本地HTML文件并解析常见标签,帮助解决网页式内容在iOS界面中的显示与样式适配问题。压缩包约1.38MB,主要包含工程源码、配置文件及必要的资源文件,可直接导入Xcode运行查看效果,免去从零搭建工程的麻烦。源码结构清晰,注释与示例兼顾,能够直观对比原始HTML与转换后的富文本效果。目前已有2715人学习下载。通过该demo可系统掌握HTML转NSAttributedString的完整链路,包括标签映射、字体颜色继承、超链接点击等处理思路;同时能了解XSS安全过滤、复杂HTML解析兼容性以及大量文本转换时的性能优化注意事项,实际项目中服务端返回的文章、公告等富文本内容往往依赖此类转换能力,适合刚接触iOS富文本开发的初中级工程师作为参考,也可作为日常开发中的工具库直接抽取使用。
1. HTML字符串与富文本互转:为什么说这层转换是所有富文本编辑器的地基
任何一个做后台管理系统、内容发布平台或在线文档的团队,迟早要撞上同一个问题:富文本编辑器里那一屏带格式的内容,存进数据库时到底是什么?答案几乎都是 HTML 字符串。而要把这段字符串还原成用户当初看到的样子,又必须把它塞回编辑区域。HTML字符串与富文本互转,本质上就是围绕contenteditable的一块胶水代码,做得稳不稳,直接决定编辑器是能用还是三天两头出 bug。
这个标题里还藏着一个容易被忽略的点:加载本地 html。很多 demo 只教怎么转字符串,却不提本地文件怎么读进来。实际开发中,用户把本地的.html文件拖进页面、解析后回填到富文本区,是非常常见却很少被写清楚的需求。本文按一条完整链路来拆:先讲清contenteditable与 HTML 的映射关系,再给出互转的最小可运行 demo,最后把加载本地 html 文件的几种方案和排错点一次性说透。
2. 富文本的本质:contenteditable与 HTML 字符串的映射关系
2.1 浏览器把 HTML 字符串解析成 DOM 树,再把 DOM 树渲染成富文本
很多初学者把「富文本」理解成某种特殊的数据格式,这是一个根本性的误会。富文本在浏览器里只有一个承载者:contenteditable属性。只要把div或iframe的contenteditable设为true,这个元素内部就变成可编辑区域,用户输入、加粗、插链接,背后全是浏览器在操作 DOM。
这意味着一个关键结论:富文本的内容从来不是神秘的二进制,而是标准 HTML。按下加粗按钮时,编辑器执行的是document.execCommand('bold'),浏览器把选中文本包进<b>或<strong>;插入图片时,编辑器往 DOM 里插了一个<img>节点。这些 DOM 节点的序列化结果,就是 HTML 字符串。
既然如此,HTML字符串与富文本互转就有了两个清晰的锚点:
- HTML 字符串 → 富文本:把字符串塞进
innerHTML,浏览器自动解析成 DOM 并渲染 - 富文本 → HTML 字符串:读取编辑区的
innerHTML,拿到序列化后的 HTML
这个过程里最值得警惕的是「字符串塞进去之后,编辑器内部状态会不会乱」。contenteditable区域是有光标位置和选区概念的,直接改innerHTML会丢失所有编辑状态。所以真正的互转不是简单的赋值,还要考虑时机、事件和异常兜底。
2.2 为什么不用textContent或value来存取富文本
textContent返回的是纯文本,所有标签都会被剥离,格式信息全丢;value只对<textarea>和<input>有效,它们根本不支持富文本。只有innerHTML能在字符串与格式化内容之间完整往返。
但innerHTML有个副作用:每次赋值都会销毁原有 DOM 节点并重建。如果一个编辑器里有图片懒加载、自定义事件绑定或第三方控件,重建意味着这些状态全部失效。理解了这一点,你就能解释很多线上 bug:为什么富文本里插入的视频在回显后不能播放,为什么自定义 @ 提及在保存再打开后点击无反应——多半是innerHTML重建砍掉了事件。
2.2.1 一个验证映射关系的最小实验
打开浏览器控制台,执行以下三行代码,能直观看到互转的底层逻辑:
// 创建一个可编辑区域并插入 HTML 字符串 const editor = document.createElement('div'); editor.contentEditable = 'true'; editor.innerHTML = '<p>第一段 <strong>加粗</strong></p>'; document.body.appendChild(editor); // 读取序列化后的 HTML console.log(editor.innerHTML); // 输出: <p>第一段 <strong>加粗</strong></p> // 读取纯文本 console.log(editor.textContent); // 输出: 第一段 加粗这段代码的关键在innerHTML的赋值与读取。赋值时浏览器走 HTML parser,把字符串变成 DOM;读取时走 HTML serializer,把 DOM 变回字符串。textContent则完全忽略标签结构,只保留文本节点。实际操作中,判断一个编辑器是否回显正确,第一件事就是看innerHTML往返是否无损。
3. HTML 字符串转富文本:三种注入方式与光标保留策略
3.1 方式一:直接赋值innerHTML,最简单但会丢光标
这是最直观的做法,适合初始化回显场景:
function setEditorContent(htmlString) { const editor = document.getElementById('richEditor'); editor.innerHTML = htmlString; }参数htmlString是服务端返回的 HTML 片段。这个方案的优点是代码量极小、解析性能好(浏览器原生处理);缺点同样明显——直接赋值会清空原有 DOM、丢光标、丢选区,而且如果字符串里有未闭合标签,浏览器会自动纠错,可能出现「存进去是<p>,回显变成<p></p>或嵌套错乱」的现象。innerHTML赋值只推荐在编辑器初始化、或用户主动切换文档时使用。
3.2 方式二:document.execCommand('insertHTML'),保留光标并触发撤销栈
execCommand虽然被标记为废弃,但所有主流浏览器至今仍在实现它,富文本编辑器领域短期内还离不开它。它的优势在于:在光标位置插入 HTML、保留编辑状态、并且进入浏览器的撤销栈(Ctrl+Z 可用)。
function insertHtmlAtCursor(htmlString) { const editor = document.getElementById('richEditor'); editor.focus(); // 先尝试 execCommand,失败则回退到 innerHTML 追加 const success = document.execCommand('insertHTML', false, htmlString); if (!success) { editor.innerHTML += htmlString; } }这段代码的核心是document.execCommand('insertHTML', false, htmlString)。第二个参数false表示不启用 UI,第三个参数是待插入的 HTML 字符串。execCommand返回布尔值,但不是所有浏览器都返回可靠结果,所以加了回退逻辑。实际操作中,如果编辑器支持图片粘贴,插入<img>用这个方式就不会破坏剪贴板事件里的文件句柄。
3.3 方式三:基于选区 API 手动插入,适合需要精确控制 DOM 的场景
如果要在插入前后做 DOM 操作——比如过滤脚本、给图片加防盗链标记——execCommand就不够灵活了。这时可以直接操作 Selection 和 Range:
function insertHtmlAtSelection(htmlString) { const editor = document.getElementById('richEditor'); editor.focus(); const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; const range = selection.getRangeAt(0); // 将 HTML 字符串解析为 DocumentFragment,保留内部结构 const template = document.createElement('template'); template.innerHTML = htmlString.trim(); const fragment = template.content; // 删除当前选中的内容,再插入新节点 range.deleteContents(); const lastNode = fragment.lastChild; range.insertNode(fragment); // 将光标移动到插入内容的末尾 if (lastNode) { range.setStartAfter(lastNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } }这里的核心是<template>元素。template.innerHTML只解析不渲染,不会触发图片加载或脚本执行,比直接用div.innerHTML更安全。range.insertNode(fragment)会把整个片段插入到光标位置,且插入后 DOM 是真实节点而非字符串,后面要再调整结构可以直接操作这些节点。
range.setStartAfter(lastNode)是把光标挪到最后一个子节点之后,避免插入后光标跳到文档开头。实际操作中,lastNode可能为 null(插入空串时),所以加了判空。
3.4 三种方式的选型标准与性能比拼
| 注入方式 | 是否丢光标 | 是否进撤销栈 | 能否在执行前后拦截 DOM | 性能 |
|---|---|---|---|---|
innerHTML直接赋值 | 丢 | 不进 | 不能 | 最快 |
execCommand('insertHTML') | 不丢 | 进 | 不能 | 快 |
| Selection + Range 手动插 | 不丢 | 需要自己实现 | 能 | 中等 |
真实项目中,我一般这样划分:初始化回显用方式一;用户在当前文档里插入模板、图片、链接用方式二;需要做内容安全过滤或精确控制插入位置的用方式三。需要提醒的是,execCommand在部分移动端 WebView 里行为不一致,insertHTML偶尔会失效,所以方式二里的回退代码不是防御性写法,而是必要兜底。
4. 富文本转 HTML 字符串与加载本地 html:读取、解析和回填
4.1 从编辑器里取 HTML:读innerHTML不等于拿到的就是干净字符串
富文本转 HTML 字符串,多数人第一反应是editor.innerHTML。这在简单 demo 里没有错,但真实场景会遇到三个问题:
第一,浏览器会补全标签。用户输入<b>粗体,innerHTML返回的却是<b>粗体</b>。这是好事,但如果你用字符串比较来判断内容是否变化,会得到错误结论。
第二,不同浏览器对空内容的序列化不一致。Chrome 返回<br>,Firefox 可能返回<p><br></p>。这会导致同一份内容在两个浏览器里存下来的字符串不同。
第三,编辑器可能加了额外的类名或 data 属性。很多富文本库(如 Quill 的部分主题)会在 DOM 上挂标记,直接读innerHTML会把内部实现细节也存进数据库,下次升级库版本时这些标记可能导致样式错乱。
一个稍微稳一点的做法是读取前先做归一化:
function getEditorHtml() { const editor = document.getElementById('richEditor'); // 先移除空段落,避免存下一堆 <p><br></p> editor.querySelectorAll('p').forEach(p => { if (p.innerHTML === '<br>' || p.textContent.trim() === '') { p.remove(); } }); return editor.innerHTML.trim(); }这段代码做的事情是:把所有「空段落」从 DOM 里删掉,再返回序列化字符串。p.textContent.trim() === ''判断段落里没有可见文本,p.innerHTML === '<br>'单独处理换行占位。实际使用中,如果产品需求是「保留空行」,这个清理就要去掉,改为把<p><br></p>替换成<p> </p>之类的占位符。
4.2 加载本地 html 的第一道坎:file://协议下fetch被 CORS 拦截
现在进入标题里的核心难点——加载本地 html 文件。最常见的错误是有人直接写fetch('./local.html'),在本地双击打开页面时必然失败,错误信息是Cross origin requests are only supported for protocol schemes: http, data...。
原因在于:file://协议下,页面源是null,任何跨文件请求都会被 CORS 拦截。Chrome 更是直接禁止file://页面发起fetch。不要把--allow-file-access-from-files当常规方案,它需要所有访问者都改浏览器启动参数,不适合任何正式场景。
4.3 正确方案:用<input type="file">加FileReader读取本地 html
既然不能用fetch读任意文件,就用文件选择框让用户主动授权。<input type="file">拿到的是File对象,不涉及 CORS,读取权限由用户手势授权。
<input type="file" accept=".html,.htm" id="htmlFileInput"> <div contenteditable="true" id="richEditor" style="min-height: 300px; border: 1px solid #ccc; padding: 12px;"></div>const fileInput = document.getElementById('htmlFileInput'); const editor = document.getElementById('richEditor'); fileInput.addEventListener('change', (event) => { const file = event.target.files[0]; if (!file) return; // 限制文件大小,避免大 HTML 文件阻塞主线程 if (file.size > 2 * 1024 * 1024) { alert('文件不能超过 2MB'); fileInput.value = ''; return; } const reader = new FileReader(); reader.onload = (e) => { const htmlString = e.target.result; // 关键过滤:去掉 script 和 iframe,防止恶意代码 const cleaned = sanitizeHtml(htmlString); editor.innerHTML = cleaned; }; reader.onerror = () => { alert('文件读取失败,请确认文件不是二进制格式'); }; reader.readAsText(file, 'utf-8'); }); function sanitizeHtml(input) { const template = document.createElement('template'); template.innerHTML = input; // 移除所有 script、iframe、object、embed 标签及其内容 template.content.querySelectorAll('script, iframe, object, embed, link, meta').forEach(el => el.remove()); // 移除所有元素上的 on* 事件属性 template.content.querySelectorAll('*').forEach(el => { [...el.attributes].forEach(attr => { if (attr.name.toLowerCase().startsWith('on')) { el.removeAttribute(attr.name); } }); }); return template.innerHTML; }这段 demo 的完整逻辑是:文件选择框拿到本地 html 文件,FileReader.readAsText(file, 'utf-8')以 UTF-8 编码读成字符串,然后先经过sanitizeHtml清洗再回填到编辑器。
sanitizeHtml里两个querySelectorAll分别处理两件事:一是删掉script、iframe、object、embed这类高危标签,二是遍历所有元素的事件属性(如onclick、onerror)。这两步做完,本地 html 文件基本就安全了。需要注意,这个清洗函数是 demo 级的,生产环境建议直接用 DOMPurify 这类成熟库,不要自己维护一份过滤名单。
4.4 中文乱码问题:readAsText的编码参数与 HTML 内部声明的冲突
加载本地 html 时,中文乱码几乎是必现问题。原因很典型:本地.html文件内部声明了<meta charset="gb2312">或<meta charset="gbk">,但FileReader.readAsText(file, 'utf-8')强制按 UTF-8 解码,编码不一致就乱码。
两个解决思路:
思路一:读二进制,再探测编码。用readAsArrayBuffer拿到原始字节,然后用TextDecoder配合编码探测。TextDecoder支持gbk和gb18030,但不支持自动探测,需要自己判断。最常用的探测方法是检查 HTML 头部charset声明:
function detectCharset(buffer) { const text = new TextDecoder('utf-8').decode(buffer); const match = text.match(/charset\s*=\s*["']?([\w-]+)/i); if (match && match[1].toLowerCase() === 'utf-8') { return 'utf-8'; } // 常见于国内旧系统的 GBK / GB2312 页面 if (match && /gb2312|gbk|gb18030/i.test(match[1])) { return 'gb18030'; } return 'utf-8'; // 默认回退 } fileInput.addEventListener('change', (event) => { const file = event.target.files[0]; if (!file) return; // 先读 ArrayBuffer const bufferReader = new FileReader(); bufferReader.onload = (e) => { const buffer = e.target.result; const charset = detectCharset(buffer); const decoder = new TextDecoder(charset); const htmlString = decoder.decode(buffer); editor.innerHTML = sanitizeHtml(htmlString); }; bufferReader.readAsArrayBuffer(file); });这里用TextDecoder('gb18030')而不是gbk,因为gb18030是gbk的超集,能解码更多字符,兼容性更好。detectCharset里的正则匹配 HTML 头部的charset声明,找到gb2312或gbk就用gb18030解码。实际操作中,还有一种偷懒的兼容法:先把 UTF-8 解码结果丢给用户看,如果出现�替换字符,就提示用户手动选择编码。这个兜底虽然不优雅,但确实能在极端编码场景下救命。
5. 进阶技巧:图片转 base64、粘贴清理与 XSS 过滤
5.1 本地 html 里的图片:src相对路径会直接失效
加载本地 html 文件后,一个高频问题:HTML 里的图片全裂了。原因很直白——html 文件在你的磁盘上,它引用的图片是./images/a.png,你把 html 内容塞进页面后,这个相对路径指向的是服务器上的/images/a.png,当然 404。
解决思路只有两个:要么把图片文件一起上传,要么把图片转成 base64 嵌进 HTML。demo 场景下推荐 base64,因为不需要额外管理文件依赖:
function convertImagesToBase64(htmlString) { const template = document.createElement('template'); template.innerHTML = htmlString; const images = template.content.querySelectorAll('img'); images.forEach((img, index) => { const src = img.getAttribute('src'); if (!src || src.startsWith('data:') || /^https?:\/\//.test(src)) { return; // 已经是 base64 或网络图片,跳过 } // 这里需要 fetch 本地文件,实际场景通常是 FileReader 逐个读取 // demo 提供占位逻辑:请求用户选择这张图片文件 console.log(`第 ${index + 1} 张图片需要手动关联: ${src}`); }); return template.innerHTML; }这段代码的意义更多是演示排查路径:遍历img标签,过滤掉已经是data:开头或http(s)开头的图片,剩下的就是需要处理的相对路径图片。完整的自动转换要走 FileReader 逐个读取图片文件再转 base64,代码量不小,且涉及图片体积膨胀问题(base64 会让体积增加约 33%),所以大多数生产系统选择「上传图片到服务器,替换 src 为线上 URL」而不是 base64。
5.2 粘贴网页内容时的隐性 HTML:text/html与text/plain的双通道
从浏览器复制一段带格式的文本,再粘贴到富文本编辑器里,剪贴板会同时提供多种格式。paste事件里可以取到两种:text/html和text/plain。如果编辑器只处理text/plain,粘贴的格式全部丢失;只处理text/html,又可能带上复制源站点的样式类名。
一个常见的处理策略是:优先尝试text/html,但先清洗,再回退到text/plain:
editor.addEventListener('paste', (event) => { event.preventDefault(); const html = event.clipboardData.getData('text/html'); const plain = event.clipboardData.getData('text/plain'); if (html) { // 清洗粘贴的 HTML,避免带来 on* 属性和 script const cleaned = sanitizeHtml(html); document.execCommand('insertHTML', false, cleaned); } else if (plain) { // 无 HTML 时插入纯文本,注意换行要转成 <br> 或分段 const safeText = plain.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); document.execCommand('insertHTML', false, safeText.replace(/\n/g, '<br>')); } });clipboardData.getData('text/html')拿到的是复制源提供的 HTML,可能包含源站点的 CSS 类名甚至内联样式,所以必须过一遍sanitizeHtml。纯文本路径里,replace(/&/g, '&')这类转义是必须的,否则用户粘贴1 < 2会被浏览器解析成标签。换行先转<br>再插入,能保证多行文本不会挤成一段。
5.3 性能边界:大 HTML 字符串注入时的卡顿与崩溃
一个 5MB 的 HTML 字符串直接塞进innerHTML,浏览器会卡顿甚至崩溃。HTML parser 解析 DOM 是同步主线程操作,节点数量大了之后,布局和样式计算也会跟着遭殃。一个省事的做法是分段注入:
function injectLargeHtml(htmlString, targetElement) { // 按 body 标签切分,或者按固定长度分段 const chunkSize = 100 * 1024; // 每段 100KB const chunks = []; for (let i = 0; i < htmlString.length; i += chunkSize) { chunks.push(htmlString.slice(i, i + chunkSize)); } targetElement.innerHTML = ''; let index = 0; function appendNext() { if (index >= chunks.length) { return; } // 分段赋值,让浏览器有机会处理其他任务 targetElement.insertAdjacentHTML('beforeend', chunks[index]); index++; requestAnimationFrame(appendNext); } requestAnimationFrame(appendNext); }insertAdjacentHTML('beforeend', chunk)是在内部末尾追加,不会重建已有 DOM,比分段拼接innerHTML性能好。requestAnimationFrame把每次追加放在不同帧里,避免一次同步解析阻塞页面。实际操作中,这个方案仍有隐患——如果第一段里<div>标签未闭合,后续段落的解析会出错。安全做法是按完整标签边界切分,而不是固定字节数切分。
5.4 验证互转结果是否无损的检查清单
项目上线前,用下面几个用例验证互转链路,比写一百行断言都管用:
| 用例 | 预期结果 | 失败时检查哪里 |
|---|---|---|
| 空编辑器保存后再回显 | 编辑器为空,无残留<br> | 读取前是否清理了空段落 |
| 粘贴一段带图片的文字再保存 | 图片 URL 能被保存和回显 | 图片是相对路径还是绝对路径 |
加载含<script>的本地 html | 脚本不执行,标签被移除 | sanitizeHtml是否覆盖了所有高危标签 |
| html 文件为 GBK 编码 | 中文显示正常 | detectCharset是否识别到了charset声明 |
| 在文本中间插入 HTML 后按 Ctrl+Z | 能撤销回插入前的状态 | 是否用了execCommand('insertHTML')或自己实现了撤销栈 |
这套清单的核心逻辑是:互转不是「字符串能放进去再取出来」这么简单,而是要验证编码、安全、光标准和 XSS 四个维度同时成立。任何一条不满足,用户都会在某个具体操作里遇到诡异问题——有的能复现,有的只在特定浏览器或特定 doc 里出现。
最后补一个实用技巧:开发时可以写一个console.log快捷键,一键打印当前编辑器的innerHTML快照,格式化后复制到本地 html 文件,再用<input type="file">加载回来。这个「存→读→回显」闭环,能帮你快速验证当前这版的互转代码是否真的无损。
本文还有配套的精品资源,点击获取