从零实现Web富文本编辑器:核心原理与最小代码示例
2026/9/9 22:41:51 网站建设 项目流程

最近一段时间,印度开发者社区里接连出现了一些轻量级的在线编辑工具,有的冲上了 GitHub Trending,有的在 Product Hunt 和 Hacker News 上被反复讨论。这些工具的功能说起来并不复杂:打开网页就能编辑内容,支持富文本或表单操作,一键导出或分享。但就是这种"看起来没什么技术含量"的产品,反而在开发者圈子里引起了不少关注。

这里真正值得开发者的,不是"又一个编辑器"本身,而是编辑体验被完整塞进浏览器之后,整个产品链路发生的变化。过去要装一个客户端软件才能完成的排版、标注、表单构建任务,现在只需要一个 URL 就能跑通。这种变化背后,是浏览器能力、前端工程化和数据模型设计的综合结果。我在这篇文章里会把这层皮剥开,从技术原理讲到最小实现,再用一个完整的 demo 演示怎么从零搭出同类工具。

读完这篇文章,你会理解三件事:第一,这类在线编辑工具的核心技术栈和实现原理是什么;第二,一个最小可用的编辑器应该包含哪些模块,代码怎么写;第三,如果要上生产环境,哪些坑是绕不过去的,选型时应该怎么判断。

1. 这篇文章真正要解决的问题

先说一个判断:近期走红的那批"印度制造"编辑工具,成功的关键不是功能堆得多,而是把编辑这个动作变成了"零安装、零配置、零学习成本"的浏览器体验。用户打开页面,键盘一敲,内容就已经在页面里了,点击导出,一个 HTML 文件就下来了。整个过程没有任何安装向导,也不需要理解文件、格式、版本兼容这些概念。

这就是这类工具解决的真正痛点:轻量任务不需要重型软件。很多人只是想把一段文字排个版,把几张图片拼个版,把一份会议纪要转成可分享的网页。这些任务放在十年前,需要打开 Word、Photoshop 或者某个桌面表单工具,学习成本和使用成本都高得离谱。而现在,一个浏览器页面就把所有事情做完了。

从技术角度看,这类工具的共同点也高度一致:

  • 前端负责编辑体验和实时渲染;
  • 数据以 HTML 片段或 JSON 结构保存在本地或服务端;
  • 导出能力通常是生成 HTML、Markdown 或图片;
  • 多数工具没有后端,或者后端只是做一个存储和分享链接。

所以,这篇文章真正的主题不是某个具体产品,而是这一类工具背后的通用技术范式。我会按照"原理、架构、实现、排错、选型"这条线,把整个技术栈拆开讲清楚。

如果你是前端开发者,想了解现代 Web 编辑器是怎么实现的,这篇文章可以直接看第 2、4、5 节。如果你是后端或全栈开发者,想评估这类工具怎么接入自己的业务系统,重点看第 3、7、8 节。如果你只是对 Trending 产品好奇,想搞清楚"这东西火在哪",从开头读到第 6 节,你应该就能形成自己的判断了。

2. 基础概念与核心原理:浏览器里的编辑体验是怎么来的

要理解这类工具,先要把几个基础概念弄清楚。这里我不打算写成百科词条,而是按"没有它时怎么做、引入它后有什么变化"的思路来讲。

2.1 编辑类工具的三种类型

首先,"编辑工具"这个说法其实覆盖了三种不同的技术方向,混为一谈最容易产生误解:

类型典型能力核心技术难点
富文本编辑器加粗、斜体、标题、列表、图片光标管理、选区控制、DOM 结构
表单构建器拖拽字段、配置校验、生成表单数据模型、JSON Schema、动态渲染
媒体编辑工具裁剪、滤镜、文字叠加Canvas、滤镜算法、性能优化

Trending 上常见的产品通常属于第一类和第二类的结合。用户在页面上看到的是一块"可编辑画布",背后其实是一套内容模型和渲染引擎。

2.2 让页面"可编辑"的三个关键技术

浏览器本身并不天然支持"编辑网页内容"这种需求。随着 Web 从只读内容向交互应用演进,浏览器提供了一组底层能力,这几个概念是所有 Web 编辑器的基础:

Contenteditable(可编辑属性)

