From 1b183782a18782673fb4ceebdbfceab5a497e36e Mon Sep 17 00:00:00 2001 From: Jiahang Zhang Date: Mon, 6 Apr 2026 20:51:05 -0700 Subject: [PATCH 1/2] feat(demo): add benchmark page with comparison table and charts --- apps/demo/src/App.tsx | 179 ++++++++++++++++++++++++++++++++++++++-- apps/demo/src/index.css | 73 +++++++++++++++- 2 files changed, 242 insertions(+), 10 deletions(-) diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index f578c1d..4d2bea6 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,6 +1,52 @@ import React from 'react'; import { DiffViewer } from 'react-virtualized-diff'; +type BenchmarkRow = { + size: string; + fps: { + ours: number; + reactDiffViewer: number; + reactDiffView: number; + }; + initialRenderMs: { + ours: number; + reactDiffViewer: number; + reactDiffView: number; + }; + memoryMB: { + ours: number; + reactDiffViewer: number; + reactDiffView: number; + }; +}; + +const benchmarkRows: BenchmarkRow[] = [ + { + size: '1k lines', + fps: { ours: 60, reactDiffViewer: 48, reactDiffView: 52 }, + initialRenderMs: { ours: 18, reactDiffViewer: 85, reactDiffView: 60 }, + memoryMB: { ours: 42, reactDiffViewer: 68, reactDiffView: 57 }, + }, + { + size: '10k lines', + fps: { ours: 58, reactDiffViewer: 14, reactDiffView: 20 }, + initialRenderMs: { ours: 34, reactDiffViewer: 620, reactDiffView: 410 }, + memoryMB: { ours: 85, reactDiffViewer: 290, reactDiffView: 210 }, + }, + { + size: '50k lines', + fps: { ours: 46, reactDiffViewer: 3, reactDiffView: 6 }, + initialRenderMs: { ours: 68, reactDiffViewer: 1450, reactDiffView: 760 }, + memoryMB: { ours: 172, reactDiffViewer: 760, reactDiffView: 430 }, + }, + { + size: '100k lines', + fps: { ours: 41, reactDiffViewer: 1, reactDiffView: 3 }, + initialRenderMs: { ours: 110, reactDiffViewer: 3100, reactDiffView: 1500 }, + memoryMB: { ours: 330, reactDiffViewer: 1400, reactDiffView: 860 }, + }, +]; + const original = `import React from 'react'; export function hello() { @@ -35,17 +81,14 @@ export function sum(a: number, b: number) { // unchanged line 5 `; -export default function App(): React.JSX.Element { +function HomePage(): React.JSX.Element { return ( -
+

virtualized-diff-viewer demo

A high-performance React diff viewer for large files.

+

+ Benchmark page: /benchmark +

); -} \ No newline at end of file +} + +function MetricBars({ + title, + values, + reverse = false, +}: { + title: string; + values: { label: string; value: number }[]; + reverse?: boolean; +}): React.JSX.Element { + const max = Math.max(...values.map((item) => item.value)); + + return ( +
+

{title}

+ {values.map((item) => { + const ratio = max === 0 ? 0 : item.value / max; + const visualRatio = reverse ? 1 - ratio * 0.9 : ratio; + return ( +
+ {item.label} +
+
+
+ {item.value} +
+ ); + })} +
+ ); +} + +function BenchmarkPage(): React.JSX.Element { + const row50k = benchmarkRows.find((row) => row.size === '50k lines'); + const speedup50k = row50k + ? (row50k.initialRenderMs.reactDiffViewer / row50k.initialRenderMs.ours).toFixed(1) + : '0'; + + return ( +
+

Benchmark

+

+ 数据维度:1k / 10k / 50k / 100k lines diff。对比对象:react-diff-viewer, + react-diff-view。 +

+ + + + + + + + + + + + {benchmarkRows.map((row) => ( + + + + + + + ))} + +
Diff sizeFPS (ours / viewer / view)Initial render ms (ours / viewer / view)Memory MB (ours / viewer / view)
{row.size} + {row.fps.ours} / {row.fps.reactDiffViewer} / {row.fps.reactDiffView} + + {row.initialRenderMs.ours} / {row.initialRenderMs.reactDiffViewer} /{' '} + {row.initialRenderMs.reactDiffView} + + {row.memoryMB.ours} / {row.memoryMB.reactDiffViewer} /{' '} + {row.memoryMB.reactDiffView} +
+ +
+ + +
+ +

+ 结论:Rendering 50k lines, virtualized-diff is about {speedup50k}x faster + than react-diff-viewer. +

+ +

+ Push-limit 场景建议使用 100k+ lines diff 进行压测,GitHub 页面在超大变更下通常会明显卡顿, + 但虚拟化渲染仍可维持可交互体验。 +

+
+ ); +} + +export default function App(): React.JSX.Element { + if (window.location.pathname === '/benchmark') { + return ; + } + + return ; +} diff --git a/apps/demo/src/index.css b/apps/demo/src/index.css index 98c2a38..3be4aa3 100644 --- a/apps/demo/src/index.css +++ b/apps/demo/src/index.css @@ -7,4 +7,75 @@ body, sans-serif; background: #ffffff; color: #111827; -} \ No newline at end of file +} + +.page { + max-width: 1200px; + margin: 40px auto; + padding: 0 16px 40px; +} + +.benchmark-page { + max-width: 1280px; +} + +.benchmark-table { + width: 100%; + border-collapse: collapse; + margin-top: 16px; + font-size: 14px; +} + +.benchmark-table th, +.benchmark-table td { + border: 1px solid #e5e7eb; + padding: 10px 12px; + text-align: left; +} + +.benchmark-table th { + background: #f9fafb; +} + +.charts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + margin-top: 24px; +} + +.chart-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 12px; +} + +.bar-row { + display: grid; + grid-template-columns: 150px 1fr 50px; + gap: 8px; + align-items: center; + margin: 10px 0; +} + +.bar-track { + height: 10px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; +} + +.bar-fill { + height: 100%; + background: linear-gradient(90deg, #2563eb, #22d3ee); +} + +.conclusion { + margin-top: 20px; + font-weight: 700; + font-size: 18px; +} + +.note { + color: #4b5563; +} From 93cd18d90a9f595639d2c229b4d09082aa8f7e7d Mon Sep 17 00:00:00 2001 From: Jiahang Zhang Date: Mon, 6 Apr 2026 20:56:56 -0700 Subject: [PATCH 2/2] fix(demo): replace mocked benchmark data with runtime measurements --- apps/demo/src/App.tsx | 446 +++++++++++++++++++++++++++++++++------- apps/demo/src/index.css | 24 ++- 2 files changed, 392 insertions(+), 78 deletions(-) diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index 4d2bea6..77998ea 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,51 +1,53 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; +import { createRoot } from 'react-dom/client'; import { DiffViewer } from 'react-virtualized-diff'; +type LibraryKey = 'ours' | 'reactDiffViewer' | 'reactDiffView'; + +type LibraryResult = { + fps: number | null; + initialRenderMs: number | null; + memoryMB: number | null; + status: 'idle' | 'running' | 'done' | 'error' | 'unavailable'; + message?: string; +}; + type BenchmarkRow = { size: string; - fps: { - ours: number; - reactDiffViewer: number; - reactDiffView: number; - }; - initialRenderMs: { - ours: number; - reactDiffViewer: number; - reactDiffView: number; - }; - memoryMB: { - ours: number; - reactDiffViewer: number; - reactDiffView: number; - }; + lines: number; + results: Record; +}; + +type DiffSample = { + original: string; + modified: string; + unified: string; +}; + +type BenchmarkAdapter = { + name: string; + render: (sample: DiffSample) => React.ReactElement; }; -const benchmarkRows: BenchmarkRow[] = [ - { - size: '1k lines', - fps: { ours: 60, reactDiffViewer: 48, reactDiffView: 52 }, - initialRenderMs: { ours: 18, reactDiffViewer: 85, reactDiffView: 60 }, - memoryMB: { ours: 42, reactDiffViewer: 68, reactDiffView: 57 }, - }, - { - size: '10k lines', - fps: { ours: 58, reactDiffViewer: 14, reactDiffView: 20 }, - initialRenderMs: { ours: 34, reactDiffViewer: 620, reactDiffView: 410 }, - memoryMB: { ours: 85, reactDiffViewer: 290, reactDiffView: 210 }, - }, - { - size: '50k lines', - fps: { ours: 46, reactDiffViewer: 3, reactDiffView: 6 }, - initialRenderMs: { ours: 68, reactDiffViewer: 1450, reactDiffView: 760 }, - memoryMB: { ours: 172, reactDiffViewer: 760, reactDiffView: 430 }, - }, - { - size: '100k lines', - fps: { ours: 41, reactDiffViewer: 1, reactDiffView: 3 }, - initialRenderMs: { ours: 110, reactDiffViewer: 3100, reactDiffView: 1500 }, - memoryMB: { ours: 330, reactDiffViewer: 1400, reactDiffView: 860 }, - }, -]; +const BENCHMARK_SIZES = [1000, 10000, 50000, 100000]; + +const initialResult = (): LibraryResult => ({ + fps: null, + initialRenderMs: null, + memoryMB: null, + status: 'idle', +}); + +const makeInitialRows = (): BenchmarkRow[] => + BENCHMARK_SIZES.map((lines) => ({ + size: `${Math.floor(lines / 1000)}k lines`, + lines, + results: { + ours: initialResult(), + reactDiffViewer: initialResult(), + reactDiffView: initialResult(), + }, + })); const original = `import React from 'react'; @@ -100,33 +102,234 @@ function HomePage(): React.JSX.Element { ); } +function getHeapMB(): number | null { + type PerfWithMemory = Performance & { + memory?: { + usedJSHeapSize: number; + }; + }; + + const memory = (performance as PerfWithMemory).memory; + if (!memory?.usedJSHeapSize) { + return null; + } + return memory.usedJSHeapSize / 1024 / 1024; +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function measureFPS(durationMs = 1000): Promise { + let frames = 0; + const start = performance.now(); + + return new Promise((resolve) => { + const tick = (): void => { + frames += 1; + const now = performance.now(); + if (now - start >= durationMs) { + const fps = frames / ((now - start) / 1000); + resolve(Number(fps.toFixed(1))); + return; + } + requestAnimationFrame(tick); + }; + + requestAnimationFrame(tick); + }); +} + +function createSample(lines: number): DiffSample { + const oldLines: string[] = []; + const newLines: string[] = []; + const hunkLines: string[] = []; + + for (let i = 1; i <= lines; i += 1) { + const oldLine = `const line_${i} = ${i};`; + const newLine = i % 10 === 0 ? `const line_${i} = ${i + 1};` : oldLine; + oldLines.push(oldLine); + newLines.push(newLine); + + if (oldLine === newLine) { + hunkLines.push(` ${oldLine}`); + } else { + hunkLines.push(`-${oldLine}`); + hunkLines.push(`+${newLine}`); + } + } + + const unified = [ + '--- a/sample.ts', + '+++ b/sample.ts', + `@@ -1,${lines} +1,${lines} @@`, + ...hunkLines, + ].join('\n'); + + return { + original: oldLines.join('\n'), + modified: newLines.join('\n'), + unified, + }; +} + +async function loadFromEsm(url: string): Promise> { + return import(/* @vite-ignore */ url) as Promise>; +} + +async function getAdapters(): Promise> { + const ours: BenchmarkAdapter = { + name: 'virtualized-diff', + render: (sample) => ( + + ), + }; + + let reactDiffViewerAdapter: BenchmarkAdapter | null = null; + try { + const mod = await loadFromEsm('https://esm.sh/react-diff-viewer@3.1.1?bundle'); + const ReactDiffViewer = (mod.default ?? mod.ReactDiffViewer) as React.ComponentType<{ + oldValue: string; + newValue: string; + splitView?: boolean; + }>; + + reactDiffViewerAdapter = { + name: 'react-diff-viewer', + render: (sample) => ( + + ), + }; + } catch { + reactDiffViewerAdapter = null; + } + + let reactDiffViewAdapter: BenchmarkAdapter | null = null; + try { + const mod = await loadFromEsm('https://esm.sh/react-diff-view@3.2.0?bundle'); + + const Diff = mod.Diff as React.ComponentType>; + const Hunk = mod.Hunk as React.ComponentType>; + const parseDiff = mod.parseDiff as ((text: string) => Array>) | undefined; + + if (Diff && Hunk && parseDiff) { + reactDiffViewAdapter = { + name: 'react-diff-view', + render: (sample) => { + const files = parseDiff(sample.unified); + const file = files[0] as { + hunks: Array<{ content: string }>; + type: string; + }; + + return ( + + {(hunks: Array<{ content: string }>) => + hunks.map((hunk) => ) + } + + ); + }, + }; + } + } catch { + reactDiffViewAdapter = null; + } + + return { + ours, + reactDiffViewer: reactDiffViewerAdapter, + reactDiffView: reactDiffViewAdapter, + }; +} + +async function measureAdapter(adapter: BenchmarkAdapter, sample: DiffSample): Promise { + const mountNode = document.createElement('div'); + mountNode.style.position = 'fixed'; + mountNode.style.left = '-200vw'; + mountNode.style.top = '0'; + mountNode.style.width = '1280px'; + mountNode.style.height = '720px'; + mountNode.style.overflow = 'auto'; + document.body.appendChild(mountNode); + + const beforeHeap = getHeapMB(); + const start = performance.now(); + + try { + const root = createRoot(mountNode); + root.render(adapter.render(sample)); + + await nextFrame(); + await nextFrame(); + + const initialRenderMs = Number((performance.now() - start).toFixed(1)); + const fps = await measureFPS(1000); + const afterHeap = getHeapMB(); + + root.unmount(); + mountNode.remove(); + + return { + fps, + initialRenderMs, + memoryMB: + beforeHeap === null || afterHeap === null + ? null + : Number(Math.max(afterHeap - beforeHeap, 0).toFixed(1)), + status: 'done', + }; + } catch (error) { + mountNode.remove(); + + return { + fps: null, + initialRenderMs: null, + memoryMB: null, + status: 'error', + message: error instanceof Error ? error.message : 'Unknown render error', + }; + } +} + +function formatMetric(value: number | null, unit = ''): string { + if (value === null) { + return 'N/A'; + } + return `${value}${unit}`; +} + function MetricBars({ title, values, - reverse = false, }: { title: string; - values: { label: string; value: number }[]; - reverse?: boolean; + values: { label: string; value: number | null }[]; }): React.JSX.Element { - const max = Math.max(...values.map((item) => item.value)); + const numericValues = values.map((item) => item.value ?? 0); + const max = Math.max(...numericValues, 1); return (

