1. 这不是简单的“高亮不同”而是一套面向真实协作场景的文本差异可视化系统你有没有遇到过这样的情况同事发来一份修改后的合同草案你得逐字核对和原始版本的差别产品经理甩过来一个需求文档更新版你得确认哪几处逻辑描述被重写了或者团队用 Git 提交了一段 Markdown 文档变更但 diff 输出全是行号和符号根本没法快速判断语义是否被歪曲这时候“文本对比”四个字背后的真实需求从来不是“找出 ASCII 码不同的字符”而是——在人类可读的语义层面精准定位、清晰呈现、可靠追溯每一次有意义的修改。我做文本对比工具开发整整八年从最早用 Python 的 difflib 写命令行脚本到后来给金融风控系统定制 Web 端差异审查模块再到最近三个月深度打磨一个 Vue3 富文本表格差异对比插件踩过的坑、验证过的方案、用户反复强调的痛点都指向同一个结论并排对比不是 UI 布局问题而是信息密度、语义锚定与交互反馈三者精密咬合的工程问题。这个标题里的“并排对比显示实现”核心难点不在“怎么把两段文字左右放”而在于——当左边是带格式的富文本比如加粗、列表、表格嵌套右边是另一份结构相似但内容微调的富文本时如何让“差异”既不丢失上下文又不被格式噪音淹没如何让“删除”和“新增”在视觉上形成可逆的操作暗示如何让一个非技术背景的法务人员一眼看出“第3.2条第二款末尾被删掉了‘但需经双方书面确认’这12个字”而不是看到一串红色删除线加乱码所以这不是一个“前端组件封装”任务而是一个融合了 DOM 结构解析、语义块级 diff 算法、CSS 渲染隔离、用户操作意图建模的完整闭环。接下来我会拆解整个实现路径不讲抽象理论只说我在银行合规文档比对项目里实测有效的方案。2. 核心设计思路为什么放弃传统 line-by-line diff转向块级语义比对2.1 传统行对比在富文本场景下的三大致命缺陷很多开发者第一反应是直接套用diff-match-patch或jsdiff这类经典库把两段 HTML 字符串丢进去让它吐出插入/删除/替换的 token 序列再用ins和del包裹渲染。我试过而且是在一个真实的保险条款修订系统里上线了两周结果法务部集体投诉“改了3处系统标出27处全是格式标签的增删真正要审的业务逻辑变更反而被淹没了。”问题出在三个层面HTML 标签噪声放大富文本编辑器如 Quill、Tiptap保存的 HTML 天然包含大量无语义的 wrapper 标签span classql-font-serif、空格占位符nbsp;、内联样式stylecolor: #333;。哪怕用户只改了一个词编辑器可能重写整段p标签导致 diff 引擎认为“整行都变了”。实测数据显示在 Tiptap 生成的 HTML 中仅因光标位置变化引发的无关标签重排就占到 diff 差异总量的 68%。语义断裂p甲方应于span classhighlight5个工作日内/span完成支付/p对比p甲方应于span classhighlight3个工作日内/span完成支付/p传统 diff 会标记span classhighlight5个工作日内/span为删除span classhighlight3个工作日内/span为插入。但人类阅读时关注的是“5个工作日 → 3个工作日”这个数值变更而非 span 标签本身。把标签当原子单位处理等于把语法树当语义单元必然失真。表格与嵌套结构崩溃这是最致命的。当对比两个含表格的文档时tabletrtdA/tdtdB/td/tr/table和tabletrtdA/tdtdC/td/tr/tableline-by-line diff 会报告第二行tdB/td被删tdC/td被增。但用户需要的是“第二列单元格内容由 B 变更为 C”而不是“删了一行 td 标签又加了一行”。更糟的是如果表格里有合并单元格colspan2diff 会彻底错乱因为 HTML 字符串顺序和视觉网格顺序完全不一致。提示别迷信“diff 库越老越稳”。difflib在纯文本场景确实可靠但它的设计哲学是“最小编辑距离”目标是压缩传输带宽不是辅助人类决策。把通信协议层的算法直接搬到协作审查层就像用游标卡尺去量一栋楼的沉降——精度够但维度错。2.2 我们采用的块级语义比对架构DOM 解析 → 语义切片 → 结构对齐 → 差异渲染我们最终落地的方案核心是绕开 HTML 字符串直接操作 DOM 树并定义一套轻量级语义块规则。整个流程分四步每一步都有明确的设计取舍DOM 解析与净化不直接innerHTML htmlString而是用DOMParser解析 HTML 字符串然后递归遍历节点剥离所有无语义的属性class、style、>template TextDiff :left-htmloriginalHtml :right-htmlmodifiedHtml diff-changehandleDiffChange / /template script setup import { TextDiff } from vue3-text-diff const originalHtml p甲方应于em5个工作日内/em完成支付/p const modifiedHtml p甲方应于em3个工作日内/em完成支付/p const handleDiffChange (diffResult) { // diffResult 结构{ totalBlocks: 12, changedBlocks: 3, movedBlocks: 1, ... } console.log(差异统计, diffResult) } /script为什么这么设计因为在实际项目中90% 的用户业务方、法务、产品经理根本不关心 diff 算法他们只关心“哪里变了”和“变了多少”。插件内部封装了全部复杂逻辑对外暴露的只有输入HTML 字符串和输出差异事件。我们刻意回避了options参数因为历史经验表明一旦开放ignoreWhitespace: true、sensitivity: case这类选项80% 的用户会错误配置导致结果不可信。所有策略都在内部固化中文环境默认忽略全角/半角空格、不区分大小写、强制启用移动检测。3.2 DOM 解析与净化用原生 API 避免第三方依赖我们不用htmlparser2或cheerio而是直接用浏览器原生DOMParser原因有三安全不执行脚本不加载外部资源纯内存解析轻量省去 120KB 的第三方库打包体积可控能精确控制节点遍历逻辑。核心净化函数如下已精简保留关键逻辑function parseAndClean(html) { const parser new DOMParser() const doc parser.parseFromString(html, text/html) // 递归净化函数 function cleanNode(node) { // 移除 script/style 标签及其内容 if (node.tagName SCRIPT || node.tagName STYLE) { node.remove() return } // 处理内联格式标签span/font/strong/em if (node.tagName SPAN || node.tagName FONT || node.tagName STRONG || node.tagName EM) { // 提取文本和格式标记 const textContent node.textContent const format node.tagName STRONG ? bold : node.tagName EM ? italic : node.hasAttribute(style) node.style.fontWeight bold ? bold : normal // 创建纯文本节点附带>td v-for(cell, index) in rowCells :keyindex :class{ diff-changed: isCellChanged(cell) } span v-ifisCellChanged(cell) classdiff-old{{ cell.oldValue }}/span span v-ifisCellChanged(cell) classdiff-new{{ cell.newValue }}/span span v-else{{ cell.value }}/span /td实测表明该方案对含 50 行 x 10 列的复杂表格diff 计算耗时稳定在 120ms 内Chrome 118远低于用户感知阈值 200ms。3.4 并排布局的 CSS 实现响应式栅格与滚动同步“并排”不是简单display: flex。真实场景中左右文档长度常严重不均左文档 200 行右文档 50 行且用户需要横向滚动查看长表格。我们的 CSS 方案容器层display: grid; grid-template-columns: 1fr 1fr; gap: 24px;确保左右等宽内容层左右两栏各自overflow-y: auto; max-height: 60vh;独立滚动滚动同步用scroll事件监听但不是简单scrollTop other.scrollTop因为 DOM 高度不同会导致跳动。我们采用“滚动比例映射”const syncScroll (selfEl, otherEl) { const selfRatio selfEl.scrollTop / (selfEl.scrollHeight - selfEl.clientHeight) const targetScrollTop selfRatio * (otherEl.scrollHeight - otherEl.clientHeight) otherEl.scrollTop targetScrollTop }这样当左栏滚到底部 50%右栏也滚到其自身高度的 50%视觉上更自然。响应式断点屏幕宽度 768px 时自动切换为上下布局flex-direction: column并添加“切换视图”按钮适配移动端审核。注意不要用position: sticky固定标题行。在并排对比中左右标题行需严格对齐sticky会因渲染时机差异导致错位。我们用transform: translateY()will-change: transform实现硬件加速的平滑固定。4. 实操过程从零搭建一个可运行的对比页面4.1 初始化 Vue3 项目与插件安装我们以 Vite 作为构建工具创建一个最小可行环境。全程使用 pnpm比 npm 快 3 倍且硬链接节省磁盘pnpm create vitelatest text-diff-demo --template vue cd text-diff-demo pnpm install # 安装核心插件注意这是本地开发版非 npm 发布版 pnpm add githttps://github.com/your-org/vue3-text-diff.git#main # 启动开发服务器 pnpm dev此时访问http://localhost:5173应看到默认 Vue 欢迎页。接下来我们替换src/App.vue为对比页面。4.2 构建对比页面HTML 结构、数据绑定与事件处理src/App.vue完整代码含注释说明关键点template div classapp-container !-- 顶部控制区 -- div classcontrol-panel h1富文本差异对比工具/h1 div classbtn-group button clickloadSample classbtn btn-primary加载示例/button button clickclearAll classbtn btn-outline清空/button /div /div !-- 主对比区域 -- div classdiff-container !-- 左侧原始文档 -- div classdiff-column div classcolumn-header h2原始版本/h2 span classversion-tagv1.0/span /div div classeditor-area textarea v-modelleftHtml placeholder粘贴原始 HTML 或富文本内容... classeditor-input / /div /div !-- 右侧修改版本 -- div classdiff-column div classcolumn-header h2修改版本/h2 span classversion-tagv1.1/span /div div classeditor-area textarea v-modelrightHtml placeholder粘贴修改后的 HTML 或富文本内容... classeditor-input / /div /div /div !-- 对比结果展示 -- div classresult-section div classresult-header h2差异分析结果/h2 div classstats span总块数strong{{ diffStats.totalBlocks }}/strong/span span变更块strong classchanged{{ diffStats.changedBlocks }}/strong/span span移动块strong classmoved{{ diffStats.movedBlocks }}/strong/span /div /div div classdiff-result !-- 这里是插件核心 -- TextDiff :left-htmlleftHtml :right-htmlrightHtml diff-changeonDiffChange classdiff-plugin / /div /div /div /template script setup import { ref, onMounted } from vue import { TextDiff } from vue3-text-diff // 响应式数据 const leftHtml ref() const rightHtml ref() const diffStats ref({ totalBlocks: 0, changedBlocks: 0, movedBlocks: 0 }) // 加载示例数据模拟真实合同片段 const loadSample () { leftHtml.value h3付款条款/h3 p甲方应于em5个工作日内/em向乙方支付合同总价的strong80%/strong。/p table trth项目/thth金额万元/th/tr trtd开发费/tdtd120.00/td/tr trtd实施费/tdtd80.00/td/tr /table rightHtml.value h3付款条款/h3 p甲方应于em3个工作日内/em向乙方支付合同总价的strong80%/strong逾期每日按em0.05%/em支付违约金。/p table trth项目/thth金额万元/th/tr trtd开发费/tdtd120.00/td/tr trtd实施费/tdtd85.00/td/tr /table } // 清空所有内容 const clearAll () { leftHtml.value rightHtml.value diffStats.value { totalBlocks: 0, changedBlocks: 0, movedBlocks: 0 } } // diff 变更回调 const onDiffChange (stats) { diffStats.value stats } // 组件挂载时加载示例 onMounted(() { loadSample() }) /script style scoped .app-container { max-width: 1400px; margin: 0 auto; padding: 24px; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; } .control-panel { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; padding-bottom: 16px; border-bottom: 1px solid #e0e0e0; } .diff-container { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 32px; } .diff-column { display: flex; flex-direction: column; height: 500px; } .column-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .version-tag { background: #f0f9ff; color: #0d6efd; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; } .editor-area { flex: 1; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; } .editor-input { width: 100%; height: 100%; padding: 16px; border: none; resize: none; font-size: 14px; line-height: 1.5; outline: none; } .result-section { background: #f8f9fa; border-radius: 8px; padding: 24px; } .result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } .stats span { margin-right: 24px; font-size: 14px; } .changed { color: #dc3545; } .moved { color: #ffc107; } .diff-plugin { width: 100%; } /style这段代码的关键在于数据驱动所有状态HTML 内容、统计数字都是响应式ref保证 UI 实时更新语义化结构用h3、table等真实标签模拟富文本输出而非 div 堆砌用户体验细节版本标签v1.0/v1.1暗示文档迭代btn-primary/btn-outline提供视觉层次性能考量v-model绑定 textarea但 diff 计算在diff-change事件中异步触发避免输入时卡顿。4.3 样式细节打磨让差异“看得见、摸得着”差异渲染的 CSS 是最后也是最重要的环节。我们不依赖插件内置样式而是用 scoped CSS 精确控制/* 差异块通用样式 */ .diff-block { position: relative; padding: 8px 12px; margin: 4px 0; border-radius: 4px; transition: all 0.2s ease; } /* 删除样式左侧 */ .diff-block[data-diffdeleted] { background-color: #f8d7da; border-left: 4px solid #dc3545; } .diff-block[data-diffdeleted]::before { content: —; position: absolute; left: -24px; top: 50%; transform: translateY(-50%); color: #dc3545; font-weight: bold; } /* 插入样式右侧 */ .diff-block[data-diffinserted] { background-color: #d4edda; border-left: 4px solid #198754; } .diff-block[data-diffinserted]::before { content: ; position: absolute; left: -24px; top: 50%; transform: translateY(-50%); color: #198754; font-weight: bold; } /* 移动样式虚线连接 */ .diff-block[data-diffmoved-from] { border-left: 3px dashed #0d6efd; background-color: #e2e3e5; } .diff-block[data-diffmoved-to] { border-left: 3px dashed #0d6efd; background-color: #d1ecf1; } /* 表格单元格差异 */ .diff-changed { background-color: #fff3cd !important; position: relative; } .diff-changed .diff-old { text-decoration: line-through; color: #856404; } .diff-changed .diff-new { color: #785804; font-weight: bold; background-color: #fff3cd; padding: 0 4px; border-radius: 2px; } /* 滚动条美化仅 Webkit */ .diff-column ::-webkit-scrollbar { width: 8px; } .diff-column ::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; } .diff-column ::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; } .diff-column ::-webkit-scrollbar-thumb:hover { background: #a8a8a8; }这些样式的设计逻辑删除用红色实线减号符合国际通用符号学红色触发警觉插入用绿色实线加号绿色代表“新增”加号直观移动用蓝色虚线蓝色象征“连接”虚线表示“非直接变更”表格变更用黄色底纹删除线/加粗黄色是警告色但比红色温和适合内容微调滚动条细窄节省横向空间避免干扰并排布局。5. 常见问题与排查技巧实录那些文档里不会写的实战经验5.1 问题速查表高频故障与一键修复问题现象可能原因排查步骤修复方案左右两栏高度严重不一致滚动不同步max-height设置过小或内容中有未闭合标签导致 DOM 解析异常1. 检查浏览器控制台是否有DOMException错误2. 用console.log(leftHtml.value)查看原始 HTML 是否合法用DOMParser解析后检查doc.body.innerHTML是否为空若空则原始 HTML 有严重语法错误需预处理如用正则补全缺失的/p表格单元格差异不显示整行变黄表格结构不匹配如左表 3 列右表 4 列坐标映射失败1. 在onDiffChange回调中打印diffResult.tableDiff2. 检查gridMismatch: true字段手动调整 HTML确保左右表格tr数量一致td总数一致或启用插件的strictTable: false选项会降级为行级对比中文标点被错误标记为差异如“。” vs “。”全角/半角空格、零宽字符未净化1. 复制问题文本到 VS Code开启“显示空白字符”2. 检查是否有U200B零宽空格在parseAndClean函数中增加textContent textContent.replace(/[\u200B-\u200F\uFEFF]/g, )清理零宽字符富文本中的图片不显示或显示为 broken image插件默认移除img标签因无法 diff 二进制内容1. 检查cleanNode函数是否删除了img2. 查看diffResult中是否有imageBlocks字段在净化阶段将img替换为span classdiff-image-placeholder[图片]/span并添加>import { debounce } from lodash-es const debouncedDiff debounce(() { // 触发 diff }, 500) watch([leftHtml, rightHtml], debouncedDiff)更优方案用contenteditable替代 textarea监听input事件只在用户停顿 300ms 后触发 diff光标完全不受影响。坑二表格rowspan/colspan计算偏差导致坐标错位现象一个colspan2的单元格在右表被识别为两个独立单元格。原因浏览器解析 HTML 时对colspan的处理有细微差异尤其在嵌套表格中DOMParser生成的节点树可能与渲染树不一致。避坑技巧放弃纯 DOM 解析改用document.createElement(div).innerHTML html然后用getBoundingClientRect()获取每个td的实际渲染坐标构建像素级网格或更简单在插件初始化时强制要求用户指定表格列数table>