探索JavaScript实现富文本编辑器的多种方法与常见问题解决方案

引言
富文本编辑器(Rich Text Editor)是现代Web应用中不可或缺的组件,广泛应用于内容管理系统、博客平台、在线文档编辑等场景。它允许用户在浏览器中直接编辑格式化的文本、插入图片、创建列表等,而无需了解HTML或Markdown语法。本文将深入探讨使用JavaScript实现富文本编辑器的多种方法,并针对常见问题提供详细的解决方案。
1. 基于浏览器原生API的实现方法
1.1
contenteditable
属性
浏览器原生支持的
contenteditable
属性是实现富文本编辑器的基础。通过将该属性设置为
true
,任何HTML元素都可以变为可编辑区域。





原生 contenteditable 示例






在这里开始输入内容…




1.2
document.execCommand
方法
document.execCommand
是浏览器提供的原生API,用于执行富文本编辑命令。虽然该方法已被标记为过时(deprecated),但在许多场景下仍然可用。
常用命令:
bold
: 加粗文本
italic
: 斜体文本
underline
: 下划线文本
insertOrderedList
: 创建有序列表
insertUnorderedList
: 创建无序列表
insertImage
: 插入图片
createLink
: 创建链接
formatBlock
: 设置块级元素(如

、

)
示例代码:
// 执行加粗命令
document.execCommand(‘bold’, false, null);

// 插入HTML内容
document.execCommand(‘insertHTML’, false, ‘红色文本‘);

// 设置段落格式
document.execCommand(‘formatBlock’, false, ‘

‘);
1.3 优缺点分析
优点:
实现简单,无需复杂代码
浏览器原生支持,兼容性较好
无需引入第三方库
缺点:
execCommand
已过时,未来可能被移除
生成的HTML代码不一致,难以控制
缺乏对内容的精确控制
跨浏览器兼容性问题
2. 基于现代浏览器API的Selection和Range API
2.1 Selection和Range API基础
现代浏览器提供了更强大的Selection和Range API,可以精确控制用户选择的文本范围。
// 获取当前选区
const selection = window.getSelection();

// 创建一个新的Range
const range = document.createRange();

// 选择指定节点的内容
const editor = document.getElementById(‘editor’);
range.selectNodeContents(editor);

// 将Range添加到选区
selection.removeAllRanges();
selection.addRange(range);

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

// 插入HTML到选区
function insertHTMLAtSelection(html) {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const fragment = range.createContextualFragment(html);
range.deleteContents();
range.insertNode(fragment);
}
}

// 包装选区文本
function wrapSelection(tagName, attributes = {}) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;

const range = selection.getRangeAt(0);
const wrapper = document.createElement(tagName);

// 设置属性
Object.entries(attributes).forEach(([key, value]) => {
wrapper.setAttribute(key, value);
});

// 包装选区内容
range.surroundContents(wrapper);
selection.removeAllRanges();
}
2.2 实现自定义格式化
// 自定义加粗功能(使用Range API)
function customBold() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;

const range = selection.getRangeAt(0);
const selectedText = range.toString();

if (selectedText.length === 0) return;

// 检查是否已经加粗
const parent = range.commonAncestorContainer;
const isBold = parent.nodeType === Node.ELEMENT_NODE &&
(parent.style.fontWeight === ‘bold’ ||
parent.tagName === ‘B’ ||
parent.tagName === ‘STRONG’);

if (isBold) {
// 取消加粗
const boldElement = parent.closest(‘b, strong, [style*=”font-weight: bold”]’);
if (boldElement) {
const textNode = document.createTextNode(boldElement.textContent);
boldElement.parentNode.replaceChild(textNode, boldElement);
}
} else {
// 添加加粗
const bold = document.createElement(‘strong’);
bold.textContent = selectedText;
range.deleteContents();
range.insertNode(bold);
}

// 清除选区
selection.removeAllRanges();
}
3. 使用第三方库实现富文本编辑器
3.1 Quill.js – 轻量级且功能强大
Quill.js 是一个流行的开源富文本编辑器,具有模块化架构和丰富的API。
安装:
npm install quill
# 或者使用CDN
# #
基本使用:




Quill.js 示例






