contenteditable 深度剖析:让网页元素「活」起来

contenteditable 深度剖析:让网页元素「活」起来

前端开发者必备技能 | 深入理解 HTML 可编辑属性

📖 基本概念

contenteditable 是什么?

contenteditable 是 HTML5 的一个全局属性(Global Attribute) ,可以让任意 HTML 元素变成可编辑区域。用户可以直接点击元素并修改其内容,无需使用传统的 <input><textarea> 表单元素。

html 复制代码
<!-- 最简单用法 -->
<div contenteditable="true">点击这里编辑我</div>

<!-- 等价于 -->
<div contenteditable>我也是可编辑的</div>

属性值说明

说明 示例
true 启用编辑 <div contenteditable="true">
false 禁用编辑 <div contenteditable="false">
plaintext-only 仅纯文本(禁止富文本) <input> 行为类似
空字符串/inherit 继承父元素或默认可编辑 <div contenteditable>
html 复制代码
<!-- plaintext-only 场景:评论框只需要纯文本 -->
<article contenteditable="plaintext-only">
  这里只能输入纯文本,富文本格式会被过滤
</article>

浏览器支持情况

现代浏览器全覆盖,包括:

浏览器 支持版本
Chrome 4.0+
Firefox 3.5+
Safari 3.1+
Edge 12+
IE 6.0+(功能有限)

⚠️ 注意:虽然所有现代浏览器都支持,但行为存在差异,需要针对性处理。

⚡ 核心特性

可编辑区域的行为特性

  1. 原生光标(Carets) :自动显示插入符

  2. 文本选择:支持鼠标选中文本

  3. 富文本支持:用户可以输入带格式的文本

  4. 键盘交互:支持快捷键(Ctrl+B 加粗等)

  5. 拖拽操作:支持在元素内拖拽文本

contenteditable vs 表单元素

特性 contenteditable input/textarea
内容格式 HTML 片段(富文本) 纯文本
样式控制 灵活(继承父样式) 受限
语义化
表单提交 需手动处理 自动
XSS 风险
html 复制代码
<!-- textarea 的 value 是纯文本 -->
<textarea id="ta">你好</textarea>
<script>
  console.log(document.getElementById('ta').value); // "你好"
</script>

<!-- contenteditable 的 innerHTML 是 HTML -->
<div contenteditable="true">你好</div>
<script>
  // 用户可能输入 <strong>粗体</strong>
  console.log(editor.innerHTML); // "你好" 或 "<strong>粗体</strong>"
</script>

默认的富文本能力

contenteditable="true" 时,浏览器天然支持:

  • 富文本粘贴:从网页复制的带格式内容会保留样式

  • 撤销/重做:Ctrl+Z / Ctrl+Shift+Z

  • 拖拽重新排列:选中文本可拖拽移动位置

  • 浏览器内置格式化:Ctrl+B/I/U 等

🎯 使用场景分析

1. 在线富文本编辑器

最简单的富文本编辑器实现:

html 复制代码
<div contenteditable="true" 
     id="editor"
     style="border: 1px solid #ccc; min-height: 200px; padding: 16px;">
</div>

<button onclick="format('bold')">加粗</button>
<button onclick="format('italic')">斜体</button>

<script>
function format(cmd) {
  document.execCommand(cmd, false, null);
}
</script>

2. 可编辑表格

CMS 系统中常见的需求:

html 复制代码
<table border="1" style="border-collapse: collapse; width: 100%;">
  <tr>
    <th>商品名称</th>
    <th>价格</th>
    <th>库存</th>
  </tr>
  <tr>
    <td contenteditable="true">iPhone 15</td>
    <td contenteditable="true">5999</td>
    <td contenteditable="true">100</td>
  </tr>
  <tr>
    <td contenteditable="true">MacBook Pro</td>
    <td contenteditable="true">12999</td>
    <td contenteditable="true">50</td>
  </tr>
</table>

<script>
// 保存表格数据
document.querySelectorAll('table').forEach(table => {
  table.addEventListener('blur', (e) => {
    if (e.target.isContentEditable) {
      console.log('Cell updated:', e.target.textContent);
      // 发送到服务器
    }
  }, true);
});
</script>

3. 即时编辑(Click-to-Edit)

