首页
/ 告别内容对比难题:ReactQuill 双编辑器同步方案详解

告别内容对比难题:ReactQuill 双编辑器同步方案详解

2026-02-05 05:30:57作者:柏廷章Berta

你是否还在为富文本内容版本对比而烦恼?开发团队协作时,编辑同一文档产生的不同版本如何直观呈现差异?内容审核场景中,如何快速定位修改痕迹?本文将通过 ReactQuill(React 生态中最流行的富文本编辑器组件之一)实现双编辑器并排对比功能,完整覆盖从基础实现到高级特性的全流程方案。

读完本文你将掌握:

  • 双 ReactQuill 实例状态同步核心机制
  • 基于 Delta 格式的内容差异计算与可视化
  • 冲突解决策略与用户体验优化方案
  • 生产环境适配的性能调优技巧
  • 完整可复用的对比编辑器组件实现

技术选型与架构设计

ReactQuill 作为 Quill 编辑器的 React 封装,其核心优势在于对 Delta 格式的原生支持。Delta 是一种专为富文本设计的不可变数据结构,记录了内容的每一处变更,这为实现版本对比提供了底层技术保障。

核心依赖分析

{
  "dependencies": {
    "quill": "^1.3.7",  // 提供Delta操作核心能力
    "react": "^16 || ^17 || ^18",
    "react-dom": "^16 || ^17 || ^18",
    "lodash": "^4.17.4"  // 提供深度比较等工具函数
  }
}

系统架构设计

flowchart TD
    A[左侧编辑器] -- Delta变更 --> B[状态同步服务]
    C[右侧编辑器] -- Delta变更 --> B
    B -- 计算差异 --> D[差异渲染引擎]
    D -- 应用样式 --> A
    D -- 应用样式 --> C
    E[历史记录管理] <--> B
    F[冲突解决策略] --> B

核心组件包括:

  • 双编辑器实例:分别加载不同版本内容
  • 状态同步服务:处理双向变更数据流
  • Delta 差异计算引擎:识别内容增删改操作
  • 差异可视化模块:通过样式标记变更区域
  • 历史记录管理:支持版本回溯与对比

基础实现:双编辑器同步

环境配置与安装

# 克隆仓库
git clone https://gitcode.com/gh_mirrors/re/react-quill.git
cd react-quill

# 安装依赖
npm install

# 启动开发服务器
npm run demo

基础双编辑器组件

import React, { useState, useRef, useEffect } from 'react';
import ReactQuill from 'react-quill';
import 'quill/dist/quill.snow.css';

const DualEditor = ({ originalContent, modifiedContent }) => {
  // 状态管理
  const [leftContent, setLeftContent] = useState(originalContent || '');
  const [rightContent, setRightContent] = useState(modifiedContent || '');
  
  // 编辑器实例引用
  const leftEditorRef = useRef(null);
  const rightEditorRef = useRef(null);
  
  // 配置工具栏
  const modules = {
    toolbar: [
      [{ 'header': [1, 2, false] }],
      ['bold', 'italic', 'underline', 'strike', 'blockquote'],
      [{ 'list': 'ordered'}, { 'list': 'bullet' }],
      [{ 'color': [] }, { 'background': [] }],
      ['link', 'image'],
      ['clean']
    ]
  };
  
  // 格式化配置
  const formats = [
    'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
    'list', 'bullet', 'color', 'background', 'link', 'image'
  ];

  return (
    <div className="dual-editor-container" style={{ display: 'flex', gap: '20px', padding: '20px' }}>
      <div className="editor-wrapper" style={{ flex: 1 }}>
        <h3>原始版本</h3>
        <ReactQuill
          ref={leftEditorRef}
          value={leftContent}
          onChange={setLeftContent}
          modules={modules}
          formats={formats}
          theme="snow"
          placeholder="请输入内容..."
        />
      </div>
      
      <div className="editor-wrapper" style={{ flex: 1 }}>
        <h3>修改版本</h3>
        <ReactQuill
          ref={rightEditorRef}
          value={rightContent}
          onChange={setRightContent}
          modules={modules}
          formats={formats}
          theme="snow"
          placeholder="请输入内容..."
        />
      </div>
    </div>
  );
};