把一个 HTML 元素加上contenteditable="true",这个元素就变成了可编辑区域。用户点击它就可以输入文字,敲回车会生成新的段落节点,粘贴内容时浏览器会尽量保留原格式。这看起来很简单,但它把"编辑"这件事从桌面应用搬到了 DOM 世界里,是所有 Web 编辑器的地基。

Document.execCommand(编辑指令)

有了可编辑区域,下一步就是让"加粗""斜体"这些命令生效。浏览器为此提供了一套命令接口,比如执行document.execCommand('bold')就会把当前选中文字加粗。这些指令由浏览器原生实现,开发者不需要关心选区里到底有哪些节点,浏览器会自动处理。

Selection 和 Range(选区与范围)

当你用鼠标选中一段文字时,浏览器会生成一个 Selection 对象,里面可能包含多个 Range 对象,每个 Range 都记录了选区的起始节点、偏移位置和结束节点。所有编辑指令本质上都是"基于当前选区,对 DOM 做修改"。复杂编辑器之所以难写,难点就在这里:选区跨节点时的边界条件非常多。

用一张简单的流程图来理解三者关系:

用户输入/鼠标操作 ↓ contenteditable 区域捕获内容 ↓ Selection/Range 描述当前选区 ↓ execCommand 或框架指令修改 DOM ↓ DOM 变化即为最终内容

2.3 传统桌面编辑器与 Web 编辑器的区别

桌面编辑器(比如 Word)维护的是私有二进制格式,渲染和存储是两套东西。Web 编辑器的内容本身就是 HTML DOM,所见即所得是天然成立的——你在页面上看到的 DOM,就是最终保存的内容。

这也带来了一个根本性的取舍:桌面编辑器的数据模型更严格,但跨平台共享困难;Web 编辑器天然适合分享和嵌入,但数据模型松散,容易出现结构不规范的 HTML。很多生产级项目后来引入 ProseMirror、Slate、TipTap 这类框架,本质就是为了给宽松的 DOM 模型加上一层"结构化约束"。

3. 环境准备与前置条件

看完原理,我们来动手做一个最小可用的编辑工具。先准备环境。

这个 demo 的核心能力是浏览器原生的,所以对环境要求很低。有两种方式:

方式一:纯浏览器运行(最简单)

只需要三个文件:index.htmlstyle.cssmain.js。把它们放在同一目录下,直接用浏览器打开 HTML 文件即可,不需要任何构建工具。

方式二:用 Vite 启动本地开发服务(更接近实际项目)

需要提前安装 Node.js。项目初始化命令如下,版本请以本地实际安装的为准,本文重点是演示通用思路:

npm create vite@latest light-editor -- --template vanilla cd light-editor npm install npm run dev

如果npm create vite因为网络原因执行缓慢,也可以手动创建目录和文件,然后单独安装 Vite:

mkdir light-editor cd light-editor npm init -y npm install -D vite

无论用哪种方式,最终的项目结构是一致的:

light-editor/ ├── index.html ├── style.css └── main.js

需要说明的是,这个 demo 不依赖任何第三方编辑框架,全部使用浏览器原生 API 实现。这样做的目的是把原理暴露在明面上,方便你理解。

4. 核心流程拆解:一个编辑器的五个模块

一个完整的编辑工具,哪怕是最小实现,也至少要包含五个模块。下面逐个拆解。

4.1 定义数据模型

编辑器内部需要回答一个问题:用户编辑的内容,最终以什么形式保存?

在这个 demo 里,内容直接以 HTML 片段形式存在。这样做的好处是简单直接,和 DOM 编辑天然匹配;坏处是如果用户从网页复制内容,可能会带入大量样式污染。关于这个问题,第 8 节会详细讲如何处理。

4.2 构建可编辑区域

编辑区域是用户交互的核心。给它添加contenteditable="true"属性,再设置最小高度和样式,用户就能直接输入内容。

这里有一个容易被忽视的细节:可编辑区域需要设置outline: none,否则点击时会出现浏览器默认的高亮边框,影响视觉一致性。同时建议设置spellcheck="false",避免编辑中文等内容时出现大量红色波浪线干扰排版。

4.3 实现工具栏指令

工具栏按钮和document.execCommand是一一对应的。点击按钮时,我们需要先执行对应命令,再把焦点还给编辑区。

