我给自己写了一个本地文本差异比较器:改了哪里,一眼看清
目录
- 缘起:两份“差不多”的文本最难比较
- 需求拆解:差异必须能定位、能解释
- 技术选型:先用标准库解决行级比较
- 核心实现一:读取、规范化与行级差异
- 核心实现二:行内高亮与补丁导出
- UI 定制:左右同步比颜色更多更重要
- 三个让比较结果失真的坑
- 结尾:差异工具应该减少确认成本
1. 缘起:两份“差不多”的文本最难比较
完全不同的文件反而容易处理,最麻烦的是两份看起来几乎一样的文本:一份配置能运行,另一份却报错;两次接口响应只有一个字段变化;同事发回来的脚本只说“稍微改了几处”。
我以前通常这样处理:
- 肉眼把两个窗口并排,逐行寻找不同;
- 把文件临时放进 Git 仓库,再查看差异;
- 打开在线比较网站,把两段内容粘贴进去;
- 用编辑器插件对比,但不同电脑上的配置不一致。
Git 很强,但临时文本不一定属于某个仓库;在线工具很方便,可配置、日志和接口响应里可能包含敏感数据。我真正需要的是一个随手打开的本地比较器:左边放旧内容,右边放新内容,差异立即显示。
2. 需求拆解:差异必须能定位、能解释
“把不同的地方标红”并不足以描述需求。我把它拆成了几个明确目标:
| 诉求(人话) | 拆解后(可验收) |
|---|---|
| 看出哪些行变了 | 区分新增、删除和修改 |
| 一行里只改一个字也能看到 | 对相邻修改行做字符级高亮 |
| 两边位置对应 | 插入空白占位行,保持差异块对齐 |
| 长文件快速定位 | 提供差异列表和上一个/下一个按钮 |
| 忽略无意义变化 | 可选忽略行尾空格和空行 |
| 不上传内容 | 所有读取与比较都在本地完成 |
第一版只处理纯文本,不理解 JSON、XML 或代码语法。结构化比较能给出更聪明的结果,但也会引入不同解析规则。通用文本比较器最重要的是忠实展示实际字符差异。
心得:比较工具可以提供“忽略规则”,但绝不能偷偷替用户忽略内容。
3. 技术选型:先用标准库解决行级比较
程序使用 Python 和 tkinter,核心差异算法直接使用标准库 difflib:
| 能力 | 方案 |
|---|---|
| 行级差异 | difflib.SequenceMatcher |
| 补丁输出 | difflib.unified_diff |
| 文件读取 | pathlib |
| 编码检测 | BOM 判断 + 用户选择 |
| 桌面界面 | tkinter |
| 设置保存 | JSON |
SequenceMatcher 并不是所有场景下最快、最精确的差异算法,但对普通配置、代码片段和几千行文本已经足够。它还能返回相等、替换、删除和插入四类操作,正好用于构建左右对照视图。
我没有一开始就实现 Myers Diff。算法本身很值得研究,但这个工具的主要工作量其实在编码读取、视图对齐和滚动联动。先用成熟实现把完整流程做通,更符合小工具的目标。
4. 核心实现一:读取、规范化与行级差异
文件读取的第一步不是 read_text(),而是确定编码。工具会优先识别 UTF-8、UTF-8 BOM 和 UTF-16 BOM;无法确定时使用用户选择的编码,并在解码失败时明确报错,不使用静默替换字符。
读取成功后,统一把换行拆成逻辑行,同时保留原始行尾信息供状态栏提示:
def prepare_lines(text, ignore_trailing_space=False):
lines = text.splitlines()
if ignore_trailing_space:
return [line.rstrip() for line in lines]
return lines
行级比较的核心很短:
from difflib import SequenceMatcher
def compare_lines(left, right):
matcher = SequenceMatcher(None, left, right, autojunk=False)
return matcher.get_opcodes()
返回结果类似:
equal left[0:8] right[0:8]
replace left[8:10] right[8:11]
equal left[10:20] right[11:21]
delete left[20:22] right[21:21]
界面根据这些操作构建两侧显示行。遇到新增或删除时,另一侧插入占位行,让后面的相同内容重新对齐。否则只要一边多出一行,两栏从那里开始就会永久错位。
比较任务放到后台线程执行,并附带递增的任务编号。用户连续编辑文本时,旧任务即使晚于新任务完成,也不会覆盖最新结果。
心得:差异计算只是第一步,把结果稳定地映射到两栏视图才是真正影响体验的部分。
5. 核心实现二:行内高亮与补丁导出
如果一个修改块左右各有一行,就可以继续做字符级比较,找出这一行内部真正变化的位置。
def compare_chars(left_line, right_line):
matcher = SequenceMatcher(
None,
left_line,
right_line,
autojunk=False,
)
return [
(tag, i1, i2, j1, j2)
for tag, i1, i2, j1, j2 in matcher.get_opcodes()
if tag != "equal"
]
我没有对整个替换块中的每一行随意配对。左右行数不同或内容差距过大时,错误配对会产生大量毫无意义的字符高亮。只有位置相邻且相似度超过阈值的行,才启用行内比较;其他情况保持整行标记。
颜色规则保持克制:
| 差异类型 | 左侧 | 右侧 |
|---|---|---|
| 删除 | 浅红背景 | 空白占位 |
| 新增 | 空白占位 | 浅绿背景 |
| 修改 | 浅黄背景 | 浅黄背景 |
| 行内变化 | 深色强调 | 深色强调 |
除了双栏显示,工具还可以导出 unified diff:
from difflib import unified_diff
patch = unified_diff(
left_lines,
right_lines,
fromfile="before.txt",
tofile="after.txt",
lineterm="",
)
result = "\n".join(patch)
补丁文本适合粘贴到问题记录、代码评审或聊天中。导出时使用原始内容,而不是应用了“忽略空格”后的显示内容,避免生成无法准确复现变化的补丁。
6. UI 定制:左右同步比颜色更多更重要
界面主体是两个并排文本区,顶部提供打开文件、交换左右、重新比较和忽略规则。底部显示总行数、差异块数量、编码和行尾格式。
为了让两栏真正能对照,我做了几项联动:
- 任意一侧滚动时,另一侧滚到相同的显示行;
- 选中差异块后,两边同时定位并高亮;
- 横向滚动相互独立,避免长行把另一侧拖走;
- 编辑任意一侧后短暂防抖,再重新计算差异;
- 差异导航支持快捷键
F7和Shift + F7。
左侧和右侧始终显示独立的原始行号。由于插入了占位行,界面显示行号不能直接当作文件行号,必须为每一栏维护自己的映射,否则跳转到报错位置时会出现偏差。
我还加了一个“只看差异”模式,但不会直接删除相同内容,而是在差异块之间保留几行上下文。完全去掉上下文虽然更紧凑,却很难判断这处修改位于哪个配置段或函数中。
心得:双栏比较的核心不是两块文本框,而是它们在滚动、选择和行号上的准确对应。
7. 三个让比较结果失真的坑
坑一:换行格式不同会制造噪音。 Windows 常见 CRLF,Linux 常见 LF。如果按原始字节比较,每一行都可能被判断为变化。工具默认按逻辑行比较,并在状态栏单独提示两侧行尾格式不同;需要严格比较时,用户可以开启“包含行尾差异”。
坑二:自动忽略空格可能隐藏真实问题。 Python 缩进、Markdown 换行和某些配置格式中的空格都可能有语义。忽略行尾空格只能作为用户主动打开的选项,不能作为默认行为,更不能把行首空格一起删除。
坑三:超长单行会拖慢字符级比较。 压缩后的 JSON、构建产物或 Base64 内容可能一行就有几兆。对它们运行字符级 SequenceMatcher 会消耗大量时间和内存。程序检测到单行超过阈值后,只标记整行变化,并提示已跳过行内分析。
另一个容易忽略的细节是文件末尾换行。splitlines() 会让“最后一行有无换行”看起来相同,所以读取时需要额外记录文本是否以换行符结束,并把这个差异显示在文件信息区域。
8. 结尾:差异工具应该减少确认成本
写完这个工具后,我不再为了比较两小段配置临时创建目录,也不用把可能包含内部地址的内容贴进在线页面。打开工具、左右粘贴,变化会直接出现在对应位置。
一个顺手的文本比较器应该帮用户回答四个问题:
- 哪些内容被新增、删除或修改;
- 每个变化位于原文件和新文件的哪一行;
- 哪些差异是显示规则忽略的;
- 当前结果能否导出并交给别人复核。
它不需要理解所有编程语言,也不需要替代 Git。它只要在面对两份临时文本时,比肉眼更准确、比完整开发流程更轻便。
如果你也想写一个类似工具,可以先用 unified_diff 输出纯文本结果,再逐步加入左右对照和行内高亮。最早应该验证的是比较结果是否可信,而不是颜色是否漂亮。
差异越小,人工确认越容易出错;工具的意义,就是把“好像改了一点”变成清清楚楚的证据。
- 点赞
- 收藏
- 关注作者
评论(0)