export default DualEditor;

关键技术点解析

  1. 双状态管理:通过两个独立的 state 分别控制左右编辑器内容,确保初始加载的版本隔离

  2. 工具栏配置:使用与官方 demo 一致的工具栏配置(来自 demo/index.js),保证编辑体验一致性

  3. 响应式布局:通过 flex 布局实现双编辑器并排显示,在移动设备上可优化为上下布局

Delta 差异计算核心实现

Delta 格式是实现内容对比的关键。不同于 HTML 直接比较,Delta 比较能够精确识别文本的增删、格式变更等操作,是富文本对比的最优解。

Delta 格式基础

Delta 本质是一个操作数组(ops),每个操作包含:

  • insert:插入内容(字符串或对象)
  • delete:删除长度
  • retain:保留长度(可附带格式信息)

示例 Delta 对象:

{
  "ops": [
    { "insert": "Hello " },
    { "insert": "World", "attributes": { "bold": true } },
    { "insert": "!\n" }
  ]
}

差异计算实现

import { Quill } from 'react-quill';
const Delta = Quill.import('delta');

// 计算两个Delta的差异
const computeDeltaDiff = (originalDelta, modifiedDelta) => {
  // 创建原始内容的Delta副本
  const originalCopy = new Delta(originalDelta);
  
  // 计算修改内容相对于原始内容的差异Delta
  const diffDelta = originalCopy.diff(modifiedDelta);
  
  // 解析差异Delta,分类增删改操作
  const changes = {
    inserts: [],
    deletes: [],
    retains: []
  };
  
  let index = 0;
  
  diffDelta.ops.forEach(op => {
    if (op.insert) {
      changes.inserts.push({
        position: index,
        content: op.insert,
        attributes: op.attributes || {}
      });
      // 插入操作不增加索引位置
    } else if (op.delete) {
      changes.deletes.push({
        position: index,
        length: op.delete
      });
      index += op.delete;
    } else if (op.retain) {
      if (op.attributes) {
        // 带有格式变更的保留操作
        changes.retains.push({
          position: index,
          length: op.retain,
          attributes: op.attributes
        });
      }
      index += op.retain;
    }
  });
  
  return {
    diffDelta,
    changes
  };
};

双编辑器同步机制

// 在DualEditor组件中添加同步逻辑
const [syncLock, setSyncLock] = useState(false);  // 防止递归触发

// 左侧编辑器变更处理
const handleLeftChange = (content, delta, source, editor) => {
  if (syncLock || source !== 'user') return;
  
  const originalDelta = editor.getContents();
  const modifiedDelta = rightEditorRef.current.getEditorContents();
  
  const { diffDelta } = computeDeltaDiff(originalDelta, modifiedDelta);
  
  // 应用差异到右侧编辑器
  setSyncLock(true);
  rightEditorRef.current.getEditor().updateContents(diffDelta);
  setRightContent(content);
  setSyncLock(false);
};

// 右侧编辑器变更处理
const handleRightChange = (content, delta, source, editor) => {
  // 实现类似左侧变更的同步逻辑
  // ...
};

差异可视化与用户体验优化

仅仅计算出差异还不够,需要将其以直观的方式呈现给用户。我们将实现类似 Git 差异对比的视觉效果,通过颜色编码区分不同类型的变更。

自定义差异样式

/* 差异高亮样式 */
.ql-editor .diff-insert {
  background-color: rgba(76, 175, 80, 0.2);  /* 绿色背景标识新增内容 */
  border-bottom: 2px solid #4CAF50;
}

.ql-editor .diff-delete {
  background-color: rgba(244, 67, 54, 0.2);  /* 红色背景标识删除内容 */
  text-decoration: line-through;
  border-bottom: 2px solid #F44336;
}

.ql-editor .diff-format {
  background-color: rgba(255, 193, 7, 0.2);  /* 黄色背景标识格式变更 */
  border-bottom: 2px dashed #FFC107;
}

差异渲染组件

