简介一份面向 iOS 开发者的 HTML 字符串与富文本互转 demo 源码聚焦从服务器或本地 html 加载内容后在 UILabel、UITextView 等组件中正确展示的问题。资源打包为 zip整体约 1.38MB上游未标注文件总数与明细适合已有基本 iOS 开发基础、需要处理富文本展示的开发者参考。demo 覆盖 HTML 解析与 NSAttributedString 转换的核心链路使用系统 API 初始化富文本并对比简单解析与第三方库方案的适用场景。示例中考虑了常见标签如加粗、斜体、链接的呈现效果同时提醒注意标签白名单、XSS 安全防护、大量文本异步转换以及不同 iOS 版本的兼容性。可作为实现文章详情、公告、聊天消息等富文本展示时的起点代码帮助理解转换原理并快速集成或二次开发。已有 2715 人学习下载适合作为 iOS 富文本方向的学习与排错参考。1. HTML字符串与富文本互转从本地文件到可编辑文档的工程化落地很多人在处理富文本时踩过同一个坑数据库里存的是p一段内容/p前端拿到的却是被转义过的lt;pgt;或者编辑器里看着格式正常导出时发现相对路径图片全碎、样式丢失、标签嵌套错乱。这类问题都指向同一个核心能力——HTML 字符串与富文本之间的双向转换尤其是当数据源是本地的.html文件时还需要解决文件读取、字符编码、浏览器解析容错等一系列工程问题。本文围绕「HTML字符串与富文本互转(加载本地html) demo 源码」这条主线拆解转换原理、落地代码、边界参数和常见坑点。适合前端开发者、需要做文档导入导出功能的工程师以及想搞懂contenteditable与innerHTML背后机制的人。2. 富文本与HTML字符串的本质边界为什么不能直接赋值2.1 富文本的存储形态与字符串的差异富文本在浏览器里不是一种独立的数据类型而是 DOM 树的可视化呈现。编辑器如 Quill、TinyMCE内部维护的是一棵文档树序列化后得到 HTML 字符串反之HTML 字符串要进入编辑器需要被解析成 DOM 节点再插入。这个过程中最容易出问题的不是“转换”本身而是字符串的上下文语义。一段b加粗/b在 HTML 字符串里只是文本只有被浏览器解析成元素节点后才具备“加粗”的视觉语义。同理富文本编辑器中的“加粗”按钮本质是在当前选区外层包裹strong或b标签或者设置font-weight样式。理解这一点就明白了为什么前端框架里直接用v-html插入字符串会有 XSS 风险——字符串到 DOM 的解析交给了浏览器而浏览器不关心内容是否可信。一个常见误区是直接用textContent取富文本内容一取就丢格式。textContent拿回的是纯文本innerHTML拿回的才是包含标签的 HTML 字符串。这个区分是所有互转逻辑的起点。2.2 本地HTML文件的解析路径与浏览器安全策略加载本地.html文件涉及到浏览器的 File System Access API 和 FileReader默认情况下浏览器不允许网页直接读取磁盘任意位置但通过input typefile用户主动选择是合法的。拿到的是File对象它继承自Blob有一个.text()方法可以直接读取内容也可以兼容旧版用FileReader.readAsText()。文件读取得到的字符串通常是完整 HTML 文档包含!DOCTYPE html、html、head、body等结构标签。而富文本编辑器需要的是片段fragment直接塞进去会把整个文档结构打碎。所以加载本地 HTML 后的第一件事通常是提取body内部内容必要时还要把style中的样式内联成行内样式否则编辑器优先显示自己的样式表页面里的样式根本不会生效。2.3 互转的四种常见路径方向方法适用场景富文本 → HTML字符串editor.innerHTML导出、保存、传输HTML字符串 → 富文本editor.innerHTML htmlString回显、恢复编辑HTML文档 → 富文本片段解析DOM后取 body 节点加载本地 .html 文件富文本 → 纯文本innerText或递归取 childNodes搜索、摘要、计数选择哪条路径取决于你是从字符串到可编辑界面渲染还是从可编辑界面回写字符串序列化。两条路径在代码上看起来只是赋值与取值但边界条件完全不同赋值阶段要处理 XSS 过滤、样式冲突、相对路径取值阶段要处理空段落占位、浏览器自动补齐的标签、换行符差异。3. 加载本地HTML并转为富文本的完整实现3.1 用 FileReader 读取本地文件的最小方案先写一个最小的 HTML 页面包含文件选择按钮和可编辑区域。这个 demo 虽然简单但涵盖了读取、解析、注入三个核心环节。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleHTML字符串与富文本互转 Demo/title /head body input typefile idfileInput accept.html,.htm div ideditor contenteditabletrue styleborder:1px solid #ccc;min-height:300px;padding:12px;/div script // 等待用户选择文件 document.getElementById(fileInput).addEventListener(change, function(e) { const file e.target.files[0]; if (!file) return; const reader new FileReader(); // 以文本形式读取文件内容 reader.readAsText(file, UTF-8); reader.onload function(event) { const htmlDoc event.target.result; // 交给独立函数处理 const richText htmlDocToRichText(htmlDoc); document.getElementById(editor).innerHTML richText; }; }); function htmlDocToRichText(sourceHtml) { // 直接交给DOMParser解析而不是正则提取 const parser new DOMParser(); const doc parser.parseFromString(sourceHtml, text/html); // 获取body片段 const body doc.body; return body.innerHTML; } /script /body /html这段代码里最值得注意的不是 FileReader而是DOMParser。很多老代码用正则去截取body.../body一旦遇到换行、属性中含的场景就出错。DOMParser 是浏览器原生 API把整段字符串解析成完整的document对象再取.body.innerHTML既安全又可靠。FileReader.readAsText的第二个参数指定编码。UTF-8 文件没问题但如果本地文件是 GBK 编码的中文网页这里的字符就会乱码。解决办法是改为readAsArrayBuffer手动用TextDecoder指定gbk解码后面排错部分细说。3.2 样式与相对路径的处理加载本地html最核心的坑纯粹把 body 内容塞进编辑区是不够的。本地 HTML 文件里通常有link relstylesheet和img src./image/x.png直接搬进富文本编辑区后会发生两件事样式因为同源策略被阻止加载图片因为相对路径找不到 404。这一块具体要根据头部base标签或file://协议的实际情况处理。这里给出做了那层处理的加强版把 HTMLhead中的内容合并成style标签并注入到富文本起始区域——这是被多数 demo 省掉的步骤。function htmlDocumentToHtmlWithStyles(sourceHtml, baseUrl) { const parser new DOMParser(); const doc parser.parseFromString(sourceHtml, text/html); const styles doc.querySelectorAll(style, link[relstylesheet]); const styleTexts []; styles.forEach(function(styleEl) { if (styleEl.tagName.toLowerCase() style) { styleTexts.push(styleEl.textContent); } else if (styleEl.href) { // 同步获取外链样式有跨域问题这里做的是:记录href后续手动处理 styleTexts.push(/* 外链样式需另行处理: styleEl.href */); } }); const styleBlock styleTexts.map(function(css) { return style css /style; }).join(); // 图片相对路径转绝对路径 const imgs doc.querySelectorAll(img[src]); imgs.forEach(function(img) { if (img.src img.src.indexOf(http) ! 0 img.src.indexOf(data:) ! 0) { try { const absoluteUrl new URL(img.src, baseUrl || location.href).href; img.setAttribute(src, absoluteUrl); } catch (err) { console.warn(图片路径转换失败, img.src); } } }); return styleBlock doc.body.innerHTML; }这段逻辑之所以放在转换层而不是编辑器外层是因为富文本编辑器的整体样式是它自己控制的直接注入本地 HTML 的body选择器样式会污染编辑区。常见做法是读取本地文件后把body选择器替换为#editor或者.rich-content这一步可以放在拿到 styleTexts 之后做正则替换。至于外链样式link标签到富文本编辑器里通常会被编辑器过滤最稳妥的方式还是先把 CSS 内容抓出来转成style内联块。3.3 内容注入后的编辑器行为差异注入完成不代表互转结束。contenteditable的 div 在浏览器里有一套自动更正机制粘贴内容时如果包含块级元素可能会自动合并图片加载失败时会显示裂图图标空 div 在编辑时会自动生成br。要做到可控最好执行一次节点清洗。// 清洗注入后的HTML去掉危险标签和各浏览器自动生成的怪异节点 function sanitizeRichHtml(htmlString) { const template document.createElement(div); template.innerHTML htmlString; // 删除script、iframe、object、embed const dangerousTags [script, iframe, object, embed, link]; dangerousTags.forEach(tag { template.querySelectorAll(tag).forEach(node node.remove()); }); // 清理空行残留的nbsp; template.querySelectorAll(p, div, span).forEach(node { if (node.childNodes.length 0 node.innerHTML ) { node.remove(); } }); return template.innerHTML; }4. 从富文本回到HTML字符串并导出下载反向操作最重要的是两点拿到内容时是HTML 文档片段而不是完整文档导出的文件需要把片段补齐成文档结构。另外还要处理浏览器在编辑过程中自动产生的样式垃圾比如stylefont-family: ...这类行内样式以及空段落之间的divbr/div。4.1 序列化与补齐文档结构function exportEditorAsHtmlFile(editorElement, config) { const fragmentHtml editorElement.innerHTML; // 补齐文档头尾 const fullHtml !DOCTYPE html html langzh-CN head meta charsetUTF-8 title${config.title || 导出文档}/title ${config.extraStyle || } /head body ${fragmentHtml} /body /html; // 触发浏览器下载 const blob new Blob([fullHtml], { type: text/html;charsetutf-8 }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download config.filename || export.html; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // 及时释放Blob URL }这里有个不常被注意的细节Blob的类型声明为text/html;charsetutf-8Windows 下双击打开时 IE 浏览器可能因为缺少X-UA-Compatible元标签而进入怪异模式。如果目标是兼容老旧浏览器可以在 head 里加meta http-equivX-UA-Compatible contentIEedge。现代浏览器则无此顾虑。4.2 序列化时的三个阶段过滤策略富文本编辑过程中用户可能会执行无数种操作从 Word 粘贴、从网页复制、手动改 HTML。这些操作会把大量无关属性带进内容。序列化时做三遍过滤可以明显提高导出质量。第一遍移除空标签和重复嵌套。比如font套font、bb文字/b/b。递归遍历子节点可以处理但更高效的策略是转成字符串后做规范化——先用DOMParser解析一遍再从头递归清理。function cleanNestedTags(root) { const children Array.from(root.children); children.forEach(child { cleanNestedTags(child); if (child.children.length 1 child.tagName child.firstElementChild.tagName) { // 同类型标签嵌套保留最内层 child.replaceWith(...child.childNodes); } if (child.childNodes.length 0 !child.isContentEditable) { child.remove(); } }); }第二遍样式归一化。浏览器编辑时会生成style属性带font-family: inherit; font-size: 16px;这种冗余行内样式。常见做法是保留color、background-color、font-weight、text-align其余全部清掉。这里的取舍取决于业务需求比如文档管理系统往往会保留字号行内样式因为跨系统时h1标签的默认样式会因不同浏览器的 UA 样式表而异。第三遍换行与缩进。编辑器中按回车生成的是div分层但导出到静态 HTML 时更规范的是段落标签或br结尾。对导出内容做div → p的批量替换可以显著提升代码可读性也方便后续用其他工具处理。4.3 导出文件被编辑器重新打开时的往返一致性往返一致性round-trip是互转方案的终极指标编辑器导出 HTML 后再加载回去内容应当与原编辑结果等价。这里最容易翻车的是空格、换行和br的变化。contenteditable中空格会被自动转成nbsp;回车会生成div或p而 HTML 字符串中连续多个空格会被浏览器渲染成一个。导出时如果不对空格做转义回显时排版肯定变样。一个常见做法是在导出时遍历文本节点把nbsp;转成\u00A0再把普通空格改成ensp;或emsp;理论上可以保留排版但会让代码变得很难看。折中方案是在编辑器内部做一致化处理——所有空格一律用nbsp;代码洁癖用户就要接受源码里全是 nbsp 的现状。5. 编码问题与容错解析加载本地HTML的工程化补丁5.1 GBK文件乱码readAsText 解决不了的事readAsText(file, UTF-8)遇到 GBK 编码文件会直接乱码因为浏览器不会自动检测编码。解决方案是改用readAsArrayBuffer手动判定编码。function readHtmlFile(file) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload function(e) { const buffer e.target.result; const bytes new Uint8Array(buffer); // 检查UTF-8 BOM if (bytes[0] 0xEF bytes[1] 0xBB bytes[2] 0xBF) { resolve(new TextDecoder(utf-8).decode(buffer)); return; } // 尝试UTF-8解码如果解码后出现大量UFFFD则回退到GBK let decoded new TextDecoder(utf-8, { fatal: false }).decode(buffer); const replacementCount (decoded.match(/\uFFFD/g) || []).length; if (replacementCount 0) { try { decoded new TextDecoder(gbk).decode(buffer); } catch (err) { // 浏览器不支持gbk时保留UTF-8结果 } } resolve(decoded); }; reader.onerror reject; reader.readAsArrayBuffer(file); }); }注意TextDecoder(gbk)的兼容性——Chrome、Edge、Firefox 都支持但个别 WebView 内核缺少该编码。如果目标环境不支持可以引入一个 mini GBK 解码表但非大文件场景很少需要。5.2 DOMParser 对畸形 HTML 的容错本地 HTML 文件不一定是规范文档可能是从老旧网页编辑器导出的存在未闭合标签、属性不带引号、非法嵌套。DOMParser 的优点是它会按浏览器规则自动纠错缺点是纠错规则不可控。做了这些处理给parser.parseFromString返回的doc加一个checkForParseErrors校验如果解析后文档里出现parsererror节点则返回null并提示用户。实际实现时更多依赖浏览器容错真正要防的是用户上传恶意 HTML——所以前面sanitizeRichHtml那一步必须放在解析之后注入之前执行。5.3 相对路径的资源重写策略本地 HTML 文件里的图片路径可能是./img/a.png也可能指向相对路径的上级目录../images/b.jpg。加载到富文本编辑器后需要把相对路径重写为blob:URL 或data:URL。前者的做法是用 File 对象直接URL.createObjectURL(file)后者是 canvas 转 base64。哪种更好看图片数量和体积。大量图片用 blob URL 内存最省少量小图直接转 base64 更稳定——后端存储时不会丢。async function localImagesToBlob(doc, fileCollection) { const imgNodes doc.querySelectorAll(img); const taskQueue []; imgNodes.forEach(img { const src img.getAttribute(src); if (src !src.startsWith(data:) !src.startsWith(blob:)) { const matchedFile fileCollection.find(f f.webkitRelativePath.includes(src) || f.name src.split(/).pop()); if (matchedFile) { const objectUrl URL.createObjectURL(matchedFile); img.src objectUrl; } } }); await Promise.all(taskQueue); }这里的fileCollection来自input typefile webkitdirectory多选目录用户选择 HTML 时同步选择同目录的图片。上传后 blob URL 生命周期受页面影响刷新即失效。若要持久化必须后端存储或转 base64。6. 扩展技巧从 demo 到可用组件的三条升级路径6.1 基于事件自动编码让互转发生在用户无感知的层不一定要等用户点导出才转换。可以在contenteditable区域监听input事件用防抖延迟 500ms 把内容序列化并同步到隐藏的 textarea 或变量。let timer null; editor.addEventListener(input, function() { clearTimeout(timer); timer setTimeout(function() { const htmlStr editor.innerHTML; document.getElementById(hiddenContent).value htmlStr; console.log(HTML字符串已同步:, htmlStr.length, 字符); }, 500); });这为后续提交表单做准备——富文本区域本身不参与表单提交必须把 HTML 字符串塞进隐藏字段。6.2 一键预览本地HTML渲染与编辑视图的分离双栏布局左边是编辑区右边是只读预览区。常见做法是复制 HTML 字符串给 iframe 的srcdoc属性。function updatePreview(htmlStr) { const plain htmlStr.replace(/script[\s\S]*?\/script/gi, ); // 防止脚本执行 const iframe document.getElementById(previewFrame); iframe.srcdoc htmlheadmeta charsetUTF-8/headbody plain /body/html; }srcdoc可以避免srcblob URL那种外链清理的麻烦并且不受父页面样式影响是预览富文本的干净方案。6.3 最后下钻换行规范化与white-space处理使用过一个技巧清空contenteditable后用document.execCommand(defaultParagraphSeparator, false, p)设置块级元素类型让回车生成p而不是div。这样序列化后的 HTML 字符串中就没有大量嵌套div了后续转 Markdown 或写 PDF 也更方便。有些组件库提供的html2md转换器在处理div时会把结构搞乱改成p以后结果就稳定。拦截设备的 WebView 中execCommand已被标记废弃但浏览器兼容性尚好在 Electron 桌面端项目中仍然可靠。如果坚持不用废弃 API就只有通过 keydown 事件手动插入p标签并阻止默认行为成本偏高收益有限。本文还有配套的精品资源点击获取