代码对比编辑器在前端开发中并不少见,代码评审、配置文件变更检查、接口返回数据比对等场景都需要快速定位差异。如果不想引入CodeMirror、Monaco Editor这类体积较大的编辑器,使用jQuery加上轻量的LCS算法和Prism高亮库,同样可以做出一个结构清晰、易于定制的Diff工具。本文会从行级差异计算开始,逐步完成双栏渲染、语法高亮和同步滚动,最后给出完整初始化逻辑。

行级Diff的核心:LCS与回溯
两段代码的差异通常先按行进行比较。将旧文本和新文本分别分割成数组后,问题就转化成了求两个字符串数组的最长公共子序列。LCS的长度可以使用二维动态规划矩阵记录,矩阵中的每个单元格dp[i][j]表示旧文本前i行与新文本前j行的最长公共子序列长度。状态转移非常简单:如果两行完全相同,则取左上方单元格加一;否则取上方和左方单元格的较大值。
计算出完整矩阵后,还需要回溯路径来还原具体的操作序列。从矩阵右下角开始,遇到相等的行就记录为equal操作并向左上移动;如果上方值大于等于左方值,说明旧文本中这一行被删除,记录delete操作并向上移动;否则记录insert操作并向左移动。最后把剩余的行补成delete或insert。下面这段代码实现了完整的行级Diff计算,它返回一个操作数组,供后续渲染使用。
function computeDiff(oldLines, newLines) {
var m = oldLines.length;
var n = newLines.length;
var i, j;
var dp = [];
for (i = 0; i <= m; i++) {
dp[i] = [];
for (j = 0; j <= n; j++) {
dp[i][j] = 0;
}
}
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; 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]);
}
}
}
var result = [];
i = m;
j = n;
while (i > 0 && j > 0) {
if (oldLines[i - 1] === newLines[j - 1]) {
result.unshift({ type: 'equal', oldLine: oldLines[i - 1], newLine: newLines[j - 1], oldIndex: i - 1, newIndex: j - 1 });
i--;
j--;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
result.unshift({ type: 'delete', oldLine: oldLines[i - 1], newLine: null, oldIndex: i - 1, newIndex: null });
i--;
} else {
result.unshift({ type: 'insert', oldLine: null, newLine: newLines[j - 1], oldIndex: null, newIndex: j - 1 });
j--;
}
}
while (i > 0) {
result.unshift({ type: 'delete', oldLine: oldLines[i - 1], newLine: null, oldIndex: i - 1, newIndex: null });
i--;
}
while (j > 0) {
result.unshift({ type: 'insert', oldLine: null, newLine: newLines[j - 1], oldIndex: null, newIndex: j - 1 });
j--;
}
return result;
}
这段代码的时间复杂度是O(m*n),空间复杂度同样是O(m*n)。对于几百行以内的文件完全没有压力,但当文件超过几千行时,二维数组会占用较多内存,后续可以考虑使用Myers算法中的线性空间方案来替代。这里选择LCS而不是Myers,是因为它更容易理解和调试,也便于扩展字符级差异。
用jQuery渲染左右对比视图
拿到操作数组后,下一步是用jQuery构建左右两栏。页面结构可以分成两个pane,每个pane里按行追加行号与代码内容。为了让删除行、新增行和未变更行有不同背景色,需要给每一行设置相应的类名。需要注意的是,原始代码文本可能包含小于号、大于号或与HTML标签冲突的字符,如果直接使用html()方法插入会引发XSS或标签解析问题,因此普通文本必须用text()方法写入。
先定义左侧旧版本和右侧新版本两个容器,再通过遍历操作数组决定向哪一栏追加真实代码行,以及在哪一栏追加占位行。删除行只出现在左侧,插入行只出现在右侧,相等行两侧同时出现,这样才能保证滚动时行号与内容在视觉上对齐。下面是容器结构示例。
<div class="diff-container">
<div class="diff-pane">
<div class="diff-pane-header">旧版本</div>
<div class="diff-pane-body old-pane" data-pane="old"></div>
</div>
<div class="diff-pane">
<div class="diff-pane-header">新版本</div>
<div class="diff-pane-body new-pane" data-pane="new"></div>
</div>
</div>
渲染逻辑的具体代码也放在下面。appendRow函数接收容器、行号、代码内容、类型标记和是否使用HTML模式。对于equal和insert行,后续会传入经过Prism处理后的HTML;对于填充行和delete行,普通文本即可。使用jQuery创建元素时,我习惯传递一个属性对象,这样类名和文本可以分别设置,不容易混乱。
function appendFillerRow($container) {
$container.append($('<div></div>', { class: 'diff-row filler' }));
}
function appendRow($container, lineNo, content, cls, useHtml) {
var $row = $('<div></div>', { class: 'diff-row ' + cls });
var $lineNo = $('<span></span>', { class: 'line-no' }).text(lineNo);
var $code = $('<span></span>', { class: 'code-content' });
if (useHtml && content) {
$code.html(Prism.highlight(content, Prism.languages.javascript, 'javascript'));
} else {
$code.text(content || '');
}
$row.append($lineNo, $code);
$container.append($row);
}
function renderDiff(diff) {
var $oldPane = $('.old-pane').empty();
var $newPane = $('.new-pane').empty();
var oldLineNo = 1;
var newLineNo = 1;
$.each(diff, function(index, part) {
if (part.type === 'equal') {
appendRow($oldPane, oldLineNo, part.oldLine, 'equal', true);
appendRow($newPane, newLineNo, part.newLine, 'equal', true);
oldLineNo++;
newLineNo++;
} else if (part.type === 'delete') {
appendRow($oldPane, oldLineNo, part.oldLine, 'delete', false);
appendFillerRow($newPane);
oldLineNo++;
} else if (part.type === 'insert') {
appendFillerRow($oldPane);
appendRow($newPane, newLineNo, part.newLine, 'insert', true);
newLineNo++;
}
});
}
这里所有代码内容都用text()写入。等下一节加入语法高亮后,再对需要高亮的行使用html()方法。这样既能保留源代码中特殊的HTML字符,又不会破坏对比视图的结构。
集成Prism语法高亮与行内差异
Prism这类语法高亮库通常既可以对DOM元素直接高亮,也可以把文本转换成高亮后的HTML字符串。在Diff编辑器中,推荐使用Prism.highlight函数生成HTML,因为我们需要先把语法高亮的span结构与行级背景色叠加,再一次性插入对应容器。如果先插入普通文本再调用highlightElement,虽然也行,但在大量行场景下会产生多次DOM遍历,性能稍差。
在appendRow函数中增加useHtml参数后,对equal和insert行调用Prism.highlight(content, grammar, language),再把返回的HTML用html()写入代码区域。代码语言类型可以通过下拉框或自动检测得到。这里以JavaScript为例,实际使用中只要将换成对应的Prism grammar即可。调整后的代码已经包含在上一节的渲染函数中。
除了行级背景色,很多代码评审工具还会标记行内具体变化的字符。例如一行旧代码为let a = 1;,新代码为let a = 2;,行级diff只会把这两行标成删除和插入。要实现字符级高亮,可以在渲染时对相邻的删除行与插入行内容做一次字符LCS,然后给变化片段分别添加不同的行内颜色。字符LCS与行级LCS思路一致,只是比较单位从整行缩小到单个字符。
还需要处理一种边界情况:当删除行数比插入行数多时,不能简单一一对应。此时可按顺序逐组处理,比如先取第一对delete和insert做字符对比,多余的行保持整行底色。这样可以避免字符diff错配,也能让变更位置更直观。
同步滚动、样式与完整初始化
双栏对比时,两侧行数并不总是相同,例如左侧有删除行而右侧是空白填充行,但因为填充行保持了相同的高度,总行数是完全对齐的。这样一来,同步滚动只需要监听两个滚动容器,在其中一个滚动时把另一个的scrollTop设置为相同值即可。为了避免互相触发造成卡顿,代码里用syncing标志位做了一次简单的防抖。使用requestAnimationFrame可以确保滚动更新在下一帧统一执行,减少不必要的回流。
var $oldBody = $('.old-pane');
var $newBody = $('.new-pane');
var syncing = false;
$oldBody.on('scroll', function() {
if (!syncing) {
syncing = true;
requestAnimationFrame(function() {
$newBody.scrollTop($oldBody.scrollTop());
syncing = false;
});
}
});
$newBody.on('scroll', function() {
if (!syncing) {
syncing = true;
requestAnimationFrame(function() {
$oldBody.scrollTop($newBody.scrollTop());
syncing = false;
});
}
});
样式部分需要给delete、insert和equal三类行设置不同的背景色,同时让每一行使用flex布局,行号固定宽度,代码区域可横向扩展。这里给出一段简化CSS,你可以按自己的主题调整。
.diff-row { display: flex; font-family: monospace; }
.line-no { width: 40px; padding-right: 10px; text-align: right; user-select: none; color: #888; }
.code-content { white-space: pre; }
.delete { background: #ffeef0; }
.insert { background: #e6ffed; }
.equal { background: transparent; }
.filler { background: #fafafa; }
最后把前面的函数组合到一个初始化事件中。当用户点击对比按钮时,读取两个文本域的内容,分割行数组,计算diff,再调用renderDiff。对于空文本、末尾换行符以及Windows换行符\r\n,分割时可以统一使用正则处理。完整的绑定逻辑如下。
$('#compare-btn').on('click', function() {
var oldText = $('#old-input').val();
var newText = $('#new-input').val();
var oldLines = oldText.split(/\r?\n/);
var newLines = newText.split(/\r?\n/);
var diff = computeDiff(oldLines, newLines);
renderDiff(diff);
});
大文件场景下,LCS计算和渲染都可能导致页面短暂卡顿。除了替换为Myers算法或限制对比行数,还可以把渲染拆分成多个批次,每次处理若干行后让出主线程,或者只渲染可视区域的行,即虚拟滚动。如果语法高亮耗时明显,可以将Prism.highlight放入Web Worker中执行,再回传HTML字符串。不过对于大多数代码评审和配置对比场景,行数通常不会超过几千行,本文的实现已经足够稳定。

