基于contentEditable原生实现简易JS富文本编辑器核心逻辑

作者:页面旅途时间:2026-11-05 21:33:08浏览:193

深入讲解如何使用浏览器原生 contenteditable 属性、Selection 与 Range API、execCommand 命令调度,从零实现一个轻量、高效且无外部依赖的 JavaScript 简易富文本编辑器核心逻辑。

实现一个简易的 Web 原生富文本编辑器,核心并不在于引入庞大的第三方框架,而是掌握浏览器原生的 contenteditable 属性、DOM Selection/Range 选区机制以及格式命令的调度与状态同步。本文围绕原生 JavaScript 环境,系统讲解从 DOM 骨架构建、工具栏防失焦交互、文本与块级格式化命令执行,到选区状态反向高亮和纯文本安全粘贴的完整实现闭环,并提供一个高内聚、开箱即用的轻量实现示例。
基于contentEditable原生实现简易JS富文本编辑器核心逻辑
图示概览:原生 contentEditable 富文本编辑器整体交互与控制链路

一、编辑器核心架构与运行模型

在浏览器环境中,任何 HTML 容器只要声明了 contenteditable="true" 属性,浏览器排版引擎就会自动将其转换为可接受键盘键入、文本选择和光标导航的宿主区域。然而,一个功能完备的富文本编辑器绝非仅仅开启该属性,它必须在视图层、选区调度层与数据层之间建立稳定的协同控制链路。

原生富文本编辑器的体系可以精简划分为三个核心层级:

  • 表现与交互层 (Presentation & UI):负责承载用户可见的编辑区域(.editor-content)以及格式化操作按钮组(.editor-toolbar)。该层直接捕获用户的点击、输入与按键事件。
  • 核心调度与选区控制中枢 (Selection & Execution Hub):这是整个编辑器的大脑。它负责在工具栏触发时拦截默认失焦事件,解析当前窗口的 Selection 与 Range 选区位置,并通过调用底层命令调度器将排版指令准确映射到目标 DOM 节点。
  • 状态反馈与安全管道 (Feedback & Sanitization Pipeline):负责监听选区的每一次微小位移,通过状态探测 API 实时判断当前光标位置是否处于加粗、斜体或特定标题内,并将布尔状态反向驱动至工具栏按钮;同时对粘贴输入与最终导出的 HTML 设立净化屏障。
基于contentEditable原生实现简易JS富文本编辑器核心逻辑
图 1:原生 contentEditable 富文本编辑器整体架构与组件交互原理图

理解这种分层结构至关重要。很多初学者自研富文本编辑器时容易陷入一个误区:试图通过字符串正则替换 innerHTML 来变更样式。这种做法会立刻摧毁编辑区内部的所有原生 DOM 引用与撤销历史栈,导致光标瞬间跳回文首或直接失效。原生架构的标准做法,是让浏览器原生命令引擎去完成底层的 DOM 原子切分与包装,由 JavaScript 专注于选区保护与状态协同。

二、HTML 基础骨架与视图样式构建

构建编辑器的第一步是搭建清晰的 DOM 骨架。一个健壮的富文本编辑器外部容器通常包裹两个同级子容器:顶部的操作工具栏与下部的编辑内容区。

1. 极简 HTML 结构声明

在设计工具栏按钮时,我们通过自定义数据属性(如 data-cmd 与 data-val)声明按钮所绑定的操作意图,使得后续的 JavaScript 逻辑可以通过事件委托统一处理,无需为每个按钮单独编写繁琐的监听函数:

