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+(功能有限) |
⚠️ 注意:虽然所有现代浏览器都支持,但行为存在差异,需要针对性处理。
⚡ 核心特性
可编辑区域的行为特性
-
原生光标(Carets) :自动显示插入符
-
文本选择:支持鼠标选中文本
-
富文本支持:用户可以输入带格式的文本
-
键盘交互:支持快捷键(Ctrl+B 加粗等)
-
拖拽操作:支持在元素内拖拽文本
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 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 零容忍(除非做好完整防护)
关键要点
- 安全第一:永远不要信任用户输入,使用 DOMPurify 等库进行过滤
- 关注差异:不同浏览器的行为差异需要针对性处理
- 光标管理:修改内容后记得恢复光标位置
- 渐进增强:从简单开始,必要时引入编辑器库
- 替代方案:生产环境优先考虑成熟的编辑器库
📝 写在最后
contenteditable是一个「入门简单、深坑不少」的属性。它能快速实现富文本编辑,但要在生产环境稳定使用,需要处理大量的浏览器兼容性和安全问题。建议:如果是个人项目或内部工具,直接使用 contenteditable 足够;如果是面向用户的产品,强烈建议使用 TinyMCE、Quill 或 Tiptap 等成熟方案。
文档由AI辅助整理