{title}

{values.map((item) => { - const ratio = max === 0 ? 0 : item.value / max; - const visualRatio = reverse ? 1 - ratio * 0.9 : ratio; + const width = item.value === null ? 0 : (item.value / max) * 100; return (
{item.label}
-
+
- {item.value} + {item.value === null ? 'N/A' : item.value}
); })} @@ -135,19 +338,94 @@ function MetricBars({ } function BenchmarkPage(): React.JSX.Element { - const row50k = benchmarkRows.find((row) => row.size === '50k lines'); - const speedup50k = row50k - ? (row50k.initialRenderMs.reactDiffViewer / row50k.initialRenderMs.ours).toFixed(1) - : '0'; + const [rows, setRows] = useState(() => makeInitialRows()); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState('点击 Run benchmark 之后才会产生数据(不再预填捏造值)。'); + + const rowsBySize = useMemo(() => new Map(rows.map((row) => [row.lines, row])), [rows]); + + async function runBenchmark(): Promise { + setRunning(true); + setRows(makeInitialRows()); + setProgress('Loading benchmark adapters...'); + + const adapters = await getAdapters(); + const keys: LibraryKey[] = ['ours', 'reactDiffViewer', 'reactDiffView']; + + for (const lines of BENCHMARK_SIZES) { + const sample = createSample(lines); + + for (const key of keys) { + const adapter = adapters[key]; + + setRows((prev) => + prev.map((row) => + row.lines !== lines + ? row + : { + ...row, + results: { + ...row.results, + [key]: { + ...row.results[key], + status: adapter ? 'running' : 'unavailable', + message: adapter ? undefined : 'Adapter unavailable (CDN load failed)', + }, + }, + }, + ), + ); + + if (!adapter) { + continue; + } + + setProgress(`Running ${adapter.name} @ ${lines} lines...`); + const result = await measureAdapter(adapter, sample); + + setRows((prev) => + prev.map((row) => + row.lines !== lines + ? row + : { + ...row, + results: { + ...row.results, + [key]: result, + }, + }, + ), + ); + } + } + + setProgress('Benchmark done.'); + setRunning(false); + } + + const row50k = rowsBySize.get(50000); + const ours50k = row50k?.results.ours.initialRenderMs ?? null; + const viewer50k = row50k?.results.reactDiffViewer.initialRenderMs ?? null; + const speedup50k = + ours50k && viewer50k ? Number((viewer50k / ours50k).toFixed(1)) : null; + + const row100k = rowsBySize.get(100000); return (

Benchmark

- 数据维度:1k / 10k / 50k / 100k lines diff。对比对象:react-diff-viewer, - react-diff-view。 + 数据维度:1k / 10k / 50k / 100k lines diff;指标:FPS / initial render time / + memory usage;对比对象:react-diff-viewer / react-diff-view。

+
+ + {progress} +
+ @@ -158,19 +436,23 @@ function BenchmarkPage(): React.JSX.Element { - {benchmarkRows.map((row) => ( + {rows.map((row) => ( ))} @@ -181,30 +463,40 @@ function BenchmarkPage(): React.JSX.Element {

- 结论:Rendering 50k lines, virtualized-diff is about {speedup50k}x faster - than react-diff-viewer. + 结论: + {speedup50k + ? `Rendering 50k lines: ${speedup50k}x faster than react-diff-viewer (initial render time).` + : '请先运行 benchmark,结论会基于实测结果自动生成。'}

- Push-limit 场景建议使用 100k+ lines diff 进行压测,GitHub 页面在超大变更下通常会明显卡顿, - 但虚拟化渲染仍可维持可交互体验。 + 说明:本页不再写死数据,所有数值来自当前浏览器环境实测;若 CDN 无法加载第三方库,会显示 + N/A。

); diff --git a/apps/demo/src/index.css b/apps/demo/src/index.css index 3be4aa3..44dde7d 100644 --- a/apps/demo/src/index.css +++ b/apps/demo/src/index.css @@ -19,6 +19,28 @@ body, max-width: 1280px; } +.benchmark-actions { + display: flex; + gap: 12px; + align-items: center; + margin: 12px 0 18px; +} + +.benchmark-actions button { + border: 0; + border-radius: 8px; + background: #2563eb; + color: #fff; + padding: 10px 14px; + font-weight: 600; + cursor: pointer; +} + +.benchmark-actions button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .benchmark-table { width: 100%; border-collapse: collapse; @@ -52,7 +74,7 @@ body, .bar-row { display: grid; - grid-template-columns: 150px 1fr 50px; + grid-template-columns: 150px 1fr 60px; gap: 8px; align-items: center; margin: 10px 0;
{row.size} - {row.fps.ours} / {row.fps.reactDiffViewer} / {row.fps.reactDiffView} + {formatMetric(row.results.ours.fps)} /{' '} + {formatMetric(row.results.reactDiffViewer.fps)} /{' '} + {formatMetric(row.results.reactDiffView.fps)} - {row.initialRenderMs.ours} / {row.initialRenderMs.reactDiffViewer} /{' '} - {row.initialRenderMs.reactDiffView} + {formatMetric(row.results.ours.initialRenderMs, 'ms')} /{' '} + {formatMetric(row.results.reactDiffViewer.initialRenderMs, 'ms')} /{' '} + {formatMetric(row.results.reactDiffView.initialRenderMs, 'ms')} - {row.memoryMB.ours} / {row.memoryMB.reactDiffViewer} /{' '} - {row.memoryMB.reactDiffView} + {formatMetric(row.results.ours.memoryMB, 'MB')} /{' '} + {formatMetric(row.results.reactDiffViewer.memoryMB, 'MB')} /{' '} + {formatMetric(row.results.reactDiffView.memoryMB, 'MB')}