
简介这是一个基于Web技术实现的轻量级Git差异对比工具面向需要在浏览器中快速查看文件变更的开发者无需安装Git或掌握命令行操作即可获得类似git diff的直观体验。压缩包共4个文件包含1个HTML入口页面、2个JavaScript逻辑脚本和1个CSS样式表其中JavaScript负责解析diff数据并渲染可视化对比结果CSS负责高亮样式与页面布局整体仅31KB轻巧便携下载后双击即可直接运行。项目以diff2html库为基础实现了代码高亮、增删行对比、文件变更视图等常用差异展示功能界面简洁、交互直观适合前端初学者学习Git差异比对的前端实现思路也可作为日常快速对比文本文件的轻量实用工具。已有203人学习/下载使用。1. 每次改完资源文件我都在纠结要不要开IDE做前端这两年我发现自己最常干的一件事不是写新代码而是翻旧账——改完配置文件、调完样式、改回上一个版本总得确认这次到底动了什么。某次在项目X里排查一个诡异的样式问题前前后后改了七八版CSS最后想不起来第3版和第5版差在哪只能把所有版本丢进命令行里跑diff。终端输出满屏的符号确实准确可给非技术同事看一眼就彻底懵了。于是我用HTML、JS和CSS做了一个非常简易的、web实现的git diff页面左边贴旧内容右边贴新内容点击一下就能看到哪几行被删除、哪几行被添加甚至还能高亮出同一行里的细微改动。这份资源适合不想为了看差异就打开完整IDE、只想在浏览器里快速完成逐行对比的前端开发者。2. 核心计算从最长公共子序列到行级高亮2.1 为什么选择最长公共子序列而不是逐行遍历写diff算法时第一反应是逐行遍历左边取一行右边取一行完全相同就跳过不同就标红。这在两份内容顺序一致时没问题可一旦中间多删了几行后续所有行都会错位满屏都是红色完全失去对比意义。后来我整个换成最长公共子序列也就是LCS的思路先把左右文档都拆成行数组然后找出两份数组里「按顺序都能对上」的最长公共片段剩下的部分就是真正被删除或新增的行。这样即使左边删了十行、右边加了五行公共部分依然能被精准识别错位问题一次性解决。2.2 LCS算法的JavaScript实现与状态数组LCS在代码里一般用动态规划做。我建了一张二维表行是左文档的行号列是右文档的行号表格的每个格子保存从左上角走到当前位置能匹配到的最长公共行数。function computeLCS(oldLines, newLines) { const n oldLines.length; const m newLines.length; // dp[i][j] 表示 oldLines[0..i-1] 与 newLines[0..j-1] 的 LCS 长度 const dp Array.from({ length: n 1 }, () new Array(m 1).fill(0)); for (let i 1; i n; i) { for (let j 1; j m; j) { if (oldLines[i - 1] newLines[j - 1]) { // 两行内容完全相同公共序列长度加一 dp[i][j] dp[i - 1][j - 1] 1; } else { // 不相等时取上方和左方的较大值 dp[i][j] Math.max(dp[i - 1][j], dp[i][j - 1]); } } } return dp; }逻辑说明dp[i][j]是状态数组核心就是「要么当前两行相等公共长度加1要么不相等就继承左方或上方已经算好的最优值」。状态数组的行列各比实际内容多1是为了留出空行作为边界避免在循环里写各种i - 1越界判断。参数说明oldLines和newLines分别是旧内容和新内容按\n拆出来的数组这一步在调用这个函数之前就完成。n和m是行数它们的乘积直接决定表格大小所以两份文档各在几千行以内时页面依然流畅超过一万行就需要考虑下面要说的回溯优化。2.3 回溯反向扫描把LCS转成删除与新增标记计算完状态数组还不够前端要的不是「最长公共子序列有多长」而是每一行属于「删除」「新增」「相同」中的哪一种。这里需要从表格右下角反向往回走function buildDiff(oldLines, newLines, dp) { const result []; let i oldLines.length; let j newLines.length; // 从表格右下角向左上角回溯 while (i 0 || j 0) { if (i 0 j 0 oldLines[i - 1] newLines[j - 1]) { // 两行相同标记为相同行 result.unshift({ type: same, text: oldLines[i - 1], oldLine: i, newLine: j }); i--; j--; } else if (j 0 (i 0 || dp[i][j - 1] dp[i - 1][j])) { // 左边已经到头或者向右走的路径更优判定为新增行 result.unshift({ type: added, text: newLines[j - 1], oldLine: null, newLine: j }); j--; } else { // 剩下的情况统一判定为删除行 result.unshift({ type: removed, text: oldLines[i - 1], oldLine: i, newLine: null }); i--; } } return result; }逻辑说明判断条件里有个优先级的讲究。当两行相等时一定先走左上角的对角线这是LCS的「公共」部分当两行不等时比较dp[i][j-1]和dp[i-1][j]哪边大就往哪边走相当于是在找一条能让相同行保留最多的路径。参数说明返回的result数组里每条记录都带type字段之后渲染时直接决定背景色和文字颜色oldLine和newLine记录原始行号用来拼接行号列text是这一行的实际内容。unshift是因为回溯是从下往上走的用unshift可以把顺序翻正。2.4 边界参数空文件、空行和相等内容的判定实际把这个算法接到Web页面时有三个边界情况必须提前想清楚否则运行时总会出幺蛾子。空文件用户手一抖把左边清空了oldLines会变成空数组buildDiff里的i 0条件让整个循环直接进入新增分支页面会把右边所有行全部标记为新增这个表现是合理的不算bug。空行很多人会在代码里塞几个空字符串行\n拆出来的空行在LCS里是正常参与比较的空字符串等于空字符串会被判定为相同。这带来一个容易被忽略的问题如果左右两份文档空白位置不同LCS会把「空行相同」当成公共部分导致视觉上的大片偏移。想避免就只能在做LCS之前把空行过滤掉但过滤之后行号就对不上了需要额外维护一个行号映射表这份简易实现里我选择保留空行参与计算代价是连续空行多时diff结果会偏向保守。相等内容的判定JavaScript里字符串比较是严格区分大小写的连前后空格不一致都会被判定为不同。如果希望忽略行尾空格差异可以在拆行之后对每行做一次trimEnd()但要注意这个操作会丢原始内容展示时原始内容已经拿不回来了。所以我的处理是在LCS计算和回溯用的数组上做修剪在展示用的text字段里保留原样相当于比较和展示各用一套数据页面看起来舒服得多。3. 从算法到页面双文本域、滚动同步与渲染管线3.1 页面布局左旧右新还是上下排diff页面最常见的布局是左右并排左边贴旧内容右边贴新内容中间留一条窄窄的「结果列」显示差异标记。这种布局在显示器宽度足够时最直观人眼从左扫到右就能看出改动。但笔记本上开浏览器就不太够用所以我给了页面一个「上下模式」的开关用CSS的flex-direction切换排列方向窄屏时自动变成上下两个区域。结构上我用三个文本域严格来说是两个文本域加一个只读的结果区。左右两个文本域负责接收输入结果区放在中间还是右侧取决于排列方向。最关键的交互是只要左边或右边的内容发生变化就立刻触发比较不需要等用户点按钮。这个用input事件监听即可比click按钮少按一下体验上更接近「边输入边出结果」。3.2 渲染结果区行号、类型和高亮色的CSS方案结果区是整份资源里样式最讲究的部分。我用一个pre标签渲染每一行的内容每一行有三个关键的CSS类same、added、removed分别对应三种背景色添加行用浅绿底加深绿字删除行用浅红底加深红字相同行不加背景色。为了让行号能对齐我把行号做成单独的span放在行内容前面并固定在左侧不能折行。.diff-line { display: flex; align-items: center; font-family: JetBrains Mono, Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre; } .diff-line .line-num { width: 50px; text-align: right; padding-right: 10px; color: #999; user-select: none; flex-shrink: 0; } .diff-line.added { background-color: #eaffea; } .diff-line.added .line-content { color: #1a7f37; } .diff-line.removed { background-color: #ffecec; } .diff-line.removed .line-content { color: #cf222e; }逻辑说明white-space: pre是关键它让连续多个空格、行首缩进都能原样展示不会因为浏览器的默认行为把多个空格折叠成一个。这对对比配置文件和代码缩进极其重要否则缩进差异会直接看不出来。参数说明line-num是行号列宽度50px是为了容纳五位数以内的行号超出部分用flex-shrink: 0保证不压缩避免行号和内容错位。字体选择上优先用JetBrains Mono和Consolas这类等宽字体这样内容和行号在垂直方向天然对齐代码缩进层次一眼就能看出来。3.3 滚动同步左边滚到哪右边跟到哪左右文本域和结果区各自都有滚动条用户最烦的就是盯着结果区看还得手动滚两边的输入框对齐位置。我给三个容器绑定同一个scroll事件当其中一个滚动时另外两个的scrollTop同步到相同值。const containers [leftTextarea, rightTextarea, resultContainer]; containers.forEach((container) { container.addEventListener(scroll, () { const top container.scrollTop; // 同步所有内容区到同一个滚动位置 containers.forEach((other) { if (other ! container) { other.scrollTop top; } }); }); });逻辑说明scrollTop就是容器的垂直滚动偏移量scroll事件在滚动过程中会高频触发直接把当前的偏移量赋给其他容器视觉上三个区域就像粘在一起一样同步滚动。参数说明leftTextarea和rightTextarea是页面上两个输入用的textarea普通DOM节点resultContainer是结果区的外层容器。需要注意textarea和pre的滚动条宽度可能不一致导致滚动位置同步后在视觉上有几像素的偏移解决方式是把三个容器的宽度都设置成flex: 1并且不手动设置scrollbar-gutter让浏览器按统一的渲染规则来。3.4 增量渲染大文件不卡顿的关键操作把上万行的diff结果一次性全部appendChild进DOM里浏览器会白屏几秒钟用户体验直接崩盘。我改成按批次渲染每批只插入200个.diff-line节点用requestAnimationFrame控制每一帧只做一次插入操作。function renderResult(lines, startIndex 0, batchSize 200) { const fragment document.createDocumentFragment(); const endIndex Math.min(startIndex batchSize, lines.length); for (let i startIndex; i endIndex; i) { const div document.createElement(div); div.className diff-line ${lines[i].type}; div.innerHTML span classline-num${lines[i].oldLine || }/span span classline-content${escapeHtml(lines[i].text)}/span ; fragment.appendChild(div); } resultContainer.appendChild(fragment); // 还有剩余行时继续下一帧渲染 if (endIndex lines.length) { requestAnimationFrame(() renderResult(lines, endIndex, batchSize)); } }逻辑说明DocumentFragment是个轻量级的临时容器所有节点先塞进这个碎片里再一次性挂到真实DOM下避免每插入一行都触发布局重排。requestAnimationFrame保证浏览器至少完成一帧绘制后才继续下一批用户看到的是「内容一行一行加载出来」而不是长时间的白屏。参数说明batchSize是每批渲染的行数200行是我试过比较稳妥的值批量太小会频繁触发回调浪费CPU批量太大又会在一帧内占据过多渲染时间导致掉帧。escapeHtml是对内容做HTML字符转义避免用户粘贴的代码里带script或img标签被当作DOM执行纯安全考虑不能省略。4. 避坑开发diff页面时遇到的常见问题与排查记录4.1 换行符不一致导致的全红误报现象从Windows机器上拷贝的旧代码和从Mac上拷贝的新代码做对比明明逻辑没怎么变结果区却飘红了一大片看起来像整个文件都被重写了。原因Windows的老牌文本编辑器保存文件时用的是\r\n作为换行符Unix系系统用的是\n。split(\n)之后Windows文件的每一行末尾都残留一个\r和Unix文件的行内容永远不相等LCS自然匹配不上。解决拆行之后对每一行做一次replace(/\r$/, )把行尾的\r剥掉。注意只去掉结尾的\r不要把行中间可能存在的\r也一并处理否则会把内容搞坏。4.2 行号列在结果区横向滚动时丢失现象结果区内容太长横向拖动看后面的代码发现最左边的行号列跟着内容一起滚走了想确认当前看到的到底是第几行只能再滚回去。原因行号列虽然用了flex-shrink: 0但它的父容器用了display: flex之后pre标签内部的横向滚动依然会把行号当作「文本的一部分」一起挤走。解决把行号列从滚动容器里抽出来做成一个独立的position: sticky块固定在容器左侧再加z-index: 1盖在内容上面。这样内容横向滚动时行号列始终留在可视区只是需要额外处理一下背景色保证有内容被盖住时底部的红绿底色不会穿帮。4.3 空行参与LCS导致大片区域偏移现象两份数组长度都不到三十行但diff结果里大量行被判定为删除和新增中间有几行明明是相同的却显示成「先删后加」。原因空行也参与了LCS比较当两份文档的空行分布不一致时算法会选择保留「空行和空行匹配」的路径这个路径把真正的相同内容推后了于是后续内容整体错位。解决拆行后过滤掉空行再做LCS但这会破坏行号关联。我后来采用的折中方案是把空行保留在数组里但在判断两个字符串是否相等时增加一个if (line.trim() ) return true的短路判断让所有空行都视为相等。副作用是空行位置不同也被当成相同视觉上会忽视空行插入删除但对于看代码差异这个最终目的来说可以接受。4.4 大文件输入时页面卡顿三秒才出结果现象粘贴一份几千行的日志进去页面卡住不动任务管理器里显示CPU被浏览器占满过了好几秒结果区才弹出来。原因每次触发input事件都会立刻重新计算LCS状态数组是O(n*m)的空间复杂度几千行乘几千行就是上千万个格子再加上回溯和渲染全在同一事件循环里完成浏览器被一个计算任务阻塞住了。解决给比较逻辑加一个200毫秒的防抖用户在连续输入时只在停顿后触发一次比较同时在计算前判断行数乘积是否超过十万超过就切换到上面说的增量渲染模式。这两步加完之后即使是一万行的对比页面也只是稍微等一下不会完全卡住。4.5textarea的禁用态吃掉了滚动事件现象结果区设置为只读时页面滚动同步偶尔会失效左右两个输入框滚动了结果区不动。原因textarea设了disabled之后无法滚动但结果区的容器如果被设成了contenteditablefalse或者readonly在某些浏览器下会拦截滚动事件导致事件监听器拿不到最新的scrollTop。解决给textarea的readonly属性只加在输入框上结果区容器不用readonly改成aria-readonlytrue并从tabindex里去掉它的焦点顺序既保持语义上的只读又让DOM可以正常滚动。5. 交付前我常加的三个实用技巧第一个技巧是导出diff结果。团队里评审代码改动时不是每个人都愿意打开浏览器贴内容我习惯在页面右下角加一个「导出HTML报告」的按钮点击后把当前结果区的DOM序列化成HTML字符串塞进一个pre包装好的独立文件里下载。这个文件带上完整的样式内联不依赖页面CSS发给谁都能直接打开看红绿差异。做这一步时有个小细节得把style标签的内容也复制进导出文件否则打开报告时没有任何高亮就白忙活了。第二个技巧是只关注差异行。默认渲染是每一行都显示但几十行没改动的内容占据大量阅读时间。我加上一个「只看异动」的开关渲染结果前先过滤只保留type为added和removed的行相同行全部跳过但行号列依然保留原始行号。这样一眼就能看到第183行被删了、第185行被加了上下文虽然缺失但定位效率高很多非常适合快速扫改动范围。第三个技巧是URL参数预填充。在原型阶段我发现这个页面有个很实用的场景把两份文档的内容用encodeURIComponent压缩后放进URL的hash参数里页面加载时读取hash自动填入输入框并立即比较。这样可以在浏览器里把「当前diff结果」作为一个链接分享出去其他人打开这个链接看到的就是同一份对比。这个实现不复杂只需要在DOMContentLoaded时多执行一次解析和回填。从那以后我每次拿到两份待对比的文档都习惯性地先把这个页面打开贴完内容扫一眼异动行确认没问题再继续往下改。即使资源本身只覆盖了LCS行级对比这一层没做词级高亮也没有合并相邻区块但架不住它足够轻、打开就能用。希望帮到你。本文还有配套的精品资源点击获取