<!-- 编辑器总容器 -->
<div class="simple-editor" id="myEditor">
  <!-- 格式操作工具栏 -->
  <div class="editor-toolbar">
    <button type="button" class="toolbar-btn" data-cmd="bold" title="加粗"><b>B</b></button>
    <button type="button" class="toolbar-btn" data-cmd="italic" title="斜体"><i>I</i></button>
    <button type="button" class="toolbar-btn" data-cmd="underline" title="下划线"><u>U</u></button>
    <span class="toolbar-separator"></span>
    <button type="button" class="toolbar-btn" data-cmd="formatBlock" data-val="h2" title="二级标题">H2</button>
    <button type="button" class="toolbar-btn" data-cmd="formatBlock" data-val="p" title="正文段落">P</button>
    <button type="button" class="toolbar-btn" data-cmd="insertUnorderedList" title="无序列表">• 列表</button>
    <button type="button" class="toolbar-btn" data-cmd="createLink" title="插入链接">链接</button>
    <button type="button" class="toolbar-btn" data-cmd="removeFormat" title="清除格式">清除</button>
  </div>

  <!-- 原生可编辑宿主 -->
  <div class="editor-content" contenteditable="true" data-placeholder="请输入正文内容..." spellcheck="false"></div>
</div>

2. 关键 CSS 重置与体验优化

可编辑元素在默认浏览器样式下存在一些体验硬伤,例如 Chrome/Safari 在获取焦点时会产生粗黑色的 outline 轮廓,而在内容为空时高度会塌陷至 0 导致无法点击。必须通过针对性的 CSS 进行防御性处理:

.simple-editor {
  border: 1px solid #CBD5E1;
  border-radius: 6px;
  background: #FFFFFF;
  overflow: hidden;
}

.editor-toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 4px;
  padding: 8px;
  background: #F8FAFC;
  border-bottom: 1px solid #E2E8F0;
}

.toolbar-btn {
  background: #FFFFFF;
  border: 1px solid #CBD5E1;
  border-radius: 4px;
  min-width: 32px;
  height: 30px;
  padding: 0 8px;
  font-size: 13px;
  color: #334155;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: all 0.15s ease;
}

.toolbar-btn:hover {
  background: #F1F5F9;
  border-color: #94A3B8;
}

.toolbar-btn.is-active {
  background: #345E82;
  color: #FFFFFF;
  border-color: #345E82;
}

.toolbar-separator {
  width: 1px;
  height: 20px;
  background: #CBD5E1;
  margin: 0 4px;
}

/* 编辑区域样式 */
.editor-content {
  min-height: 160px;
  padding: 16px;
  font-size: 15px;
  line-height: 1.7;
  color: #1E293B;
  outline: none; /* 移除浏览器默认的聚焦外框 */
  word-break: break-word; /* 防止连续长字符破坏布局 */
}

/* 利用 :empty 与 data-placeholder 实现无侵入占位符 */
.editor-content:empty::before {
  content: attr(data-placeholder);
  color: #94A3B8;
  pointer-events: none;
}
设计细节:为什么使用 :empty 伪类实现占位提示?

在 div 内部如果使用硬编码的提示文字,用户点击键入时必须先清空 DOM,极易引发光标错位。使用 CSS 的 :empty::before 结合 attr(data-placeholder) 能够在容器完全没有子节点时纯样式呈现提示语,用户一旦键入内容,:empty 伪类立刻不满足,占位符自动隐藏,干净且零 JavaScript 侵入。

三、选区保持与防失焦交互(核心难点突破)

任何刚开始编写原生富文本编辑器的人,几乎都会遇到同一个典型故障:在编辑区选中了一段文字,去点击工具栏的“加粗”按钮,结果文字毫无变化,反而编辑器的光标瞬间消失了。要解决这个问题,必须深入理解浏览器的 Selection 与焦点机制。

1. 为什么“点击”按钮会摧毁选区?

在浏览器的标准事件生命周期中,鼠标点击一个常规的 <button> 元素时,事件触发的精确顺序为:

  1. mousedown(鼠标按键按下)
  2. focus(焦点由原本的 contenteditable 容器强制转移到被点击的 button 按钮上)
  3. blur(contenteditable 容器失焦)
  4. mouseup(鼠标按键抬起)
  5. click(完成一次完整点击)

