diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index f578c1d..77998ea 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,6 +1,54 @@ -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; + lines: number; + results: Record; +}; + +type DiffSample = { + original: string; + modified: string; + unified: string; +}; + +type BenchmarkAdapter = { + name: string; + render: (sample: DiffSample) => React.ReactElement; +}; + +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'; export function hello() { @@ -35,17 +83,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 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, +}: { + title: string; + values: { label: string; value: number | null }[]; +}): React.JSX.Element { + const numericValues = values.map((item) => item.value ?? 0); + const max = Math.max(...numericValues, 1); + + return ( +
+

{title}

+ {values.map((item) => { + const width = item.value === null ? 0 : (item.value / max) * 100; + return ( +
+ {item.label} +
+
+
+ {item.value === null ? 'N/A' : item.value} +
+ ); + })} +
+ ); +} + +function BenchmarkPage(): React.JSX.Element { + 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;指标:FPS / initial render time / + memory usage;对比对象:react-diff-viewer / react-diff-view。 +

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

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

+ +

+ 说明:本页不再写死数据,所有数值来自当前浏览器环境实测;若 CDN 无法加载第三方库,会显示 + N/A。 +

+
+ ); +} + +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..44dde7d 100644 --- a/apps/demo/src/index.css +++ b/apps/demo/src/index.css @@ -7,4 +7,97 @@ 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-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; + 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 60px; + 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; +}