用户点击标题直接编辑,类似于 Notion/Figma 的体验:

html 复制代码
<h1 class="editable-title" contenteditable="true" 
    data-placeholder="输入标题...">
  点击这里编辑标题
</h1>

<style>
.editable-title {
  outline: none;
  border-bottom: 2px dashed transparent;
  transition: border-color 0.2s;
}
.editable-title:focus {
  border-bottom-color: #4285f4;
}
.editable-title:empty::before {
  content: attr(data-placeholder);
  color: #999;
}
</style>

<script>
document.querySelector('.editable-title').addEventListener('blur', function() {
  saveToServer(this.textContent);
});
</script>

4. 评论/笔记区域

轻量级笔记应用:

html 复制代码
<div id="notes" contenteditable="true" 
     style="white-space: pre-wrap;"
     data-placeholder="在这里记录笔记...">
</div>

<script>
// 自动保存到 localStorage
const notes = document.getElementById('notes');
const saved = localStorage.getItem('user-notes');

if (saved) {
  notes.innerHTML = saved;
}

notes.addEventListener('input', debounce(() => {
  localStorage.setItem('user-notes', notes.innerHTML);
}, 500));

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
</script>

5. 协作编辑场景

配合 WebSocket 或 WebRTC 实现实时协作:

javascript 复制代码
// 基础协作框架示例
class CollaborativeEditor {
  constructor(element) {
    this.element = element;
    this.socket = new WebSocket('ws://your-server');
    
    // 监听本地变化
    this.element.addEventListener('input', () => {
      this.broadcast(this.getContent());
    });
    
    // 接收远程变化
    this.socket.onmessage = (event) => {
      const { content, userId } = JSON.parse(event.data);
      if (userId !== this.userId) {
        this.setContent(content);
      }
    };
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  setContent(html) {
    // 保存光标位置
    const selection = window.getSelection();
    const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
    
    this.element.innerHTML = html;
    
    // 恢复光标
    // ... 光标恢复逻辑
  }
  
  broadcast(content) {
    this.socket.send(JSON.stringify({
      content,
      userId: this.userId
    }));
  }
}

💻 代码示例

基础用法

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>ContentEditable 基础示例</title>
  <style>
    .editor {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 20px;
      min-height: 150px;
      font-size: 16px;
      line-height: 1.6;
      outline: none;
      transition: border-color 0.2s, box-shadow 0.2s;
    }
    .editor:focus {
      border-color: #4285f4;
      box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
    }
    .editor:empty::before {
      content: attr(data-placeholder);
      color: #aaa;
      pointer-events: none;
    }
  </style>
</head>
<body>
  <div class="editor" 
       contenteditable="true" 
       data-placeholder="输入内容...">初始内容</div>
  
  <p>HTML 内容:<span id="html-output"></span></p>
  <p>纯文本内容:<span id="text-output"></span></p>
  
  <script>
    const editor = document.querySelector('.editor');
    const htmlOutput = document.getElementById('html-output');
    const textOutput = document.getElementById('text-output');
    
    // 获取内容
    function updateOutput() {
      htmlOutput.textContent = editor.innerHTML;
      textOutput.textContent = editor.textContent;
    }
    
    editor.addEventListener('input', updateOutput);
    updateOutput();
    
    // 监听粘贴,保留纯文本
    editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  </script>
</body>
</html>

获取/设置内容

javascript 复制代码
const editor = document.getElementById('editor');

// 获取内容
const htmlContent = editor.innerHTML;      // 包含 HTML 标签
const textContent = editor.textContent;    // 仅纯文本
const innerText = editor.innerText;        // 仅纯文本(尊重CSS)

// 设置内容
editor.innerHTML = '<p>新内容</p>';

// 追加内容
editor.innerHTML += '<span>追加内容</span>';

// 安全的追加方式
function safeAppend(element, html) {
  const fragment = document.createDocumentFragment();
  const temp = document.createElement('div');
  temp.innerHTML = html;
  while (temp.firstChild) {
    fragment.appendChild(temp.firstChild);
  }
  element.appendChild(fragment);
}

实现简单的富文本功能

javascript 复制代码
class SimpleEditor {
  constructor(element) {
    this.editor = element;
    this.setupToolbar();
    this.setupKeyboardShortcuts();
  }
  
