广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何使用jQuery实现代码差异对比编辑器并集成语法高亮?

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

如何使用jQuery实现代码差异对比编辑器并集成语法高亮?

行级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字符串。不过对于大多数代码评审和配置对比场景,行数通常不会超过几千行,本文的实现已经足够稳定。

广告位 · 文章底部(后台-模块管理替换此区域)