简介:一个基于 Web 技术实现的轻量级 Git diff 对比工具,面向需要在浏览器中快速查看文件差异的开发者,无需安装命令行 Git 即可获得类似 diff 的直观体验。压缩包共 4 个文件,包含 1 个 HTML 入口页面、2 个 JavaScript 脚本(负责 diff 解析与交互逻辑)和 1 个 CSS 样式表(用于差异高亮与界面排版),整体仅 31KB,下载后双击即可本地运行。已有 203 人学习使用。工具基于 diff2html 库实现,可清晰呈现文本变更、高亮增删内容,适合作为代码对比、教学演示或临时差异查看的轻量辅助;同时,前端开发者也可参考其文件组织方式与 diff 渲染实现思路,快速上手同类页面。 有个内部小需求,让我差点把"简单"两个字理解歪了。项目里一份配置文本在两个版本之间改了不少地方,非技术的同事想直观看到改动点。我第一反应是丢一段git diff终端输出过去,结果对方完全看不明白。后来我想,干脆用web实现一个非常简易的类似git diff的页面:左边贴旧文本,右边贴新文本,点一下按钮,下方就用红绿配色把增删行标出来。做完发现,整个项目就是一个HTML文件,两百行左右,不依赖任何框架和库。这篇文章把实现思路、核心算法和调试过程中踩到的坑完整记录下来,如果你也需要一个轻量级diff对比页面,可以直接抄作业。
1. 需求界定:这个简易diff页面要解决什么问题
1.1 为什么不用现成库或在线工具
先说说我为什么没有直接上jsdiff或者找在线diff工具。
团队成员里有人不太接触命令行,每次让我截图git diff,我都得先把终端字号调到最大,然后截一大张图,截图里还经常带上一堆无关上下文。在线diff工具确实方便,但内部项目有保密要求,配置内容不能随便贴到第三方网站。所以"本地打开就能用、不联网、不依赖后端"成了硬性要求。至于jsdiff这个库,它确实很成熟,但为了一个页面去引入npm依赖,还要考虑打包、构建链,在这类小需求里反而显得笨重。
需求最终被框定为三件事:
- 页面本地可打开,纯前端实现,不依赖后端。
- 输入两段文本,输出增删差异,视觉上接近
git diff。 - 代码尽量短,逻辑透明,后续要改行为也容易。
1.2 最终做出来的形态
简单画一下交互流程:页面上方是两个textarea,左边输入"旧文本",右边输入"新文本",中间一个"开始 Diff"按钮。点击按钮后,下方渲染出一个diff视图,删除的行用浅红背景并带-号,新增的行用浅绿背景并带+号,未变化的行保持白色背景。行号分两列显示,左边旧行号,右边新行号,这样能直观看出改动发生在第几行。
这个形态其实就是git diff默认输出的unified风格,只不过从终端文字变成了浏览器页面。它不需要做到GitHub那种左右两栏并排的split视图,因为对大部分场景来说,unified视图已经足够表达增删关系,实现成本也低很多。
2. 差异计算:把两段文本变成"增删改"操作序列
2.1 预备步骤:文本拆行与换行符归一化
diff的本质是比较两个序列。所以第一步是把文本变成行数组,用split('\n')。这里有个非常常见的坑:Windows记事本保存的文本换行是\r\n,Linux和macOS是\n,如果直接按\n拆分,旧文本里的\r会残留在每一行末尾,导致一行明明内容相同,却被判定为不同。我一开始没有做归一化,结果两段内容几乎一样的文本,全被标成了红色。处理很简单:
function normalize(text) { return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); }把\r\n和单独的\r都统一成\n,再去做拆分和后续比较。这一步看起来小,但漏掉它会让整个diff结果完全不可用。
2.2 LCS动态规划:表格怎么填
把文本拆成行数组后,问题就变成了:求两个数组的最长公共子序列(LCS,Longest Common Subsequence)。这么做的直觉是:两个版本里那些"按顺序完全相同"的行,大概率就是没改过的行;剩下的行,要么是删掉的、要么是新增的。
LCS的一个经典解法是动态规划。定义一个二维数组dp[i][j],表示oldLines前i行与newLines前j行的LCS长度。转移方程很简单:
- 如果
oldLines[i-1] === newLines[j-1],dp[i][j] = dp[i-1][j-1] + 1; - 否则
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1])。
你可以把dp[i][j]想象成"两个小组点名,按顺序喊出相同名字的次数"。每遇到一行相同,就在左上角的基础上加一;不相同,就从上面或左边继承一个较大值。填完整个表格后,右下角的dp[n][m]是最长公共子序列的长度,但我们真正要的,是从表格里回溯出"每一步该标成common还是del还是add"。
2.3 回溯操作序列的代码逻辑
填表是从左上到右下,回溯则反过来,从右下往左上走:
- 如果当前两个行相等,记录为
common,然后i--, j--; - 如果不相等,比较
dp[i-1][j]和dp[i][j-1],哪个方向的值更大就往哪边走。往左走说明旧行被删除,记录为del;往上走说明新行被插入,记录为add。
这一步是diff算法里最容易写错的地方,方向搞反的话,输出结果会把增删颠倒。我按这个逻辑实现的核心函数是这样:
function diffLines(oldText, newText) { const oldLines = normalize(oldText).split('\n'); const newLines = normalize(newText).split('\n'); const n = oldLines.length; const m = newLines.length; 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]); } } } const ops = []; let i = n, j = m; while (i > 0 && j > 0) { if (oldLines[i - 1] === newLines[j - 1]) { ops.push({ type: 'common', text: oldLines[i - 1] }); i--; j--; } else if (dp[i - 1][j] >= dp[i][j - 1]) { ops.push({ type: 'del', text: oldLines[i - 1] }); i--; } else { ops.push({ type: 'add', text: newLines[j - 1] }); j--; } } while (i > 0) { ops.push({ type: 'del', text: oldLines[i - 1] }); i--; } while (j > 0) { ops.push({ type: 'add', text: newLines[j - 1] }); j--; } ops.reverse(); return ops; }注意最后ops.reverse()不能漏。因为回溯是从尾部往前推的,生成的操作序列顺序是反的,不反转的话,页面会从最后一行开始展示diff。
这里稍微解释一下为什么要选dp[i-1][j] >= dp[i][j-1]这个判定:遇到两行不相等时,把"删除旧行"和"新增新行"都当成候选操作,我们优先选择dp值更大的方向,保证拿到的是最长公共子序列对应的一条回溯路径。当两个方向一样大时,代码里优先删旧行,这会让diff结果稍微偏向"先删除后新增",和git的默认行为比较接近。
3. 渲染层:让页面看起来像git diff
3.1 行号双列的布局设计
算法算出ops数组之后,渲染就简单多了。我选择用一个div列表逐行渲染,每一行内部用flex布局,左侧两个小格子分别放旧行号和新行号,中间一个符号位,剩下的空间放内容。
行号双列的设计,参考的是GitHub Pull Request页面里unified视图的呈现方式:一行如果只是普通上下文,两个行号都在;如果是删除行,新行号的位置留空;如果是新增行,旧行号的位置留空。这样读者一眼就知道某一行对应的是旧文件的第几行、新文件的第几行。
3.2 hunk header的生成逻辑
git diff在每一段连续变更前会输出一个类似@@ -12,5 +12,6 @@的标题,告诉读者这一片改动从哪一行开始、涉及多少行。为了让页面更像git diff,我决定也加上这个hunk header。
实现的时候不能一边遍历一边直接拼HTML,因为输出header时,必须已经知道这个hunk里删了多少行、增了多少行。做法是准备一个hunk缓冲区:遇到变更行时,把行的HTML先攒进缓冲区,记录起始行号、删除行计数、新增行计数;遇到上下文行或者遍历结束时,再统一flush缓冲区,先输出header再输出这波行。核心逻辑如下:
function renderDiff(ops) { const container = document.getElementById('result'); let html = ''; let oldLineNo = 1; let newLineNo = 1; let hunkBuffer = []; let hunkOldStart = 0, hunkNewStart = 0; let hunkOldCount = 0, hunkNewCount = 0; function flushHunk() { if (hunkBuffer.length === 0) return; html += `<div class="hunk">@@ -${hunkOldStart},${hunkOldCount} +${hunkNewStart},${hunkNewCount} @@</div>`; html += hunkBuffer.join(''); hunkBuffer = []; hunkOldCount = 0; hunkNewCount = 0; } for (const op of ops) { if (op.type === 'common') { flushHunk(); html += `<div class="line common"> <span class="num">${oldLineNo}</span> <span class="num">${newLineNo}</span> <span class="sign"> </span> <span class="content">${escapeHtml(op.text)}</span> </div>`; oldLineNo++; newLineNo++; } else if (op.type === 'del') { if (hunkBuffer.length === 0) { hunkOldStart = oldLineNo; hunkNewStart = newLineNo; } hunkBuffer.push(`<div class="line del"> <span class="num">${oldLineNo}</span> <span class="num"> </span> <span class="sign">-</span> <span class="content">${escapeHtml(op.text)}</span> </div>`); hunkOldCount++; oldLineNo++; } else { if (hunkBuffer.length === 0) { hunkOldStart = oldLineNo; hunkNewStart = newLineNo; } hunkBuffer.push(`<div class="line add"> <span class="num"> </span> <span class="num">${newLineNo}</span> <span class="sign">+</span> <span class="content">${escapeHtml(op.text)}</span> </div>`); hunkNewCount++; newLineNo++; } } flushHunk(); container.innerHTML = html; }3.3 HTML转义这个不能省
内容行里如果出现<、>、&这些字符,直接塞进innerHTML会把页面结构弄坏。比如比较一段HTML代码时,原来的标签会被浏览器当成真实标签渲染,diff页面整个就花了。所以任何文本内容插入页面之前,都必须经过转义。
function escapeHtml(str) { return str.replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); }特别提醒&要先替换,否则<里的&会被二次转义成&lt;,页面显示就会变成乱码。
3.4 可以直接保存运行的完整代码
把上面几段代码拼到一起,就是一个完整的diff.html。样式上用等宽字体、浅红浅绿背景、灰色行号,保存后浏览器直接打开就能用。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>简易Git Diff</title> <style> * { box-sizing: border-box; } body { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; background: #f6f8fa; margin: 0; padding: 24px; } .container { max-width: 1200px; margin: 0 auto; } .input-area { display: flex; gap: 12px; margin-bottom: 12px; } textarea { flex: 1; height: 220px; padding: 12px; font-size: 14px; line-height: 1.6; font-family: inherit; border: 1px solid #d0d7de; border-radius: 6px; background: #fff; resize: vertical; white-space: pre; } .btn-row { text-align: center; margin-bottom: 12px; } button { background: #1f883d; color: #fff; border: none; padding: 10px 28px; border-radius: 6px; font-size: 14px; cursor: pointer; } button:hover { background: #1a7f37; } .diff-view { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; overflow: hidden; } .hunk { background: #f0f3f6; color: #57606a; padding: 4px 12px; font-size: 13px; font-weight: 600; border-top: 1px solid #d0d7de; border-bottom: 1px solid #d0d7de; } .line { display: flex; padding: 0; font-size: 14px; line-height: 1.8; } .line.del { background: #ffebe9; } .line.add { background: #e6ffec; } .line.common { background: #fff; } .line .num { width: 48px; min-width: 48px; text-align: right; padding-right: 8px; color: #8c959f; user-select: none; border-right: 1px solid #ececec; } .line .sign { width: 20px; min-width: 20px; text-align: center; color: #57606a; } .line .content { flex: 1; padding-left: 8px; white-space: pre; overflow-wrap: anywhere; } .line.del .content { color: #cf222e; } .line.add .content { color: #1a7f37; } </style> </head> <body> <div class="container"> <h2>Web Diff</h2> <div class="input-area"> <textarea id="oldText" placeholder="旧文本 / Old"></textarea> <textarea id="newText" placeholder="新文本 / New"></textarea> </div> <div class="btn-row"> <button id="diffBtn">开始 Diff</button> </div> <div id="result" class="diff-view"></div> </div> <script> function escapeHtml(str) { return str.replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"'); } function normalize(text) { return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } function diffLines(oldText, newText) { const oldLines = normalize(oldText).split('\n'); const newLines = normalize(newText).split('\n'); const n = oldLines.length; const m = newLines.length; 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]); } } } const ops = []; let i = n, j = m; while (i > 0 && j > 0) { if (oldLines[i - 1] === newLines[j - 1]) { ops.push({ type: 'common', text: oldLines[i - 1] }); i--; j--; } else if (dp[i - 1][j] >= dp[i][j - 1]) { ops.push({ type: 'del', text: oldLines[i - 1] }); i--; } else { ops.push({ type: 'add', text: newLines[j - 1] }); j--; } } while (i > 0) { ops.push({ type: 'del', text: oldLines[i - 1] }); i--; } while (j > 0) { ops.push({ type: 'add', text: newLines[j - 1] }); j--; } ops.reverse(); return ops; } function renderDiff(ops) { const container = document.getElementById('result'); let html = ''; let oldLineNo = 1; let newLineNo = 1; let hunkBuffer = []; let hunkOldStart = 0, hunkNewStart = 0; let hunkOldCount = 0, hunkNewCount = 0; function flushHunk() { if (hunkBuffer.length === 0) return; html += '<div class="hunk">@@ -' + hunkOldStart + ',' + hunkOldCount + ' +' + hunkNewStart + ',' + hunkNewCount + ' @@</div>'; html += hunkBuffer.join(''); hunkBuffer = []; hunkOldCount = 0; hunkNewCount = 0; } for (const op of ops) { if (op.type === 'common') { flushHunk(); html += '<div class="line common">' + '<span class="num">' + oldLineNo + '</span>' + '<span class="num">' + newLineNo + '</span>' + '<span class="sign"> </span>' + '<span class="content">' + escapeHtml(op.text) + '</span>' + '</div>'; oldLineNo++; newLineNo++; } else if (op.type === 'del') { if (hunkBuffer.length === 0) { hunkOldStart = oldLineNo; hunkNewStart = newLineNo; } hunkBuffer.push('<div class="line del">' + '<span class="num">' + oldLineNo + '</span>' + '<span class="num"> </span>' + '<span class="sign">-</span>' + '<span class="content">' + escapeHtml(op.text) + '</span>' + '</div>'); hunkOldCount++; oldLineNo++; } else { if (hunkBuffer.length === 0) { hunkOldStart = oldLineNo; hunkNewStart = newLineNo; } hunkBuffer.push('<div class="line add">' + '<span class="num"> </span>' + '<span class="num">' + newLineNo + '</span>' + '<span class="sign">+</span>' + '<span class="content">' + escapeHtml(op.text) + '</span>' + '</div>'); hunkNewCount++; newLineNo++; } } flushHunk(); container.innerHTML = html; } document.getElementById('diffBtn').addEventListener('click', () => { const oldText = document.getElementById('oldText').value; const newText = document.getElementById('newText').value; const ops = diffLines(oldText, newText); renderDiff(ops); }); </script> </body> </html>4. 实际使用中踩过的坑和解决办法
4.1 递归实现LCS导致大文本爆栈
我第一版写LCS时图省事,用的是递归加记忆化。文本量小的时候一切正常,后来粘贴了一份几百行的日志进去,浏览器标签页直接卡死,控制台报Maximum call stack size exceeded。原因很简单:递归深度随着文本行数线性增长,而浏览器调用栈有限,再加上双重递归的调用路径,行数一大就会爆栈。
后来改成上面那种用二维数组的迭代写法,用双重for循环填表,彻底避开递归深度问题。代价是内存占用变大了,O(n*m)的空间复杂度,几百行文本完全没压力,但几万行时二维数组会吃掉几十MB甚至更多内存。这算是这个简易版本的一个性能边界。
4.2 重复行带来的错位匹配,LCS天生缺陷
这是比较文本差异算法时绕不开的问题。LCS的目标是"最长"公共子序列,它只看数量最大化,不看局部连续性。当文本里有大量重复行时,LCS可能把远处不相关的相同行误认为"没改动",导致diff结果看起来非常反直觉。
举个具体场景:一份JS文件里有很多行右括号}和空行。旧文件和新文件都改了第一行函数名,但后面紧跟的内容变了,由于大量重复的}存在,LCS可能把旧文件的某个}匹配到新文件后面很远的},中间所有间隔内容都被当成删除和新增。结果就是diff视图里出现一大片删除和一大片新增,而不是理论上"改动点集中在一小段"的直观结果。
这个问题的根源在于LCS算法本身不考虑"行的物理位置相近"这个先验。git实际使用的是Myers算法,并配合一些启发式规则来避免这种错位。简易版可以先接受这个问题,在页面上加一个提示:"对比结果仅供参考,超长文本建议使用专业工具"。
4.3 换行符不统一造成的"假差异"
前面在归一化\r\n时提过一嘴,这里再展开讲。Windows下编辑过的文件,每行末尾是\r\n;Linux或macOS下是\n。如果旧文件是Windows格式,新文件是Linux格式,肉眼上看内容完全一样,但拆分行数组后,旧文件每行末尾都多了一个\r字符,逐行比较时每一行都不相等,整个diff会全部标红。
我第一次遇到这个现象时,一度以为是算法写错了,排查了很久才发现是换行符的问题。所以在diffLines函数的第一行就做统一处理,先normalize再拆分。类似的,如果比较的是从数据库字段里取出来的文本,有时还会出现\r单独存在的情况,归一化时也要把单独的\r转成\n。
4.4 页面渲染性能与超大文本卡顿
算法层面,LCS的时间复杂度是O(n*m),空间复杂度也是O(n*m)。当旧文本和新文本各有5000行时,二维数组有2500万个元素,即便每个元素只是个数字,内存压力也很大,页面在填表阶段就可能卡住几秒。
实际上我测试过:两个各有2000行的文本,填表加渲染大概在几百毫秒级别,还能接受;到5000行就开始有明显卡顿;上万行基本告别这个简易版本了。如果确实要处理大文件,有两个低成本优化方向:
- 先用"行哈希"粗筛,完全相同且连续的行先提取出来,只对差异片段跑LCS;
- 把LCS换成Myers算法,它的平均复杂度更优,而且git本身就用它。
前者实现简单,对大多数真实场景已经够用;后者需要一点算法功底,但能显著提升上限。
5. 后续还能怎么升级
5.1 从LCS换成Myers算法
如果这个页面要继续用,我会把核心算法替换成Myers diff算法。Myers基于图搜索思想,把找diff转化成求最短编辑路径,核心优势是:结果更符合直觉、性能通常比LCS好。git、diff工具里大量使用的就是这个算法。
不过Myers的实现比LCS复杂不少,涉及贪心回溯和"蛇"的概念。对不熟悉的人,我建议先照着LCS版本把页面跑通,理解"操作序列"这个概念后,再去替换算法。算法接口可以保持不变:输入两段文本,输出ops数组,渲染层一行都不用改。
5.2 增加字符级差异、忽略空白等选项
行级diff够用之后,可以继续做字符级高亮。比如某一行只改了中间的一个变量名,纯行级diff只会标出"这行被修改了",而字符级diff能进一步把具体那个词标出来。实现思路是:对每一对"被删除行紧跟着新增行"的组合,再把两个字符串按字符数组跑一次同样的LCS逻辑。
还可以加忽略空白的选项。很多配置文件里的缩进变动很烦人,可以在比较逻辑里对行内容先做一次trim()再比较,页面加一个checkbox即可。这类小功能对非技术同事来说非常实用。
5.3 什么情况下果断引入jsdiff
我前面说这个小需求不引库,但并不是说jsdiff不好。如果需求复杂度上来,比如要支持字符级diff、要处理超大文件、要兼容旧浏览器,或者你不想维护算法细节,那就应该果断引入jsdiff这类成熟库。它内部实现了Myers算法和多种diff模式,API也很简单,几行代码就能接上。
取舍的标准我一直是:一行代码能解决的事,先别急着造轮子;但两三百行能解决、且需要完全可控和零依赖时,造个简单轮子也是值得的。这个简易diff页面就是后面这种情况。项目里如果只是临时看个配置、对比个日志,这个单文件页面比"装一个npm包再配一套构建链"实在多了。
最后再分享一个我实际使用的小技巧:因为有hunk header,我会在渲染后的页面上按Ctrl+F搜索@@,就能快速在所有变更片段之间跳转,比在超长diff里滚动找红绿块快得多。这个页面后来还被同事要过去,我们把textarea换成了文件上传,对比两个版本的上传文件,应用范围一下广了不少。
本文还有配套的精品资源,点击获取