☰
零依赖HTML实现git diff行级文本对比:从LCS算法到浏览器渲染
2026/10/18 19:12:39 网站建设 项目流程

简介:一份基于Web技术构建的简易Git diff页面工具,面向需要在浏览器中快速查看文件差异的开发者,无需安装Git或熟悉命令行即可直观比较工作目录中的变更内容。压缩包体积仅31KB,共4个文件,包括1个HTML入口文件、2个JavaScript逻辑脚本和1个CSS样式表,分别负责页面结构、diff数据解析与交互、以及差异高亮的视觉呈现,解压后双击HTML即可离线运行,轻量且易上手。已有203人浏览/学习。借助该工具可重点理解Git diff的数据结构与前端渲染思路:例如diff2html库如何将统一格式的差异文本转换为带行号、增删标记的HTML片段,以及如何通过CSS控制新增行、删除行和折叠区块的显示效果。对于学习Web前端解析与Git原理的初学者,这是一份可运行、能拆解的小型示例,便于直接阅读源码并改写扩展。

1. 一个HTML文件搞定行级Diff:这才是把git diff搬进浏览器的正确姿势

我有个不太好的习惯:改配置前总爱备份一份,改完再对比看哪里变了。以前用命令行git diff确实好用,但有些同事更习惯打开一个网页,左边粘贴旧内容、右边粘贴新内容,点一下按钮,哪行删了哪行加了一目了然。于是我就用原生HTML、CSS和JavaScript做了这么个极简页面,没有后端、没有构建工具、没有框架依赖,纯粹一个静态网页。它能对任意两段文本做逐行比较,用git diff的经典风格把新增行、删除行、上下文行区分渲染,还支持行号对齐和快速统计。这东西适合谁?适合那些需要一个“零安装、能双击打开就跑”的文本对比工具,或者想自己学一学diff核心算法怎么写的开发者。你完全可以把它当成一个迷你版git diff,但它的全部源码加起来不到几百行,足够新手读透。

2. 技术选型与目录结构:零依赖的代价与收益

2.1 为什么不用后端和框架

最开始我也想过要不要加一个Python Flask后端,把diff算法放服务端跑,前端只负责展示。但后来仔细想了想使用场景:这个工具需要的是“随手打开、随手用完关上”,如果起一个后端服务,意味着本机必须装Python环境,还得在终端敲命令启动,这对只想要一个网页工具的人来说太反直觉了。所以我把所有逻辑都放在浏览器端,用原生JavaScript实现diff算法,连jQuery都没用。好处很直接:没有安装步骤、没有跨域问题、没有服务端压力,甚至你可以把它放在本地磁盘直接双击index.html打开,需要的只是浏览器支持ES6语法。

2.2 目录结构与文件职责

整个项目没有用打包工具,目录结构简单到不能再简单:

simple-diff/ |-- index.html |-- css/ | `-- style.css |-- js/ | |-- diff.js | `-- app.js

index.html是整个页面的骨架,包含两个textarea输入区、一个触发按钮和一个结果展示容器。css/style.css负责所有视觉样式,包括git diff的绿色新增行、红色删除行、灰色上下文行,以及行号列和滚动条样式。js/diff.js是核心算法文件,封装了行级diff的完整逻辑,对外暴露一个diffLines函数。js/app.js负责把textarea里的内容取出来,调用diff.js计算差异,再把结果渲染成DOM节点挂到页面上。

这样的分层方式是为了把算法和DOM操作分开:diff.js不接触任何DOM,输入输出都是纯字符串和数组,方便单独测试;app.js只关心怎么拿数据、怎么渲染结果。如果你想把这个diff算法移植到别的地方,只用拷贝diff.js就行,连样式都不用管。我一般会把算法和视图严格拆开,这样哪怕以后要改成React组件或Node脚本,也不会被现有页面绑架。

2.3 启动与预览

启动方式基本可以忽略不计:只要用浏览器打开index.html就能用。如果你正在开发调试,我习惯用VS Code的Live Server插件起一个本地静态服务,这样能顺便测试一下在不同文件路径下的加载情况。注意一个细节:由于没有引入外部字体和CDN资源,整个页面断网也能跑,这一点对内部工具来说很重要。

3. 核心算法拆解:从LCS到逐行高亮

3.1 最长公共子序列的朴素实现

diff的本质是找两个文本之间的差异,计算机领域最经典的做法是求“最长公共子序列”(Longest Common Subsequence,即LCS)。找到LCS之后,剩下的就是删除和新增的内容。这里我先用最常见的动态规划思路实现一个朴素版本,正确性优先,性能后面再聊。