由于在第 2、3 步焦点就已经离开了编辑区域,浏览器会判定用户已经脱离了文本编辑上下文,原本属于编辑区的激活选区(Range)立刻被收起或折叠。当最终在 click 事件回调中执行格式化命令时,当前窗口的活动选区早已空空如也,命令自然失效。

基于contentEditable原生实现简易JS富文本编辑器核心逻辑
图 2:选区生命周期与工具栏防失焦交互步骤流

2. 核心解决方案:在 mousedown 中执行 preventDefault()

彻底解决该问题的关键,在于拦截失焦的源头。我们必须在 mousedown 事件阶段(而非 click 阶段)监听操作,并调用 event.preventDefault():

toolbar.addEventListener('mousedown', (event) => {
  const button = event.target.closest('.toolbar-btn');
  if (!button) return;

  // 关键核心:阻止浏览器默认的焦点转移行为!
  // 此时编辑区始终保持聚焦状态 (:focus),选区 Range 毫发无损!
  event.preventDefault();

  const command = button.dataset.cmd;
  const value = button.dataset.val || null;

  // 执行具体格式化命令
  this.executeCommand(command, value);
});

通过执行 e.preventDefault(),浏览器的焦点转移机制被当场阻断。编辑区的光标既不会丢失,也不会触发 blur 事件,当前由鼠标框选的文本高亮状态依然完好保留,后续的命令调度可以直接精确作用于该选区。

防坑提醒:严禁使用 click 事件来触发格式化

很多开发者习惯性地使用 button.addEventListener('click', ...),然后试图在回调里通过 selection.addRange(savedRange) 去手动恢复选区。这种做法在移动端或跨浏览器时极为脆弱,常常导致页面滚动跳跃。在 mousedown 阶段阻止默认失焦 才是现代 Web 编辑器的一致标准规范。

四、格式化命令分发与 DOM 节点变换机制

在确保选区稳定不失焦的前提下,我们就可以将工具栏的动作转化为底层的 DOM 节点变换。在原生 JavaScript 中,驱动 contenteditable 容器发生富文本格式变化的核心入口是 document.execCommand()。

1. 核心命令矩阵分类

虽然该方法在规范中被标记为过时(Legacy),但由于底层 Range 操作极其复杂且必须深度集成撤销历史栈,所有主流浏览器(Chrome、Firefox、Safari、Edge)至今依然完整保留并依赖其实质实现:

命令名称 (Command) 参数值 (Value) 作用与 DOM 变换效果
bold null 将当前选区文本使用 <b> 或 <strong> 标签包裹或解构
italic null 将选区文本包裹或解构 <i> 或 <em> 标签
underline null 将选区文本包裹或解构 <u> 标签
formatBlock "<h2>" 或 "<p>" 将光标所在的整个块级父容器提升为 H2 标题或降级为普通段落
insertUnorderedList null 将当前段落转换为包含 <ul><li> 的无序列表项
createLink URL 字符串 将当前选区文本转换为指向指定地址的超链接 <a href="...">
removeFormat null 清除当前选区内的所有行内样式和标签包裹
基于contentEditable原生实现简易JS富文本编辑器核心逻辑
图 3:contentEditable 原生命令执行与 DOM 节点变换机制

2. DOM 节点的分裂与原子自愈剖析

如上图 3 所示,当用户对段落 <p>原生文本逻辑详解</p> 中的“文本逻辑”执行 bold 时,浏览器排版引擎内部执行了以下一系列原子操作:

  • 文本节点切分 (TextNode Splitting):将单一的 TextNode 根据选区的起始偏移量 startOffset=2 和结束偏移量 endOffset=6 精准分裂为三段独立文本节点;
  • 标签节点封装 (Element Injection):动态创建一个 <b> 元素,并将中间切分出的目标文本节点移入其中;
  • 选区映射重建 (Range Auto-Healing):重新将 Selection 边界吸附在新的 <b> 节点内,确保用户视觉上的选区高亮完全无缝;
  • 自动压入原生历史栈 (Undo Stack Push):整个操作作为一个原子事务被记录在浏览器的历史操作栈中。用户此时按下键盘的 Ctrl+Z(Mac 下为 Cmd+Z),浏览器可以毫秒级完整回滚,无需手写复杂的栈数据结构。