  setupToolbar() {
    document.querySelectorAll('[data-command]').forEach(btn => {
      btn.addEventListener('click', () => {
        const cmd = btn.dataset.command;
        const value = btn.dataset.value || null;
        
        if (cmd === 'createlink') {
          const url = prompt('输入链接地址:');
          if (url) this.exec(cmd, false, url);
        } else if (cmd === 'insertImage') {
          const url = prompt('输入图片地址:');
          if (url) this.exec(cmd, false, url);
        } else {
          this.exec(cmd, false, value);
        }
      });
    });
  }
  
  setupKeyboardShortcuts() {
    this.editor.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.metaKey) {
        switch (e.key.toLowerCase()) {
          case 'b': e.preventDefault(); this.exec('bold'); break;
          case 'i': e.preventDefault(); this.exec('italic'); break;
          case 'u': e.preventDefault(); this.exec('underline'); break;
          case 's': e.preventDefault(); this.exec('save'); break;
        }
      }
    });
  }
  
  exec(command, showUI, value) {
    if (command === 'save') {
      this.save();
    } else {
      document.execCommand(command, showUI, value);
    }
  }
  
  exec(command, showUI, value) {
    document.execCommand(command, showUI, value);
  }
  
  save() {
    console.log('HTML:', this.editor.innerHTML);
    console.log('Text:', this.editor.textContent);
    // 发送到服务器
    fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ content: this.editor.innerHTML }),
      headers: { 'Content-Type': 'application/json' }
    });
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  setContent(html) {
    this.editor.innerHTML = html;
  }
  
  clear() {
    this.editor.innerHTML = '';
  }
}

// 使用
const editor = new SimpleEditor(document.getElementById('editor'));

与 Selection/Range API 配合

现代替代 execCommand 的方式:

javascript 复制代码
// 获取选区
function getSelectionInfo() {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return null;
  
  const range = selection.getRangeAt(0);
  return {
    text: range.toString(),
    startContainer: range.startContainer,
    startOffset: range.startOffset,
    endContainer: range.endContainer,
    endOffset: range.endOffset,
    collapsed: range.collapsed
  };
}

// 包裹选中内容
function wrapSelection(tagName, attributes = {}) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  if (range.collapsed) {
    console.warn('没有选中文本');
    return;
  }
  
  const element = document.createElement(tagName);
  Object.entries(attributes).forEach(([key, value]) => {
    element.setAttribute(key, value);
  });
  
  try {
    range.surroundContents(element);
  } catch (e) {
    // 选区跨越多个节点时,需要使用 extractContents
    console.warn('选区跨越多个节点,使用备用方案');
  }
}

// 示例:加粗选中文本
function boldSelection() {
  wrapSelection('strong');
}

// 示例:创建链接
function linkSelection(url) {
  wrapSelection('a', { href: url, target: '_blank' });
}

// 在光标位置插入内容
function insertAtCursor(html) {
  const selection = window.getSelection();
  if (selection.rangeCount === 0) return;
  
  const range = selection.getRangeAt(0);
  range.deleteContents();
  
  const fragment = document.createRange().createContextualFragment(html);
  const lastNode = fragment.lastChild;
  
  range.insertNode(fragment);
  
  // 将光标移动到插入内容之后
  if (lastNode) {
    range.setStartAfter(lastNode);
    range.collapse(true);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

// 示例:在光标位置插入表情
function insertEmoji(emoji) {
  insertAtCursor(`<span class="emoji">${emoji}</span>`);
}

⚠️ 注意事项与坑点

1. XSS 安全问题

这是 contenteditable 最大的坑! 用户输入的内容会被浏览器解析为 HTML。

html 复制代码
<!-- 恶意输入示例 -->
<div contenteditable="true">
  <img src onerror="alert('XSS!')">
  <script>document.cookie</script>
  <div onclick="stealData()">点我</div>
</div>

防御方案

javascript 复制代码
// ❌ 危险:直接输出用户输入
div.innerHTML = userInput;

// ✅ 安全方案 1:转义 HTML
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// ✅ 安全方案 2:使用 DOMPurify 白名单过滤
import DOMPurify from 'dompurify';

function sanitize(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['class']
  });
}

