ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

纯前端实现类似 git diff 的文本对比工具:从 LCS 算法到性能优化

纯前端实现类似 git diff 的文本对比工具:从 LCS 算法到性能优化 简介这是一个基于Web技术实现的轻量级Git差异对比工具面向需要在浏览器中直观查看文件变更的前端开发者与Git入门学习者。相比命令行中的git diff该工具通过网页界面呈现差异内容降低使用门槛双击入口文件即可在本地运行无需安装额外软件。资源包共4个文件压缩后仅31KB结构非常精简。包含主页面HTML、两个JavaScript逻辑文件以及一个CSS样式文件其中JS负责解析diff数据并渲染高亮差异CSS控制页面布局与配色整体代码量小适合作为前端实操案例研读。目前已有203人学习下载。通过这套源码可以掌握diff2html库的典型用法理解如何将Git差异数据转化为可视化网页展示也能借鉴其文件组织方式快速搭建自己的轻量级工具页面。1. 一个“web 版 git diff”能解决什么从肉眼找差异到浏览器内计算如果你和我一样经常在发版前拿着新旧两个文件眯着眼在一堆行里找哪里变了那这个用 web 实现的简易 git diff 页面就是给你准备的。它不依赖 git 命令行也不需要任何后端服务打开一个网页把两份文本粘贴进去点一下按钮增删改的行就按 git diff 的习惯被标出来。这类工具真正要解决的不是“展示差异”而是“计算差异”本身。git diff 背后是一套成熟的差异算法网页里要从零复刻一个完整版没必要但拿 LCS 动态规划做一个足够简单、几百行文本内可用的版本完全可行。核心工作就两件把差异算出来再选一种不让人眼花的渲染方式。适合做它的人也很明确前端开发者要做个内部比对工具测试人员想快速核对配置文件或者文档维护者经常对比两个版本的 README。不需要会 git 内部原理能看懂 JavaScript 基础语法就能跟着走完。接下来从算法选型、最小页面、性能优化到踩坑记录一步步讲清楚。2. 做对三件事才能叫“类似 git diff”算法、输入归一化与渲染模型2.1 三条实现路线纯前端算法、后端 difflib、调系统 git diff标题里写“web 实现”落地路径其实有三条。第一条是纯前端所有计算都在浏览器里跑第二条是后端用 Python 的 difflib 生成 HTML 表格第三条是后端直接调系统里的 git diff拿 patch 文本自己解析。我给这套简易工具的定位是免安装、可分享、打开即用所以排在第一位的是纯前端。用后端 difflib 确实能省掉自己写 diff 算法但它的 HtmlDiff 生成的表格样式早已写死想改成 git diff 那种左右对照或分块折叠的阅读习惯反而要花更多力气去改模板。至于调系统 git diff --no-index拿到的是一段带 hunk 头的文本补丁你得自己解析行号、增减标记工作量并不比实现一个最小 diff 算法小还引入了“服务器上必须装 git”的额外依赖。下面这张对比表是我在选型时习惯列给自己看的供你参考。路线部署成本数据隐私渲染自由度适合场景纯前端算法零部署静态页面即可数据全程不出浏览器完全可控个人工具、内网轻量页面后端 difflib需要起 Python 服务文本会传服务器样式难定制快速原型、已有后端团队调系统 git diff需要服务且装 git文本会传服务器需解析 patch定制麻烦已依赖 git 的后端系统选纯前端还有一个隐藏好处敏感代码片段在本地内存里走一圈就没了不用担心粘进公共工具被记录。对简易场景来说这条理由往往比技术理由更打动人。2.2 输入归一化CRLF、编码和行尾空行怎么处理不少第一次写 web diff 的人把两份文本按 \n 拆成数组就去算 LCS结果发现旧文件每一行都被判成“修改”页面一片红绿。原因十有八九出在换行符上Windows 文本行尾是 \r\nLinux 和 macOS 是 \n直接在浏览器里做 split(\n)旧文件每行尾巴上挂着一个 \r新文件没有逐行比对自然全部失配。所以输入进 diff 算法之前必须先做一次归一化。这一步成本极低但决定了后面所有结果是否可信。我一般会先统一换行再处理末尾空行function normalize(text) { // 统一换行符Windows 的 \r\n 和旧 Mac 的 \r 都转成 \n return text.replace(/\r\n/g, \n) .replace(/\r/g, \n) // 去掉末尾换行避免 a\n 和 a 被拆成不同行数 .replace(/\n$/, ); } function splitLines(normalized) { const lines normalized.split(\n); // split 后末尾出现空串说明原文本以换行结尾这里统一丢弃 if (lines[lines.length - 1] ) lines.pop(); return lines; }参数说明normalize 里的两个 replace 分别覆盖 CRLF 和单独的 CR第三行去掉末尾换行是为了防止“以换行结尾”和“不以换行结尾”的同一份内容被误判成多了一行。splitLines 末尾的 pop 是配套动作如果你确实需要感知“文件末尾有没有空行”把 pop 注释掉即可。编码是另一层坑。浏览器里 textarea 的值一定是 UTF-8不需要处理但用 FileReader 拖入本地 GBK 文件时不显式传编码就会乱码。这个问题细节放到第 5 章排查部分展开。2.3 渲染模型左右两栏表格为什么比上下滚动省心git diff 命令的输出是上下块式没变的行照常排列改动块里先列带 - 的删除行再列带 的新增行。网页里要完全复刻这个阅读习惯需要处理块与块之间的折叠和跳转对“简易”定位来说成本偏高。更常见的做法是左右两栏对照左边旧文本右边新文本删掉的行在左边标红新增的行在右边标绿没变的行左右都显示。两栏表格的好处是天然按操作对齐。每个 diff 操作固定占一行不会出现“左边第 10 行对应右边第几行”这种对位问题。结构示意如下table classdiff-table tr classrow-commontdconst a 1;/tdtdconst a 1;/td/tr tr classrow-deletetdconst b 2;/tdtd/td/tr tr classrow-addtd/tdtdconst b 3;/td/tr /tablecommon 行左右相同delete 行右边留空add 行左边留空。人眼扫红色就是删了扫绿色就是加了不需要任何坐标换算。真正想要 git 原味的分块视图等这个两栏版本跑通后再加也不迟到时候核心的 ops 数据结构可以直接复用。3. 用纯前端把最小 diff 页面跑起来LCS 实现与表格渲染3.1 最小页面骨架两个文本框、一个按钮、一个结果容器先把页面骨架立起来。我给这类小工具的习惯是一个 HTML 文件内联样式脚本单独放一个 diff.js浏览器直接打开就能跑。完整骨架如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleWeb Diff/title style body { font-family: monospace; margin: 2rem; } .editor { display: flex; gap: 1rem; } textarea { width: 50%; height: 300px; font: inherit; } .diff-table { width: 100%; border-collapse: collapse; margin-top: 1rem; } .diff-table td { padding: 2px 8px; white-space: pre-wrap; vertical-align: top; } .row-common td { background: #fff; } .row-delete td:first-child { background: #ffe0e0; } .row-add td:last-child { background: #e0ffe0; } /style /head body div classeditor textarea idoldText placeholder旧文本/textarea textarea idnewText placeholder新文本/textarea /div button iddiffBtn开始 Diff/button div idresult/div script srcdiff.js/script /body /html这里有两个细节直接影响体验。字体用 monospace保证新旧文本逐行对齐时字符宽度一致td 上设置 white-space: pre-wrap行内多个连续空格不会被浏览器压缩成单个diff 结果里的缩进才能忠实还原。两个 textarea 各占一半宽度中间留 gap比较符合并排阅读的习惯。3.2 核心 diff 算法LCS 动态规划与回溯最小可用版本我选 LCS最长公共子序列动态规划。原理一句话dp[i][j] 表示旧文本前 i 行与新文本前 j 行的最长公共子序列长度两行内容相同就累加不同则取上方和左方的较大值。表填完之后从右下角往回走依次还原出 common、delete、add 三种操作。function buildLcsDp(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 1; i n; i) { for (let j 1; j m; j) { if (a[i - 1] b[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]); } } } return dp; }参数说明a 和 b 是 splitLines 输出的字符串数组数组元素比较用的是全等 所以第 2 章的归一化在这里起作用——行尾多一个 \r 或空格这一行就会被当成完全不同的内容。空间上这里存了完整的 (n1)*(m1) 二维表换来的是回溯逻辑的简单直接几百行文件内存占用可以忽略。回溯函数负责把 dp 表翻译成操作序列function buildOps(a, b, dp) { const ops []; let i a.length, j b.length; while (i 0 || j 0) { if (i 0 j 0 a[i - 1] b[j - 1]) { ops.unshift({ type: common, oldLine: a[i - 1], newLine: b[j - 1] }); i--; j--; } else if (j 0 (i 0 || dp[i][j - 1] dp[i - 1][j])) { ops.unshift({ type: add, newLine: b[j - 1] }); j--; } else { ops.unshift({ type: delete, oldLine: a[i - 1] }); i--; } } return ops; }回溯的优先级是能匹配相同行先走匹配匹配不了就看 dp 值往值更大的方向走j 方向代表新增i 方向代表删除。unshift 把后向前生成的节点逆序插到头部最终 ops 是正常从头到尾的顺序方便直接渲染。3.3 渲染与事件绑定把 ops 变成红色与绿色得到 ops 之后渲染就是机械活。每个操作映射成一个表格行common 行左右都填内容delete 行只填左列add 行只填右列。注意一点所有文本拼进 innerHTML 之前必须做 HTML 转义否则用户粘贴的内容里若包含
返回列表