feat: v0.4.0 差异计算 worker 化 - 大文件 diff 移入 Worker 后台计算不阻塞界面、输入去抖与过期任务丢弃(终止重建实现取消)、小输入同步快路径、无 Worker 环境回退、计算中遮罩与导出禁用

This commit is contained in:
2026-08-17 23:26:55 +08:00
parent 1d1b92e624
commit 1ecf5ae7d3
17 changed files with 472 additions and 29 deletions
+3 -1
View File
@@ -12,6 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(含去抖与过期任务丢弃),界面始终保持流畅响应
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角 - **交换左右侧**:一键互换两侧内容与视角
@@ -37,6 +38,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
| 构建 | electron-vite (Vite 5) | | 构建 | electron-vite (Vite 5) |
| 打包 | electron-builder | | 打包 | electron-builder |
| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff | | Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff |
| 后台计算 | Web WorkerVite 内联打包) |
| 编码 | iconv-lite | | 编码 | iconv-lite |
--- ---
@@ -93,7 +95,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.3.2 - 版本:0.4.0
--- ---
+4 -3
View File
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
> 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。 > 版本迭代**只允许修改 `y` 和 `z`**`x` 保持 `0` 不变。
当前基线版本:**`0.3.2`** 当前基线版本:**`0.4.0`**
--- ---
@@ -65,8 +65,9 @@ z = 补丁版本号(Patch
0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步 0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行 0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
0.3.2 ← 报告对齐与性能收尾(当前):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性 0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比) 0.4.0 ← 差异计算 worker 化(当前):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
``` ```
+18 -2
View File
@@ -47,15 +47,22 @@ src/renderer/src/
│ └── setup.ts # 全局 setupjest-dom 匹配器 + 自动 cleanup │ └── setup.ts # 全局 setupjest-dom 匹配器 + 自动 cleanup
├── diff/ ├── diff/
│ ├── diffEngine.test.ts # diff 引擎单元测试 │ ├── diffEngine.test.ts # diff 引擎单元测试
│ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖)
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
│ ├── report.test.ts # 报告生成测试 │ ├── report.test.ts # 报告生成测试
│ └── textUtils.test.ts # 文本扩展名与行数统计测试 │ └── textUtils.test.ts # 文本扩展名与行数统计测试
├── hooks/
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理)
│ ├── useDiff.ts
│ └── useDismiss.ts
├── components/ ├── components/
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出) │ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放) │ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗 │ ├── TextInputModal.test.tsx # 粘贴文本弹窗
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单) │ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单/计算中禁用
└── __tests__/ └── __tests__/
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错) ├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退
└── main.test.ts # 入口挂载冒烟测试 └── main.test.ts # 入口挂载冒烟测试
``` ```
@@ -98,6 +105,15 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换 - 交换左右侧后面板文件互换
- 大输入(超快路径阈值)在无 Worker 环境回退同步计算,无计算中遮罩
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
- 重路径:进入 computing 且 diff 置空;200ms 去抖后才派发 worker;响应按 jobId 匹配,错误/过期 jobId 被丢弃
- 输入变更:旧 worker 终止重建(等价取消);去抖期内变更则旧任务未派发即取消(零浪费计算)
- worker 不可用(环境缺失/工厂返回 null):主线程同步回退
- 卸载:终止 worker 并清理去抖定时器
- 工厂:无 Worker 环境返回 null;可用环境返回实例
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.3.2", "version": "0.4.0",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data:; worker-src 'self' blob:"
/> />
</head> </head>
<body> <body>
+32 -17
View File
@@ -7,8 +7,7 @@ import {
type ReactElement, type ReactElement,
type MouseEvent as ReactMouseEvent type MouseEvent as ReactMouseEvent
} from 'react' } from 'react'
import { computeDiff } from './diff/diffEngine' import type { DiffOptions, DiffSummary } from './diff/diffEngine'
import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine'
import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView' import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView'
import Toolbar from './components/Toolbar' import Toolbar from './components/Toolbar'
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu' import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
@@ -16,6 +15,7 @@ import TextInputModal from './components/TextInputModal'
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report' import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils' import { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
import { useDismiss } from './hooks/useDismiss' import { useDismiss } from './hooks/useDismiss'
import { useDiff } from './hooks/useDiff'
import type { PaneMeta } from './components/DiffView' import type { PaneMeta } from './components/DiffView'
import type { SideCell } from './diff/diffEngine' import type { SideCell } from './diff/diffEngine'
@@ -31,6 +31,9 @@ function stamp(): string {
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */ /** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
const MAX_FILE_SIZE = 10 * 1024 * 1024 const MAX_FILE_SIZE = 10 * 1024 * 1024
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
interface PaneState { interface PaneState {
meta: PaneMeta meta: PaneMeta
path: string path: string
@@ -125,11 +128,11 @@ export default function App(): ReactElement {
} }
}, []) }, [])
// 超多行预警:行数超过阈值时提示计算耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身 // 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面
const warnHeavyLines = useCallback( const warnHeavyLines = useCallback(
(text: string): void => { (text: string): void => {
const lines = countLines(text) const lines = countLines(text)
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),计算与首次加载可能耗时`) if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),差异将在后台计算,可能需要一些时间`)
}, },
[showToast] [showToast]
) )
@@ -205,28 +208,28 @@ export default function App(): ReactElement {
[showToast, warnHeavyLines] [showToast, warnHeavyLines]
) )
const diff: DiffResult = useMemo(() => { // diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options) const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
}, [paneL, paneR, options])
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff]) const rows = useMemo(() => diff?.rows ?? [], [diff])
const summary: DiffSummary = diff.summary const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows])
const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY
// “仅看差异”视图行集:过滤未更改行,在连续省略处插入折叠提示行(占一个虚拟行位) // “仅看差异”视图行集:过滤未更改行,在连续省略处插入折叠提示行(占一个虚拟行位)
const displayRows = useMemo(() => { const displayRows = useMemo(() => {
if (!onlyDiff) return diff.rows as DisplayItem[] if (!onlyDiff) return rows as DisplayItem[]
if (diff.rows.length === 0) return [] as DisplayItem[] if (rows.length === 0) return [] as DisplayItem[]
if (changedRows.length === 0) { if (changedRows.length === 0) {
const m: FoldMarker = { const m: FoldMarker = {
fold: true, fold: true,
skipped: diff.rows.length, skipped: rows.length,
note: '两文件内容完全一致,已折叠全部未更改行' note: '两文件内容完全一致,已折叠全部未更改行'
} }
return [m] return [m]
} }
const out: DisplayItem[] = [] const out: DisplayItem[] = []
let skipped = 0 let skipped = 0
for (const r of diff.rows) { for (const r of rows) {
if (r.isChanged) { if (r.isChanged) {
if (skipped > 0) { if (skipped > 0) {
out.push({ fold: true, skipped }) out.push({ fold: true, skipped })
@@ -239,7 +242,7 @@ export default function App(): ReactElement {
} }
if (skipped > 0) out.push({ fold: true, skipped }) if (skipped > 0) out.push({ fold: true, skipped })
return out return out
}, [diff, onlyDiff, changedRows.length]) }, [rows, onlyDiff, changedRows.length])
// 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新) // 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新)
const swapPanes = useCallback((): void => { const swapPanes = useCallback((): void => {
@@ -308,6 +311,8 @@ export default function App(): ReactElement {
const exportReport = useCallback( const exportReport = useCallback(
async (fmt: ReportFormat) => { async (fmt: ReportFormat) => {
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
if (!diff) return
const ctx: ReportContext = { const ctx: ReportContext = {
leftName: paneL?.meta.name ?? '', leftName: paneL?.meta.name ?? '',
rightName: paneR?.meta.name ?? '', rightName: paneR?.meta.name ?? '',
@@ -316,7 +321,7 @@ export default function App(): ReactElement {
leftPath: paneL?.path || undefined, leftPath: paneL?.path || undefined,
rightPath: paneR?.path || undefined rightPath: paneR?.path || undefined
} }
const content = buildReport(diff.rows, ctx, summary, fmt) const content = buildReport(rows, ctx, summary, fmt)
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}` const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
let res: { ok: boolean; path: string | null } let res: { ok: boolean; path: string | null }
try { try {
@@ -333,7 +338,7 @@ export default function App(): ReactElement {
}) })
} }
}, },
[diff, paneL, paneR, summary, showToast] [diff, rows, paneL, paneR, summary, showToast]
) )
const anyPane = paneL !== null || paneR !== null const anyPane = paneL !== null || paneR !== null
@@ -438,6 +443,7 @@ export default function App(): ReactElement {
navCount={changedRows.length} navCount={changedRows.length}
onNav={go} onNav={go}
onExport={(fmt) => void exportReport(fmt)} onExport={(fmt) => void exportReport(fmt)}
computing={computing}
onlyDiff={onlyDiff} onlyDiff={onlyDiff}
onOnlyDiffChange={setOnlyDiff} onOnlyDiffChange={setOnlyDiff}
onSwap={swapPanes} onSwap={swapPanes}
@@ -464,7 +470,9 @@ export default function App(): ReactElement {
</div> </div>
<div className="status-spacer" /> <div className="status-spacer" />
<div className="status-right"> <div className="status-right">
{summary.changedLines === 0 ? ( {computing ? (
<span className="eq-badge busy"></span>
) : summary.changedLines === 0 ? (
<span className="eq-badge same"></span> <span className="eq-badge same"></span>
) : ( ) : (
<span className="eq-badge diff"></span> <span className="eq-badge diff"></span>
@@ -472,6 +480,13 @@ export default function App(): ReactElement {
</div> </div>
</footer> </footer>
{computing && (
<div className="computing-overlay">
<span className="computing-ring" />
<span></span>
</div>
)}
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />} {menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
{pasteSide && ( {pasteSide && (
<TextInputModal <TextInputModal
+21
View File
@@ -361,6 +361,27 @@ describe('App - 提示与容错', () => {
}) })
}) })
describe('App - 大输入与 worker 回退', () => {
it('超过快路径阈值的文件在无 Worker 环境回退同步计算', async () => {
const big = Array.from({ length: 2100 }, (_, i) => `row${i}`).join('\n')
window.api = mockApi({
openFile: async () => ({
path: '/tmp/big.txt',
name: 'big.txt',
text: big,
encoding: 'UTF-8',
binary: false
})
})
render(<App />)
fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText('导出报告')).toBeInTheDocument()
// jsdom 无 Worker:同步回退完成计算,不出现计算中遮罩,行已渲染
expect(screen.queryByText('正在计算差异…')).not.toBeInTheDocument()
expect(screen.getAllByText(/row0/).length).toBeGreaterThan(0)
})
})
describe('App - 粘贴下拉交互', () => { describe('App - 粘贴下拉交互', () => {
it('按 Esc 关闭粘贴下拉', () => { it('按 Esc 关闭粘贴下拉', () => {
render(<App />) render(<App />)
+7 -1
View File
@@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine'
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 } const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
function setup() { function setup(extra?: { computing?: boolean }) {
const onOptionsChange = vi.fn() const onOptionsChange = vi.fn()
const onNav = vi.fn() const onNav = vi.fn()
const onExport = vi.fn() const onExport = vi.fn()
@@ -20,6 +20,7 @@ function setup() {
navCount={3} navCount={3}
onNav={onNav} onNav={onNav}
onExport={onExport} onExport={onExport}
computing={extra?.computing ?? false}
onlyDiff={false} onlyDiff={false}
onOnlyDiffChange={onOnlyDiffChange} onOnlyDiffChange={onOnlyDiffChange}
onSwap={onSwap} onSwap={onSwap}
@@ -100,4 +101,9 @@ describe('Toolbar', () => {
fireEvent.keyDown(document, { key: 'Escape' }) fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument() expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument()
}) })
it('后台计算中禁用导出按钮', () => {
setup({ computing: true })
expect(screen.getByText('导出报告')).toBeDisabled()
})
}) })
+9 -1
View File
@@ -17,6 +17,8 @@ interface ToolbarProps {
navCount: number navCount: number
onNav: (dir: 1 | -1) => void onNav: (dir: 1 | -1) => void
onExport: (fmt: ReportFormat) => void onExport: (fmt: ReportFormat) => void
/** 后台差异计算中(导出不可用) */
computing: boolean
/** 仅看差异视图开关 */ /** 仅看差异视图开关 */
onlyDiff: boolean onlyDiff: boolean
onOnlyDiffChange: (v: boolean) => void onOnlyDiffChange: (v: boolean) => void
@@ -32,6 +34,7 @@ export default function Toolbar({
navCount, navCount,
onNav, onNav,
onExport, onExport,
computing,
onlyDiff, onlyDiff,
onOnlyDiffChange, onOnlyDiffChange,
onSwap onSwap
@@ -113,7 +116,12 @@ export default function Toolbar({
</div> </div>
<div className="tool-group export-wrap" ref={exportWrapRef}> <div className="tool-group export-wrap" ref={exportWrapRef}>
<button className="btn primary" onClick={() => setExportOpen((o) => !o)}> <button
className="btn primary"
disabled={computing}
title={computing ? '正在后台计算差异' : undefined}
onClick={() => setExportOpen((o) => !o)}
>
</button> </button>
{exportOpen && ( {exportOpen && (
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { createDiffWorker } from './createDiffWorker'
describe('createDiffWorker', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('环境不支持 Worker 时返回 nulljsdom 回退路径)', () => {
expect(typeof Worker).toBe('undefined')
expect(createDiffWorker()).toBeNull()
})
it('环境支持时返回 worker 实例', () => {
const created: unknown[] = []
class FakeWorker {
constructor() {
created.push(this)
}
}
vi.stubGlobal('Worker', FakeWorker)
// jsdom 无 URL.createObjectURL,直接定义补桩(spyOn 要求属性已存在)
Object.defineProperty(URL, 'createObjectURL', {
value: (): string => 'blob:fake-worker',
configurable: true,
writable: true
})
try {
const w = createDiffWorker()
expect(w).not.toBeNull()
expect(created).toHaveLength(1)
} finally {
delete (URL as { createObjectURL?: unknown }).createObjectURL
}
})
})
+17
View File
@@ -0,0 +1,17 @@
import DiffWorker from './diffWorker?worker&inline'
export type DiffWorkerFactory = () => Worker | null
/**
* 创建 diff 计算 worker。
* 采用 Vite 内联打包(base64 blob),兼容生产环境 file:// 加载(index.html 的 CSP 已放行 worker-src blob:)。
* 环境不支持 Worker(如 jsdom 测试环境)或实例化失败时返回 null,由调用方回退主线程同步计算。
*/
export const createDiffWorker: DiffWorkerFactory = () => {
if (typeof Worker === 'undefined') return null
try {
return new DiffWorker()
} catch {
return null
}
}
+27
View File
@@ -0,0 +1,27 @@
import { computeDiff } from './diffEngine'
import type { DiffOptions, DiffResult } from './diffEngine'
/** worker 请求:jobId 用于匹配响应与丢弃过期任务(输入已变化时的旧计算结果) */
export interface DiffWorkerRequest {
jobId: number
leftText: string
rightText: string
options: DiffOptions
}
/** worker 响应:携带对应请求的 jobId */
export interface DiffWorkerResponse {
jobId: number
result: DiffResult
}
// worker 全局对象与页面 DOM 类型环境互斥,用结构化声明规避 lib 冲突
const ctx = self as unknown as {
addEventListener(type: 'message', cb: (e: MessageEvent<DiffWorkerRequest>) => void): void
postMessage(msg: DiffWorkerResponse): void
}
ctx.addEventListener('message', (e) => {
const { jobId, leftText, rightText, options } = e.data
ctx.postMessage({ jobId, result: computeDiff(leftText, rightText, options) })
})
+1 -1
View File
@@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean {
return TEXT_EXTENSIONS.includes(ext) return TEXT_EXTENSIONS.includes(ext)
} }
/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身 */ /** 超多行预警阈值:超过该行数时提示后台计算可能耗时(diff 计算 worker 化后不阻塞界面 */
export const HEAVY_LINES = 50000 export const HEAVY_LINES = 50000
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */ /** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
+174
View File
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDiff } from './useDiff'
import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine'
import type { DiffWorkerFactory } from '../diff/createDiffWorker'
/** 模拟 Worker:记录派发请求,可手动回传结果;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */
class FakeWorker {
onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null
terminated = false
posted: { jobId: number }[] = []
postMessage(data: { jobId: number }): void {
this.posted.push(data)
}
terminate(): void {
this.terminated = true
}
respond(jobId: number, result: DiffResult): void {
if (this.terminated) return
this.onmessage?.({ data: { jobId, result } })
}
}
/** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */
function makeFactory(): DiffWorkerFactory & { workers: FakeWorker[] } {
const workers: FakeWorker[] = []
const f = (() => {
const w = new FakeWorker()
workers.push(w)
return w as unknown as Worker
}) as DiffWorkerFactory & { workers: FakeWorker[] }
f.workers = workers
return f
}
function heavyText(lines: number): string {
return Array.from({ length: lines }, (_, i) => `L${i}`).join('\n')
}
// 注意:renderHook 回调内联对象(如 {})会在每次渲染生成新引用并触发 effect 双跑,
// 测试中必须将 left/right/options 提升为稳定引用(生产中 options 为 state,天然稳定)
const NO_OPTIONS: DiffOptions = {}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useDiff - 快路径', () => {
it('小输入同步计算,不创建 worker', () => {
const factory = vi.fn(makeFactory())
const { result } = renderHook(() => useDiff('a\nb', 'a\nc', NO_OPTIONS, factory))
expect(result.current.diff).not.toBeNull()
expect(result.current.diff!.summary.changedLines).toBe(1)
expect(result.current.computing).toBe(false)
expect(factory).not.toHaveBeenCalled()
})
it('结果与直接调用 computeDiff 一致', () => {
const { result } = renderHook(() => useDiff('x', 'y', { ignoreCase: true }))
expect(result.current.diff).toEqual(computeDiff('x', 'y', { ignoreCase: true }))
})
})
describe('useDiff - worker 后台计算', () => {
it('重输入先进入 computing,去抖后派发,回传后更新', () => {
const factory = makeFactory()
const left = heavyText(1100)
const right = heavyText(1100)
const { result } = renderHook(() => useDiff(left, right, NO_OPTIONS, factory))
expect(result.current.computing).toBe(true)
expect(result.current.diff).toBeNull()
// 去抖期内尚未派发
expect(factory.workers[0].posted).toHaveLength(0)
act(() => {
vi.advanceTimersByTime(250)
})
const w = factory.workers[0]
expect(w.posted).toHaveLength(1)
const jobId = w.posted[0].jobId
// 错误 jobId 的响应被丢弃
act(() => {
w.respond(999, computeDiff('a', 'a', {}))
})
expect(result.current.diff).toBeNull()
const res = computeDiff('p', 'q', {})
act(() => {
w.respond(jobId, res)
})
expect(result.current.diff).toEqual(res)
expect(result.current.computing).toBe(false)
})
it('输入变化后旧 worker 被终止且迟到响应被丢弃', () => {
const factory = makeFactory()
const left = heavyText(1100)
const right = heavyText(1100)
const hook = renderHook(
({ options }: { options: DiffOptions }) => useDiff(left, right, options, factory),
{ initialProps: { options: { ignoreCase: false } } }
)
act(() => {
vi.advanceTimersByTime(250)
})
const w1 = factory.workers[0]
const jobId1 = w1.posted[0].jobId
// 变更选项 → 新任务
hook.rerender({ options: { ignoreCase: true } })
expect(w1.terminated).toBe(true)
expect(hook.result.current.computing).toBe(true)
expect(hook.result.current.diff).toBeNull()
// 旧 worker 的迟到响应被丢弃
act(() => {
w1.respond(jobId1, computeDiff('a', 'a', {}))
})
expect(hook.result.current.diff).toBeNull()
// 新 worker 的结果生效
const w2 = factory.workers[1]
act(() => {
vi.advanceTimersByTime(250)
})
const jobId2 = w2.posted[0].jobId
expect(jobId2).toBeGreaterThan(jobId1)
const res2 = computeDiff('m', 'n', {})
act(() => {
w2.respond(jobId2, res2)
})
expect(hook.result.current.diff).toEqual(res2)
expect(hook.result.current.computing).toBe(false)
})
it('去抖期内输入变更:旧任务未派发即取消(零浪费计算)', () => {
const factory = makeFactory()
const hook = renderHook((p: string) => useDiff(p, '', NO_OPTIONS, factory), {
initialProps: heavyText(2100)
})
act(() => {
vi.advanceTimersByTime(100)
})
hook.rerender(heavyText(2300))
expect(factory.workers[0].posted).toHaveLength(0)
expect(factory.workers[0].terminated).toBe(true)
act(() => {
vi.advanceTimersByTime(300)
})
expect(factory.workers[1].posted).toHaveLength(1)
})
it('worker 不可用时回退主线程同步计算', () => {
const left = heavyText(2100)
// 工厂必须稳定引用:内联箭头函数每次渲染变引用会引发 effect 无限重跑
const nullFactory: DiffWorkerFactory = () => null
const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, nullFactory))
expect(result.current.diff).toEqual(computeDiff(left, 'x', {}))
expect(result.current.computing).toBe(false)
})
it('卸载时终止 worker 并清理去抖定时器', () => {
const factory = makeFactory()
const left = heavyText(2100)
const hook = renderHook(() => useDiff(left, '', NO_OPTIONS, factory))
const w = factory.workers[0]
hook.unmount()
expect(w.terminated).toBe(true)
// 卸载后去抖到期不再派发
act(() => {
vi.advanceTimersByTime(300)
})
expect(w.posted).toHaveLength(0)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { computeDiff, type DiffOptions, type DiffResult } from '../diff/diffEngine'
import { createDiffWorker, type DiffWorkerFactory } from '../diff/createDiffWorker'
import type { DiffWorkerResponse } from '../diff/diffWorker'
import { countLines } from '../diff/textUtils'
/** 快路径行数上限:两侧总行数不超过该值时在渲染线程同步计算(毫秒级,避免选项开关切换时闪烁加载态) */
const FAST_PATH_MAX_LINES = 2000
/** 重输入去抖:合并连续的选项/内容变更,超时后才派发 worker 计算(去抖期内取消 = 零浪费计算) */
const DEBOUNCE_MS = 200
export interface DiffState {
/** 差异结果;重输入计算期间为 null(配 computing 展示加载态) */
diff: DiffResult | null
/** 是否正在后台计算(仅重路径会出现 true) */
computing: boolean
}
/**
* worker 化的 diff 计算 hook。
* 小输入同步快路径(行为与 worker 化之前完全一致);
* 大输入去抖后派发 worker 后台计算,输入变化时终止旧 worker 重建(JS worker 无法中断计算,销毁重建即取消),
* 响应按 jobId 匹配丢弃过期任务;worker 不可用的环境回退主线程同步计算。
* workerFactory 仅测试注入用,生产调用保持缺省;注入时需保证引用稳定。
*/
export function useDiff(
left: string,
right: string,
options: DiffOptions,
workerFactory?: DiffWorkerFactory
): DiffState {
const heavy = useMemo(
() => countLines(left) + countLines(right) > FAST_PATH_MAX_LINES,
[left, right]
)
const factory = workerFactory ?? createDiffWorker
// 快路径:同步计算,渲染当帧即得结果
const fastDiff = useMemo(() => {
if (!heavy) return computeDiff(left, right, options)
return null
}, [left, right, options, heavy])
const [asyncDiff, setAsyncDiff] = useState<DiffResult | null>(null)
const [computing, setComputing] = useState(false)
const jobIdRef = useRef(0)
useEffect(() => {
if (!heavy) return
// 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异
setAsyncDiff(null)
const w = factory()
if (!w) {
// 无 Worker 支持(jsdom/老环境):主线程同步回退
setAsyncDiff(computeDiff(left, right, options))
setComputing(false)
return
}
const jobId = ++jobIdRef.current
setComputing(true)
const timer = window.setTimeout(() => {
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
// 丢弃过期任务:输入已变化时旧计算结果不再生效
if (e.data.jobId !== jobId) return
setAsyncDiff(e.data.result)
setComputing(false)
}
w.postMessage({ jobId, leftText: left, rightText: right, options })
}, DEBOUNCE_MS)
return () => {
window.clearTimeout(timer)
w.terminate()
}
}, [left, right, options, heavy, factory])
return heavy ? { diff: asyncDiff, computing } : { diff: fastDiff, computing: false }
}
+37
View File
@@ -89,6 +89,7 @@ button:disabled {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative;
} }
.app-header { .app-header {
@@ -739,4 +740,40 @@ button:disabled {
.eq-badge.diff { .eq-badge.diff {
color: var(--muted); color: var(--muted);
background: var(--surface); background: var(--surface);
}
.eq-badge.busy {
color: var(--accent-2);
background: rgba(34, 211, 238, 0.12);
}
/* ============ 后台计算遮罩 ============ */
.computing-overlay {
position: absolute;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(7, 11, 18, 0.55);
backdrop-filter: blur(2px);
color: var(--text);
font-size: 13px;
letter-spacing: 1px;
pointer-events: none;
user-select: none;
}
.computing-ring {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(148, 163, 255, 0.25);
border-top-color: var(--accent-2);
animation: computing-spin 0.8s linear infinite;
flex: none;
}
@keyframes computing-spin {
to {
transform: rotate(360deg);
}
} }
+7 -1
View File
@@ -19,7 +19,13 @@ export default defineConfig({
provider: 'v8', provider: 'v8',
reporter: ['text', 'text-summary'], reporter: ['text', 'text-summary'],
include: ['src/renderer/src/**/*.{ts,tsx}'], include: ['src/renderer/src/**/*.{ts,tsx}'],
exclude: ['src/renderer/src/**/*.{test,spec}.{ts,tsx}', 'src/renderer/src/test/**', 'src/renderer/src/env.d.ts'], exclude: [
'src/renderer/src/**/*.{test,spec}.{ts,tsx}',
'src/renderer/src/test/**',
'src/renderer/src/env.d.ts',
// worker 线程入口无法在 jsdom 执行,其调用的 computeDiff 已由 diffEngine 测试覆盖
'src/renderer/src/diff/diffWorker.ts'
],
all: true, all: true,
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败 // CI 门禁:四项覆盖率不得低于 80%,否则测试失败
thresholds: { thresholds: {