// ✅ 安全方案 3:使用 beforeinput 拦截
editor.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    // 自定义粘贴处理
    const text = e.getTargetRanges()[0].text;
    document.execCommand('insertText', false, text);
  }
});

2. 样式继承问题

contenteditable 会继承父元素的许多样式:

css 复制代码
/* ❌ 问题:输入的文字可能继承奇怪的颜色 */
.parent {
  color: red;
  font-family: cursive;
}

/* ✅ 解决方案:明确设置 */
[contenteditable] {
  color: inherit;
  font-family: inherit;
  font-size: inherit;
  /* 关键:允许继承但可被覆盖 */
}

/* ✅ 更好的方案:使用 plaintext-only */
[contenteditable="plaintext-only"] {
  all: unset;  /* 重置所有继承 */
  display: block;
  /* 然后显式设置需要的样式 */
}

3. 焦点管理

javascript 复制代码
// ❌ 问题:程序设置内容会丢失光标
editor.innerHTML = 'new content';  // 光标位置丢失

// ✅ 正确做法:保存和恢复光标
function setContentPreservingCursor(element, html) {
  const selection = window.getSelection();
  const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
  
  // 保存相对位置
  let startOffset = 0, endOffset = 0;
  let startNode, endNode;
  
  if (range) {
    const preRange = document.createRange();
    preRange.selectNodeContents(element);
    preRange.setEnd(range.startContainer, range.startOffset);
    startOffset = preRange.toString().length;
    
    preRange.setEnd(range.endContainer, range.endOffset);
    endOffset = preRange.toString().length;
    
    startNode = range.startContainer;
    endNode = range.endContainer;
  }
  
  element.innerHTML = html;
  
  // 恢复位置(简化版,实际需要更复杂)
  if (range) {
    const newRange = document.createRange();
    // ... 恢复逻辑
  }
}

// ✅ 更简洁的方案:使用 beforeinput 事件

4. 换行行为差异

不同浏览器按 Enter 键产生的 HTML 元素不同:

浏览器 产生的元素
Chrome <div>
Firefox <br>
Safari <p>
javascript 复制代码
// ✅ 解决方案:统一换行行为
editor.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    // 检测浏览器并插入统一的元素
    const browser = detectBrowser();
    
    if (browser === 'chrome') {
      e.preventDefault();
      document.execCommand('insertLineBreak');
    }
  }
});

// ✅ 更好的方案:在初始化时统一配置
document.execCommand('defaultParagraphSeparator', false, 'p');

5. 粘贴内容过滤

javascript 复制代码
editor.addEventListener('paste', (e) => {
  e.preventDefault();
  
  // 获取剪贴板内容
  const clipboardData = e.clipboardData || window.clipboardData;
  
  // 方式 1:只粘贴纯文本(最安全)
  const text = clipboardData.getData('text/plain');
  document.execCommand('insertText', false, text);
  
  // 方式 2:粘贴但过滤危险标签
  const html = clipboardData.getData('text/html');
  if (html) {
    const sanitized = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
      ALLOWED_ATTR: ['href', 'target']
    });
    document.execCommand('insertHTML', false, sanitized);
  }
});

6. MutationObserver 监听变化

javascript 复制代码
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    switch (mutation.type) {
      case 'characterData':
        console.log('文本变化:', mutation.target.textContent);
        break;
      case 'childList':
        console.log('子节点变化');
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            console.log('新增元素:', node.tagName);
          }
        });
        break;
    }
  });
});

observer.observe(editor, {
  characterData: true,
  childList: true,
  subtree: true
});

// 清理
// observer.disconnect();

🔧 相关 API

document.execCommand(已废弃但仍在用)

javascript 复制代码
// 常用命令
document.execCommand('bold', false, null);           // 加粗
document.execCommand('italic', false, null);          // 斜体
document.execCommand('underline', false, null);      // 下划线
document.execCommand('strikeThrough', false, null);  // 删除线
document.execCommand('createLink', false, url);      // 创建链接
document.execCommand('insertImage', false, url);      // 插入图片
document.execCommand('formatBlock', false, 'p');     // 段落格式
document.execCommand('insertUnorderedList', false, null); // 无序列表
document.execCommand('insertOrderedList', false, null);   // 有序列表
document.execCommand('undo', false, null);           // 撤销
document.execCommand('redo', false, null);           // 重做
document.execCommand('selectAll', false, null);       // 全选