这里的实现要点是命令参数的传递。像"加粗"这类命令不需要参数,而"格式化为标题"必须传入h2这类值。所以按钮上需要用><!-- 文件路径:light-editor/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>轻量编辑工具 Demo</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="app"> <header class="toolbar"> <button type="button">/* 文件路径:light-editor/style.css */ * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; margin: 0; background: #f5f6f8; color: #1f2329; } .app { max-width: 860px; margin: 40px auto; padding: 0 16px; } .toolbar { display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px; background: #ffffff; border: 1px solid #e2e4e8; border-bottom: none; border-radius: 12px 12px 0 0; } .toolbar button { border: 1px solid #d0d3d9; background: #ffffff; border-radius: 6px; padding: 6px 12px; font-size: 14px; cursor: pointer; transition: background 0.2s ease; } .toolbar button:hover { background: #f0f2f5; } #editor { min-height: 320px; background: #ffffff; border: 1px solid #e2e4e8; border-radius: 0 0 12px 12px; padding: 24px; outline: none; font-size: 16px; line-height: 1.7; } #editor:focus { border-color: #4e8cff; } #editor img { max-width: 100%; }

样式的重点:工具栏和编辑区使用"上圆下方"的组合,视觉上形成一体;编辑区设置了最小高度和焦点边框,交互反馈清晰;#editor img { max-width: 100% }是为了防止插入大图撑破布局。

5.3 逻辑层:main.js

// 文件路径:light-editor/main.js const editor = document.getElementById('editor'); const saveBtn = document.getElementById('saveBtn'); const exportBtn = document.getElementById('exportBtn'); const STORAGE_KEY = 'light-editor-content'; // 1. 工具栏事件委托:统一处理所有编辑指令 document.querySelectorAll('.toolbar button[data-cmd]').forEach((button) => { button.addEventListener('click', () => { const cmd = button.dataset.cmd; const value = button.dataset.value || null; document.execCommand(cmd, false, value); editor.focus(); }); }); // 2. 插入链接:单独处理,避免默认指令的交互过于粗糙 document.querySelector('[data-cmd="createLink"]').addEventListener('click', () => { const url = window.prompt('请输入链接地址:', 'https://'); if (url) { document.execCommand('createLink', false, url); } editor.focus(); }); // 3. 从 localStorage 恢复内容 function loadContent() { const saved = localStorage.getItem(STORAGE_KEY); if (saved) { editor.innerHTML = saved; } } // 4. 保存到本地 saveBtn.addEventListener('click', () => { localStorage.setItem(STORAGE_KEY, editor.innerHTML); window.alert('已保存到浏览器本地'); }); // 5. 导出为 HTML 文件 exportBtn.addEventListener('click', () => { const fullHtml = `<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>导出内容</title> <style>body { max-width: 860px; margin: 40px auto; padding: 0 16px; line-height: 1.7; }</style> </head> <body> ${editor.innerHTML} </body> </html>`; const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'editor-export.html'; a.click(); URL.revokeObjectURL(url); }); // 初始化:恢复上次保存的内容 loadContent();

这段代码有几个关键逻辑需要解释:

  • document.execCommand(cmd, false, value)的第二个参数是 UI 开关,在标准浏览器里传false即可。第三个参数是命令所需的附加值,比如insertHTML需要传 HTML 字符串,formatBlock需要传标签名。
  • 插入链接单独处理,是因为createLink默认不会弹输入框,需要自己实现交互。
  • 导出时把编辑区内容包进一个完整 HTML 文档,保证下载下来的文件可以独立打开浏览。
  • URL.createObjectURL生成临时下载链接,下载完成后调用revokeObjectURL释放内存,避免内存泄漏。

5.4 表单构建场景:JSON Schema 渲染示例

如果你的产品目标是"表单编辑"而不是"富文本编辑",数据模型通常是 JSON。下面是最小示例:

