ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从一行默认文本到完整编辑链路:Markdown、富文本与XSS安全实战

从一行默认文本到完整编辑链路:Markdown、富文本与XSS安全实战 在很多后台管理系统中都能看到这样一段默认文本This is my editing。它可能是富文本编辑器初次打开时的占位内容也可能是新建文档的初始草稿甚至是测试同学随手留下的一行字符。很多人觉得它无关紧要反正用户改掉就好了。但如果你真的要把一个“可以编辑”的页面做扎实就会发现围绕这一行文本从客户端输入、序列化、存储、回显、安全过滤到版本回溯每一环都有肉眼看不见的坑。这篇文章不打算讲某个大而全的付费编辑器而是从一个最普通的字符串场景出发把编辑功能背后的完整链路拆开讲清楚。读完你可以做到三件事第一理解编辑器开发的核心不是“输入框加按钮”而是内容模型、输入边界和安全边界第二用最小代码跑通一个可保存、可回显、能防御基础 XSS 的编辑模块第三在真实项目中知道该从哪里做加固哪里可以先用简单方案哪里必须上重型编辑器。1. 这篇文章真正要解决的问题先问一个问题在业务系统里做一个“编辑”功能真的只是放一个textarea那么简单吗如果你只需要收集一行备注那确实简单。但现实中的编辑场景要复杂得多笔记应用要支持 Markdown 预览后台管理系统要支持富文本排版在线文档要支持多人同时编辑内容发布系统要允许粘贴带格式的文本同时还要防止粘贴进来的脚本执行。这些需求一旦出现“编辑”就从一个 DOM 操作问题变成了一个数据流问题。回到This is my editing这行文本。它看起来只是一个字符串可一旦进入编辑器就会引发一连串问题用户一个字都没改直接点了保存这时要不要把默认文本当成正式内容写入数据库用户把内容清空是允许保存空文档还是弹提示“内容不能为空”用户粘贴了一段带 HTML 的文本其中包含script或onclick属性前端预览时会不会执行用户编辑了一篇超长文档每敲一个字都整页刷新会不会卡死浏览器两个用户同时编辑同一份文档后保存的人会不会把前一个人的内容整个覆盖掉这些问题都不是“多写几行判断”就能解决的它们贯穿了前端采集、序列化、接口提交、服务端校验、持久化、渲染回显的完整链路。如果一开始没有想清楚内容模型后面每加一个功能都会很痛苦。我的判断是一个编辑器能不能做好不在于外观有多华丽而在于三个边界是否清晰——输入边界、存储边界、渲染边界。输入边界决定客户端能接受什么存储边界决定服务端以什么格式保存渲染边界决定内容在浏览器中如何安全地展示。这篇文章会围绕这三条边界展开并用代码演示一条最小可用的实现路径。适合读这篇文章的读者有两类一类是刚接手内容型系统、需要从零设计编辑模块的前端或全栈开发者另一类是已经在用现成富文本组件但遇到 XSS 漏洞、内容丢失、粘贴样式错乱等线上问题、想搞清楚原理的人。2. 基础概念编辑功能的三种形态在动手写代码之前先理解编辑器的三种形态。这不是为了罗列概念而是因为选型错误才是大多数编辑功能翻车的根本原因。2.1 纯文本编辑最基础的形态就是一个textarea或input用户输入的内容就是纯字符串没有格式。它的优点是简单、可控、安全风险极低缺点是无法表达加粗、标题、列表、图片等富文本信息。纯文本适合备注、标签、配置项、简短评论等场景。如果业务只需要“记录一段话”千万不要为了界面好看去引入富文本编辑器。很多需求表面上是“想要富文本”实际上只是“想要一个好看点的输入框”。2.2 Markdown 文本加渲染Markdown 编辑器的核心特征是“内容以纯文本存储展示时按规则渲染成 HTML”。用户编辑的是带#、**、-等标记的原文预览区把这些标记解析成标题、加粗、列表。这种形态兼顾了存储简单和表现力数据库里存的是纯文本天然方便做 diff、检索和版本对比。但它有一个关键的坑渲染时一定要先做 HTML 转义再替换 Markdown 标记否则用户输入script就会被浏览器直接当成脚本执行。这一点我们在后面的代码示例中会专门演示。Markdown 编辑器适合技术文档、博客后台、README 编辑、代码注释类场景。2.3 富文本编辑contenteditable富文本编辑器使用的是contenteditable能力用户在页面上看到的内容就是最终排版效果也就是“所见即所得”。这类编辑器通常直接操作 DOM用户加粗一段文字底层得到的可能是b或strong包裹的 HTML。contenteditable本身只是一个属性浏览器不会帮你管理内容结构。早期的网页编辑器大量依赖document.execCommand来执行加粗、插入列表等命令但这个方法早已被标记为废弃行为在各浏览器中也不一致。因此现代开源富文本编辑器基本都不再直接依赖它而是自己维护一套文档模型例如 ProseMirror、Slate、Lexical 以及基于 ProseMirror 的 TipTap。理解这层区别很重要现代富文本编辑器之所以“重”不是因为它们功能多而是因为它们需要把用户操作同步到一套可控的数据模型上再通过模型驱动视图更新。你要做加粗不能直接命令浏览器“把选中文字加粗”而是要修改文档模型中的节点标记再重新渲染视图。2.4 三种形态对比编辑形态存储内容前端复杂度安全风险适用场景纯文本字符串低低备注、标签、简短内容Markdown带标记的纯文本中中渲染层需转义技术文档、博客后台富文本HTML 或自定义文档 JSON高高需严格过滤后台排版、在线文档、复杂内容从实际项目来看很多团队一上来就选富文本导致项目体积变大、内容结构难以控制、安全过滤又没跟上。更稳妥的做法是默认从纯文本或 Markdown 开始只有当业务确实需要复杂排版时再迁移到富文本方案。3. 环境准备与最小方案选型本文的示例偏前端全栈但依赖很少。你不需要搭一个复杂脚手架只需要有基本的浏览器和 Node.js 环境。操作系统Windows、macOS、Linux 均可浏览器Chrome、Edge、Firefox 等现代浏览器Node.js建议使用 LTS 版本版本以你本机为准包管理器npm 或 pnpm 二选一示例会使用 Express 演示一个极简后端接口但这不是必需的。如果你不想安装依赖可以直接看 5.1 和 5.2 两个前端示例它们不依赖任何第三方库。Adapter可以先准备一个项目目录结构如下editor-demo/ ├── public/ │ └── index.html ├── server.js └── package.json如果目录还不存在可以执行mkdir -p editor-demo/public cd editor-demo npm init -y npm install express这里的重点是跑通流程而不是追新版本所以没有固定版本号。等你真正进入生产项目时再以团队锁定的版本为准。4. 核心流程拆解从输入到回显的四步链路编辑功能无论用什么前端框架后端用 Java、Node.js、Python 还是 Go逻辑都可以抽象成四步采集输入、解析与序列化、存储、渲染回显。下面逐一步拆开看。4.1 采集输入采集输入是指监听用户输入行为拿到最新内容。常见事件有三种input事件在每次输入时触发适合做自动保存草稿表单submit或按钮 click 事件适合做正式的提交保存blur事件适合做“失焦保存”。这里最容易踩的坑是“保存时机混乱”。如果你既做了自动保存又做了按钮提交有可能会出现草稿覆盖正式内容的问题如果用户快速输入每次按键都发请求又可能造成接口压力。所以在最前面就要想清楚这次编辑是临时草稿还是正式提交草稿走本地或自动保存接口正式提交必须由用户明确触发。4.2 解析与序列化解析与序列化是把“用户看到的内容”变成“可以存储的内容”以及反向操作。纯文本最简单原样存储即可。Markdown 需要保留原始标记文本渲染时再解析。富文本则复杂得多你存储的是 HTML 字符串还是自定义 JSON 文档模型如果是 HTML浏览器解析出来的 DOM 结构可能不一致如果是 JSON 文档模型就需要实现序列化和反序列化两套逻辑。很多编辑器项目失控就是从“存 HTML 字符串”开始的。HTML 字符串看起来直观实际上既有 XSS 风险又难以做细粒度的版本对比因为同一段视觉内容可以对应多种不同的 HTML 写法。现代编辑器更推荐用结构化的文档模型存储。4.3 存储存储是编辑内容的落地环节。前端可以做 localStorage 草稿缓存服务端可以写入数据库。这里必须明确数据的校验规则内容最大长度是多少是否允许空字符串默认内容什么时候写入字段结构是否带版本号。服务端永远不能信任客户端提交的数据。即使前端已经做了长度限制后端也要重新校验。因为绕过前端直接调接口的成本太低接口层才是数据安全的最后防线。4.4 渲染回显渲染回显是把存储内容重新变成页面展示。这一步的核心不是“把字符串塞进 innerHTML”而是“如何安全地塞进去”。用户第一次看到编辑器通常是从服务端加载已有内容。如果内容里包含img onerroralert(1)而前端直接渲染就会触发脚本执行。这是一个非常经典的存储型 XSS 场景。安全做法是在渲染前做白名单过滤只保留允许的标签和属性去掉script、iframe、onclick、javascript:等危险内容。如果把这四步连起来看This is my editing只是整个链路中一个微不足道的默认输入值但真正成熟的编辑系统恰恰是围绕“这段文本如何被安全地读到、存下、再展示出来”来设计的。5. 完整示例与代码实现下面给出四个示例覆盖本地编辑、安全渲染、服务端保存和默认内容处理。建议按顺序看因为前一个示例是后一个的基础。5.1 示例一极简本地 Markdown 编辑器这个示例不依赖任何第三方库核心功能是在textarea中输入 Markdown 文本右侧实时预览内容自动保存到 localStorage页面刷新后自动恢复。它把“采集输入、序列化、存储、渲染回显”四步完整走了一遍。文件路径editor-demo/public/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title极简 Markdown 编辑器/title style body { max-width: 900px; margin: 40px auto; padding: 0 20px; font-family: system-ui, sans-serif; } #editor { width: 100%; min-height: 200px; padding: 12px; font-size: 16px; line-height: 1.6; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; } #preview { border: 1px solid #ddd; border-radius: 6px; padding: 12px; margin-top: 12px; min-height: 200px; background: #fafafa; } /style /head body h1极简 Markdown 编辑器/h1 textarea ideditor placeholder请输入 Markdown 内容/textarea div idpreview/div script const STORAGE_KEY md-editor-draft; const DEFAULT_CONTENT This is my editing; const editor document.getElementById(editor); const preview document.getElementById(preview); function getInitialContent() { const saved localStorage.getItem(STORAGE_KEY); return saved null ? DEFAULT_CONTENT : saved; } function escapeHtml(text) { return text .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); } function renderMarkdown(text) { const escaped escapeHtml(text); return escaped .replace(/^### (.*)$/gm, h3$1/h3) .replace(/^## (.*)$/gm, h2$1/h2) .replace(/^# (.*)$/gm, h1$1/h1) .replace(/\*\*(.*?)\*\*/g, strong$1/strong) .replace(/\n/g, br /); } function updatePreview() { preview.innerHTML renderMarkdown(editor.value); } editor.value getInitialContent(); editor.addEventListener(input, function () { localStorage.setItem(STORAGE_KEY, editor.value); updatePreview(); }); updatePreview(); /script /body /html这段代码最关键的地方是escapeHtml函数。它先把用户输入中的、、等字符转义成实体再进行 Markdown 标记替换。这样用户输入script时预览区显示的是普通文本script浏览器不会执行脚本。如果顺序反过来先替换 Markdown 标记再转义就会生成不可预期的 HTML 结构。5.2 示例二contenteditable 内容的安全清洗如果业务真的需要富文本最基础的安全底线是不要信任innerHTML。下面这个示例给出一个教学级的白名单清洗函数它会把危险标签和危险属性过滤掉。文件路径editor-demo/public/sanitize.jsconst SAFE_TAGS new Set([ B, STRONG, I, EM, U, A, P, BR, UL, OL, LI, DIV, SPAN, H1, H2, H3 ]); const SAFE_ATTRS new Set([href, target, rel]); function buildSafeNode(node) { if (node.nodeType Node.TEXT_NODE) { return document.createTextNode(node.textContent); } if (node.nodeType ! Node.ELEMENT_NODE) { return document.createTextNode(); } const tagName node.tagName.toUpperCase(); if (!SAFE_TAGS.has(tagName)) { return document.createTextNode(node.textContent || ); } const clone document.createElement(tagName.toLowerCase()); for (const attr of Array.from(node.attributes)) { if (SAFE_ATTRS.has(attr.name)) { if (attr.name href /^javascript:/i.test(attr.value)) { continue; } clone.setAttribute(attr.name, attr.value); } } for (const child of Array.from(node.childNodes)) { clone.appendChild(buildSafeNode(child)); } return clone; } function sanitizeHtml(html) { const doc new DOMParser().parseFromString(html, text/html); const safeRoot document.createElement(div); for (const child of Array.from(doc.body.childNodes)) { safeRoot.appendChild(buildSafeNode(child)); } return safeRoot.innerHTML; } const dirtyHtml p onclickalert(1)img srcx onerroralert(1)hello/p scriptalert(2)/script; document.getElementById(preview).innerHTML sanitizeHtml(dirtyHtml);这个实现的核心思路是“白名单而不是黑名单”。黑名单过滤很容易被绕过比如用scrscriptipt、大小写混写、事件属性变形等方式绕过。白名单则严格得多不在列表里的标签一律移除不在列表里的属性一律删除javascript:开头的链接直接丢弃。需要说明的是这个函数用于教学是合格的用于生产环境还远远不够。生产环境建议使用成熟的 HTML 清洗库例如 DOMPurify具体版本以项目安装为准。不要重新发明安全过滤器这是无数安全漏洞反复证明过的教训。5.3 示例三Node.js 服务端保存接口前两个示例的存储都在浏览器本地真实项目还需要服务端保存。下面用 Express 实现一个最小接口它接收前端提交的内容校验类型和长度并保存到内存 Map 中。文件路径editor-demo/server.jsconst express require(express); const app express(); app.use(express.json({ limit: 1mb })); const documents new Map(); app.get(/api/documents/:id, (req, res) { const doc documents.get(req.params.id); if (!doc) { res.status(404).json({ message: document not found }); return; } res.json(doc); }); app.post(/api/documents/:id, (req, res) { const id req.params.id; const content req.body.content; if (typeof content ! string) { res.status(400).json({ message: content must be a string }); return; } if (content.length 100000) { res.status(413).json({ message: content too large }); return; } documents.set(id, { id, content, updatedAt: new Date().toISOString() }); res.json(documents.get(id)); }); const PORT 3000; app.listen(PORT, () { console.log(server running at http://localhost:${PORT}); });启动方式node server.js这个示例中有三个地方值得注意。第一express.json({ limit: 1mb })限制了请求体大小避免用户提交超大内容拖垮服务。第二接口层对typeof content ! string做了判断防止提交 JSON 数组、对象等非字符串类型。第三Map 只是演示用的内存存储服务重启后数据会丢失生产环境必须换成数据库并加入用户权限校验。5.4 示例四默认内容与空值处理现在回到This is my editing。它扮演的是“默认草稿”的角色。默认内容处理的核心问题是用户是在编辑一篇已有文档还是在新建一篇空文档两种情况逻辑不同。文件路径editor-demo/public/init.jsfunction createDocument({ savedContent, defaultContent }) { if (typeof savedContent string savedContent.trim() ! ) { return { content: savedContent, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; } return { content: defaultContent, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; } // 新建文档时默认给一段示例文本 const doc createDocument({ savedContent: localStorage.getItem(doc-1), defaultContent: This is my editing }); console.log(doc.content);这里的判断逻辑是如果已经保存过内容就优先恢复用户内容如果没有保存过才使用默认文本。这样不会出现“用户明明保存过刷新后却被默认文本覆盖”的问题。还有一个容易混淆的点默认文本和placeholder是两回事。placeholder是一段灰色提示文字不会写入内容适合“告诉用户这里该输入什么”默认文本是真实写入输入框的值用户一旦保存就会变成正式内容。如果你的需求只是引导用户输入请用placeholder不要用value。否则就会出现数据库里存满了一堆 “This is my editing” 的尴尬情况。6. 运行结果与效果验证先启动服务端node server.js然后直接用浏览器打开editor-demo/public/index.html在本地模式下验证编辑和预览。你应该看到页面上 textarea 中默认显示This is my editing预览区同步显示这行文本修改 textarea 内容后预览区实时变化刷新页面后修改的内容仍然保留。再验证服务端接口。另开一个终端执行curl -X POST http://localhost:3000/api/documents/doc-1 \ -H Content-Type: application/json \ -d {content:This is my editing}预期返回{ id: doc-1, content: This is my editing, updatedAt: 2025-01-01T00:00:00.000Z }再执行curl http://localhost:3000/api/documents/doc-1预期返回刚才保存的内容。如果请求一个不存在的 id会返回 404 和document not found。判断成功的标准是前端刷新后内容不丢失。预览区的 HTML 不会被当成真实标签执行。接口能正确保存和读取内容。提交非字符串内容时返回 400。提交超长内容时返回 413。如果某一步失败优先查看浏览器控制台是否有 JavaScript 报错然后看 Network 面板里接口请求的响应体最后看服务端终端日志。大多数问题都出在路径不对、字段名不一致或服务未启动这三个地方。7. 常见问题与排查思路编辑功能上线后最常见的问题远不是“不好看”而是内容丢失、样式混乱、安全漏洞。下面整理一份排查表。问题现象可能原因排查方式解决方案刷新后内容丢失localStorage 未写入或存储被禁用打开 DevTools 的 Application 面板查看 Local Storage确认 key 一致检查是否处于隐私模式预览区出现了 HTML 标签文本渲染前未做 HTML 转义查看 renderMarkdown 函数中是否先调用 escapeHtml先转义特殊字符再执行 Markdown 替换富文本粘贴内容样式错乱浏览器把剪贴板里的 HTML 一并带入监听 paste 事件查看 clipboardData拦截 paste清洗 HTML 或只保留纯文本保存后页面空白内容中包含脚本或非法 DOM查看控制台报错和 Network 响应前后端都做 HTML 清洗部署 CSP接口返回 413内容超过服务端限制查看请求体大小和服务端日志调整 express.json 的 limit 或前端限制长度两个用户同时编辑互相覆盖没有版本控制或并发控制检查保存接口是否携带版本号加乐观锁字段 version冲突时提示用户默认文本被当成正式内容保存把默认值写入了 value 且未做判断检查初始化逻辑和保存逻辑引导输入用 placeholder正式内容需用户明确提交这些问题的共同根源是开发时把“编辑”当成了一个纯前端交互问题忽略了数据流和后端边界。很多问题看似发生在界面层实际修复点都在数据层。8. 最佳实践与工程建议既然编辑功能覆盖了前端、接口、存储和安全多个环节工程建议也需要分多层来看。8.1 定义清晰的内容模型动手写代码之前先把 Document 模型定义清楚。它至少包含这些字段{ id: doc-1, title: My Document, content: This is my editing, contentType: markdown, version: 1, createdAt: 2025-01-01T00:00:00.000Z, updatedAt: 2025-01-01T00:00:00.000Z }contentType用来标记内容是纯文本、Markdown 还是富文本 HTML。这个字段在早期比想象力重要得多因为它决定了后续的渲染和清洗策略。version字段用于乐观锁保存时携带版本号后端发现版本不一致就拒绝覆盖。8.2 前端与服务端双层校验前端校验是为了用户体验服务端校验才是安全底线。前端可以限制输入长度服务端必须再次限制前端做了 HTML 清洗服务端入库前仍然要清洗一遍。因为攻击者完全可以绕过前端直接调用接口往数据库里写入恶意内容。8.3 安全边界要前置只要涉及 HTML 渲染就必须有默认拒绝的思维默认不允许任何标签和属性只有白名单内的内容才放行。建议从项目第一天就引入可靠的清洗库而不是等出现 XSS 漏洞后再补救。同时部署 CSP 响应头从浏览器层面限制脚本执行来源即使有内容绕过过滤脚本也无法从外部域名加载。8.4 保存策略要分场景临时草稿可以使用防抖自动保存例如用户停止输入 500 毫秒后保存正式内容必须由用户点击提交按钮触发。防抖保存可以减少接口请求次数同时也避免用户输入过程中内容丢失。8.5 不要一开始就上重型富文本如果业务只需要短文本或 Markdown就不要引入 ProseMirror 或 Slate。重型编辑器带来的是更高的学习成本、更大的打包体积、更复杂的内容模型。从实际项目来看80% 的“富文本需求”用 Markdown 加图片上传就能满足。8.6 为后续协作留下空间如果产品方向是“在线协同编辑”请尽早调研 CRDT 和 OT 方案。这类功能不可能后期轻松叠加因为它会改变内容存储结构和冲突处理策略。即使第一版不做多人同时编辑也建议保留version字段和操作日志表为将来留出余地。9. 总结与后续学习方向从This is my editing这行文本出发我们完整走了一遍编辑功能的链路采集输入、解析序列化、服务端存储、安全渲染。你最终会发现编辑器真正的复杂度不在界面而在内容模型和边界控制上。能处理好 XSS、空值、超长内容和并发覆盖这四个问题的编辑模块已经具备投产的基本条件处理不好这四个问题换再高级的编辑器框架也只是换了一个爆炸方式。如果你正准备在系统里加入编辑能力建议从最朴素的方案开始纯文本或 Markdown配合防抖自动保存和服务端校验。当产品明确需要复杂排版或多人协作时再切换到 ProseMirror、Slate、Lexical 等成熟方案。下一阶段可以重点学习这几个方向富文本编辑器的文档模型研究 ProseMirror 或 Slate 如何描述加粗、段落、图片等节点而不是停留在execCommand层面。HTML 清洗原理对比 DOMPurify 的实现思路理解白名单过滤、命名空间处理、CSS 清理等细节。版本控制与冲突处理了解乐观锁、文档快照、操作日志以及 OT 与 CRDT如 Yjs的基本原理。编辑器与 AI 的结合现在很多产品已经在编辑器中加入 AI 补全、续写、翻译、改写能力实现方式通常是在编辑器的输入事件和渲染层之间插入异步处理流程。建议先动手做一个基于 Markdown 的最小编辑器把本地保存、服务端接口、XSS 清洗三条链路全部跑通再逐步扩展。这一段路走完你对编辑器的理解会超过很多直接用现成组件的开发者。
返回列表