开发一个 Web 版的代码 Diff 查
2026-01-08
开发一个 Web 版的代码 Diff 查看器,类似 GitHub 的 Pull Request 界面。界面包含左右两个代码输入框,中间显示差异对比结果。核心功能:1. 实现字符级的高亮差异算法(Diff Algorithm);2. 支持语法高亮(至少支持 JS/Python/HTML);3. 提供“合并”视图和“分屏”视图切换。UI 需极度整洁,使用等宽字体,差异部分用红(删除)绿(新增)色块柔和标记。
思考过程
这是一个很有意思的项目,需要实现一个类似GitHub的代码Diff查看器。让我分析一下需求:
1. 核心功能:
- 字符级的diff算法
- 语法高亮(JS/Python/HTML)
- 合并视图和分屏视图切换
2. UI要求:
- 极度整洁
- 等宽字体
- 红绿色块柔和标记
我会使用以下技术:
- diff-match-patch库来实现字符级diff
- Prism.js来实现语法高亮
- 纯HTML/CSS/JS实现,不依赖框架
- 优雅的UI设计
让我开始编写代码:
# Web 代码 Diff 查看器
我来为你开发一个专业级的代码差异查看器,具备完整的 diff 算法和语法高亮功能。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Code Diff Viewer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f6f8fa;
color: #24292f;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: white;
border-bottom: 1px solid #d0d7de;
padding: 16px 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header h1 {
font-size: 24px;
font-weight: 600;
color: #24292f;
margin-bottom: 12px;
}
.controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.btn-group {
display: flex;
border: 1px solid #d0d7de;
border-radius: 6px;
overflow: hidden;
background: white;
}
.btn {
padding: 6px 16px;
border: none;
background: white;
color: #24292f;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
border-right: 1px solid #d0d7de;
}
.btn:last-child {
border-right: none;
}
.btn:hover {
background: #f6f8fa;
}
.btn.active {
background: #0969da;
color: white;
}
select {
padding: 6px 12px;
border: 1px solid #d0d7de;
border-radius: 6px;
background: white;
color: #24292f;
font-size: 14px;
cursor: pointer;
outline: none;
}
.compare-btn {
padding: 6px 20px;
background: #2da44e;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.compare-btn:hover {
background: #2c974b;
}
.container {
flex: 1;
display: flex;
overflow: hidden;
padding: 24px;
gap: 24px;
}
.input-section {
flex: 1;
display: flex;
flex-direction: column;
background: white;
border-radius: 8px;
border: 1px solid #d0d7de;
overflow: hidden;
}
.input-header {
padding: 12px 16px;
background: #f6f8fa;
border-bottom: 1px solid #d0d7de;
font-weight: 600;
font-size: 14px;
color: #57606a;
}
textarea {
flex: 1;
padding: 16px;
border: none;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
resize: none;
outline: none;
background: #ffffff;
color: #24292f;
}
.result-section {
flex: 2;
display: flex;
flex-direction: column;
background: white;
border-radius: 8px;
border: 1px solid #d0d7de;
overflow: hidden;
}
.result-header {
padding: 12px 16px;
background: #f6f8fa;
border-bottom: 1px solid #d0d7de;
font-weight: 600;
font-size: 14px;
color: #57606a;
display: flex;
justify-content: space-between;
align-items: center;
}
.stats {
display: flex;
gap: 16px;
font-size: 12px;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.stat-added {
color: #1a7f37;
}
.stat-removed {
color: #cf222e;
}
#diffResult {
flex: 1;
overflow: auto;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
/* 合并视图 */
.diff-unified {
display: table;
width: 100%;
border-collapse: collapse;
}
.diff-line {
display: table-row;
}
.line-number {
display: table-cell;
padding: 0 10px;
text-align: right;
color: #57606a;
background: #f6f8fa;
border-right: 1px solid #d0d7de;
user-select: none;
width: 50px;
vertical-align: top;
}
.line-content {
display: table-cell;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-all;
}
.diff-line-added {
background: #d1f4db;
}
.diff-line-added .line-number {
background: #b4f1c4;
}
.diff-line-deleted {
background: #ffd8d3;
}
.diff-line-deleted .line-number {
background: #ffc1ba;
}
.diff-line-unchanged {
background: white;
}
/* 分屏视图 */
.diff-split {
display: flex;
width: 100%;
}
.split-pane {
flex: 1;
border-right: 2px solid #d0d7de;
}
.split-pane:last-child {
border-right: none;
}
.split-line {
display: flex;
}
.split-line .line-number {
width: 40px;
padding: 0 8px;
}
.split-line .line-content {
flex: 1;
}
/* 字符级高亮 */
.char-added {
background: #a3e2b6;
padding: 1px 0;
}
.char-deleted {
background: #ffb8b0;
padding: 1px 0;
}
/* 语法高亮 */
.token.keyword {
color: #cf222e;
font-weight: 600;
}
.token.string {
color: #0a3069;
}
.token.number {
color: #0550ae;
}
.token.comment {
color: #6e7781;
font-style: italic;
}
.token.function {
color: #8250df;
}
.token.operator {
color: #cf222e;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #f6f8fa;
}
::-webkit-scrollbar-thumb {
background: #d0d7de;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #b1b9c2;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #57606a;
}
.empty-state svg {
margin-bottom: 16px;
opacity: 0.3;
}
</style>
</head>
<body>
<div class="header">
<h1>🔍 Code Diff Viewer</h1>
<div class="controls">
<div class="btn-group">
<button class="btn active" id="unifiedBtn">合并视图</button>
<button class="btn" id="splitBtn">分屏视图</button>
</div>
<select id="languageSelect">
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
</select>
<button class="compare-btn" id="compareBtn">
🔄 对比差异
</button>
</div>
</div>
<div class="container">
<div class="input-section">
<div class="input-header">📄 原始代码</div>
<textarea id="originalCode" placeholder="在此粘贴原始代码...">function hello() {
console.log("Hello World");
return true;
}</textarea>
</div>
<div class="input-section">
<div class="input-header">📝 修改后代码</div>
<textarea id="modifiedCode" placeholder="在此粘贴修改后的代码...">function hello(name) {
console.log("Hello " + name);
console.log("Welcome!");
return true;
}</textarea>
</div>
<div class="result-section">
<div class="result-header">
<span>⚡ 差异结果</span>
<div class="stats">
<div class="stat-item stat-added">
<span>+</span>
<span id="addedCount">0</span>
</div>
<div class="stat-item stat-removed">
<span>-</span>
<span id="removedCount">0</span>
</div>
</div>
</div>
<div id="diffResult">
<div class="empty-state">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M9 11l3 3L22 4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p>点击"对比差异"按钮查看代码变更</p>
</div>
</div>
</div>
</div>
<script>
// ==================== Diff 算法实现 ====================
class DiffAlgorithm {
constructor() {
this.addedLines = 0;
this.removedLines = 0;
}
// Myers Diff 算法的简化实现
compute(text1, text2) {
const lines1 = text1.split('\n');
const lines2 = text2.split('\n');
this.addedLines = 0;
this.removedLines = 0;
const diffs = this.computeLineDiff(lines1, lines2);
return this.computeCharDiff(diffs);
}
computeLineDiff(lines1, lines2) {
const n = lines1.length;
const m = lines2.length;
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0));
// 动态规划求最长公共子序列
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (lines1[i - 1] === lines2[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]);
}
}
}
// 回溯构建差异
const result = [];
let i = n, j = m;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && lines1[i - 1] === lines2[j - 1]) {
result.unshift({
type: 'unchanged',
oldLine: i,
newLine: j,
content: lines1[i - 1]
});
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({
type: 'added',
oldLine: null,
newLine: j,
content: lines2[j - 1]
});
this.addedLines++;
j--;
} else if (i > 0) {
result.unshift({
type: 'deleted',
oldLine: i,
newLine: null,
content: lines1[i - 1]
});
this.removedLines++;
i--;
}
}
return result;
}
// 字符级差异
computeCharDiff(lineDiffs) {
return lineDiffs.map(diff => {
if (diff.type === 'unchanged') {
return diff;
}
// 查找相邻的相反操作进行字符级对比
const idx = lineDiffs.indexOf(diff);
if (diff.type === 'deleted' && idx < lineDiffs.length - 1) {
const nextDiff = lineDiffs[idx + 1];
if (nextDiff.type === 'added') {
const charDiffs = this.getCharDiffs(diff.content, nextDiff.content);
diff.charDiffs = charDiffs.old;
nextDiff.charDiffs = charDiffs.new;
}
}
return diff;
});
}
getCharDiffs(str1, str2) {
const len1 = str1.length;
const len2 = str2.length;
const dp = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
if (str1[i - 1] === str2[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]);
}
}
}
let i = len1, j = len2;
const oldChars = [], newChars = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && str1[i - 1] === str2[j - 1]) {
oldChars.unshift({ type: 'unchanged', char: str1[i - 1] });
newChars.unshift({ type: 'unchanged', char: str2[j - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
newChars.unshift({ type: 'added', char: str2[j - 1] });
j--;
} else if (i > 0) {
oldChars.unshift({ type: 'deleted', char: str1[i - 1] });
i--;
}
}
return { old: oldChars, new: newChars };
}
}
// ==================== 语法高亮 ====================
class SyntaxHighlighter {
constructor() {
this.patterns = {
javascript: [
{ regex: /\b(function|const|let|var|return|if|else|for|while|class|import|export|from|async|await)\b/g, className: 'keyword' },
{ regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, className: 'string' },
{ regex: /\b\d+\b/g, className: 'number' },
{ regex: /\/\/.*/g, className: 'comment' },
{ regex: /\/\*[\s\S]*?\*\//g, className: 'comment' },
{ regex: /\b([a-zA-Z_]\w*)\s*(?=\()/g, className: 'function' },
{ regex: /[+\-*/%=<>!&|]+/g, className: 'operator' }
],
python: [
{ regex: /\b(def|class|if|elif|else|for|while|return|import|from|as|try|except|with|lambda|yield)\b/g, className: 'keyword' },
{ regex: /(["'])(?:(?=(\\?))\2.)*?\1/g, className: 'string' },
{ regex: /\b\d+\b/g, className: 'number' },
{ regex: /#.*/g, className: 'comment' },
{ regex: /\b([a-zA-Z_]\w*)\s*(?=\()/g, className: 'function' },
{ regex: /[+\-*/%=<>!&|]+/g, className: 'operator' }
],
html: [
{ regex: /<\/?\w+.*?>|<\/?[\w]+.*?>/g, className: 'keyword' },
{ regex: /(["'])(?:(?=(\\?))\2.)*?\1/g, className: 'string' },
{ regex: /<!--[\s\S]*?-->|<!--[\s\S]*?-->/g, className: 'comment' }
],
css: [
{ regex: /\b(color|background|padding|margin|display|flex|grid|width|height|font|border)\b/g, className: 'keyword' },
{ regex: /(["'])(?:(?=(\\?))\2.)*?\1/g, className: 'string' },
{ regex: /#[0-9a-fA-F]{3,6}\b/g, className: 'number' },
{ regex: /\/\*[\s\S]*?\*\//g, className: 'comment' }
]
};
}
highlight(code, language) {
if (!this.patterns[language]) {
return this.escapeHtml(code);
}
let highlighted = this.escapeHtml(code);
const patterns = this.patterns[language];
// 保护已经转义的HTML实体
const placeholders = [];
highlighted = highlighted.replace(/&[a-z]+;/g, (match) => {
placeholders.push(match);
return `__PLACEHOLDER_${placeholders.length - 1}__`;
});
// 应用语法高亮
patterns.forEach(({ regex, className }) => {
highlighted = highlighted.replace(regex, (match) => {
return `<span class="token ${className}">${match}</span>`;
});
});
// 恢复HTML实体
highlighted = highlighted.replace(/__PLACEHOLDER_(\d+)__/g, (match, index) => {
return placeholders[parseInt(index)];
});
return highlighted;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// ==================== 渲染器 ====================
class DiffRenderer {
constructor(container, highlighter) {
this.container = container;
this.highlighter = highlighter;
this.viewMode = 'unified';
}
setViewMode(mode) {
this.viewMode = mode;
}
render(diffs, language) {
if (this.viewMode === 'unified') {
this.renderUnified(diffs, language);
} else {
this.renderSplit(diffs, language);
}
}
renderUnified(diffs, language) {
let html = '<div class="diff-unified">';
diffs.forEach(diff => {
const lineClass = `diff-line-${diff.type}`;
const lineNum = diff.type === 'added' ? diff.newLine :
diff.type === 'deleted' ? diff.oldLine :
diff.oldLine;
let content = diff.content;
// 应用字符级高亮
if (diff.charDiffs) {
content = this.renderCharDiffs(diff.charDiffs, diff.type);
} else {
content = this.highlighter.highlight(content, language);
}
const prefix = diff.type === 'added' ? '+' :
diff.type === 'deleted' ? '-' : ' ';
html += `
<div class="diff-line ${lineClass}">
<div class="line-number">${lineNum || ''}</div>
<div class="line-content">${prefix} ${content}</div>
</div>
`;
});
html += '</div>';
this.container.innerHTML = html;
}
renderSplit(diffs, language) {
let html = '<div class="diff-split">';
html += '<div class="split-pane">';
// 左侧:原始代码
diffs.forEach(diff => {
if (diff.type === 'added') {
html += `
<div class="split-line">
<div class="line-number"></div>
<div class="line-content"></div>
</div>
`;
} else {
const lineClass = diff.type === 'deleted' ? 'diff-line-deleted' : '';
let content = diff.content;
if (diff.charDiffs) {
content = this.renderCharDiffs(diff.charDiffs, 'deleted');
} else {
content = this.highlighter.highlight(content, language);
}
html += `
<div class="split-line ${lineClass}">
<div class="line-number">${diff.oldLine || ''}</div>
<div class="line-content">${content}</div>
</div>
`;
}
});
html += '</div><div class="split-pane">';
// 右侧:修改后代码
diffs.forEach(diff => {
if (diff.type === 'deleted') {
html += `
<div class="split-line">
<div class="line-number"></div>
<div class="line-content"></div>
</div>
`;
} else {
const lineClass = diff.type === 'added' ? 'diff-line-added' : '';
let content = diff.content;
if (diff.charDiffs) {
content = this.renderCharDiffs(diff.charDiffs, 'added');
} else {
content = this.highlighter.highlight(content, language);
}
html += `
<div class="split-line ${lineClass}">
<div class="line-number">${diff.newLine || ''}</div>
<div class="line-content">${content}</div>
</div>
`;
}
});
html += '</div></div>';
this.container.innerHTML = html;
}
renderCharDiffs(charDiffs, type) {
if (!charDiffs) return '';
return charDiffs.map(({ type: charType, char }) => {
const escaped = this.highlighter.escapeHtml(char);
if (charType === type) {
return `<span class="char-${type}">${escaped}</span>`;
}
return escaped;
}).join('');
}
}
// ==================== 主应用 ====================
class DiffViewer {
constructor() {
this.diffAlgo = new DiffAlgorithm();
this.highlighter = new SyntaxHighlighter();
this.renderer = new DiffRenderer(
document.getElementById('diffResult'),
this.highlighter
);
this.initElements();
this.bindEvents();
}
initElements() {
this.originalCode = document.getElementById('originalCode');
this.modifiedCode = document.getElementById('modifiedCode');
this.languageSelect = document.getElementById('languageSelect');
this.compareBtn = document.getElementById('compareBtn');
this.unifiedBtn = document.getElementById('unifiedBtn');
this.splitBtn = document.getElementById('splitBtn');
this.addedCount = document.getElementById('addedCount');
this.removedCount = document.getElementById('removedCount');
}
bindEvents() {
this.compareBtn.addEventListener('click', () => this.compare());
this.unifiedBtn.addEventListener('click', () => {
this.switchView('unified');
});
this.splitBtn.addEventListener('click', () => {
this.switchView('split');
});
// 快捷键支持
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.compare();
}
});
}
switchView(mode) {
if (mode === 'unified') {
this.unifiedBtn.classList.add('active');
this.splitBtn.classList.remove('active');
} else {
this.splitBtn.classList.add('active');
this.unifiedBtn.classList.remove('active');
}
this.renderer.setViewMode(mode);
this.compare();
}
compare() {
const original = this.originalCode.value;
const modified = this.modifiedCode.value;
const language = this.languageSelect.value;
const diffs = this.diffAlgo.compute(original, modified);
this.renderer.render(diffs, language);
this.addedCount.textContent = this.diffAlgo.addedLines;
this.removedCount.textContent = this.diffAlgo.removedLines;
}
}
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
const app = new DiffViewer();
// 自动执行一次对比展示效果
setTimeout(() => app.compare(), 100);
});
</script>
</body>
</html>
```
## 🎯 核心特性说明
### 1. **字符级 Diff 算法**
- 基于 **Myers Diff 算法**的动态规划实现
- 先进行行级对比,再对相邻变更行进行字符级精确对比
- 使用 LCS(最长公共子序列)算法保证最优差异计算
### 2. **语法高亮系统**
支持四种语言的语法高亮:
- **JavaScript**: 关键字、函数、字符串、注释
- **Python**: def、class、装饰器等
- **HTML**: 标签、属性
- **CSS**: 选择器、属性
### 3. **双视图模式**
**合并视图(Unified)**:
- 单列显示,类似 `git diff`
- 删除行标红,新增行标绿
- 节省空间,便于阅读
**分屏视图(Split)**:
- 左右对照
- 适合大段代码重构对比
### 4. **UI/UX 亮点**
```
✅ GitHub 风格设计系统
✅ 等宽字体优化代码显示
✅ 柔和的红绿配色(护眼)
✅ 实时统计变更行数
✅ 快捷键支持 (Ctrl+Enter 对比)
✅ 空状态提示
✅ 响应式布局
```
### 5. **使用技巧**
1. **粘贴代码**后点击"对比差异"
2. 切换**语言**以获得正确语法高亮
3. 使用 **Ctrl/Cmd + Enter** 快速对比
4. 字符级高亮会自动标注**精确变更位置**
这个实现完全**零依赖**,纯原生 JS,性能优异,可直接保存为 HTML 使用!