// 检查命令支持
if (document.queryCommandSupported('bold')) {
  document.execCommand('bold', false, null);
}

⚠️ 警告execCommand 已被 MDN 标记为废弃,但目前在所有浏览器中仍可使用。对于简单场景可以直接使用,对于复杂编辑器建议使用 Selection/Range API。

Selection API

javascript 复制代码
const selection = window.getSelection();

// 获取选中的文本
console.log(selection.toString());

// 获取 Range 对象
if (selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  
  // 常用属性
  console.log(range.startContainer);   // 起始容器节点
  console.log(range.startOffset);      // 起始偏移量
  console.log(range.endContainer);      // 结束容器节点
  console.log(range.endOffset);        // 结束偏移量
  console.log(range.collapsed);        // 是否折叠(无选中)
  console.log(range.commonAncestorContainer); // 共同祖先
  
  // 方法
  range.deleteContents();              // 删除选中内容
  range.extractContents();             // 提取选中内容(从 DOM 移除)
  range.cloneContents();               // 克隆选中内容
  range.insertNode(node);              // 插入节点
  range.surroundContents(node);        // 用节点包裹选中内容
}

// 设置选区
const newRange = document.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
selection.removeAllRanges();
selection.addRange(newRange);

// 折叠选区
selection.collapseToStart();  // 折叠到起始位置
selection.collapseToEnd();     // 折叠到结束位置

// 全选
selection.selectAllChildren(element);

Range API

javascript 复制代码
const range = document.createRange();

// 设置边界
range.setStart(node, offset);
range.setEnd(node, offset);

// 便捷方法
range.selectNode(node);           // 选中整个节点
range.selectNodeContents(node);    // 选中节点内容
range.setStartBefore(node);       // 开始于节点前
range.setStartAfter(node);        // 开始于节点后
range.setEndBefore(node);         // 结束于节点前
range.setEndAfter(node);          // 结束于节点后

// 比较位置
range.compareBoundaryPoints('START_TO_START', otherRange);
range.compareBoundaryPoints('START_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_END', otherRange);
range.compareBoundaryPoints('END_TO_START', otherRange);

// 操作内容
range.cloneContents();      // 克隆选中内容
range.deleteContents();      // 删除选中内容
range.extractContents();     // 提取内容
range.insertNode(node);     // 插入节点
range.surroundContents(node); // 包裹内容

// 复制粘贴
range.cloneRange();         // 克隆范围
range.detach();            // 释放范围(优化性能)

// 折叠
range.collapse(true);       // 折叠到起点
range.collapse(false);      // 折叠到终点

Input 事件

javascript 复制代码
const editor = document.getElementById('editor');

// input 事件:内容变化后触发
editor.addEventListener('input', () => {
  console.log('内容变化:', editor.innerHTML);
  saveContent();
});

// beforeinput 事件:内容变化前触发,可取消
editor.addEventListener('beforeinput', (e) => {
  // 拦截粘贴为纯文本
  if (e.inputType === 'insertFromPaste') {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    insertText(text);
  }
  
  // 限制字数
  if (editor.textContent.length >= MAX_LENGTH && 
      e.inputType === 'insertText') {
    e.preventDefault();
  }
});

// compositionstart/end:处理输入法
editor.addEventListener('compositionstart', () => {
  console.log('开始输入中文...');
});
editor.addEventListener('compositionend', () => {
  console.log('中文输入完成');
  handleInput();
});

InputEvent 的 inputType 枚举

javascript 复制代码
// 常用 inputType 值
'insertText'           // 插入文本
'insertLineBreak'      // 插入换行
'insertParagraph'      // 插入段落
'insertOrderedList'    // 插入有序列表
'insertUnorderedList'  // 插入无序列表
'insertFromPaste'      // 从粘贴板粘贴
'formatBold'           // 格式-加粗
'formatItalic'         // 格式-斜体
'formatUnderline'      // 格式-下划线
'deleteContentBackward' // 删除前一个字符
'deleteContentForward'  // 删除后一个字符
'deleteWordBackward'    // 删除前一个单词
'deleteWordForward'     // 删除后一个单词

✅ 最佳实践