const DiffHighlighter = ({ editorRef, changes }) => {
  useEffect(() => {
    if (!editorRef.current) return;
    
    const editor = editorRef.current.getEditor();
    const contentDOM = editor.container.querySelector('.ql-editor');
    
    // 清除之前的差异标记
    contentDOM.querySelectorAll('[class^="diff-"]').forEach(el => {
      el.classList.remove('diff-insert', 'diff-delete', 'diff-format');
    });
    
    // 应用新的差异标记
    const applyChanges = () => {
      // 实现基于位置的DOM元素标记逻辑
      // 注意:需要处理复杂的富文本结构定位
      // ...
    };
    
    // 使用Quill的text-change事件确保DOM已更新
    editor.once('text-change', applyChanges);
  }, [changes]);
  
  return null;  // 此组件不渲染任何DOM元素
};

冲突解决策略

当两个编辑器同时修改同一区域内容时,需要实现冲突解决机制:

const resolveContentConflict = (leftDelta, rightDelta, conflictPosition) => {
  // 策略1:以右侧(修改版本)为准
  // 策略2:保留双方修改,添加冲突标记
  // 策略3:使用三向合并算法(更复杂但更智能)
  
  // 实现策略2:保留双方修改
  const conflictDelta = new Delta()
    .retain(conflictPosition)
    .insert('<<<<<<< 原始版本\n')
    .concat(leftDelta.slice(conflictPosition))
    .insert('\n=======\n')
    .concat(rightDelta.slice(conflictPosition))
    .insert('\n>>>>>>> 修改版本\n');
    
  return conflictDelta;
};

高级功能实现

历史记录管理

const useEditorHistory = (initialContent) => {
  const [history, setHistory] = useState([]);
  const [currentIndex, setCurrentIndex] = useState(-1);
  
  // 记录新状态到历史记录
  const recordState = (content, delta, source) => {
    if (source !== 'user') return;
    
    // 截断当前索引之后的历史
    const newHistory = history.slice(0, currentIndex + 1);
    
    newHistory.push({
      timestamp: new Date().toISOString(),
      content,
      delta
    });
    
    // 限制历史记录最大长度
    const MAX_HISTORY = 50;
    if (newHistory.length > MAX_HISTORY) {
      newHistory.shift();
    }
    
    setHistory(newHistory);
    setCurrentIndex(newHistory.length - 1);
  };
  
  // 撤销操作
  const undo = () => {
    if (currentIndex <= 0) return null;
    setCurrentIndex(currentIndex - 1);
    return history[currentIndex - 1].content;
  };
  
  // 重做操作
  const redo = () => {
    if (currentIndex >= history.length - 1) return null;
    setCurrentIndex(currentIndex + 1);
    return history[currentIndex + 1].content;
  };
  
  // 跳转到指定历史记录
  const goToHistory = (index) => {
    if (index < 0 || index >= history.length) return null;
    setCurrentIndex(index);
    return history[index].content;
  };
  
  return {
    history,
    currentIndex,
    recordState,
    undo,
    redo,
    goToHistory
  };
};

内容合并功能

const mergeContents = (originalContent, modifiedContent, conflictResolver) => {
  // 获取两个编辑器的Delta内容
  const originalEditor = leftEditorRef.current.getEditor();
  const modifiedEditor = rightEditorRef.current.getEditor();
  
  const originalDelta = originalEditor.getContents();
  const modifiedDelta = modifiedEditor.getContents();
  
  // 计算基础差异
  const { diffDelta } = computeDeltaDiff(originalDelta, modifiedDelta);
  
  // 创建合并后的Delta
  const mergedDelta = new Delta(originalDelta).compose(diffDelta);
  
  // 应用合并结果到左侧编辑器
  originalEditor.setContents(mergedDelta);
  
  // 返回合并后的HTML内容
  return originalEditor.getHTML();
};

性能优化与生产环境适配

节流与防抖策略

import debounce from 'lodash/debounce';

// 差异计算防抖处理(避免高频编辑时的性能问题)
const debouncedComputeDiff = useCallback(
  debounce((originalDelta, modifiedDelta) => {
    const result = computeDeltaDiff(originalDelta, modifiedDelta);
    setChanges(result.changes);
  }, 300),  // 300ms防抖延迟
  []
);

内存优化

