零依赖HTML实现git diff行级文本对比:从LCS算法到浏览器渲染
简介一份基于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.jsindex.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设置不同的classCSS就能自动控制背景色。行号为空时不要填充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 差异结果映射到DOMrenderResult函数拿到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页面也能成为你理解文本对比算法的第一步。本文还有配套的精品资源点击获取