// 文件路径:light-editor/form-schema.js const formSchema = { title: '活动报名表', fields: [ { type: 'input', name: 'name', label: '姓名', placeholder: '请输入姓名', required: true }, { type: 'select', name: 'city', label: '所在城市', options: ['北京', '上海', '广州', '深圳'] }, { type: 'textarea', name: 'remark', label: '备注', rows: 3 } ] }; function renderForm(schema) { const container = document.createElement('div'); container.innerHTML = `<h3>${schema.title}</h3>`; schema.fields.forEach((field) => { const wrapper = document.createElement('div'); wrapper.classList.add('form-field'); const label = document.createElement('label'); label.textContent = field.label; if (field.required) { label.textContent += ' *'; } let control; if (field.type === 'select') { control = document.createElement('select'); control.name = field.name; field.options.forEach((opt) => { const option = document.createElement('option'); option.value = opt; option.textContent = opt; control.appendChild(option); }); } else if (field.type === 'textarea') { control = document.createElement('textarea'); control.name = field.name; control.rows = field.rows || 3; } else { control = document.createElement('input'); control.type = 'text'; control.name = field.name; control.placeholder = field.placeholder || ''; } wrapper.appendChild(label); wrapper.appendChild(control); container.appendChild(wrapper); }); return container; } // 使用示例:document.getElementById('app').appendChild(renderForm(formSchema));

这个例子的核心价值在于:编辑器和渲染器共享同一份 JSON Schema。用户在编辑表单时改的是 Schema,业务系统展示时读的也是 Schema,两者天然一致。

5.5 运行方式

如果是方式一,直接双击index.html在浏览器打开。如果是方式二,在项目根目录执行:

npm run dev

然后在终端输出的本地地址(通常是http://localhost:5173)打开页面。

6. 运行结果与效果验证

页面打开后,应该能看到一个居中的工具栏和下方白色编辑区。验证清单如下:

序号操作预期结果
1点击编辑区,输入一段文字文字正常显示,光标正常移动
2选中文字,点击"加粗"选中文字变为粗体
3点击"标题",输入内容新段落或光标所在段落变成 H2 标题
4点击"列表"当前段落变成无序列表项
5点击"插入链接",输入 URL选中的文字变成可点击链接
6点击"保存",刷新页面上次编辑的内容被恢复
7点击"导出 HTML",打开下载的文件内容完整,样式独立可读

如果第 6 步失败,优先检查浏览器是否禁用了本地存储,或者页面是否处于无痕模式。如果第 7 步导出的文件打开后样式错乱,检查编辑区是否包含外部粘贴的样式属性,这类残留是导出文件不稳定的常见原因。

这里要特别提醒:如果点击导出没有任何反应,第一步先看浏览器是否拦截了下载。部分浏览器对a.click()触发下载的策略比较严格,可以在浏览器设置里允许该站点下载,或者改用window.open(url)验证。

7. 常见问题与排查思路

这个 demo 跑通不难,但在真实项目中,下面这些问题几乎一定会遇到。我整理成了一张排查表。

问题现象可能原因排查方式解决方案
点击工具栏后,编辑区里的文字格式不变焦点不在编辑区,execCommand 找不到选区点击按钮后立即editor.focus()在命令执行后恢复焦点;或使用mousedown阻止按钮抢焦点
加粗后再次点击无法取消命令本身不可逆,或当前选区跨了多个 DOM 节点浏览器控制台执行document.queryCommandState('bold')使用编辑框架(如 Slate)管理选区状态,实现可逆操作
从网页复制内容后,编辑区出现大量背景色和字体标签粘贴的 HTML 携带内联样式检查editor.innerHTML是否包含style属性拦截 paste 事件,清洗 HTML 白名单后再插入
内容保存后刷新丢失localStorage 不可用,或域名不一致控制台执行localStorage.getItem('light-editor-content')确认协议和域名;使用 try/catch 降级到内存存储
手机浏览器上编辑区无法弹出光标可编辑区域内层元素拦截了 touch 事件用 Chrome 设备模拟器复现检查 CSS 是否设置了pointer-events: none或遮挡元素
导出文件在微信或部分浏览器中打开排版异常导出的 HTML 缺少 viewport 或依赖外部样式检查完整 HTML 文档结构把关键样式内联到导出的<style>
连续输入中文时出现"丢字"contenteditable 在 IME 组合输入时的 DOM 更新竞争观察输入时input事件触发的 HTML 变化不要频繁在 input 事件里重写 innerHTML,用节流或框架处理

8. 最佳实践与工程建议

如果只是做一个 demo,第 5 节的代码已经够用。但要把这类工具做成可交付的产品,下面几条工程建议我建议认真看完。

8.1 安全是第一优先级

千万不要直接把用户编辑的 HTML 存进数据库再原样渲染。用户可能从任意网页复制内容,这些内容里可能包含恶意脚本。即使你的系统只在内部使用,也必须假设输入是不可信的。

两个最基本的措施:

  • 保存前做 HTML 清洗,只保留白名单标签和属性,剥离scriptiframeon*事件属性;
  • 渲染时避免使用dangerouslySetInnerHTML(React)或v-html(Vue)直接注入未清洗内容。

需要清洗库时,可以选择 DOMPurify 这类成熟方案,不要自己写正则去匹配 HTML——正则处理 HTML 是出了名的不可靠。

8.2 别迷信 execCommand,复杂场景要换框架

document.execCommand已经被标记为过时(deprecated),但浏览器仍然保留。它适合做简单工具,但不适合做复杂产品,原因有三:

  • 各浏览器实现细节不一致,跨浏览器行为难以保证;
  • 无法精确控制光标的每一步变化,协作编辑和复杂排版几乎不可能;
  • 指令的撤销栈行为不受开发者控制,无法与自定义操作融合。

如果你的产品需要多人协作、块级拖拽、嵌套列表、自定义节点,建议直接选 ProseMirror 或 Slate 这类现代编辑框架。虽然学习曲线陡峭,但它们把选区建模、事务更新、撤销重做这些硬骨头都啃下来了,长期看省下的成本远大于学习成本。

8.3 内容自动保存要用防抖

用户输入是高频事件,直接监听 input 事件保存会频繁操作 localStorage 或发送请求。正确做法是使用防抖(debounce),比如"停止输入 500ms 后再保存":

let timer = null; editor.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(() => { localStorage.setItem(STORAGE_KEY, editor.innerHTML); }, 500); });

