简介一个基于 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\nLinux和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动态规划表格怎么填把文本拆成行数组后问题就变成了求两个数组的最长公共子序列LCSLongest 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 diff3.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 classhunk -${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 classline common span classnum${oldLineNo}/span span classnum${newLineNo}/span span classsign /span span classcontent${escapeHtml(op.text)}/span /div; oldLineNo; newLineNo; } else if (op.type del) { if (hunkBuffer.length 0) { hunkOldStart oldLineNo; hunkNewStart newLineNo; } hunkBuffer.push(div classline del span classnum${oldLineNo}/span span classnum /span span classsign-/span span classcontent${escapeHtml(op.text)}/span /div); hunkOldCount; oldLineNo; } else { if (hunkBuffer.length 0) { hunkOldStart oldLineNo; hunkNewStart newLineNo; } hunkBuffer.push(div classline add span classnum /span span classnum${newLineNo}/span span classsign/span span classcontent${escapeHtml(op.text)}/span /div); hunkNewCount; newLineNo; } } flushHunk(); container.innerHTML html; }3.3 HTML转义这个不能省内容行里如果出现、、这些字符直接塞进innerHTML会把页面结构弄坏。比如比较一段HTML代码时原来的标签会被浏览器当成真实标签渲染diff页面整个就花了。所以任何文本内容插入页面之前都必须经过转义。function escapeHtml(str) { return str.replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); }特别提醒要先替换否则lt;里的会被二次转义成amp;lt;页面显示就会变成乱码。3.4 可以直接保存运行的完整代码把上面几段代码拼到一起就是一个完整的diff.html。样式上用等宽字体、浅红浅绿背景、灰色行号保存后浏览器直接打开就能用。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.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 classcontainer h2Web Diff/h2 div classinput-area textarea idoldText placeholder旧文本 / Old/textarea textarea idnewText placeholder新文本 / New/textarea /div div classbtn-row button iddiffBtn开始 Diff/button /div div idresult classdiff-view/div /div script function escapeHtml(str) { return str.replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); } 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 classhunk - 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 classline common span classnum oldLineNo /span span classnum newLineNo /span span classsign /span span classcontent escapeHtml(op.text) /span /div; oldLineNo; newLineNo; } else if (op.type del) { if (hunkBuffer.length 0) { hunkOldStart oldLineNo; hunkNewStart newLineNo; } hunkBuffer.push(div classline del span classnum oldLineNo /span span classnum /span span classsign-/span span classcontent escapeHtml(op.text) /span /div); hunkOldCount; oldLineNo; } else { if (hunkBuffer.length 0) { hunkOldStart oldLineNo; hunkNewStart newLineNo; } hunkBuffer.push(div classline add span classnum /span span classnum newLineNo /span span classsign/span span classcontent 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 /html4. 实际使用中踩过的坑和解决办法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\nLinux或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我会在渲染后的页面上按CtrlF搜索就能快速在所有变更片段之间跳转比在超长diff里滚动找红绿块快得多。这个页面后来还被同事要过去我们把textarea换成了文件上传对比两个版本的上传文件应用范围一下广了不少。本文还有配套的精品资源点击获取