简介:一个基于 Web 技术的轻量级 Git diff 可视化工具,面向需要快速查看代码差异的开发者、前端学习者,以及希望摆脱命令行操作的临时用户,无需掌握 Git 命令即可使用。它用浏览器界面还原了 Git 版本控制系统中 diff 的核心功能,无需安装 Git 或配置任何环境依赖,下载解压后双击入口页面,就能在本地浏览器中直接查看文件版本之间的差异内容,整个过程不依赖外部服务器。压缩包共 4 个文件,入口 HTML 负责页面结构与模块加载,2 个 JavaScript 脚本承担 diff 数据解析、差异高亮和展开折叠等交互逻辑,1 个 CSS 样式表控制整体视觉呈现;整包仅 31KB,结构十分轻量,目录结构一目了然,便于阅读和二次修改。目前已有 203 人学习下载。通过源码可以学习到前端解析 diff 数据、实现差异视图的完整思路,也可直接把它当作独立小工具,用于日常代码审阅、教学演示或轻量变更比对,并在此基础上继续扩展更多 Git 能力。 我一直觉得,对比文本差异是日常开发里最频繁的小操作之一。改完配置、换了构建产物、或者同事丢过来一段改版后的代码,第一反应就是拿git diff看一眼。但真要让别人也用起来,命令行就不是那么友好了——尤其当你只是临时想对比两段文本,并不想初始化一个 git 仓库的时候。
所以我就花了一个下午,用纯前端写了一个极简的 Web 版 diff 页面。没接后端、没上框架、没有一大堆依赖,就是一个 HTML 文件加一小段逻辑,打开就能用:左边贴旧文本,右边贴新文本,点一下按钮,增删改动一目了然。这个需求看起来简单,真正动手做的时候,有几个细节还是值得聊一聊的。这篇文章就记录一下我当时是怎么拆需求、选方案、写核心逻辑,以及踩过的几个坑。
1. 需求拆解与方案选型
1.1 先搞清楚“简易”到底要支持到什么程度
很多工具一上来就想做重,结果做一半就烂尾了。我这个页面从一开始就只给自己定了四条需求:
- 页面打开就能用,不需要部署、不需要登录、不需要联网。
- 支持粘贴两段文本,分别放在旧文本和新文本两个输入框中。
- 点击“对比”按钮后,在页面下方展示左右两栏的差异视图。
- 差异高亮到行级别,增、删、改能一眼看出来。
注意,这里刻意没有做“相同内容自动隐藏”“修改前后内容对照”“直接替换到目标文本”这类进阶功能,因为最简单的版本必须先跑通,做得太多反而让核心链路不清晰。你只需要明确一点:你是在做一个对比工具,不是一个代码编辑器,功能边界越清楚,实现就越简单。
1.2 为什么选纯前端而不是后端方案
最初我犹豫过一个方案:让用户上传两个文件到后端,后端调用git diff --no-index或者 Python 的difflib,再把结果吐回前端渲染。
这个方案实现起来确实快,但有几个短板很明显:
- 需要搭服务、开端口、处理上传,用户的使用门槛变高了。
- 文件内容属于临时数据,越少经过服务器越安全,纯前端处理完即丢,不进内存不留缓存。
- 完全离线可用,局域网内拷一个文件过去就能用,这在某些内网环境里是刚需。
所以我最终选择了用原生 HTML + JavaScript 实现,连构建工具都没用。整个页面只有一个index.html,双击就能在浏览器里打开。这个选择也决定了后端那些花哨能力——比如目录级别的对比、文件系统监听——都不在这个版本考虑范围内。
1.3 Diff 算法:用现成库还是手写 LCS
这是整个项目里唯一让我纠结了十分钟的技术选型。
提到 diff,最经典的是 LCS(最长公共子序列)算法,传统教学里用动态规划就能解,但直接拿来生产环境会暴露两个问题:
- 空间复杂度是
O(m*n),对比两篇稍微长一点的文本,内存就膨胀得厉害。 - 只算 LCS 还不够,你还得自己构造“删除哪些行、插入哪些行、哪些行不变”,这个回溯过程很容易写出隐患。
所以我直接选择了成熟的开源库jsdiff(也叫diff),它内部使用的是经过优化的 Myers diff 算法,性能和可读性都有保障。对的,你没看错,这里不需要我重复造轮子。在做一个“简易工具”时,把算法这种高成本部分交给靠谱的依赖,把精力放在页面交互和渲染上,才是更合理的分工。
2. 页面布局与视觉方案
2.1 左右对照式布局的实现
页面主体我用了最常见的“上下输入 + 下方结果”的结构。一开始可以把旧文本和新文本的<textarea>并排放着,方便输入时对照;点击对比按钮后,结果区域会刷新成左右两栏。
结果区域的左右两栏,直接采用弹性布局:
<div id="diff-result"> <div class="diff-column" id="old-column"></div> <div class="diff-column" id="new-column"></div> </div>对应的样式我建议这样设置:
#diff-result { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid #ddd; } .diff-column { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 14px; line-height: 1.6; background: #fff; overflow: auto; max-height: 70vh; }为什么用 grid 而不是 flex?因为左右两栏必须严格等宽,grid 的1fr 1fr天然保证这一点,而 flex 需要额外处理flex: 1和宽度基准的问题,没必要自找麻烦。结果区域加上max-height和overflow: auto,这样对比长文本时页面本身不会无限变高,滚动只发生在结果容器内部。
2.2 差异高亮的颜色语义
颜色这块我直接参考了 git diff 在终端里的视觉习惯,这样看惯了命令行的同学不需要重新学习:
- 左侧旧文本中被删除的行:浅红色背景,行首加一个红色的减号。
- 右侧新文本中新增的行:浅绿色背景,行首加一个绿色的加号。
- 未变化的公共行:白色背景,不加标记。
控制高亮我推荐用行内样式或单独的 class。我在实现时给差异行加了三类 class:
.diff-line-added { background-color: #e6ffec; } .diff-line-removed { background-color: #ffebe9; } .diff-line-normal { background-color: #ffffff; }这里有个容易忽略的点:普通的 GitHub 风格 diff 还会对“修改行”专门画出字符级别的高亮,但在简易版里,我把“修改”直接拆成“删除旧行 + 新增新行”两个行为。这样处理视觉上非常直白,逻辑上也更简单——因为追踪字符级别变化意味着要做二次 diff,增加复杂度却不一定会提升这个简易工具的实用性。
2.3 行号与内容对齐的细节
行号是一个看起来不起眼、做起来很考验细节的部分。我的方案是:每一行渲染成一个<div>,内部用网格结构把行号区域和内容区域分开。
<div class="line-row"> <span class="line-number">12</span> <span class="line-content">const a = 1;</span> </div>样式如下:
.line-row { display: grid; grid-template-columns: 48px 1fr; align-items: start; min-height: 1.6em; } .line-number { text-align: right; padding-right: 8px; color: #999; user-select: none; background: #fafafa; border-right: 1px solid #eee; }user-select: none很重要,不然复制的文本会带着行号,非常烦人。行号的最小列宽固定在 48px,数字右对齐,这样超过三位数的行号也能对齐得很整齐。
3. 核心实现:从文本到 Diff 结果
3.1 引入 jsdiff 并理解它的返回结构
我这里的做法是直接通过 CDN 引入 jsdiff 的 UMD 包,没有走 npm 安装流程,因为目标就是一个单文件 HTML:
<script src="https://cdn.jsdelivr.net/npm/diff@5.1.0/dist/diff.min.js"></script>然后在业务代码里调用:
const changes = Diff.diffLines(oldText, newText);diffLines的返回值是一个数组,每个元素形如:
{ value: "const a = 1;\n", added: undefined, removed: undefined }- 如果只有
value,说明这一段是两边共有的内容。 - 如果
added为true,说明这一段只出现在新文本里。 - 如果
removed为true,说明这一段只出现在旧文本里。
注意一个常见误区:diffLines按行切割文本,但它返回的每个 change 块可能包含多行,所以不能直接把一个 change 当成一行渲染,需要先按换行符展开。这是我踩的第一个坑。
3.2 拆分差异块并渲染左右两栏
我的渲染策略分三步走:
- 把
changes展开成“行数组”:每行包含content、type(normal、add、remove)。 - 维护两个指针
leftIndex和rightIndex,遍历行数组,把normal和remove行放入左侧,把normal和add行放入右侧。 - 左右两侧的行一一对应,最后统一渲染。
核心逻辑大概是这样的:
function buildDiffRows(oldText, newText) { const changes = Diff.diffLines(oldText, newText); const leftRows = []; const rightRows = []; for (const change of changes) { const lines = change.value.split("\n"); // diffLines 返回的 value 末尾通常带换行符,split 后会产生一个空字符串 // 这里直接把最后的空串过滤掉,否则渲染时多出一个空行 const meaningfulLines = lines.filter((line, index) => index < lines.length - 1 || line !== ""); for (const line of meaningfulLines) { if (change.added) { rightRows.push({ content: line, type: "add" }); } else if (change.removed) { leftRows.push({ content: line, type: "remove" }); } else { leftRows.push({ content: line, type: "normal" }); rightRows.push({ content: line, type: "normal" }); } } } return { leftRows, rightRows }; }这么设计之后,左侧的remove行和右侧的add行天然挨在一起,视觉上正好对齐,不需要额外做复杂的位置映射。
3.3 渲染时的性能优化策略
数据量小的时候,直接innerHTML拼接字符串完全没问题。但是当文本到几千行级别时,一次性向 DOM 里塞几千个节点,浏览器明显会卡一下。
我实测下来,两个方案按性价比排序:
- 用
DocumentFragment批量插入:这是最轻量、改动最小的优化,性能提升非常明显。做法是先构建片段,再把片段一次性挂到结果容器上,避免频繁触发回流。
const fragment = document.createDocumentFragment(); for (const row of allRows) { const div = document.createElement("div"); div.className = "line-row " + row.type; // 往 div 里塞行号和内容 fragment.appendChild(div); } diffResult.appendChild(fragment);- 上虚拟滚动:如果文本规模到上万行,那确实需要考虑虚拟滚动,但这就超出了“简易”的范围了。我的建议是先把输入框限制在 500KB 以内,如果超出就弹提示引导用户分段对比。这不是懒,而是这个工具定位决定的。
另外不要忽视事件层面的优化:如果做了“用户输入时实时对比”的功能,一定要给textarea的输入事件加防抖,否则每敲一个字符都会触发一次 diff 计算,体验非常糟糕。我设置的是 300ms 的防抖延迟,实测够了。
4. 实现过程中遇到的坑与排查实录
4.1 换行符差异导致误报
我第一次渲染时发现,在 Windows 上创建的文件,粘贴进来后所有行都被标记为“删除+新增”,整个页面红绿交错,看起来非常夸张。
排查之后发现罪魁祸首是换行符:旧文本用的\r\n,新文本用的\n。diffLines在判断行变化时,会把\r的差异也算进去,所以看起来整段都变了。
解决办法是在对比前统一规范化换行符:
function normalizeText(text) { return text.replace(/\r\n/g, "\n"); }这个处理一定不能省,尤其工具是给别人用的时候,你永远不知道对方会从什么系统上复制文本过来。
4.2 diffLines 的尾部空行问题
另一个跟换行紧密相关的坑是:当文本末尾有换行符时,value.split("\n")总会多出一个空字符串元素。如果不处理,渲染结果里右侧会多出一行绿色空行,非常影响观感。
我在buildDiffRows里用filter过滤的方式处理了,但这里有个细节:如果一行就只有一个空字符串,说明这是真正的空行,应当被保留;如果lines数组里的最后一个空字符串,则是因为尾部换行符产生的,应该过滤掉。这两种情况千万不要混为一谈。我上面的代码里用的判断index < lines.length - 1 || line !== ""就同时兼容了这两种场景,你直接拿去用就行。
4.3 中文内容对比不准
有朋友反馈,对比两段中文文本时,明明只改了一个词,但整个段落都被标记为修改。
这个其实不是 bug,而是行级 diff 的天然限制。diffLines按“行”作为最小单位,如果一行里有任何字符发生变化,这一整行就会从旧文本中删除、再在新文本中原样新增。git diff里大家常看到的字符级高亮,是因为它在行级 diff 之后又对差异行做了一次字符级 diff,属于词级或字符级二次对比。
如果想改进,可以给差异行再加一层字符级处理。在简易版本里,我建议的做法是:当同一行的两个版本内容都比较短时(比如小于 200 个字符),调用Diff.diffChars做字符级对比,然后给变化的字符加一层更醒目的背景色。这个扩展值得做,但一定优先级排后,先把行级效果跑通再说。
4.4 大文本对比卡顿与内存飙升
一次我拿一份约 8000 行的日志做测试,页面直接卡了将近十秒,然后内存涨到 400 多 MB。深入排查后发现两个问题:
一是渲染前调用的normalizeText和split本身没问题,问题出在Diff.diffLines内部对每一行做 hash 计算时,8000 行规模下这个开销被放大了。
二是我第一次渲染采用了“先清空容器再逐一appendChild”的方式,每次插入都触发布局计算,性能自然拉胯。
实际解决方案是:
- 先对输入大小做前置校验,超过 500KB 直接给用户提示,不启动 diff。
- 使用
DocumentFragment批量插入。 - 把对比按钮的点击处理改成防抖 + 加载中提示。
这里也顺带分享一个经验:别急着用虚拟滚动,先看渲染流程是不是可以批量处理,很多时候性能问题并不是数据量大,而是 DOM 操作太频繁导致的。
4.5 复制文本时的行号干扰
早期版本我没有做user-select: none,实测发现用户复制右侧新文本时,会把左侧的行号一起复制进去,导致粘贴到别处的内容带入大量数字前缀,非常尴尬。
后来我不仅给行号加了user-select: none,还额外给结果区加了tabindex="-1",避免它在页面 Tab 键遍历时干扰正常键盘操作。这里提醒各位,凡是做文本对比工具,行号区域必须不可选中,这是基本功,不是锦上添花。
5. 更多可用的实用扩展思路
简易版本做出来后,我自己又加了几个小扩展,每个大概半小时以内就能搞定,但使用体验会提升不少。
第一个是“仅显示变化行”的开关。默认渲染全部行,当文本很长时,用户其实只想看差异区域。开启后,连续相同的行会被压缩成一行提示,比如“此处省略 120 行相同内容”,点击可以展开。这个功能对 5000 行以上的文档比对极其实用。
第二个是将结果导出为 HTML 或统一 diff 格式。我增加了一个“复制为统一 diff”按钮,可以直接生成类似git diff的文本:
--- old +++ new @@ -1,3 +1,4 @@这样即使对方不在线,你也可以把差异结果直接贴在评论里,沟通效率高很多。
第三个是支持拖拽文件到输入框。这个用 FileReader 就能实现,代码量不大,但解决了“从编辑器复制大段代码时偶尔格式错乱”的烦恼。真正做的时候别忘在拖入时做文件大小和类型检查,避免用户误拖入二进制文件直接卡死页面。
不过帮我评估了一下,如果你想做一个更完整的版本,接入 monaco editor 这样成熟的代码编辑器来替代 textarea 是一个大方向,但这也意味着页面复杂度和体积都会显著上升,是否要做,先问自己一句:用户的痛点是不是真的在于“编辑器不好用”?如果不是,别再往下做了。
最后聊一个实操体会
做完这个页面最大的感触是,简单的工具反而更容易被高频使用。因为没有任何安装门槛,后来团队里不少人都在用:前端对接口返回、运维看配置变更、测试同学核对不同版本的环境变量,都直接把这个 HTML 拷走了。
如果你也想在本地快速跑起来,直接把上面的代码块拼进一个index.html,用浏览器打开就行。建议一开始别加太多功能,先把“粘贴两段文本、看出增删”这条主链路走通,然后再根据自己的实际场景慢慢加。工具是给人用的,不是用来炫技的——能让人愿意用、用得顺手,比实现得多华丽重要得多。
本文还有配套的精品资源,点击获取