// 组件卸载时清理资源
useEffect(() => {
  return () => {
    if (leftEditorRef.current) {
      const editor = leftEditorRef.current.getEditor();
      editor.off('text-change', applyChanges);
    }
    if (rightEditorRef.current) {
      const editor = rightEditorRef.current.getEditor();
      editor.off('text-change', applyChanges);
    }
    debouncedComputeDiff.cancel();
  };
}, [debouncedComputeDiff]);

生产环境CDN配置

<!-- 替换demo/index.html中的外部资源引用 -->
<script src="https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<link href="https://cdn.bootcdn.net/ajax/libs/quill/1.3.7/quill.snow.min.css" rel="stylesheet">

完整组件代码

以下是整合所有功能的完整双编辑器对比组件:

import React, { useState, useRef, useEffect, useCallback } from 'react';
import ReactQuill, { Quill } from 'react-quill';
import 'quill/dist/quill.snow.css';
import { isEqual } from 'lodash';
import debounce from 'lodash/debounce';

const Delta = Quill.import('delta');

// 差异计算函数
const computeDeltaDiff = (originalDelta, modifiedDelta) => {
  // 实现上文定义的差异计算逻辑
  // ...
};

// 历史记录Hook
const useEditorHistory = (initialContent) => {
  // 实现上文定义的历史记录管理逻辑
  // ...
};

const DualEditor = ({ 
  originalContent = '', 
  modifiedContent = '',
  onCompareComplete = () => {} 
}) => {
  const [leftContent, setLeftContent] = useState(originalContent);
  const [rightContent, setRightContent] = useState(modifiedContent);
  const [syncLock, setSyncLock] = useState(false);
  const [changes, setChanges] = useState(null);
  
  const leftEditorRef = useRef(null);
  const rightEditorRef = useRef(null);
  
  // 历史记录管理
  const {
    recordState: recordLeftHistory,
    undo: undoLeft,
    redo: redoLeft
  } = useEditorHistory(originalContent);
  
  const {
    recordState: recordRightHistory,
    undo: undoRight,
    redo: redoRight
  } = useEditorHistory(modifiedContent);
  
  // 防抖处理差异计算
  const debouncedComputeDiff = useCallback(
    debounce((originalDelta, modifiedDelta) => {
      const result = computeDeltaDiff(originalDelta, modifiedDelta);
      setChanges(result.changes);
      onCompareComplete(result);
    }, 300),
    [onCompareComplete]
  );
  
  // 同步左右编辑器内容
  const syncEditors = useCallback((sourceEditor, targetEditorRef) => {
    if (!sourceEditor || !targetEditorRef.current) return;
    
    const sourceDelta = sourceEditor.getContents();
    const targetEditor = targetEditorRef.current.getEditor();
    const targetDelta = targetEditor.getContents();
    
    if (!isEqual(sourceDelta, targetDelta)) {
      setSyncLock(true);
      debouncedComputeDiff(sourceDelta, targetDelta);
      setSyncLock(false);
    }
  }, [debouncedComputeDiff]);
  
  // 左侧编辑器变更处理
  const handleLeftChange = (content, delta, source, editor) => {
    if (syncLock || source !== 'user') return;
    setLeftContent(content);
    recordLeftHistory(content, delta, source);
    syncEditors(editor, rightEditorRef);
  };
  
  // 右侧编辑器变更处理
  const handleRightChange = (content, delta, source, editor) => {
    if (syncLock || source !== 'user') return;
    setRightContent(content);
    recordRightHistory(content, delta, source);
    syncEditors(editor, leftEditorRef);
  };
  
  // 工具栏配置
  const modules = {
    toolbar: [
      [{ 'header': [1, 2, false] }],
      ['bold', 'italic', 'underline', 'strike', 'blockquote'],
      [{ 'list': 'ordered'}, { 'list': 'bullet' }],
      [{ 'color': [] }, { 'background': [] }],
      ['link', 'image'],
      ['clean']
    ],
    history: {
      delay: 1000,
      maxStack: 50,
      userOnly: true
    }
  };
  
  // 格式化配置
  const formats = [
    'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
    'list', 'bullet', 'color', 'background', 'link', 'image'
  ];
  
  // 差异高亮组件
  const DiffHighlighter = () => {
    useEffect(() => {
      if (!changes || !leftEditorRef.current || !rightEditorRef.current) return;
      
      // 实现差异可视化逻辑
      // ...
    }, [changes]);
    
    return null;
  };
  
  // 组件卸载时清理
  useEffect(() => {
    return () => {
      debouncedComputeDiff.cancel();
    };
  }, [debouncedComputeDiff]);
  
  return (
    <div className="dual-editor-container" style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '20px' }}>
      <div className="editor-controls" style={{ display: 'flex', gap: '10px', justifyContent: 'center' }}>
        <div className="left-controls">
          <button onClick={() => undoLeft()} disabled={!leftEditorRef.current}>撤销</button>
          <button onClick={() => redoLeft()} disabled={!leftEditorRef.current}>重做</button>
        </div>
        <div className="right-controls">
          <button onClick={() => undoRight()} disabled={!rightEditorRef.current}>撤销</button>
          <button onClick={() => redoRight()} disabled={!rightEditorRef.current}>重做</button>
        </div>
        <button onClick={() => {
          // 合并内容按钮逻辑
        }}>合并内容</button>
      </div>
      
      <div className="editor-pair" style={{ display: 'flex', gap: '20px', flex: 1 }}>
        <div className="editor-wrapper" style={{ flex: 1 }}>
          <h3>原始版本</h3>
          <ReactQuill
            ref={leftEditorRef}
            value={leftContent}
            onChange={(content, delta, source, editor) => {
              handleLeftChange(content, delta, source, editor);
            }}
            modules={modules}
            formats={formats}
            theme="snow"
            placeholder="请输入原始内容..."
          />
        </div>
        
        <div className="editor-wrapper" style={{ flex: 1 }}>
          <h3>修改版本</h3>
          <ReactQuill
            ref={rightEditorRef}
            value={rightContent}
            onChange={(content, delta, source, editor) => {
              handleRightChange(content, delta, source, editor);
            }}
            modules={modules}
            formats={formats}
            theme="snow"
            placeholder="请输入修改内容..."
          />
        </div>
      </div>
      
      <DiffHighlighter />
    </div>
  );
};