如果产品需要更完善的数据安全,可以在这个基础上加上"版本快照":每次保存前把上一次的内容快照存起来,用户可以手动回滚。这本质上是编辑器的撤销栈扩展,数据模型上对应的是一个内容版本链表。

8.4 生产选型判断:什么时候用简单方案,什么时候用重型框架?

我的建议可以压缩成一句话:你的内容模型有多复杂,编辑器就有多复杂。

  • 只需要加粗、斜体、列表,内容以短文本为主——用 contenteditable 封装一个小组件即可;
  • 需要排版控制、图片上传、Markdown 切换——建议用 TipTap(基于 ProseMirror)这类现成封装;
  • 需要多人实时协作、文档树、复杂块级交互——直接上 ProseMirror 或 Slate,并且要做好专门的架构设计。

很多项目失败,不是技术选型选错,而是没有一个渐进路径:一开始用简单方案,后来硬往里塞复杂功能,最后整个编辑器的 DOM 状态完全不可控。更好的做法是先定义清楚内容模型,再反推编辑器选型。

9. 总结与后续学习方向

这篇文章从印度开发者社区近期走红的编辑工具切入,拆开了这类产品背后的技术内核:contenteditable 提供可编辑区域,execCommand 承担格式化指令,Selection 和 Range 描述选区,数据模型决定内容的形态。随后用不到两百行代码实现了一个包含编辑、保存、导出三个核心能力的完整 demo,并给出了从 demo 走向生产时最常见的坑和对应解法。

如果要在实际项目里验证今天的内容,我建议你按这个顺序实践:

  • 先把第 5 节的 demo 完整跑通,理解编辑、保存、导出这条链路;
  • 然后尝试自己加一个"清除格式"按钮或"插入图片"功能,体会 execCommand 的边界在哪里;
  • 接着把表单 Schema 渲染示例跑起来,理解"编辑内容 JSON 化"的意义;
  • 最后才去评估是否需要引入 ProseMirror 或 Slate 这类框架。

值得继续深入的方向有三个:第一个是 ProseMirror 的 Schema 和 Transaction 设计,它是目前主流编辑框架中工程化最完备的实现;第二个是 HTML 清洗与内容安全,这是所有内容型产品上线前必须补的一课;第三个是编辑器与后端存储的衔接,包括内容版本管理、增量保存和多人协作,这才是"编辑工具"进化成"协同产品"的分水岭。

建议把这篇文章收藏备用,尤其是在你决定开始做编辑器类产品、或者需要在项目里嵌入内容编辑能力的时候,回来看一看第 7 节和第 8 节的排查清单,至少能帮你少踩几个最常见的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询