安全的内容处理

javascript 复制代码
// 1. 永远不要相信用户输入
function sanitizeUserInput(dirty) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: [
      'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
      'p', 'br', 'hr',
      'ul', 'ol', 'li',
      'blockquote',
      'a', 'img',
      'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup',
      'code', 'pre'
    ],
    ALLOWED_ATTR: ['href', 'src', 'alt', 'class', 'target'],
    ALLOW_DATA_ATTR: false
  });
}

// 2. 显示时二次转义
function escapeHtml(text) {
  return text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

// 3. CSP 配置
// Content-Security-Policy: script-src 'self'; style-src 'self' 'unsafe-inline'

数据绑定方案

javascript 复制代码
class ContentEditor {
  constructor(element, options = {}) {
    this.element = element;
    this.options = {
      placeholder: options.placeholder || '',
      onChange: options.onChange || (() => {}),
      debounceMs: options.debounceMs || 300,
      ...options
    };
    
    this.init();
  }
  
  init() {
    this.element.contentEditable = 'true';
    this.element.dataset.placeholder = this.options.placeholder;
    
    // 初始化内容
    if (this.options.initialValue) {
      this.element.innerHTML = this.options.initialValue;
    }
    
    this.bindEvents();
  }
  
  bindEvents() {
    // 防抖保存
    let timer;
    this.element.addEventListener('input', () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        this.options.onChange(this.getContent());
      }, this.options.debounceMs);
    });
    
    // 失去焦点时立即保存
    this.element.addEventListener('blur', () => {
      clearTimeout(timer);
      this.options.onChange(this.getContent());
    });
    
    // 粘贴过滤
    this.element.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      document.execCommand('insertText', false, text);
    });
  }
  
  getContent() {
    return this.element.innerHTML;
  }
  
  getText() {
    return this.element.textContent;
  }
  
  setContent(html) {
    this.element.innerHTML = html;
  }
  
  clear() {
    this.element.innerHTML = '';
  }
  
  focus() {
    this.element.focus();
  }
}

// 使用
const editor = new ContentEditor(document.getElementById('editor'), {
  initialValue: '<p>Hello World</p>',
  placeholder: '输入内容...',
  onChange: (content) => {
    console.log('保存:', content);
    localStorage.setItem('draft', content);
  },
  debounceMs: 500
});

富文本编辑器推荐

对于生产环境,建议使用成熟的富文本编辑器库:

特点 适用场景
TinyMCE 功能全面、插件丰富、企业级 企业应用、CMS
Quill 轻量、API 简洁、文档友好 轻量级应用
Tiptap Vue/React 友好、扩展性强 现代 SPA
Slate.js 完全可定制、插件化 高度定制需求
ProseMirror Schema 驱动、协作支持 复杂文档、协作
Editor.js 块编辑、JSON 输出 博客、笔记

🚀 现代替代方案

Quill 2.0

javascript 复制代码
import Quill from 'quill';

// 初始化
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'image', 'blockquote', 'code-block'],
      ['clean']
    ]
  }
});

// 获取/设置内容
quill.on('text-change', () => {
  console.log('HTML:', quill.root.innerHTML);
  console.log('Delta:', quill.getContents());
});

quill.setContents({
  ops: [
    { insert: 'Hello ' },
    { insert: 'World', attributes: { bold: true } },
    { insert: '!\n' }
  ]
});

Tiptap

javascript 复制代码
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';

const editor = new Editor({
  element: document.querySelector('#editor'),
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
  onUpdate: ({ editor }) => {
    console.log(editor.getHTML());
    console.log(editor.getJSON());
  }
});

// 命令
editor.chain().focus().toggleBold().run();
editor.chain().focus().setParagraph().run();

小型项目:使用 contenteditable + Selection API

javascript 复制代码
// 极简富文本框(无依赖)
class MinimalEditor {
  constructor(container) {
    this.container = container;
    this.editor = document.createElement('div');
    this.editor.contentEditable = true;
    this.editor.className = 'minimal-editor';
    this.container.appendChild(this.editor);
    
    this.setupStyles();
    this.setupToolbar();
    this.setupPasteHandler();
  }
  