export default DualEditor;

应用场景与扩展方向

典型应用场景

  1. 团队协作编辑:多人协作编辑文档时,实时显示不同成员的修改内容
  2. 内容审核系统:编辑修改文章后,审核人员可直观查看变更点
  3. 版本控制系统:集成到 CMS 中,对比不同版本的内容差异
  4. 教育场景:教师批改作业时,标记学生作文的修改痕迹

功能扩展方向

  1. 三向对比:支持同时对比三个版本的内容差异
  2. 变更统计:计算修改字数、增删比例等统计数据
  3. 导出报告:将对比结果导出为 HTML 或 PDF 报告
  4. 协作评论:基于差异点添加评论和讨论功能
  5. AI 辅助对比:使用 AI 识别语义级别的内容变更

总结与最佳实践

ReactQuill 双编辑器对比方案通过 Delta 格式的差异计算,实现了高效精准的富文本内容对比。关键技术点包括:

  1. Delta 差异计算:利用 Quill 原生支持的 Delta 操作,精确识别内容变更
  2. 双编辑器同步:通过状态锁定机制避免递归触发,实现双向同步
  3. 差异可视化:自定义样式标记增删改操作,提升用户体验
  4. 性能优化:防抖处理和资源清理确保生产环境稳定运行

最佳实践建议:

  • 对于大型文档,考虑实现分片对比和虚拟滚动
  • 复杂场景下使用 Web Worker 进行差异计算,避免阻塞主线程
  • 提供多种视图模式切换(并排、内联、拆分)满足不同需求
  • 保存对比历史,支持回溯查看变更记录

通过本文介绍的方案,你可以为 React 应用快速集成专业级的富文本内容对比功能,显著提升团队协作效率和内容管理体验。

点赞收藏关注:获取更多 ReactQuill 高级应用技巧,下期将带来「自定义模块开发与第三方插件集成」深度教程!

登录后查看全文
热门项目推荐
相关项目推荐