function lcsLength(a, b) { const m = a.length, n = b.length; const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) { for (let j = 1; j <= n; j++) { if (a[i - 1] === b[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; }

这个函数的输入是两个数组,例如a是旧文本按行分割后的数组,b是新文本按行分割后的数组。dp[i][j]表示a的前i行和b的前j行之间的LCS长度。当a[i-1]和b[j-1]相等时,长度在左上角基础上加1;否则取上方和左方的较大值。这段代码返回的是完整的动态规划表,后续回溯需要用这张表找出哪些行属于公共部分。

注意,这里我用的是“行”作为比较单位,而不是字符。这样更符合git diff的展示习惯:一整行要么算相同,要么算新增或删除。如果你需要精细到字符级别的差异,就得在行内再跑一次LCS,这个项目先不涉及。

3.2 回溯差异序列并生成操作指令

仅仅有dp表还不能直接得到增删行列表,还需要从表的右下角向左上角回溯。回溯规则很简单:如果a[i-1]等于b[j-1],说明是公共行,标记为“保留”;如果dp[i][j]来自上方,说明a的当前行在b中没有对应,标记为“删除”;如果来自左方,说明b的当前行在a中没有对应,标记为“新增”。实际操作中我习惯让删除优先于新增,这样更接近git diff的默认行为。

function buildDiff(origin, target, dp) { let i = origin.length, j = target.length; const ops = []; while (i > 0 || j > 0) { if (i > 0 && j > 0 && origin[i - 1] === target[j - 1]) { ops.push({ type: 'same', text: origin[i - 1], oldLine: i, newLine: j }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { ops.push({ type: 'add', text: target[j - 1], oldLine: null, newLine: j }); j--; } else { ops.push({ type: 'del', text: origin[i - 1], oldLine: i, newLine: null }); i--; } } return ops.reverse(); }

这段代码里的dp就是上一个函数返回的二维数组。ops数组是最终的操作序列,每个元素包含type、text、oldLine、newLine四个字段。oldLine为null表示该行是新增的,newLine为null表示该行是删除的。注意回溯时先判断相同行,再优先判断“新增”,最后才是“删除”,这个顺序能减少很多意外结果。最终得到的ops是从后往前累积的,所以最后一定要reverse一次,恢复成从上到下的阅读顺序。

3.3 从算法到git风格渲染

得到ops序列之后,下一步就是渲染。渲染的逻辑并不复杂:把每个操作映射为一行带背景色的div,左侧显示行号,右侧显示文本。相同行用浅灰背景,新增行用浅绿背景,删除行用浅红背景。如果删除行后面紧跟着新增行,我还会用特殊连接线把它们组织在一起,模拟git diff里“修改”的表现效果。这里贴一段app.js里的渲染核心逻辑:

ops.forEach(op => { const row = document.createElement('div'); row.className = `diff-row diff-${op.type}`; const oldLine = document.createElement('span'); oldLine.className = 'line-num'; oldLine.textContent = op.oldLine || ''; const newLine = document.createElement('span'); newLine.className = 'line-num'; newLine.textContent = op.newLine || ''; const content = document.createElement('span'); content.className = 'line-content'; content.textContent = op.text; row.appendChild(oldLine); row.appendChild(newLine); row.appendChild(content); container.appendChild(row); });

这段代码里,每个diff-row都是flex布局,三个span从左到右依次是旧行号、新行号、内容文本。通过给row设置不同的class,CSS就能自动控制背景色。行号为空时不要填充0或-,直接用空字符串占位,视觉上更干净。这么做的好处是,整个差异结果是一个连续的纵向列表,便于浏览器一次性渲染,也方便用户直接滚动阅读。如果你想看并排diff,只需要在CSS里换一个布局,不涉及算法改动。

4. 从打开页面到看出差异:完整流程

4.1 事件绑定与输入获取

用户完整的使用流程是这样:页面打开后,两个textarea左侧放旧文本、右侧放新文本,点击“对比”按钮,下方的结果区域输出差异。按钮的事件绑定放在app.js的init函数里,使用DOMContentLoaded确保整个DOM加载完成再挂事件。

document.addEventListener('DOMContentLoaded', () => { const btn = document.getElementById('diffBtn'); btn.addEventListener('click', function() { const oldText = document.getElementById('oldText').value; const newText = document.getElementById('newText').value; const result = diffLines(oldText, newText); renderResult(result); }); });

这里diffLines是diff.js暴露的入口函数,它内部会先把输入字符串按换行符切割,然后调用lcsLength和buildDiff。需要特别说明的是,切割时我会额外处理\r\n和\n两类换行符,保证从Windows和Unix系统复制出来的文本都能正确识别。如果直接按\n切割,从Windows复制过来的文本每一行末尾都会残留一个\r字符,导致明明内容相同却显示为差异,这是最常见的翻车点之一。

4.2 差异结果映射到DOM

renderResult函数拿到ops数组后,先清空结果容器,然后遍历ops逐个生成div节点。为了支持大数据量时的性能,这里我用了DocumentFragment来批量挂载节点,避免每次appendChild都触发一次页面重绘。渲染完后,页面左下角会更新一行统计信息:“新增12行,删除8行”,这个数字直接来自ops数组的count统计,不额外做遍历。

4.3 交互细节:行号对齐与滚动同步

这个简易页面的交互亮点在于行号对齐。当你同时看到两边的操作行时,同一行的oldLine和newLine分别显示在前后两个span里。上下文行的两个行号是连续的,删除行只有旧行号,新增行只有新行号。阅读起来和git diff的hunk区段非常类似。另一个细节是结果区域的最大高度固定,超过后内部滚动,避免整个页面被一条很长的差异结果拉到底部。这个页面的缺陷是只支持单向滚动,如果你想要左右两栏并排且同步滚动,那就得在二次开发时重新设计视图,这不属于当前版本的范畴。

我建议你拿到源码第一件事,就是先打开页面,分别粘贴这两段测试内容:左边粘贴一段带数字编号的句子,右边把其中某几个数字改掉,再删除一行,新增一行,点击对比后观察行号和颜色的变化。这样能快速建立对输出格式的直觉,之后再读算法代码会轻松很多。

5. 避坑指南:文本对比工具里的常见排查记录

5.1 现象:看起来一样的两行,却被判定为存在差异

这个情况出现的频率高得离谱。最常见的原因就是空行问题:旧文本每行结尾都是Linux风格的\n,新文本每行结尾却是Windows风格的\r\n,肉眼完全看不出区别,但算法认为\r也是一个字符,于是整行匹配失败。解决方式是在文本切割阶段统一把\r\n替换成\n,也就是我之前说的先做换行符归一化。我在这里吃过亏,后来干脆把文本预处理写成一个独立函数tidyText,任何入口都先过一遍,杜绝后患。

5.2 现象:超过一千行的文本对比时页面卡顿

原因在于朴素LCS的时间复杂度是O(m*n),当oldText有5000行、newText也有5000行时,需要计算2500万个格子,浏览器会直接卡死。解决思路是限制输入大小,或者换用更高效的Myers差分算法。考虑到这只是一个“非常简易”的页面,我的做法是:在点按钮前先比较两个数组的长度乘积,如果超过某个阈值比如2000000,就弹窗提示用户分段对比,而不是让页面无响应。从工程角度看,这个提示比默默卡死友好得多。

5.3 现象:删除行和新增行没有成对显示,修改看起来像先删后加

git diff原生会把连续的删除行和新增行显示在一个hunk里,排在一起。但我的简单回溯算法在有交错改动时,可能把删除块和新增块拆开,中间隔着上下文行。这会让用户误以为改动是两处而不是一处。解决方法是后处理ops序列:当检测到一段删除行后面紧跟着一段新增行,且它们之间没有上下文行时,把它们合并为一个“修改”块,在视觉上通过一条竖线连接起来。这个后处理可以放在buildDiff和renderResult之间,作为独立的normalizeOps函数。具体实现不复杂,但效果很直观,值得加上。

5.4 现象:结果区域出现横向滚动条,文本过长被截断

默认情况下,长文本行会在容器宽度不够时折行。折行会让同一行的高度变高,破坏行号对齐。如果你不想折行,可以给行内容加white-space: pre和overflow-x: auto。但pre样式在浏览器里不会自动换行,长行会出现横向滚动条,需要整个结果区域同时支持水平滚动。我的建议是:对比代码类文本时使用横向滚动,对比普通文本时允许折行,把切换做成一个开关放在页面上,交由用户自行选择。

6. 改造思路:给简易页面加上Side-by-Side并排模式

这个项目最大的改动空间在视图层,而不是算法层。如果你想把它升级成类似Beyond Compare那样的左右并排页面,可以保留现有diff.js不动,只重写app.js里的渲染逻辑。做法是把待渲染的ops序列拆成左右两列:保留行两边都放同一行文本;删除行只放左列;新增行只放右列。同时要计算出同一行在两列中的纵向对应关系,让左右行号能对齐。比较巧妙的做法是引入一个gridLineMap,把每个操作映射到一个虚拟网格行号,再根据网格行号定位到左右两列的绝对位置。

我一般会先用一个数组记录每个虚拟网格行对应的左右行号,然后分别渲染左右两列DOM,并监听一个滚动条事件,同步滚另一个。如果希望支持字符级高亮,可以在同一行内部再做一次字符LCS,但这会让行高变得不一致。直到现在,我给内部的同事演示版本仍是单列模式,但已经把diff.js单独抽到项目里复用。每次有人问我“为什么老版本会漏掉某些修改”,我都能很准确地告诉他,这是因为复用dp回溯时的优先级顺序问题,而不是算法本身错了。从那以后,我每写一次diff相关的页面,都会强制走一遍“小数据手动构造用例、大数据压测、异常输入兜底”的测试流程,这已经成了我的肌肉记忆。希望这个简易的web diff页面也能成为你理解文本对比算法的第一步。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询