简介:一个基于Web技术实现的简易Git diff可视化工具,帮助不想安装Git客户端或不熟悉命令行的开发者和学习者,在浏览器中直观比较文件的差异内容。整个zip包容量仅31KB,共4个文件,包含1个HTML页面入口、2个JavaScript逻辑脚本和1个CSS样式表,所有代码自包含,下载后双击HTML文件即能直接运行,无需搭建服务器或安装依赖。已有203人学习/下载,适合前端初学者作为小型实践项目研读。从实现中可以看到如何利用原生JavaScript解析和渲染diff数据,并完成差异高亮、折叠交互等界面功能,也能体会HTML、CSS、JS三者如何协作构建一个完整可用的Web工具,为后续扩展成更丰富的版本对比应用提供清晰的思路基础。
1. 一个 web 实现的非常简易的类似 git diff 的页面:它到底解决什么问题
git 每天都在用,git diff 的输出格式大家也熟——但真要在浏览器里做一个 web 实现的、类似 git diff 的页面,很多人第一步就卡住了:不是卡在界面怎么画,而是不知道「差异」这个事在代码层面怎么算出来。这个标题描述的东西,本质上是一个读两段文本、按行比对、再把增删改的结果画成易读页面的前端小工具,适合有文本对比需求、想给编辑器或后台系统加 diff 能力、或者单纯想搞懂 diff 底层原理的开发者。
一个常见的翻车过程是:先画好了红绿背景的表格,然后发现两栏行数对不上;等把行对齐了,又发现大文件一点就卡死。全文会沿着「算法 → 渲染 → 数据流 → 避坑 → 验证」的顺序,给出一份能直接保存成单页 HTML 跑起来的最小实现,并说明每个参数为什么这么设、什么时候该换方案。
2. 先把差异算明白:diff 算法选型与手写最小实现
2.1 行级 diff 的本质:LCS 与最短编辑脚本
两份文件按行拆成数组 a 和 b 之后,diff 的问题就变成:从 a 变到 b,最少需要几次删除和几次插入。这就是最短编辑脚本(SES)问题。它和最长公共子序列(LCS)是等价的——找出一条最长的不需要改动的行序列,剩下的行要么删除、要么插入。比如 a 是["a","b","c"],b 是["a","x","c"],LCS 是["a","c"],差异就是删掉"b"、插入"x"。
最直观的做法是动态规划:dp[i][j]表示 a 的前 i 行和 b 的前 j 行能构成的 LCS 长度。两层循环填表,最后从表格右下角回溯,就能得到完整的操作序列。这个方案时间复杂度和空间复杂度都是 O(n×m)。对几百行的配置文件来说毫无压力,但两个 5000 行的文件互相比较,格子数就是 2500 万,无论内存还是耗时都开始失控。
另一种做法是编辑图贪心回溯,也就是社区常说的基于 Myers 论文的那套思路:把比较过程看成在网格里从(0,0)走到(n,m),斜线代表两行相等,水平线和垂直线代表删除和插入,问题变成在网格里找一条尽量多走斜线的最短路径,复杂度约 O((n+m)×D),D 是实际差异的规模。
| 维度 | LCS 动态规划 | 编辑图贪心回溯 |
|---|---|---|
| 实现难度 | 低,回溯逻辑直观 | 中,需要理解贪心与路径记录 |
| 时间复杂度 | O(n×m) | O((n+m)×D),D 为差异行数 |
| 空间复杂度 | O(n×m),可用扁平数组压缩 | O(n+m),小得多 |
| 适合场景 | 单次比较 1500 行以内 | 上千行长文件、频繁比较 |
| 注意事项 | 行数大时内存涨得很快 | 行数少时常数略大 |
选型结论很简单:标题写的是「非常简易」,那就默认用 LCS 动态规划,把行数上限设好,超过上限再提示或换算法。下面先实现这个最稳的版本。
2.2 用 TypeScript 实现最小 diff 核心:DP 表与回溯
// diff-core.ts:计算 a 到 b 的行级操作序列 type DiffOp = | { type: "equal"; row: string } // 两文件都有的行 | { type: "delete"; row: string } // a 里有,b 里没有 | { type: "insert"; row: string }; // b 里有,a 里没有 function diffLines(a: string[], b: string[]): DiffOp[] { const n = a.length, m = b.length; // dp[i][j]:a[0..i) 与 b[0..j) 的 LCS 长度 // 倒序填表,回溯时从 (0,0) 顺着更大值方向走即可 const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0) ); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } // 回溯:从左上角走到右下角,每一步选“更优”的方向 const ops: DiffOp[] = []; let i = 0, j = 0; while (i < n && j < m) { if (a[i] === b[j]) { ops.push({ type: "equal", row: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: "delete", row: a[i] }); i++; } else { ops.push({ type: "insert", row: b[j] }); j++; } } // 处理剩余尾巴 while (i < n) ops.push({ type: "delete", row: a[i++] }); while (j < m) ops.push({ type: "insert", row: b[j++] }); return ops; }这段代码有几个参数值得说明。a 和 b 是字符串数组,行与行的比较用的是全等===,也就是说行尾多了个空格也算差异,后面会讲怎么加「忽略行尾空白」的选项。dp表倒序填写是因为回溯从(0,0)出发,查dp[i+1][j]和dp[i][j+1]时这两格已经算好。回溯里遇到两行相等时优先走 equal,这保证连续未修改的行会相邻出现,方便后续聚合成块。
这个实现的时间和空间都是 O(n×m),我在本地方便起见默认把 n 和 m 的乘积上限控制在 200 万左右,也就是两个数组各约 1500 行以内。超出这个量级,页面会出现可见卡顿,后面避坑章节会给具体处理办法。
2.3 从操作序列到编辑块:聚合规则与统一格式头
diffLines 返回的是线性操作序列,但直接按这个序列一行行渲染,页面会被大量连续未修改行撑得很长,而且「删除一段、插入一段」本来是一次修改动作的两面,分开画会让用户觉得是两次改动。所以一般做法是把操作序列聚合成编辑块:连续的 equal 行构成一个 equal 块,紧挨着的 delete 和 insert 行合并成一个 change 块。
// 编辑块:equal 为未修改段,change 为一次增删修改段 interface Block { type: "equal" | "change"; oldLines: string[]; // change 块里存删除行;equal 块里存原行 newLines: string[]; // change 块里存新增行;equal 块里存原行 oldStart: number; // 旧文件起始行号,从 1 开始 newStart: number; // 新文件起始行号,从 1 开始 } function toBlocks(ops: DiffOp[]): Block[] { const blocks: Block[] = []; let i = 0; let oldNo = 1, newNo = 1; while (i < ops.length) { if (ops[i].type === "equal") { const lines: string[] = []; while (i < ops.length && ops[i].type === "equal") { lines.push((ops[i] as any).row); i++; } blocks.push({ type: "equal", oldLines: [...lines], newLines: [...lines], oldStart: oldNo, newStart: newNo }); oldNo += lines.length; newNo += lines.length; } else { const oldLines: string[] = [], newLines: string[] = []; while (i < ops.length && ops[i].type !== "equal") { if (ops[i].type === "delete") oldLines.push((ops[i] as any).row); else newLines.push((ops[i] as any).row); i++; } blocks.push({ type: "change", oldLines, newLines, oldStart: oldNo, newStart: newNo }); oldNo += oldLines.length; newNo += newLines.length; } } return blocks; }聚合规则看起来简单,容易写错的是行号的累计:oldNo和newNo必须在每次消费操作序列后同步更新,change 块里删除行数量和新增加行数量往往不等,行号分别递增。这样每个块才能知道自己在两个文件里各自从第几行开始。
为什么我要强调先出操作序列、再聚合而不是直接扫一遍出块?因为后面对 change 块做词级 diff、以及生成@@ -l,n +l,m @@这样的统一格式头时,都要依赖块内行号和原始文本。比如拼 hunk 头:
function hunkHeader(b: Block): string { return `@@ -${b.oldStart},${b.oldLines.length} +${b.newStart},${b.newLines.length} @@`; }git 在行数为 1 时会省略逗号和数量,简易版可以不追求完全一致,拼出来的头用于展示已经足够。到这里「差异」已经有了明确的数据结构,下一步是把它画到页面上。
3. 把 diff 结果画出来:渲染层结构与三个细节
3.1 用两栏 table 而不是两个滚动面板:对齐是第一优先级
diff 页面的渲染方案常见有三种:单栏统一视图、左右双栏 table、左右两个独立滚动面板。真实踩坑经验是:两个独立面板看着自由,实际会死在「行对齐」上——左边第 100 行和右边第 100 行在视觉上永远差几个像素,滚动同步逻辑要一直调,最后一根筋搭错就错位。
| 方案 | 对齐质量 | 长文本阅读 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| 单栏统一视图 | 无左右对齐需求 | 好 | 低 | 看整体变更、复制结果 |
| 双栏 table 同 tr | 严格逐行对齐 | 中 | 低 | 逐行校对增删 |
| 双滚动面板 | 容易错位 | 好 | 高,需同步 scrollTop | 不推荐用在简易版 |
我一般会选双栏 table,核心思路是把一对「旧行 + 新行」放进同一个<tr>里,让浏览器天然保证左右两格在同一水平线。行号、内容、背景色都作为单元格分开处理。这个选择直接省掉了滚动同步的复杂度,也避免了两栏高度不一致的玄学问题。
3.2 DOM 结构与样式要点:行号、内容与背景色的分层
渲染单位是行,每一行渲染成四个单元格:旧行号、旧内容、新行号、新内容。结构如下:
<table class="diff-table"> <tbody id="diffBody"> <tr class="row-equal"> <td class="gutter old">12</td> <td class="content old">const x = 1;</td> <td class="gutter new">12</td> <td class="content new">const x = 1;</td> </tr> <tr class="row-change"> <td class="gutter old">13</td> <td class="content old">const x = 2;</td> <td class="gutter new">13</td> <td class="content new">const y = 3;</td> </tr> </tbody> </table>CSS 里有几个参数是反复试出来的。内容单元格必须设white-space: pre-wrap,否则行内连续空格和缩进会被浏览器折叠,看起来和源码对不上;word-break要设成break-word而不是break-all,长单词尽量不断行,diff 阅读体验会好很多。行号列固定宽度、右对齐、用等宽字体,背景色统一灰色,与内容区区分开。
table-layout不要设成 fixed,让两列内容自适应宽度;border-collapse: collapse去掉单元格缝隙,红绿背景才不会出现细白线。行号列建议用user-select: none,用户复制内容时不会被行号干扰——这个细节很多人做完才发现。
3.3 三个必须处理的渲染细节:修改块配对、词级高亮、首尾空行
修改块配对。change 块里删除行数和新增行数通常不一样。比如旧文件改了 3 行、新文件只有 1 行,渲染时按索引一一配对,多余的旧行右边补空单元格,多出来的新行左边补空单元格。规则是:oldLines[i]和newLines[i]成对,i超过另一边的长度时空的那一格用null表示。这个配对直接决定视觉上「改了这一行」的对应关系,不能简单地把删除行全部堆在上面、新增行全部堆在下面。
词级高亮。行级 diff 只能告诉你某一行变了,但用户往往想看到底改了行里的哪个词。做法是对每个 change 块里配对成功的两行再做一次字符级 diff:把字符串用Array.from按 Unicode 码点拆成字符数组,跑一遍和行级完全相同的 LCS 逻辑,得到字符操作序列,然后差异字符加深背景色。这个逻辑我会加一个阈值,单行长度超过 200 个字符就不做词级 diff,直接整行高亮——长行的字符级 DP 很慢,而且是肉眼根本看不出高亮的场景。
首尾空行。文本以换行符结尾时,split("\n")会多出一个空字符串元素。这个空行是文件结尾的换行符产生的,不是真实差异。如果两个文件一个有结尾换行、一个没有,不处理的话就会多出一行「删除空行」的假差异。处理方式是在读取阶段统一丢掉末尾空串,但注意开头和中间的空行是有意义的,不能去。
4. 串起完整数据流:从文件读取到页面展示
4.1 输入归一化:换行符、编码与末尾空行
页面拿到文件后,第一件事不是 diff,而是把文本归一化成「干净的行数组」。现实里的文本文件换行符五花八门:Windows 用\r\n、旧 Mac 用\r、Linux 用\n。直接按\nsplit,Windows 文件每行末尾都会残留一个\r,和 Linux 文件逐行比较时每一行都是差异。
// 读取文件并归一化为行数组 async function readLines(file) { const raw = await file.text(); // Blob.text() 按 UTF-8 解码 const text = raw.replace(/\r\n?/g, "\n"); // 统一换行符 let lines = text.split("\n"); if (lines[lines.length - 1] === "") { lines.pop(); // 丢掉末尾换行符产生的空串 } return lines; }file.text()是异步的,返回 Promise,调用处记得await。这个 API 默认按 UTF-8 解码,GBK 编码的中文文件会乱码,简易版先不管,真遇到再退回FileReader.readAsArrayBuffer自己解码。replace(/\r\n?/g, "\n")同时处理了\r\n和单独的\r,这一步不做,后面算法再对也没用。末尾空串的判断必须在 split 之后立刻做,因为 diff 算法会对每一行做全等比较,多一个空串就多一组假差异。
4.2 渲染模型:把 diff 结果整理成行数组
有了编辑块还不能直接渲染,因为块和行之间是一对多的关系。我习惯在渲染前加一层扁平化,把每个 block 展开成一行一行的渲染模型,每个字段都为 null 或字符串,渲染函数只认这一种结构,不关心 block 逻辑:
// 渲染行:null 表示该侧没有对应行 interface RenderRow { oldNo: number | null; newNo: number | null; oldText: string | null; newText: string | null; kind: "equal" | "change"; } function flattenBlocks(blocks: Block[]): RenderRow[] { const rows: RenderRow[] = []; for (const b of blocks) { const max = Math.max(b.oldLines.length, b.newLines.length); for (let k = 0; k < max; k++) { rows.push({ oldNo: k < b.oldLines.length ? b.oldStart + k : null, newNo: k < b.newLines.length ? b.newStart + k : null, oldText: k < b.oldLines.length ? b.oldLines[k] : null, newText: k < b.newLines.length ? b.newLines[k] : null, kind: b.type, }); } } return rows; }这个扁平化函数别小看,它把前面 block 里的行号、行内容全部翻译成渲染层需要的样子。比如 change 块旧文件有 3 行、新文件只有 1 行,展开后就是 3 个 RenderRow,第一个有旧行也有新行,后两个旧行有内容、新行全是 null。渲染函数拿到 null 就知道要补空单元格。
哪些字段是 null,直接决定了表格里哪些格子留白。渲染层永远不做「这个行该是删除还是新增」的判断,只认结构,这也是把 diff 算法和 DOM 操作解耦的关键一步。
4.3 最小页面:一份原生 HTML 跑通全部流程
下面是一份可以直接保存成.html文件、双击打开就能跑的最小实现,不需要构建工具、不需要引入任何依赖。算法部分就是把前面的diffLines用纯 JavaScript 重写一遍,注释保留了关键逻辑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>简易文本 Diff 页面</title> <style> body { font-family: "SF Mono", Consolas, monospace; margin: 20px; } .toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; } textarea { width: 100%; height: 120px; font-family: inherit; } .diff-table { width: 100%; border-collapse: collapse; table-layout: auto; } .diff-table td { padding: 2px 8px; vertical-align: top; white-space: pre-wrap; word-break: break-word; } .gutter { width: 44px; text-align: right; color: #888; background: #f6f8fa; user-select: none; } .row-equal td.content { background: #fff; } .row-change td.content.old { background: #ffeef0; } .row-change td.content.new { background: #e6ffec; } .hl-old { background: #ffd7d5; } .hl-new { background: #c8f7c5; } </style> </head> <body> <div class="toolbar"> <input type="file" id="fileOld" /> <input type="file" id="fileNew" /> </div> <textarea id="textOld" placeholder="旧文本…">alpha beta gamma</textarea> <textarea id="textNew" placeholder="新文本…">alpha beta delta</textarea> <button onclick="runDiff()">开始对比</button> <table class="diff-table"><tbody id="diffBody"></tbody></table> <script> // 1. 读取输入:优先文件,没选就用 textarea function getLines(id, file) { if (file) { return file.text().then(t => { t = t.replace(/\r\n?/g, "\n").replace(/^\uFEFF/, ""); // 去 BOM let lines = t.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); return lines; }); } const t = document.getElementById(id).value; const lines = t.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); return Promise.resolve(lines); } // 2. LCS 动态规划,回溯生成操作序列 function diffLines(a, b) { const n = a.length, m = b.length; const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); for (let i = n - 1; i >= 0; i--) { for (let j = m - 1; j >= 0; j--) { dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } const ops = []; let i = 0, j = 0; while (i < n && j < m) { if (a[i] === b[j]) { ops.push({ type: 0, row: a[i] }); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 1, row: a[i] }); i++; } else { ops.push({ type: 2, row: b[j] }); j++; } } while (i < n) ops.push({ type: 1, row: a[i++] }); while (j < m) ops.push({ type: 2, row: b[j++] }); return ops; } // 3. 聚合编辑块:type 0 为 equal,1 为 delete,2 为 insert function toBlocks(ops) { const blocks = []; let i = 0, oldNo = 1, newNo = 1; while (i < ops.length) { if (ops[i].type === 0) { const lines = []; while (i < ops.length && ops[i].type === 0) lines.push(ops[i++].row); blocks.push({ type: 0, oldLines: lines.slice(), newLines: lines.slice(), oldStart: oldNo, newStart: newNo }); oldNo += lines.length; newNo += lines.length; } else { const oldLines = [], newLines = []; while (i < ops.length && ops[i].type !== 0) { if (ops[i].type === 1) oldLines.push(ops[i].row); else newLines.push(ops[i].row); i++; } blocks.push({ type: 1, oldLines, newLines, oldStart: oldNo, newStart: newNo }); oldNo += oldLines.length; newNo += newLines.length; } } return blocks; } // 4. 渲染:把 block 展开成行,生成 HTML function render(blocks) { const body = document.getElementById("diffBody"); body.innerHTML = ""; for (const b of blocks) { const max = Math.max(b.oldLines.length, b.newLines.length); for (let k = 0; k < max; k++) { const tr = document.createElement("tr"); tr.className = b.type === 0 ? "row-equal" : "row-change"; const oldNo = k < b.oldLines.length ? b.oldStart + k : ""; const newNo = k < b.newLines.length ? b.newStart + k : ""; const oldText = k < b.oldLines.length ? b.oldLines[k] : ""; const newText = k < b.newLines.length ? b.newLines[k] : ""; tr.innerHTML = `<td class="gutter old">${oldNo}</td>` + `<td class="content old">${escapeHtml(oldText)}</td>` + `<td class="gutter new">${newNo}</td>` + `<td class="content new">${escapeHtml(newText)}</td>`; body.appendChild(tr); } } } function escapeHtml(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); } async function runDiff() { const oldFile = document.getElementById("fileOld").files[0]; const newFile = document.getElementById("fileNew").files[0]; const a = await getLines("textOld", oldFile); const b = await getLines("textNew", newFile); if (a.length * b.length > 2e6) { alert("行数过多,建议改用编辑图算法或拆分文件"); return; } render(toBlocks(diffLines(a, b))); } </script> </body> </html>这段代码注意几个参数:乘积阈值2e6是给 LCS 动态规划的兜底,两个 1500 行长文件相乘刚好到上限附近,再大就该提示用户了。text()读取的文本如果带 BOM,第一行第一列会有不可见字符,所以代码里用replace(/^\uFEFF/, "")去掉。escapeHtml不能省,用户粘贴的文本里出现<script>之类的标签时,直接插 innerHTML 会被浏览器解析掉。
所有逻辑都写在全局作用域里,对简易页面够用,但如果你打算把这个模块塞进现有工程,建议把diffLines、toBlocks、render拆成单独函数导出,方便加单元测试。先跑通这条链路,再谈优化。
5. 避坑指南:做 diff 页面最容易翻车的四个现场
5.1 大文件卡死:O(n×m) 内存与递归爆栈的玄学
现象:两个约 5000 行的日志文件一点「对比」,页面卡住几秒,然后浏览器弹出「无响应」。有些实现还会在回溯时用递归,直接爆栈,控制台报Maximum call stack size exceeded。
原因:LCS 动态规划的 dp 表是n个数组套m个数字,5000 乘 5000 就是 2500 万格。JavaScript 里每个数组都有对象开销,这个 dp 表轻松吃掉几百 MB 内存;同时双重循环占满主线程,页面自然卡死。
解决:第一道防线是行数乘积阈值,超过就明确提示而不是硬算;第二道防线是把 dp 表从二维数组换成Uint32Array扁平数组,内存立刻降到 10MB 量级;第三道防线是配合setTimeout分片计算,让浏览器在计算间隙有机会刷新。一个经验值是:单次比较超过 1500 行时,就该考虑换编辑图算法而不是继续优化 LCS 了。
5.2 假差异:\r\n 与 split 出来的空行
现象:同一个文件,从 Windows 编辑器里复制粘贴后,整个文件每一行都标红;或者两个内容完全一样的文件,末尾多出一行「删除空行」。
原因:Windows 文件的换行是\r\n,按\nsplit 后每行末尾挂着\r,和 Linux 风格的行做全等比较自然全部失败。末尾空行则是文件结尾换行符的产物:"a\n".split("\n")结果是["a", ""]。
解决:读取阶段统一执行replace(/\r\n?/g, "\n"),再 split;split 后如果最后一个元素是空串就 pop 掉。这两步必须放在 diff 之前,而且要在任何可能修改文本内容的逻辑之前做。还有一个附加习惯:把「忽略行尾空白」做成开关,默认关闭,因为打开后用户可能漏掉真实的行尾空格差异。
5.3 中文词级高亮乱切:按词还是按字
现象:只把「登录成功」改成「注册成功」,词级高亮把整行刷成高亮,看不出改的是前两个字还是后两个字。
原因:网上常见的词级 diff 实现按英文空格拆词,中文文本没有空格,整句被当成一个词。还有用split("")按 UTF-16 码元切的做法,遇到 emoji 会切出乱码,比如一个字符被拆成两段,高亮位置错乱。
解决:中文场景用Array.from(line)按 Unicode 码点切字符,然后字符级 LCS,这样「登录」到「注册」能精确标出前两个字符。注意字符级 DP 的耗时比行级高一个数量级,单行超过 200 字符就不做词级 diff,直接整行高亮。宁可牺牲精度,不能卡交互。
5.4 滚动同步错位:为什么我不用两个面板
现象:左右两个面板各自带滚动条,内容多的时候往下滚,右边已经滚到第 300 行,左边才到第 280 行,对着看到底哪一行是改动,眼睛都要看花。
原因:两个容器的内容高度不一致,行号、字体渲染、换行都可能差几个像素,任何微小的行高差异都会在滚动到几百行后被放大;硬做 scrollTop 比例同步,遇到字体加载前后行高不一致,立刻翻车。
解决:改用单表格双列同 tr,左右天然对齐,滚动同步问题从根上消失。如果确实需要左右独立滚动,有一个后悔药:在两个容器的 scroll 事件里按scrollTop / scrollHeight的比例同步,同时必须固定行高和字体,禁止异步字体加载后改变行高。但我的建议是,简易 diff 页面永远选同 tr 方案。
6. 离 git diff 再近一步:验证方法与进阶技巧
6.1 回归验证:拿 git diff 的输出当裁判
页面做完后,第一件事不是加功能,而是验证 diff 结果对不对。最可靠的对照方法是直接调用 git 的 diff 能力,比较两个文件而不依赖 git 仓库:
git diff --no-index --unified=0 old.txt new.txt--unified=0让 git 不输出上下文行,只输出真正的变更块。拿它的增删行数和行号范围,和你的页面输出对一遍:每个 hunk 的起始行号、删除行数量、新增行数量一致,基本可以确认算法和行号累计没错。这个验证我每次改完算法都会跑一遍,比肉眼瞪页面高效太多。
6.2 词级高亮参数怎么调
词级 diff 有两个参数值得调:单行长度阈值,默认 200 字符,文本行普遍偏短可以降到 100,日志类长文本建议提到 400;另一个是忽略空白字符,改成只对 trim 后的文本做字符比较,但显示时保留原始格式,这样「只是行尾多了个空格」的场景不会出现整个单词的高亮误报。
6.3 什么时候该换编辑图算法
行数一旦稳定超过 1500 行,LCS 的那套 DP 就该退位了。编辑图贪心回溯的复杂度是 O((n+m)×D),D 是真实差异行数,代码变更越少跑得越快,而 LCS 不管差异多少都要扫完全表。换了算法之后也不要急着在渲染层做虚拟滚动——先确认确实有用户会频繁比较几千行的文件,再把 diff 计算挪进 Web Worker,避免阻塞主线程。这两步是进阶方向,简易版用不到。
做这个页面的习惯我一直留着:先写算法、再写渲染,任何 diff 工具上线前都用git diff --no-index --unified=0做一次回归对照。行号、块边界、词级高亮这三处是最容易悄悄出错的地方,靠肉眼很难发现,只有拿标准工具对齐过,心里才踏实。希望帮到你。
本文还有配套的精品资源,点击获取