Quill.js 高级配置:
const advancedQuill = new Quill(‘#editor’, {
theme: ‘snow’,
modules: {
toolbar: {
container: [
[{ ‘header’: [1, 2, 3, 4, 5, 6, false] }],
[‘bold’, ‘italic’, ‘underline’, ‘strike’, ‘blockquote’],
[{ ‘list’: ‘ordered’}, { ‘list’: ‘bullet’ }],
[{ ‘indent’: ‘-1’}, { ‘indent’: ‘+1’ }],
[{ ‘direction’: ‘rtl’ }],
[{ ‘size’: [‘small’, false, ‘large’, ‘huge’] }],
[{ ‘color’: [] }, { ‘background’: [] }],
[{ ‘font’: [] }],
[{ ‘align’: [] }],
[‘link’, ‘image’, ‘video’, ‘formula’],
[‘clean’]
],
handlers: {
// 自定义图片上传
‘image’: function() {
const input = document.createElement(‘input’);
input.setAttribute(‘type’, ‘file’);
input.setAttribute(‘accept’, ‘image/*’);
input.click();
input.onchange = function() {
const file = input.files[0];
if (file) {
// 这里应该实现实际的上传逻辑
const reader = new FileReader();
reader.onload = function(e) {
const url = e.target.result;
const range = advancedQuill.getSelection(true);
advancedQuill.insertEmbed(range.index, ‘image’, url);
};
reader.readAsDataURL(file);
}
};
}
}
}
},
placeholder: ‘开始创作…’,
readOnly: false
});
3.2 TinyMCE – 企业级解决方案
TinyMCE 是一个功能全面的企业级富文本编辑器,适合需要高级功能的场景。
CDN引入:

基本配置:




TinyMCE 示例






3.3 Slate.js – 现代化、可扩展的编辑器框架
Slate.js 是一个完全可定制的富文本编辑器框架,基于React构建,适合需要深度定制的场景。
安装:
npm install slate slate-react react react-dom
基本使用:
import React, { useState, useCallback } from ‘react’;
import { createEditor, Transforms, Editor, Text } from ‘slate’;
import { Slate, Editable, withReact } from ‘slate-react’;

// 自定义元素渲染
const Element = ({ attributes, children, element }) => {
switch (element.type) {
case ‘block-quote’:
return

{children}

;
case ‘heading-one’:
return

{children}

;
case ‘heading-two’:
return

{children}

;
case ‘list-item’:
return

  • {children}
  • ;
    case ‘numbered-list’:
    return

      {children}

    ;
    case ‘bulleted-list’:
    return

      {children}

    ;
    default:
    return

    {children}

    ;
    }
    };

    // 自定义叶节点渲染
    const Leaf = ({ attributes, children, leaf }) => {
    if (leaf.bold) {
    children = {children};
    }
    if (leaf.italic) {
    children = {children};
    }
    if (leaf.underline) {
    children = {children};
    }
    if (leaf.code) {
    children = {children};
    }
    return {children};
    };

    // 示例组件
    const SlateEditor = () => {
    const [value, setValue] = useState(initialValue);
    const renderElement = useCallback(props => , []);
    const renderLeaf = useCallback(props => , []);
    const [editor] = useState(() => withReact(createEditor()));

    // 自定义命令
    const handleKeyDown = (event) => {
    if (!event.ctrlKey) {
    return;
    }

    switch (event.key) {
    case ‘b’: {
    event.preventDefault();
    Editor.addMark(editor, ‘bold’, true);
    break;
    }
    case ‘i’: {
    event.preventDefault();
    Editor.addMark(editor, ‘italic’, true);
    break;
    }
    case ‘u’: {
    event.preventDefault();
    Editor.addMark(editor, ‘underline’, true);
    break;
    }
    case ‘`’: {
    event.preventDefault();
    const [match] = Editor.nodes(editor, {
    match: n => n.type === ‘code-block’,
    });
    Transforms.setNodes(
    editor,
    { type: match ? ‘paragraph’ : ‘code-block’ },
    { match: n => Editor.isBlock(editor, n) }
    );
    break;
    }
    }
    };

    return (
    setValue(newValue)}
    >






    );
    };

    const initialValue = [
    {
    type: ‘paragraph’,
    children: [{ text: ‘这是一个基于Slate.js的编辑器示例。’ }],
    },
    ];

    export default SlateEditor;
    4. 常见问题及解决方案
    4.1 跨浏览器兼容性问题
    问题描述:
    不同浏览器生成的HTML结构不一致,导致样式错乱。
    解决方案:
    // 统一HTML输出格式
    function normalizeHTML(html) {
    // 移除多余的空格和换行
    html = html.replace(/\s+/g, ‘ ‘).trim();

    // 统一标签格式
    html = html.replace(//g, ‘‘)
    .replace(/<\/b>/g, ‘
    ‘)
    .replace(//g, ‘‘)
    .replace(/<\/i>/g, ‘
    ‘);

    // 移除空的内联元素
    html = html.replace(/]*><\/span>/g, ”);

    return html;
    }

    // 使用DOMParser安全解析HTML
    function parseHTML(html) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, ‘text/html’);
    return doc.body.innerHTML;
    }

    // 清理HTML,防止XSS攻击
    function sanitizeHTML(html) {
    const temp = document.createElement(‘div’);
    temp.innerHTML = html;

    // 移除script标签
    const scripts = temp.querySelectorAll(‘script’);
    scripts.forEach(script => script.remove());

    // 移除事件处理器
    const allElements = temp.querySelectorAll(‘*’);
    allElements.forEach(el => {
    const attrs = el.attributes;
    for (let i = attrs.length – 1; i >= 0; i–) {
    const attr = attrs[i];
    if (attr.name.startsWith(‘on’)) {
    el.removeAttribute(attr.name);
    }
    }
    });

    return temp.innerHTML;
    }
    4.2 粘贴内容处理
    问题描述:
    从Word、PDF或其他网页粘贴内容时,会带入大量冗余样式和HTML结构。
    解决方案:
    // 粘贴事件处理
    function handlePaste(event) {
    event.preventDefault();

    const clipboardData = event.clipboardData || window.clipboardData;
    let pastedText = clipboardData.getData(‘text/plain’);
    let pastedHTML = clipboardData.getData(‘text/html’);

    // 如果没有HTML,只插入纯文本
    if (!pastedHTML) {
    document.execCommand(‘insertText’, false, pastedText);
    return;
    }

    // 清理HTML
    const cleanHTML = cleanPastedHTML(pastedHTML);
    document.execCommand(‘insertHTML’, false, cleanHTML);
    }

    function cleanPastedHTML(html) {
    // 创建临时容器
    const temp = document.createElement(‘div’);
    temp.innerHTML = html;

    // 移除不需要的标签
    const removeTags = [‘style’, ‘script’, ‘meta’, ‘link’, ‘iframe’, ‘object’];
    removeTags.forEach(tag => {
    const elements = temp.querySelectorAll(tag);
    elements.forEach(el => el.remove());
    });

    // 移除所有样式属性,只保留基本格式
    const allElements = temp.querySelectorAll(‘*’);
    allElements.forEach(el => {
    // 保留class为特定值的元素
    const allowedClasses = [‘highlight’, ‘code-block’];
    if (el.className) {
    const classes = el.className.split(‘ ‘).filter(c => allowedClasses.includes(c));
    if (classes.length > 0) {
    el.className = classes.join(‘ ‘);
    } else {
    el.removeAttribute(‘class’);
    }
    }

    // 移除所有style属性
    el.removeAttribute(‘style’);

    // 移除空属性
    Array.from(el.attributes).forEach(attr => {
    if (!attr.value) {
    el.removeAttribute(attr.name);
    }
    });
    });

    // 处理链接
    const links = temp.querySelectorAll(‘a’);
    links.forEach(link => {
    link.setAttribute(‘target’, ‘_blank’);
    link.setAttribute(‘rel’, ‘noopener noreferrer’);
    });

    return temp.innerHTML;
    }

    // 在编辑器上绑定粘贴事件
    document.getElementById(‘editor’).addEventListener(‘paste’, handlePaste);
    4.3 图片上传与处理
    问题问题:
    图片上传需要处理文件选择、压缩、上传进度、错误处理等。
    解决方案:
    // 图片上传管理器
    class ImageUploader {
    constructor(options = {}) {
    this.maxSize = options.maxSize || 5 * 1024 * 1024; // 5MB
    this.allowedTypes = options.allowedTypes || [‘image/jpeg’, ‘image/png’, ‘image/gif’, ‘image/webp’];
    this.uploadUrl = options.uploadUrl || ‘/api/upload’;
    this.onProgress = options.onProgress || (() => {});
    this.onSuccess = options.onSuccess || (() => {});
    this.onError = options.onError || (() => {});
    }

    // 压缩图片
    async compressImage(file) {
    return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (e) => {
    const img = new Image();
    img.onload = () => {
    const canvas = document.createElement(‘canvas’);
    const ctx = canvas.getContext(‘2d’);

    // 计算新尺寸
    let width = img.width;
    let height = img.height;
    const maxWidth = 1920;
    const maxHeight = 1080;

    if (width > maxWidth || height > maxHeight) {
    const ratio = Math.min(maxWidth / width, maxHeight / height);
    width *= ratio;
    height *= ratio;
    }

    canvas.width = width;
    canvas.height = height;
    ctx.drawImage(img, 0, 0, width, height);

    // 转换为Blob
    canvas.toBlob((blob) => {
    resolve(blob);
    }, file.type, 0.8); // 80% 质量
    };
    img.onerror = reject;
    img.src = e.target.result;
    };
    reader.onerror = reject;
    reader.readAsDataURL(file);
    });
    }

    // 上传文件
    async upload(file) {
    // 验证文件类型
    if (!this.allowedTypes.includes(file.type)) {
    this.onError(‘不支持的文件类型’);
    return null;
    }

    // 验证文件大小
    if (file.size > this.maxSize) {
    this.onError(`文件大小不能超过 ${this.maxSize / 1024 / 1024}MB`);
    return null;
    }

    try {
    // 压缩图片
    const compressedBlob = await this.compressImage(file);

    // 创建FormData
    const formData = new FormData();
    formData.append(‘file’, compressedBlob, file.name);

    // 上传
    const xhr = new XMLHttpRequest();

    return new Promise((resolve, reject) => {
    xhr.upload.addEventListener(‘progress’, (e) => {
    if (e.lengthComputable) {
    const percentComplete = (e.loaded / e.total) * 100;
    this.onProgress(percentComplete);
    }
    });

    xhr.addEventListener(‘load’, () => {
    if (xhr.status === 200) {
    const response = JSON.parse(xhr.responseText);
    this.onSuccess(response);
    resolve(response.url);
    } else {
    this.onError(‘上传失败’);
    reject(‘上传失败’);
    }
    });

    xhr.addEventListener(‘error’, () => {
    this.onError(‘网络错误’);
    reject(‘网络错误’);
    });

    xhr.open(‘POST’, this.uploadUrl);
    xhr.send(formData);
    });
    } catch (error) {
    this.onError(‘处理图片时出错’);
    return null;
    }
    }

    // 处理文件选择
    async handleFileSelect(event) {
    const files = event.target.files;
    if (files.length === 0) return null;

    const file = files[0];
    return await this.upload(file);
    }
    }

    // 在编辑器中使用
    function initImageUpload(editorElement) {
    const uploader = new ImageUploader({
    uploadUrl: ‘/api/upload-image’,
    onProgress: (percent) => {
    console.log(`上传进度: ${percent.toFixed(2)}%`);
    // 显示进度条
    showProgressBar(percent);
    },
    onSuccess: (response) => {
    console.log(‘上传成功:’, response);
    hideProgressBar();
    // 插入图片到编辑器
    insertImageToEditor(response.url);
    },
    onError: (error) => {
    console.error(‘上传失败:’, error);
    hideProgressBar();
    alert(‘上传失败: ‘ + error);
    }
    });

    // 创建隐藏的文件输入
    const fileInput = document.createElement(‘input’);
    fileInput.type = ‘file’;
    fileInput.accept = ‘image/*’;
    fileInput.style.display = ‘none’;
    fileInput.addEventListener(‘change’, (e) => {
    uploader.handleFileSelect(e);
    });
    document.body.appendChild(fileInput);

    // 插入图片按钮
    const insertBtn = document.createElement(‘button’);
    insertBtn.textContent = ‘插入图片’;
    insertBtn.onclick = () => fileInput.click();
    editorElement.parentNode.insertBefore(insertBtn, editorElement);
    }

    // 显示进度条
    function showProgressBar(percent) {
    let progressBar = document.getElementById(‘upload-progress’);
    if (!progressBar) {
    progressBar = document.createElement(‘div’);
    progressBar.id = ‘upload-progress’;
    progressBar.style.cssText = `
    position: fixed;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    background: white;
    padding: 15px;
    border-radius: 8px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.15);
    z-index: 10000;
    `;
    document.body.appendChild(progressBar);
    }
    progressBar.innerHTML = `

    上传中…

    `;
    }

    function hideProgressBar() {
    const progressBar = document.getElementById(‘upload-progress’);
    if (progressBar) {
    progressBar.remove();
    }
    }

    function insertImageToEditor(url) {
    // 根据使用的编辑器类型插入图片
    if (window.quill) {
    const range = window.quill.getSelection(true);
    window.quill.insertEmbed(range.index, ‘image’, url);
    } else if (window.tinyMCE) {
    window.tinyMCE.activeEditor.insertContent(`上传的图片`);
    } else {
    document.execCommand(‘insertImage’, false, url);
    }
    }
    4.4 内容安全与XSS防护
    问题描述:
    用户输入的内容可能包含恶意脚本,导致XSS攻击。
    解决方案:
    // 使用DOMPurify库进行HTML净化
    // 需要先引入:

    function sanitizeContent(html) {
    // 配置DOMPurify
    const config = {
    ALLOWED_TAGS: [
    ‘p’, ‘br’, ‘strong’, ’em’, ‘u’, ‘s’, ‘span’, ‘div’,
    ‘h1’, ‘h2’, ‘h3’, ‘h4’, ‘h5’, ‘h6’,
    ‘ul’, ‘ol’, ‘li’,
    ‘blockquote’,
    ‘a’, ‘img’,
    ‘table’, ‘thead’, ‘tbody’, ‘tr’, ‘td’, ‘th’,
    ‘code’, ‘pre’
    ],
    ALLOWED_ATTR: [
    ‘href’, ‘target’, ‘rel’,
    ‘src’, ‘alt’, ‘title’, ‘width’, ‘height’,
    ‘class’, ‘style’,
    ‘colspan’, ‘rowspan’,
    ‘align’, ‘valign’
    ],
    ALLOWED_STYLE_PROPS: [
    ‘color’, ‘background-color’, ‘font-weight’, ‘font-style’,
    ‘text-decoration’, ‘text-align’, ‘font-size’,
    ‘margin’, ‘padding’, ‘border’, ‘border-radius’,
    ‘width’, ‘height’
    ],
    FORBID_TAGS: [‘script’, ‘iframe’, ‘object’, ’embed’, ‘link’, ‘style’],
    FORBID_ATTR: [‘onerror’, ‘onload’, ‘onclick’, ‘onmouseover’, ‘onmouseout’, ‘onfocus’, ‘onblur’],
    KEEP_CONTENT: false,
    RETURN_TRUSTED_TYPE: false
    };

    // 自定义钩子来处理style属性
    config.ADD_ATTR = [‘style’];
    config.ALLOW_DATA_ATTR = false;

    // 使用DOMPurify净化
    const clean = DOMPurify.sanitize(html, config);

    // 额外的自定义清理
    const temp = document.createElement(‘div’);
    temp.innerHTML = clean;

    // 移除空的内联元素
    temp.querySelectorAll(‘span, div’).forEach(el => {
    if (!el.textContent.trim() && el.children.length === 0) {
    el.remove();
    }
    });

    // 验证链接
    temp.querySelectorAll(‘a’).forEach(link => {
    const href = link.getAttribute(‘href’);
    if (!href || href.startsWith(‘javascript:’) || href.startsWith(‘data:’)) {
    link.removeAttribute(‘href’);
    } else {
    link.setAttribute(‘target’, ‘_blank’);
    link.setAttribute(‘rel’, ‘noopener noreferrer’);
    }
    });

    // 验证图片
    temp.querySelectorAll(‘img’).forEach(img => {
    const src = img.getAttribute(‘src’);
    if (!src || src.startsWith(‘data:’) || src.startsWith(‘javascript:’)) {
    img.remove();
    }
    });

    return temp.innerHTML;
    }

    // 在编辑器保存时使用
    function saveEditorContent() {
    const rawHTML = getEditorContent(); // 获取原始HTML
    const safeHTML = sanitizeContent(rawHTML);

    // 发送到服务器
    fetch(‘/api/save-content’, {
    method: ‘POST’,
    headers: {
    ‘Content-Type’: ‘application/json’,
    },
    body: JSON.stringify({ content: safeHTML })
    }).then(response => {
    if (response.ok) {
    console.log(‘内容已安全保存’);
    }
    });
    }

    // 服务器端验证(Node.js示例)
    /*
    const express = require(‘express’);
    const DOMPurify = require(‘isomorphic-dompurify’);
    const { JSDOM } = require(‘jsdom’);

    const app = express();
    app.use(express.json());

    app.post(‘/api/save-content’, (req, res) => {
    const { content } = req.body;

    // 服务器端再次净化
    const cleanContent = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: [‘p’, ‘strong’, ’em’, ‘u’, ‘s’, ‘span’, ‘div’, ‘h1’, ‘h2’, ‘h3’, ‘ul’, ‘ol’, ‘li’, ‘blockquote’, ‘a’, ‘img’, ‘code’, ‘pre’],
    ALLOWED_ATTR: [‘href’, ‘target’, ‘rel’, ‘src’, ‘alt’, ‘title’, ‘class’]
    });

    // 保存到数据库
    // await db.save({ content: cleanContent });

    res.json({ success: true, content: cleanContent });
    });
    */
    4.5 撤销/重做功能实现
    问题描述:
    需要实现复杂的撤销/重做功能,管理编辑历史。
    解决方案:
    // 历史管理器
    class HistoryManager {
    constructor(editor, maxSteps = 100) {
    this.editor = editor;
    this.maxSteps = maxSteps;
    this.undoStack = [];
    this.redoStack = [];
    this.isApplying = false;
    }

    // 保存当前状态
    saveState() {
    if (this.isApplying) return;

    const content = this.editor.innerHTML;
    const selection = this.saveSelection();

    // 如果与上一个状态相同,不保存
    if (this.undoStack.length > 0) {
    const lastState = this.undoStack[this.undoStack.length – 1];
    if (lastState.content === content) {
    return;
    }
    }

    this.undoStack.push({
    content: content,
    selection: selection,
    timestamp: Date.now()
    });

    // 限制历史记录数量
    if (this.undoStack.length > this.maxSteps) {
    this.undoStack.shift();
    }

    // 清空重做栈
    this.redoStack = [];

    console.log(‘状态已保存,当前历史记录数:’, this.undoStack.length);
    }

    // 撤销
    undo() {
    if (this.undoStack.length <= 1) { console.log('没有更多可撤销的内容'); return; } this.isApplying = true; // 将当前状态移到重做栈 const currentState = this.undoStack.pop(); this.redoStack.push(currentState); // 恢复上一个状态 const previousState = this.undoStack[this.undoStack.length - 1]; this.editor.innerHTML = previousState.content; if (previousState.selection) { this.restoreSelection(previousState.selection); } this.isApplying = false; console.log('撤销成功,剩余撤销:', this.undoStack.length, '重做:', this.redoStack.length); } // 重做 redo() { if (this.redoStack.length === 0) { console.log('没有可重做的内容'); return; } this.isApplying = true; // 从重做栈取出 const nextState = this.redoStack.pop(); this.undoStack.push(nextState); this.editor.innerHTML = nextState.content; if (nextState.selection) { this.restoreSelection(nextState.selection); } this.isApplying = false; console.log('重做成功,剩余撤销:', this.undoStack.length, '重做:', this.redoStack.length); } // 保存选区位置 saveSelection() { const selection = window.getSelection(); if (selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const preSelectionRange = range.cloneRange(); preSelectionRange.selectNodeContents(this.editor); preSelectionRange.setEnd(range.startContainer, range.startOffset); const start = preSelectionRange.toString().length; return { start: start, end: start + range.toString().length }; } // 恢复选区位置 restoreSelection(savedSelection) { if (!savedSelection) return; const selection = window.getSelection(); const range = document.createRange(); let currentPos = 0; let foundStart = false; let foundEnd = false; function traverseNodes(node) { if (foundEnd) return; if (node.nodeType === Node.TEXT_NODE) { const textLength = node.textContent.length; if (!foundStart && currentPos + textLength >= savedSelection.start) {
    const startOffset = savedSelection.start – currentPos;
    range.setStart(node, startOffset);
    foundStart = true;
    }

    if (foundStart && !foundEnd && currentPos + textLength >= savedSelection.end) {
    const endOffset = savedSelection.end – currentPos;
    range.setEnd(node, endOffset);
    foundEnd = true;
    return;
    }

    currentPos += textLength;
    } else if (node.nodeType === Node.ELEMENT_NODE) {
    for (let i = 0; i < node.childNodes.length; i++) { traverseNodes(node.childNodes[i]); if (foundEnd) break; } } } traverseNodes(this.editor); if (foundStart && foundEnd) { selection.removeAllRanges(); selection.addRange(range); } } // 清空历史 clear() { this.undoStack = []; this.redoStack = []; this.isApplying = false; } // 获取当前状态 getCurrentState() { return this.undoStack.length > 0 ? this.undoStack[this.undoStack.length – 1] : null;
    }
    }

    // 在编辑器中使用
    function initHistoryManager(editorElement) {
    const history = new HistoryManager(editorElement);

    // 监听输入事件
    let saveTimeout;
    editorElement.addEventListener(‘input’, () => {
    clearTimeout(saveTimeout);
    saveTimeout = setTimeout(() => {
    history.saveState();
    }, 500); // 延迟500ms保存,避免频繁操作
    });

    // 监听选择变化
    editorElement.addEventListener(‘mouseup’, () => {
    setTimeout(() => {
    if (history.undoStack.length > 0) {
    const currentState = history.undoStack[history.undoStack.length – 1];
    const currentSelection = history.saveSelection();
    if (currentSelection) {
    currentState.selection = currentSelection;
    }
    }
    }, 10);
    });

    // 键盘快捷键
    editorElement.addEventListener(‘keydown’, (e) => {
    // Ctrl+Z 撤销
    if (e.ctrlKey && e.key === ‘z’ && !e.shiftKey) {
    e.preventDefault();
    history.undo();
    }
    // Ctrl+Y 或 Ctrl+Shift+Z 重做
    else if ((e.ctrlKey && e.key === ‘y’) || (e.ctrlKey && e.shiftKey && e.key === ‘z’)) {
    e.preventDefault();
    history.redo();
    }
    // Ctrl+S 保存
    else if (e.ctrlKey && e.key === ‘s’) {
    e.preventDefault();
    saveContent();
    }
    });

    // 暴露全局方法
    window.editorHistory = history;

    return history;
    }

    // 保存内容示例
    function saveContent() {
    const content = document.getElementById(‘editor’).innerHTML;
    const safeContent = sanitizeContent(content);

    // 模拟保存到服务器
    console.log(‘保存内容:’, safeContent);
    alert(‘内容已保存!’);
    }
    4.6 性能优化
    问题描述:
    大型文档编辑时出现卡顿、输入延迟等问题。
    解决方案:
    // 性能优化管理器
    class EditorPerformanceOptimizer {
    constructor(editor, options = {}) {
    this.editor = editor;
    this.batchDelay = options.batchDelay || 100;
    this.maxHistoryLength = options.maxHistoryLength || 50;
    this.debounceTimers = new Map();
    this.batchQueue = [];
    this.isProcessing = false;
    }

    // 防抖函数
    debounce(key, fn, delay) {
    if (this.debounceTimers.has(key)) {
    clearTimeout(this.debounceTimers.get(key));
    }
    const timer = setTimeout(() => {
    fn();
    this.debounceTimers.delete(key);
    }, delay);
    this.debounceTimers.set(key, timer);
    }

    // 批量处理更新
    batchUpdate(operations) {
    this.batchQueue.push(…operations);

    if (!this.isProcessing) {
    this.isProcessing = true;
    setTimeout(() => {
    this.processBatch();
    }, this.batchDelay);
    }
    }

    processBatch() {
    if (this.batchQueue.length === 0) {
    this.isProcessing = false;
    return;
    }

    // 合并多个操作
    const operations = this.batchQueue.splice(0);

    // 使用DocumentFragment进行批量DOM操作
    const fragment = document.createDocumentFragment();

    operations.forEach(op => {
    if (op.type === ‘insert’) {
    const temp = document.createElement(‘div’);
    temp.innerHTML = op.html;
    while (temp.firstChild) {
    fragment.appendChild(temp.firstChild);
    }
    }
    });

    // 一次性插入
    if (fragment.childNodes.length > 0) {
    this.editor.appendChild(fragment);
    }

    // 继续处理剩余队列
    if (this.batchQueue.length > 0) {
    setTimeout(() => this.processBatch(), 0);
    } else {
    this.isProcessing = false;
    }
    }

    // 虚拟滚动优化(针对超长文档)
    enableVirtualScroll() {
    const container = this.editor.parentElement;
    const viewportHeight = container.clientHeight;
    const itemHeight = 24; // 估算行高
    const visibleItems = Math.ceil(viewportHeight / itemHeight) + 2;

    let startIndex = 0;
    let allItems = [];

    // 监听滚动
    container.addEventListener(‘scroll’, () => {
    const scrollTop = container.scrollTop;
    const newStartIndex = Math.floor(scrollTop / itemHeight);

    if (newStartIndex !== startIndex) {
    startIndex = newStartIndex;
    this.renderVisibleItems(startIndex, visibleItems, allItems);
    }
    });
    }

    renderVisibleItems(start, count, items) {
    const fragment = document.createDocumentFragment();

    for (let i = start; i < Math.min(start + count, items.length); i++) { const item = items[i]; const div = document.createElement('div'); div.style.height = '24px'; div.textContent = item; fragment.appendChild(div); } this.editor.innerHTML = ''; this.editor.appendChild(fragment); } // 内存清理 cleanup() { // 清理定时器 this.debounceTimers.forEach(timer => clearTimeout(timer));
    this.debounceTimers.clear();

    // 清理队列
    this.batchQueue = [];

    // 清理事件监听器(如果需要)
    // this.editor.removeEventListener(…)
    }

    // 优化粘贴操作
    optimizePaste(event) {
    event.preventDefault();

    const clipboardData = event.clipboardData;
    const items = clipboardData.items;

    // 处理图片
    for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile(); this.handleImagePaste(file); return; } } // 处理文本 const text = clipboardData.getData('text/plain'); const html = clipboardData.getData('text/html'); // 优先使用纯文本,避免复杂的HTML解析 if (text && !html) { document.execCommand('insertText', false, text); } else if (html) { // 使用防抖处理HTML粘贴 this.debounce('paste', () => {
    const cleanHTML = cleanPastedHTML(html);
    document.execCommand(‘insertHTML’, false, cleanHTML);
    }, 50);
    }
    }

    handleImagePaste(file) {
    // 使用之前的ImageUploader
    const uploader = new ImageUploader();
    uploader.upload(file).then(url => {
    if (url) {
    document.execCommand(‘insertImage’, false, url);
    }
    });
    }
    }

    // 使用示例
    function initOptimizedEditor(editorElement) {
    const optimizer = new EditorPerformanceOptimizer(editorElement);

    // 优化粘贴
    editorElement.addEventListener(‘paste’, (e) => optimizer.optimizePaste(e));

    // 优化输入
    editorElement.addEventListener(‘input’, () => {
    optimizer.debounce(‘input’, () => {
    // 执行耗时的DOM操作或分析
    analyzeContent(editorElement.innerHTML);
    }, 300);
    });

    // 监听内存使用
    if (performance.memory) {
    setInterval(() => {
    const memory = performance.memory;
    if (memory.usedJSHeapSize > 100 * 1024 * 1024) { // 100MB
    console.warn(‘内存使用过高,建议清理历史记录’);
    if (window.editorHistory) {
    window.editorHistory.clear();
    }
    }
    }, 5000);
    }

    return optimizer;
    }

    // 内容分析(模拟耗时操作)
    function analyzeContent(html) {
    // 模拟复杂分析
    const temp = document.createElement(‘div’);
    temp.innerHTML = html;

    const stats = {
    wordCount: temp.textContent.trim().split(/\s+/).length,
    imageCount: temp.querySelectorAll(‘img’).length,
    linkCount: temp.querySelectorAll(‘a’).length,
    charCount: temp.textContent.length
    };

    console.log(‘内容分析:’, stats);
    return stats;
    }
    4.7 移动端适配
    问题描述:
    移动端输入体验差,虚拟键盘遮挡编辑区域,触摸事件处理复杂。
    解决方案:
    // 移动端优化管理器
    class MobileEditorOptimizer {
    constructor(editor, toolbar) {
    this.editor = editor;
    this.toolbar = toolbar;
    this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
    this.visualViewport = window.visualViewport;

    if (this.isMobile) {
    this.initMobileOptimizations();
    }
    }

    initMobileOptimizations() {
    // 1. 防止虚拟键盘遮挡
    if (this.visualViewport) {
    this.visualViewport.addEventListener(‘resize’, () => {
    this.adjustForVirtualKeyboard();
    });
    }

    // 2. 优化触摸事件
    this.setupTouchEvents();

    // 3. 自动聚焦管理
    this.setupAutoFocus();

    // 4. 工具栏折叠
    this.setupCollapsibleToolbar();

    // 5. 防止页面滚动
    this.preventPageScroll();
    }

    adjustForVirtualKeyboard() {
    const viewportHeight = this.visualViewport.height;
    const keyboardHeight = window.innerHeight – viewportHeight;

    if (keyboardHeight > 100) { // 虚拟键盘显示
    // 滚动到编辑器
    this.editor.scrollIntoView({ behavior: ‘smooth’, block: ‘center’ });

    // 调整编辑器高度
    const availableHeight = viewportHeight – this.toolbar.offsetHeight – 50;
    this.editor.style.minHeight = `${availableHeight}px`;
    } else {
    // 恢复正常高度
    this.editor.style.minHeight = ‘300px’;
    }
    }

    setupTouchEvents() {
    // 长按选择优化
    let longPressTimer;
    let touchStartY;

    this.editor.addEventListener(‘touchstart’, (e) => {
    touchStartY = e.touches[0].clientY;
    longPressTimer = setTimeout(() => {
    // 触发原生选择菜单
    this.showCustomSelectionMenu(e.touches[0]);
    }, 500);
    });

    this.editor.addEventListener(‘touchmove’, (e) => {
    // 如果移动超过10px,取消长按
    if (Math.abs(e.touches[0].clientY – touchStartY) > 10) {
    clearTimeout(longPressTimer);
    }
    });

    this.editor.addEventListener(‘touchend’, () => {
    clearTimeout(longPressTimer);
    });

    // 双击选择单词
    let lastTap = 0;
    this.editor.addEventListener(‘touchend’, (e) => {
    const currentTime = new Date().getTime();
    const tapLength = currentTime – lastTap;

    if (tapLength < 500 && tapLength > 0) {
    // 双击事件
    this.selectWordAtTouch(e.changedTouches[0]);
    e.preventDefault();
    }
    lastTap = currentTime;
    });
    }

    selectWordAtTouch(touch) {
    const range = document.caretRangeFromPoint(touch.clientX, touch.clientY);
    if (range) {
    // 扩展范围到单词边界
    const text = range.startContainer.textContent;
    const offset = range.startOffset;

    // 查找单词边界
    let start = offset;
    let end = offset;

    while (start > 0 && /\S/.test(text[start – 1])) start–;
    while (end < text.length && /\S/.test(text[end])) end++; range.setStart(range.startContainer, start); range.setEnd(range.startContainer, end); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } } showCustomSelectionMenu(touch) { // 创建自定义选择菜单 const menu = document.createElement('div'); menu.style.cssText = ` position: fixed; left: ${touch.clientX}px; top: ${touch.clientY - 60}px; background: #333; color: white; padding: 8px; border-radius: 8px; display: flex; gap: 8px; z-index: 10000; transform: translateX(-50%); `; const buttons = [ { text: 'B', command: 'bold' }, { text: 'I', command: 'italic' }, { text: 'U', command: 'underline' }, { text: '复制', action: () => document.execCommand(‘copy’) }
    ];

    buttons.forEach(btn => {
    const button = document.createElement(‘button’);
    button.textContent = btn.text;
    button.style.cssText = `
    background: #555;
    border: none;
    color: white;
    padding: 6px 12px;
    border-radius: 4px;
    cursor: pointer;
    `;

    if (btn.command) {
    button.onclick = () => {
    document.execCommand(btn.command);
    menu.remove();
    };
    } else if (btn.action) {
    button.onclick = () => {
    btn.action();
    menu.remove();
    };
    }

    menu.appendChild(button);
    });

    document.body.appendChild(menu);

    // 点击其他地方关闭
    setTimeout(() => {
    document.addEventListener(‘click’, function closeMenu() {
    menu.remove();
    document.removeEventListener(‘click’, closeMenu);
    }, { once: true });
    }, 100);
    }

    setupAutoFocus() {
    // 点击编辑器外部时,自动聚焦到最后
    document.addEventListener(‘click’, (e) => {
    if (!this.editor.contains(e.target) &&
    !this.toolbar.contains(e.target) &&
    !e.target.closest(‘.ql-toolbar’) && // Quill工具栏
    !e.target.closest(‘.tox-tinymce’)) { // TinyMCE工具栏

    // 检查是否在编辑器区域内
    const editorRect = this.editor.getBoundingClientRect();
    const clickY = e.clientY;

    if (clickY > editorRect.bottom) {
    this.focusEditorEnd();
    }
    }
    });
    }

    focusEditorEnd() {
    const range = document.createRange();
    const selection = window.getSelection();

    if (this.editor.lastChild) {
    range.selectNodeContents(this.editor.lastChild);
    range.collapse(false);
    } else {
    range.selectNodeContents(this.editor);
    range.collapse(true);
    }

    selection.removeAllRanges();
    selection.addRange(range);

    // 滚动到可见区域
    setTimeout(() => {
    this.editor.scrollIntoView({ behavior: ‘smooth’, block: ‘center’ });
    }, 100);
    }

    setupCollapsibleToolbar() {
    if (!this.toolbar) return;

    // 创建折叠按钮
    const collapseBtn = document.createElement(‘button’);
    collapseBtn.textContent = ‘☰’;
    collapseBtn.style.cssText = `
    position: fixed;
    bottom: 20px;
    right: 20px;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    background: #007bff;
    color: white;
    border: none;
    font-size: 20px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.3);
    z-index: 9999;
    cursor: pointer;
    `;

    let isCollapsed = false;

    collapseBtn.addEventListener(‘click’, () => {
    isCollapsed = !isCollapsed;

    if (isCollapsed) {
    this.toolbar.style.display = ‘none’;
    collapseBtn.textContent = ‘☰’;
    collapseBtn.style.background = ‘#28a745’;
    } else {
    this.toolbar.style.display = ‘flex’;
    collapseBtn.textContent = ‘✕’;
    collapseBtn.style.background = ‘#dc3545’;
    }
    });

    document.body.appendChild(collapseBtn);

    // 滚动时自动隐藏/显示
    let lastScrollY = window.scrollY;
    window.addEventListener(‘scroll’, () => {
    const currentScrollY = window.scrollY;
    const scrollDelta = currentScrollY – lastScrollY;

    if (Math.abs(scrollDelta) > 50) {
    if (scrollDelta > 0 && !isCollapsed) {
    // 向下滚动,隐藏工具栏
    this.toolbar.style.transform = ‘translateY(-100%)’;
    this.toolbar.style.opacity = ‘0’;
    } else {
    // 向上滚动,显示工具栏
    this.toolbar.style.transform = ‘translateY(0)’;
    this.toolbar.style.opacity = ‘1’;
    }
    }

    lastScrollY = currentScrollY;
    });
    }

    preventPageScroll() {
    // 防止编辑器内滚动冒泡到页面
    this.editor.addEventListener(‘touchmove’, (e) => {
    const scrollHeight = this.editor.scrollHeight;
    const clientHeight = this.editor.clientHeight;
    const scrollTop = this.editor.scrollTop;
    const touchY = e.touches[0].clientY;
    const startY = this.startTouchY || touchY;

    // 到达顶部或底部时允许页面滚动
    if (scrollTop === 0 && touchY > startY) {
    return; // 允许页面滚动
    } else if (scrollTop + clientHeight >= scrollHeight && touchY < startY) { return; // 允许页面滚动 } // 否则阻止页面滚动 e.preventDefault(); }, { passive: false }); // 记录触摸开始位置 this.editor.addEventListener('touchstart', (e) => {
    this.startTouchY = e.touches[0].clientY;
    });
    }

    // 优化输入法编辑器(IME)输入
    setupIMEOptimization() {
    let isComposing = false;

    this.editor.addEventListener(‘compositionstart’, () => {
    isComposing = true;
    });

    this.editor.addEventListener(‘compositionend’, () => {
    isComposing = false;
    // IME输入结束后触发保存或分析
    setTimeout(() => {
    this.debounce(‘ime-save’, () => {
    console.log(‘IME输入完成’);
    }, 500);
    }, 10);
    });

    // 在IME输入期间不进行复杂操作
    this.editor.addEventListener(‘input’, (e) => {
    if (isComposing) {
    e.stopPropagation();
    return;
    }
    });
    }

    debounce(key, fn, delay) {
    if (this.debounceTimers) {
    clearTimeout(this.debounceTimers.get(key));
    } else {
    this.debounceTimers = new Map();
    }

    const timer = setTimeout(() => {
    fn();
    this.debounceTimers.delete(key);
    }, delay);

    this.debounceTimers.set(key, timer);
    }
    }

    // 移动端检测和初始化
    function initMobileEditor() {
    const editor = document.getElementById(‘editor’);
    const toolbar = document.querySelector(‘.toolbar’);

    if (!editor) return;

    // 检测移动设备
    const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);

    if (isMobile) {
    console.log(‘移动设备检测到,启用移动端优化’);

    // 添加移动设备特定的CSS
    const style = document.createElement(‘style’);
    style.textContent = `
    @media (max-width: 768px) {
    #editor {
    font-size: 16px; /* 防止iOS缩放 */
    min-height: 200px;
    padding: 12px;
    line-height: 1.8;
    }

    .toolbar {
    flex-wrap: wrap;
    gap: 4px;
    padding: 8px;
    position: sticky;
    top: 0;
    background: white;
    z-index: 100;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }

    .toolbar button {
    padding: 8px 12px;
    font-size: 14px;
    min-width: 44px; /* iOS最小点击区域 */
    min-height: 44px;
    }

    /* 隐藏默认滚动条 */
    #editor::-webkit-scrollbar {
    display: none;
    }

    /* 防止长按菜单 */
    #editor {
    -webkit-touch-callout: none;
    -webkit-user-select: text;
    user-select: text;
    }
    }
    `;
    document.head.appendChild(style);

    // 初始化移动端优化器
    const mobileOptimizer = new MobileEditorOptimizer(editor, toolbar);

    // 额外的IME优化
    mobileOptimizer.setupIMEOptimization();

    return mobileOptimizer;
    }
    }
    5. 最佳实践总结
    5.1 选择合适的实现方式
    简单需求
    :使用原生
    contenteditable
    +
    execCommand
    中等复杂度
    :使用 Quill.js
    企业级需求
    :使用 TinyMCE
    深度定制
    :使用 Slate.js
    React项目
    :优先考虑 Slate.js 或 Quill.js 的React封装
    5.2 安全第一
    始终在客户端和服务器端进行HTML净化
    使用DOMPurify等成熟库
    验证所有上传的文件
    实施内容安全策略(CSP)
    5.3 性能考虑
    使用防抖和节流优化频繁操作
    对于大型文档,考虑虚拟滚动
    定期清理历史记录
    避免在输入过程中进行复杂的DOM操作
    5.4 移动端优化
    使用16px以上的字体大小防止iOS自动缩放
    确保按钮最小点击区域为44x44px
    处理虚拟键盘的显示/隐藏
    优化触摸事件和手势
    5.5 用户体验
    提供清晰的工具栏和快捷键
    实现撤销/重做功能
    显示保存状态和错误提示
    支持拖拽上传和粘贴图片
    结论
    JavaScript实现富文本编辑器有多种方法,从简单的原生API到复杂的第三方库。选择合适的方案需要根据具体需求、团队技术栈和项目规模来决定。无论选择哪种方式,都需要重点关注安全性、性能优化和用户体验。通过本文提供的详细示例和解决方案,开发者可以构建出功能强大、安全可靠的富文本编辑器应用。