五、工具栏按钮激活状态的双向实时同步

一个合格的富文本编辑器,不能只是“单向发射命令”,更要具备“双向状态感知”。例如,当用户把光标移动到一段已经是粗体的文字内部时,工具栏上的 B 按钮必须自动高亮;一旦移出,按钮必须立即取消高亮。

1. 状态监听源:selectionchange 事件

要捕捉光标与选区的每一次移动,最理想的事件是挂载在 document 上的 selectionchange 事件。当用户用鼠标拖拽选区、用方向键移动光标,甚至退格键删除字符时,该事件都会被高频触发。

2. 状态探测 API:queryCommandState 与 queryCommandValue

浏览器提供了与 execCommand 配套的状态探针函数:

  • document.queryCommandState(cmd):返回布尔值。若当前选区已应用该行内格式(例如加粗),则返回 true,否则返回 false。
  • document.queryCommandValue(cmd):返回字符串。常用于检测块级标签类型,例如当前块是不是 h2 或 p。
updateToolbarState() {
  const selection = window.getSelection();
  // 必须确保选区在当前编辑器内部,避免其他区域干扰
  if (!selection.rangeCount) return;
  const range = selection.getRangeAt(0);
  if (!this.editorElement.contains(range.commonAncestorContainer)) {
    return;
  }

  // 遍历工具栏所有按钮,依次判断激活状态
  const buttons = this.toolbarElement.querySelectorAll('.toolbar-btn');
  buttons.forEach(btn => {
    const cmd = btn.dataset.cmd;
    const val = btn.dataset.val;

    if (!cmd) return;

    let isActive = false;
    try {
      if (cmd === 'formatBlock' && val) {
        // 查询块级标签名(部分浏览器返回大写,统一转小写比对)
        const currentBlock = (document.queryCommandValue(cmd) || '').toLowerCase();
        isActive = currentBlock === val.toLowerCase();
      } else {
        // 查询行内格式布尔值
        isActive = document.queryCommandState(cmd);
      }
    } catch (e) {
      isActive = false;
    }

    // 动态添加或移除 is-active 高亮类名
    btn.classList.toggle('is-active', !!isActive);
  });
}

通过这种方式,界面状态始终与底层 DOM 语义保持严丝合缝的一致性,使用户的操作反馈明确、直观。

六、内容提取、纯文本粘贴清洗与安全防御

编辑器在实际项目中必然承担着数据输入的职责,这就引申出两个绕不开的工程课题:防止外部格式污染 与 XSS 注入防御。

1. 拦截剪贴板与纯文本清洗 (Paste Guard)

contenteditable 默认允许用户粘贴任何外部富文本。如果用户从 Word 文档或外部网页复制内容并直接粘贴,会带入大量冗余且丑陋的内联样式(如 style="font-family: Calibri; mso-spacerun: 'yes';" 等),严重破坏网站既定风格。

最简单且有效的治理方案,是监听 paste 事件,拦截浏览器默认的富文本粘贴行为,强制提取纯文本后通过 document.execCommand('insertText') 注入:

this.editorElement.addEventListener('paste', (event) => {
  // 阻止默认富文本粘贴(阻断外部脏样式)
  event.preventDefault();

  // 严格从剪贴板仅提取纯文本数据
  const plainText = (event.clipboardData || window.clipboardData).getData('text/plain');

  // 使用 insertText 保持原生撤销历史栈完好
  document.execCommand('insertText', false, plainText);
});

2. 内容导出与 XSS 防护边界

