尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

纯前端实现Web版文本Diff工具:从零构建行级差异对比页面

纯前端实现Web版文本Diff工具:从零构建行级差异对比页面 简介一个基于 Web 技术的轻量级 Git diff 可视化工具面向需要快速查看代码差异的开发者、前端学习者以及希望摆脱命令行操作的临时用户无需掌握 Git 命令即可使用。它用浏览器界面还原了 Git 版本控制系统中 diff 的核心功能无需安装 Git 或配置任何环境依赖下载解压后双击入口页面就能在本地浏览器中直接查看文件版本之间的差异内容整个过程不依赖外部服务器。压缩包共 4 个文件入口 HTML 负责页面结构与模块加载2 个 JavaScript 脚本承担 diff 数据解析、差异高亮和展开折叠等交互逻辑1 个 CSS 样式表控制整体视觉呈现整包仅 31KB结构十分轻量目录结构一目了然便于阅读和二次修改。目前已有 203 人学习下载。通过源码可以学习到前端解析 diff 数据、实现差异视图的完整思路也可直接把它当作独立小工具用于日常代码审阅、教学演示或轻量变更比对并在此基础上继续扩展更多 Git 能力。 我一直觉得对比文本差异是日常开发里最频繁的小操作之一。改完配置、换了构建产物、或者同事丢过来一段改版后的代码第一反应就是拿git diff看一眼。但真要让别人也用起来命令行就不是那么友好了——尤其当你只是临时想对比两段文本并不想初始化一个 git 仓库的时候。所以我就花了一个下午用纯前端写了一个极简的 Web 版 diff 页面。没接后端、没上框架、没有一大堆依赖就是一个 HTML 文件加一小段逻辑打开就能用左边贴旧文本右边贴新文本点一下按钮增删改动一目了然。这个需求看起来简单真正动手做的时候有几个细节还是值得聊一聊的。这篇文章就记录一下我当时是怎么拆需求、选方案、写核心逻辑以及踩过的几个坑。1. 需求拆解与方案选型1.1 先搞清楚“简易”到底要支持到什么程度很多工具一上来就想做重结果做一半就烂尾了。我这个页面从一开始就只给自己定了四条需求页面打开就能用不需要部署、不需要登录、不需要联网。支持粘贴两段文本分别放在旧文本和新文本两个输入框中。点击“对比”按钮后在页面下方展示左右两栏的差异视图。差异高亮到行级别增、删、改能一眼看出来。注意这里刻意没有做“相同内容自动隐藏”“修改前后内容对照”“直接替换到目标文本”这类进阶功能因为最简单的版本必须先跑通做得太多反而让核心链路不清晰。你只需要明确一点你是在做一个对比工具不是一个代码编辑器功能边界越清楚实现就越简单。1.2 为什么选纯前端而不是后端方案最初我犹豫过一个方案让用户上传两个文件到后端后端调用git diff --no-index或者 Python 的difflib再把结果吐回前端渲染。这个方案实现起来确实快但有几个短板很明显需要搭服务、开端口、处理上传用户的使用门槛变高了。文件内容属于临时数据越少经过服务器越安全纯前端处理完即丢不进内存不留缓存。完全离线可用局域网内拷一个文件过去就能用这在某些内网环境里是刚需。所以我最终选择了用原生 HTML JavaScript 实现连构建工具都没用。整个页面只有一个index.html双击就能在浏览器里打开。这个选择也决定了后端那些花哨能力——比如目录级别的对比、文件系统监听——都不在这个版本考虑范围内。1.3 Diff 算法用现成库还是手写 LCS这是整个项目里唯一让我纠结了十分钟的技术选型。提到 diff最经典的是 LCS最长公共子序列算法传统教学里用动态规划就能解但直接拿来生产环境会暴露两个问题空间复杂度是O(m*n)对比两篇稍微长一点的文本内存就膨胀得厉害。只算 LCS 还不够你还得自己构造“删除哪些行、插入哪些行、哪些行不变”这个回溯过程很容易写出隐患。所以我直接选择了成熟的开源库jsdiff也叫diff它内部使用的是经过优化的 Myers diff 算法性能和可读性都有保障。对的你没看错这里不需要我重复造轮子。在做一个“简易工具”时把算法这种高成本部分交给靠谱的依赖把精力放在页面交互和渲染上才是更合理的分工。2. 页面布局与视觉方案2.1 左右对照式布局的实现页面主体我用了最常见的“上下输入 下方结果”的结构。一开始可以把旧文本和新文本的textarea并排放着方便输入时对照点击对比按钮后结果区域会刷新成左右两栏。结果区域的左右两栏直接采用弹性布局div iddiff-result div classdiff-column idold-column/div div classdiff-column idnew-column/div /div对应的样式我建议这样设置#diff-result { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid #ddd; } .diff-column { font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; font-size: 14px; line-height: 1.6; background: #fff; overflow: auto; max-height: 70vh; }为什么用 grid 而不是 flex因为左右两栏必须严格等宽grid 的1fr 1fr天然保证这一点而 flex 需要额外处理flex: 1和宽度基准的问题没必要自找麻烦。结果区域加上max-height和overflow: auto这样对比长文本时页面本身不会无限变高滚动只发生在结果容器内部。2.2 差异高亮的颜色语义颜色这块我直接参考了 git diff 在终端里的视觉习惯这样看惯了命令行的同学不需要重新学习左侧旧文本中被删除的行浅红色背景行首加一个红色的减号。右侧新文本中新增的行浅绿色背景行首加一个绿色的加号。未变化的公共行白色背景不加标记。控制高亮我推荐用行内样式或单独的 class。我在实现时给差异行加了三类 class.diff-line-added { background-color: #e6ffec; } .diff-line-removed { background-color: #ffebe9; } .diff-line-normal { background-color: #ffffff; }这里有个容易忽略的点普通的 GitHub 风格 diff 还会对“修改行”专门画出字符级别的高亮但在简易版里我把“修改”直接拆成“删除旧行 新增新行”两个行为。这样处理视觉上非常直白逻辑上也更简单——因为追踪字符级别变化意味着要做二次 diff增加复杂度却不一定会提升这个简易工具的实用性。2.3 行号与内容对齐的细节行号是一个看起来不起眼、做起来很考验细节的部分。我的方案是每一行渲染成一个div内部用网格结构把行号区域和内容区域分开。div classline-row span classline-number12/span span classline-contentconst a 1;/span /div样式如下.line-row { display: grid; grid-template-columns: 48px 1fr; align-items: start; min-height: 1.6em; } .line-number { text-align: right; padding-right: 8px; color: #999; user-select: none; background: #fafafa; border-right: 1px solid #eee; }user-select: none很重要不然复制的文本会带着行号非常烦人。行号的最小列宽固定在 48px数字右对齐这样超过三位数的行号也能对齐得很整齐。3. 核心实现从文本到 Diff 结果3.1 引入 jsdiff 并理解它的返回结构我这里的做法是直接通过 CDN 引入 jsdiff 的 UMD 包没有走 npm 安装流程因为目标就是一个单文件 HTMLscript srchttps://cdn.jsdelivr.net/npm/diff5.1.0/dist/diff.min.js/script然后在业务代码里调用const changes Diff.diffLines(oldText, newText);diffLines的返回值是一个数组每个元素形如{ value: const a 1;\n, added: undefined, removed: undefined }如果只有value说明这一段是两边共有的内容。如果added为true说明这一段只出现在新文本里。如果removed为true说明这一段只出现在旧文本里。注意一个常见误区diffLines按行切割文本但它返回的每个 change 块可能包含多行所以不能直接把一个 change 当成一行渲染需要先按换行符展开。这是我踩的第一个坑。3.2 拆分差异块并渲染左右两栏我的渲染策略分三步走把changes展开成“行数组”每行包含content、typenormal、add、remove。维护两个指针leftIndex和rightIndex遍历行数组把normal和remove行放入左侧把normal和add行放入右侧。左右两侧的行一一对应最后统一渲染。核心逻辑大概是这样的function buildDiffRows(oldText, newText) { const changes Diff.diffLines(oldText, newText); const leftRows []; const rightRows []; for (const change of changes) { const lines change.value.split(\n); // diffLines 返回的 value 末尾通常带换行符split 后会产生一个空字符串 // 这里直接把最后的空串过滤掉否则渲染时多出一个空行 const meaningfulLines lines.filter((line, index) index lines.length - 1 || line ! ); for (const line of meaningfulLines) { if (change.added) { rightRows.push({ content: line, type: add }); } else if (change.removed) { leftRows.push({ content: line, type: remove }); } else { leftRows.push({ content: line, type: normal }); rightRows.push({ content: line, type: normal }); } } } return { leftRows, rightRows }; }这么设计之后左侧的remove行和右侧的add行天然挨在一起视觉上正好对齐不需要额外做复杂的位置映射。3.3 渲染时的性能优化策略数据量小的时候直接innerHTML拼接字符串完全没问题。但是当文本到几千行级别时一次性向 DOM 里塞几千个节点浏览器明显会卡一下。我实测下来两个方案按性价比排序用DocumentFragment批量插入这是最轻量、改动最小的优化性能提升非常明显。做法是先构建片段再把片段一次性挂到结果容器上避免频繁触发回流。const fragment document.createDocumentFragment(); for (const row of allRows) { const div document.createElement(div); div.className line-row row.type; // 往 div 里塞行号和内容 fragment.appendChild(div); } diffResult.appendChild(fragment);上虚拟滚动如果文本规模到上万行那确实需要考虑虚拟滚动但这就超出了“简易”的范围了。我的建议是先把输入框限制在 500KB 以内如果超出就弹提示引导用户分段对比。这不是懒而是这个工具定位决定的。另外不要忽视事件层面的优化如果做了“用户输入时实时对比”的功能一定要给textarea的输入事件加防抖否则每敲一个字符都会触发一次 diff 计算体验非常糟糕。我设置的是 300ms 的防抖延迟实测够了。4. 实现过程中遇到的坑与排查实录4.1 换行符差异导致误报我第一次渲染时发现在 Windows 上创建的文件粘贴进来后所有行都被标记为“删除新增”整个页面红绿交错看起来非常夸张。排查之后发现罪魁祸首是换行符旧文本用的\r\n新文本用的\n。diffLines在判断行变化时会把\r的差异也算进去所以看起来整段都变了。解决办法是在对比前统一规范化换行符function normalizeText(text) { return text.replace(/\r\n/g, \n); }这个处理一定不能省尤其工具是给别人用的时候你永远不知道对方会从什么系统上复制文本过来。4.2 diffLines 的尾部空行问题另一个跟换行紧密相关的坑是当文本末尾有换行符时value.split(\n)总会多出一个空字符串元素。如果不处理渲染结果里右侧会多出一行绿色空行非常影响观感。我在buildDiffRows里用filter过滤的方式处理了但这里有个细节如果一行就只有一个空字符串说明这是真正的空行应当被保留如果lines数组里的最后一个空字符串则是因为尾部换行符产生的应该过滤掉。这两种情况千万不要混为一谈。我上面的代码里用的判断index lines.length - 1 || line ! 就同时兼容了这两种场景你直接拿去用就行。4.3 中文内容对比不准有朋友反馈对比两段中文文本时明明只改了一个词但整个段落都被标记为修改。这个其实不是 bug而是行级 diff 的天然限制。diffLines按“行”作为最小单位如果一行里有任何字符发生变化这一整行就会从旧文本中删除、再在新文本中原样新增。git diff里大家常看到的字符级高亮是因为它在行级 diff 之后又对差异行做了一次字符级 diff属于词级或字符级二次对比。如果想改进可以给差异行再加一层字符级处理。在简易版本里我建议的做法是当同一行的两个版本内容都比较短时比如小于 200 个字符调用Diff.diffChars做字符级对比然后给变化的字符加一层更醒目的背景色。这个扩展值得做但一定优先级排后先把行级效果跑通再说。4.4 大文本对比卡顿与内存飙升一次我拿一份约 8000 行的日志做测试页面直接卡了将近十秒然后内存涨到 400 多 MB。深入排查后发现两个问题一是渲染前调用的normalizeText和split本身没问题问题出在Diff.diffLines内部对每一行做 hash 计算时8000 行规模下这个开销被放大了。二是我第一次渲染采用了“先清空容器再逐一appendChild”的方式每次插入都触发布局计算性能自然拉胯。实际解决方案是先对输入大小做前置校验超过 500KB 直接给用户提示不启动 diff。使用DocumentFragment批量插入。把对比按钮的点击处理改成防抖 加载中提示。这里也顺带分享一个经验别急着用虚拟滚动先看渲染流程是不是可以批量处理很多时候性能问题并不是数据量大而是 DOM 操作太频繁导致的。4.5 复制文本时的行号干扰早期版本我没有做user-select: none实测发现用户复制右侧新文本时会把左侧的行号一起复制进去导致粘贴到别处的内容带入大量数字前缀非常尴尬。后来我不仅给行号加了user-select: none还额外给结果区加了tabindex-1避免它在页面 Tab 键遍历时干扰正常键盘操作。这里提醒各位凡是做文本对比工具行号区域必须不可选中这是基本功不是锦上添花。5. 更多可用的实用扩展思路简易版本做出来后我自己又加了几个小扩展每个大概半小时以内就能搞定但使用体验会提升不少。第一个是“仅显示变化行”的开关。默认渲染全部行当文本很长时用户其实只想看差异区域。开启后连续相同的行会被压缩成一行提示比如“此处省略 120 行相同内容”点击可以展开。这个功能对 5000 行以上的文档比对极其实用。第二个是将结果导出为 HTML 或统一 diff 格式。我增加了一个“复制为统一 diff”按钮可以直接生成类似git diff的文本--- old new -1,3 1,4 这样即使对方不在线你也可以把差异结果直接贴在评论里沟通效率高很多。第三个是支持拖拽文件到输入框。这个用 FileReader 就能实现代码量不大但解决了“从编辑器复制大段代码时偶尔格式错乱”的烦恼。真正做的时候别忘在拖入时做文件大小和类型检查避免用户误拖入二进制文件直接卡死页面。不过帮我评估了一下如果你想做一个更完整的版本接入 monaco editor 这样成熟的代码编辑器来替代 textarea 是一个大方向但这也意味着页面复杂度和体积都会显著上升是否要做先问自己一句用户的痛点是不是真的在于“编辑器不好用”如果不是别再往下做了。最后聊一个实操体会做完这个页面最大的感触是简单的工具反而更容易被高频使用。因为没有任何安装门槛后来团队里不少人都在用前端对接口返回、运维看配置变更、测试同学核对不同版本的环境变量都直接把这个 HTML 拷走了。如果你也想在本地快速跑起来直接把上面的代码块拼进一个index.html用浏览器打开就行。建议一开始别加太多功能先把“粘贴两段文本、看出增删”这条主链路走通然后再根据自己的实际场景慢慢加。工具是给人用的不是用来炫技的——能让人愿意用、用得顺手比实现得多华丽重要得多。本文还有配套的精品资源点击获取
返回列表