  setupStyles() {
    const style = document.createElement('style');
    style.textContent = `
      .minimal-editor {
        border: 1px solid #ddd;
        padding: 16px;
        min-height: 100px;
        outline: none;
      }
      .minimal-editor:focus { border-color: #4285f4; }
      .minimal-editor-toolbar { margin-bottom: 8px; }
      .minimal-editor button {
        padding: 4px 8px;
        margin-right: 4px;
        cursor: pointer;
      }
    `;
    document.head.appendChild(style);
  }
  
  setupToolbar() {
    const toolbar = document.createElement('div');
    toolbar.className = 'minimal-editor-toolbar';
    toolbar.innerHTML = `
      <button type="button" data-cmd="bold"><b>B</b></button>
      <button type="button" data-cmd="italic"><i>I</i></button>
      <button type="button" data-cmd="underline"><u>U</u></button>
      <button type="button" data-cmd="createLink">🔗</button>
    `;
    
    toolbar.addEventListener('click', (e) => {
      const btn = e.target.closest('button');
      if (!btn) return;
      
      const cmd = btn.dataset.cmd;
      if (cmd === 'createLink') {
        const url = prompt('URL:');
        if (url) this.exec('createLink', url);
      } else {
        this.exec(cmd);
      }
    });
    
    this.container.insertBefore(toolbar, this.editor);
  }
  
  setupPasteHandler() {
    this.editor.addEventListener('paste', (e) => {
      e.preventDefault();
      const text = e.clipboardData.getData('text/plain');
      this.exec('insertText', text);
    });
  }
  
  exec(cmd, value = null) {
    document.execCommand(cmd, false, value);
  }
  
  getContent() {
    return this.editor.innerHTML;
  }
  
  getText() {
    return this.editor.textContent;
  }
}

// 使用
const editor = new MinimalEditor(document.getElementById('container'));

📋 总结

什么时候用 contenteditable?

适合的场景

  • 轻量级富文本编辑(笔记、评论)
  • 即时编辑(click-to-edit)
  • 可编辑表格/列表
  • 需要灵活布局的编辑区域
  • 原型/内部工具

不适合的场景

  • 企业级文档编辑(用 TinyMCE)
  • 需要复杂协作(用 Tiptap/ProseMirror + Yjs)
  • 严格的格式控制(用成熟的编辑器库)
  • 对 XSS 零容忍(除非做好完整防护)

关键要点

  1. 安全第一:永远不要信任用户输入,使用 DOMPurify 等库进行过滤
  2. 关注差异:不同浏览器的行为差异需要针对性处理
  3. 光标管理:修改内容后记得恢复光标位置
  4. 渐进增强:从简单开始,必要时引入编辑器库
  5. 替代方案:生产环境优先考虑成熟的编辑器库

📝 写在最后

contenteditable 是一个「入门简单、深坑不少」的属性。它能快速实现富文本编辑,但要在生产环境稳定使用,需要处理大量的浏览器兼容性和安全问题。

建议:如果是个人项目或内部工具,直接使用 contenteditable 足够;如果是面向用户的产品,强烈建议使用 TinyMCE、Quill 或 Tiptap 等成熟方案。

文档由AI辅助整理

相关推荐
栀栀栀栀栀栀2 小时前
强迫症犯了(゚∀゚) 2026/4/26
前端·javascript·vue.js
Lucas_coding2 小时前
【xiaozhi-客户端】xiaozhi-web-client 连接客户端 6位有效码
前端
谪星·阿凯2 小时前
电商系统Web渗透测试实战指南
前端·网络·安全·web安全·网络安全
redreamSo2 小时前
HeyGen 开源了一个"用 HTML 写视频"的框架,我研究了一下,发现事情没那么简单
前端·开源·音视频开发
GISer_Jing2 小时前
前端视角:AI正在重构B端产品,传统配置化开发终将被取代?
前端·人工智能
We་ct2 小时前
LeetCode 63. 不同路径 II:动态规划解题详解
前端·算法·leetcode·typescript·动态规划
布局呆星2 小时前
Vue3+TS封装Axios请求全攻略
前端·javascript·ajax·typescript
kyriewen2 小时前
React Diff算法:3个“神级假设”让虚拟DOM快得像闪电
前端·react.js·面试
偶像佳沛2 小时前
零基础教你claude code 接入 deepseek V4
前端·javascript