当需要向后端服务器提交内容时,可以通过 editorElement.innerHTML 提取 HTML 字符串。但是必须清楚:前端 contenteditable 导出的任何 HTML 本质上都是不受信任的用户输入!

生产强制规范:持久化前必须做 XSS 净化

恶意用户完全可以通过浏览器开发者工具在编辑区内注入 <img src="x" onerror="alert(1)"> 等恶意脚本。在将内容存入数据库或渲染给其他用户前,必须引入安全清洗库(如客户端使用 DOMPurify.sanitize(html),或在服务端使用严格的 HTML 白名单过滤器),彻底剥离 <script>、行内事件监听器(onload、onerror)以及非法协议(javascript:)。

七、完整原生编辑器实现与运行验证

现在,我们将上述所有核心逻辑融会贯通,封装为一个高内聚、易维护的 JavaScript 类 SimpleRichEditor。你可以直接将其复制到任何项目中运行测试。

/**
 * 极简原生富文本编辑器核心实现
 */
class SimpleRichEditor {
  constructor(containerId, options = {}) {
    this.container = document.getElementById(containerId);
    if (!this.container) {
      throw new Error(`找不到容器节点 #${containerId}`);
    }

    this.options = Object.assign({
      placeholder: '请输入正文内容...',
      onChange: null
    }, options);

    this.initDOM();
    this.bindEvents();
  }

  initDOM() {
    this.container.classList.add('simple-editor');
    
    // 构建工具栏与编辑区
    this.container.innerHTML = `
      <div class="editor-toolbar">
        <button type="button" class="toolbar-btn" data-cmd="bold" title="加粗"><b>B</b></button>
        <button type="button" class="toolbar-btn" data-cmd="italic" title="斜体"><i>I</i></button>
        <button type="button" class="toolbar-btn" data-cmd="underline" title="下划线"><u>U</u></button>
        <span class="toolbar-separator"></span>
        <button type="button" class="toolbar-btn" data-cmd="formatBlock" data-val="h2" title="二级标题">H2</button>
        <button type="button" class="toolbar-btn" data-cmd="formatBlock" data-val="p" title="正文段落">P</button>
        <button type="button" class="toolbar-btn" data-cmd="insertUnorderedList" title="无序列表">• 列表</button>
        <button type="button" class="toolbar-btn" data-cmd="createLink" title="插入链接">链接</button>
        <button type="button" class="toolbar-btn" data-cmd="removeFormat" title="清除格式">清除</button>
      </div>
      <div class="editor-content" contenteditable="true" data-placeholder="${this.options.placeholder}" spellcheck="false"></div>
    `;

    this.toolbar = this.container.querySelector('.editor-toolbar');
    this.editor = this.container.querySelector('.editor-content');
  }

