开发一个 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: /&lt;\/?\w+.*?&gt;|<\/?[\w]+.*?>/g, className: 'keyword' }, { regex: /(["'])(?:(?=(\\?))\2.)*?\1/g, className: 'string' }, { regex: /&lt;!--[\s\S]*?--&gt;|<!--[\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 使用!