  bindEvents() {
    // 1. 工具栏防失焦拦截与命令分发
    this.toolbar.addEventListener('mousedown', (e) => {
      const btn = e.target.closest('.toolbar-btn');
      if (!btn) return;

      // 阻止默认失焦,保持编辑区选区
      e.preventDefault();

      const cmd = btn.dataset.cmd;
      const val = btn.dataset.val || null;
      this.executeCommand(cmd, val);
    });

    // 2. 选区变化时更新按钮高亮状态
    document.addEventListener('selectionchange', () => {
      this.syncActiveState();
    });

    // 3. 拦截粘贴事件,强制清洗为纯文本
    this.editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = (e.clipboardData || window.clipboardData).getData('text/plain');
      document.execCommand('insertText', false, text);
    });

    // 4. 输入变更通知
    this.editor.addEventListener('input', () => {
      if (typeof this.options.onChange === 'function') {
        this.options.onChange(this.getHTML());
      }
    });
  }

  executeCommand(command, value = null) {
    this.editor.focus();

    if (command === 'createLink') {
      const url = prompt('请输入跳转超链接 URL:', 'https://');
      if (url && url.trim() !== '' && url !== 'https://') {
        document.execCommand('createLink', false, url.trim());
      }
      return;
    }

    // 执行原生格式化
    document.execCommand(command, false, value);
    this.syncActiveState();
  }

  syncActiveState() {
    const selection = window.getSelection();
    if (!selection.rangeCount) return;
    
    // 边界保护:若光标不在当前编辑区内则不处理
    const range = selection.getRangeAt(0);
    if (!this.editor.contains(range.commonAncestorContainer)) return;

    const btns = this.toolbar.querySelectorAll('.toolbar-btn');
    btns.forEach(btn => {
      const cmd = btn.dataset.cmd;
      const val = btn.dataset.val;
      if (!cmd) return;

      let active = false;
      try {
        if (cmd === 'formatBlock' && val) {
          const block = (document.queryCommandValue(cmd) || '').toLowerCase();
          active = (block === val.toLowerCase());
        } else {
          active = document.queryCommandState(cmd);
        }
      } catch (err) {
        active = false;
      }
      btn.classList.toggle('is-active', !!active);
    });
  }

  getHTML() {
    return this.editor.innerHTML;
  }

  getText() {
    return this.editor.innerText;
  }

  setHTML(html) {
    this.editor.innerHTML = html;
    this.syncActiveState();
  }
}

交互验证箱 (Live Demo)

以下内嵌了一个基于上述原生核心构建的可操作编辑器演示实例。你可以在其中直接输入字符、框选文字并点击工具栏,观察样式变化与下方实时序列化导出的 HTML 源码:

LIVE COMPONENT PREVIEW // 原生核心可运行实例 100% 原生 JS 驱动

这是一段用于测试的初始文本,试着选中这段文字并切换格式。

实时导出的序列化 HTML 源码 (editor.innerHTML):
<p>这是一段用于测试的初始文本,试着选中<b>这段文字</b>并切换格式。</p>

八、原生方案工程边界与落地总结

通过上述实现,我们仅用了百余行原生 JavaScript 代码,就完整实现了富文本编辑的核心交互闭环。在实际工程技术选型中,如何理性看待基于 contentEditable 原生方案的优势与局限性?

基于contentEditable原生实现简易JS富文本编辑器核心逻辑
图 4:原生富文本编辑器核心实现全景与生产防坑总结矩阵

如总结矩阵所示,原生实现方案拥有极高的性价比:

  • 零依赖与秒级加载:无需引入动辄数百 KB 的重型第三方编辑器包,这对页面体积敏感的轻量级业务(如工单流转、评论回复框、公告简单编辑)至关重要。
  • 完整复用原生性能与交互:充分复用了浏览器自带的高性能排版渲染引擎与系统级撤销重做(Undo/Redo)历史管理,避免了纯 JavaScript 自建文档树带来的内存开销与光标抖动。

与此同时,必须正视原生实现的技术边界:

  • 跨浏览器换行节点差异:在没有特殊拦截处理时,按回车键在 Chrome 下默认生成 <div>,在 Firefox 下可能生成 <br> 或 <p>。若业务对 HTML 结构有严苛的规范要求,需要在 keydown 事件中拦截回车键并做归一化封装。
  • 复杂嵌套数据结构的瓶颈:当需求扩展到复杂的图文混排卡片、内嵌多维数据表格或代码高亮块时,纯 DOM 驱动的原生方案将很难维持数据模型与视图的强一致性。如果后续项目演进到需要多人实时协同编辑(OT/CRDT 算法支持)或严格受控的自定义富文档模型,应当平滑迁移至 ProseMirror 或 Slate 等采用“虚拟不可变数据树驱动”的进阶编辑器架构。

对于大多数常见的中后台管理系统、内嵌评论与简易文本排版需求而言,掌握 contenteditable、mousedown 防失焦拦截与 Selection/Range 核心调度原理,已经足以支撑构建出轻量、稳定且体验优秀的富文本核心逻辑。

作者最新文章
相关文章
更多