feat: v0.4.0 差异计算 worker 化 - 大文件 diff 移入 Worker 后台计算不阻塞界面、输入去抖与过期任务丢弃(终止重建实现取消)、小输入同步快路径、无 Worker 环境回退、计算中遮罩与导出禁用
This commit is contained in:
@@ -12,6 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
||||
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
||||
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
||||
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(含去抖与过期任务丢弃),界面始终保持流畅响应
|
||||
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
|
||||
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
|
||||
- **交换左右侧**:一键互换两侧内容与视角
|
||||
@@ -37,6 +38,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
| 构建 | electron-vite (Vite 5) |
|
||||
| 打包 | electron-builder |
|
||||
| Diff 引擎 | jsdiff(行级 LCS + 词级词法 diff) |
|
||||
| 后台计算 | Web Worker(Vite 内联打包) |
|
||||
| 编码 | iconv-lite |
|
||||
|
||||
---
|
||||
@@ -93,7 +95,7 @@ src/
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.3.2
|
||||
- 版本:0.4.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-3
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||
|
||||
当前基线版本:**`0.3.2`**
|
||||
当前基线版本:**`0.4.0`**
|
||||
|
||||
---
|
||||
|
||||
@@ -65,8 +65,9 @@ z = 补丁版本号(Patch)
|
||||
0.2.6 ← 主进程异步收尾与边角防护(已发布):保存报告异步写盘 · 拖拽解码 handler 异步化 · 空白粘贴拦截 · 超多行卡顿预警 · 清理 mac 无效签名配置 · 文档与测试同步
|
||||
0.3.0 ← 大文件性能与差异导航增强(已发布):自研虚拟滚动 · 仅看差异过滤(折叠提示行) · F7/Shift+F7 导航快捷键 · 交换左右侧 · 忽略空行
|
||||
0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
|
||||
0.3.2 ← 报告对齐与性能收尾(当前):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
||||
0.4.0 ← 新增功能(候选:主进程解码 worker 化 · 渲染进程 diff 计算 worker 化 · Playwright E2E · 文件夹对比)
|
||||
0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
||||
0.4.0 ← 差异计算 worker 化(当前):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
||||
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
||||
...
|
||||
0.y.z ← 长期停留,永不进入 1.x
|
||||
```
|
||||
|
||||
+18
-2
@@ -47,15 +47,22 @@ src/renderer/src/
|
||||
│ └── setup.ts # 全局 setup:jest-dom 匹配器 + 自动 cleanup
|
||||
├── diff/
|
||||
│ ├── diffEngine.test.ts # diff 引擎单元测试
|
||||
│ ├── diffWorker.ts # diff 计算 Web Worker(内联打包,覆盖率排除,逻辑由 diffEngine 覆盖)
|
||||
│ ├── createDiffWorker.test.ts # worker 工厂(环境回退/实例化)
|
||||
│ ├── createDiffWorker.ts # worker 工厂:环境不支持时返回 null 由调用方回退
|
||||
│ ├── report.test.ts # 报告生成测试
|
||||
│ └── textUtils.test.ts # 文本扩展名与行数统计测试
|
||||
├── hooks/
|
||||
│ ├── useDiff.test.ts # worker 化计算 hook(快路径/去抖/过期丢弃/回退/卸载清理)
|
||||
│ ├── useDiff.ts
|
||||
│ └── useDismiss.ts
|
||||
├── components/
|
||||
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
||||
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
||||
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单)
|
||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单/计算中禁用)
|
||||
└── __tests__/
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错)
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
|
||||
└── main.test.ts # 入口挂载冒烟测试
|
||||
```
|
||||
|
||||
@@ -98,6 +105,15 @@ src/renderer/src/
|
||||
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
|
||||
- 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.3.2",
|
||||
"version": "0.4.0",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
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>
|
||||
<body>
|
||||
|
||||
+32
-17
@@ -7,8 +7,7 @@ import {
|
||||
type ReactElement,
|
||||
type MouseEvent as ReactMouseEvent
|
||||
} from 'react'
|
||||
import { computeDiff } from './diff/diffEngine'
|
||||
import type { DiffOptions, DiffResult, DiffSummary } from './diff/diffEngine'
|
||||
import type { DiffOptions, DiffSummary } from './diff/diffEngine'
|
||||
import DiffView, { type DisplayItem, type FoldMarker } from './components/DiffView'
|
||||
import Toolbar from './components/Toolbar'
|
||||
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 { isTextFile, countLines, HEAVY_LINES } from './diff/textUtils'
|
||||
import { useDismiss } from './hooks/useDismiss'
|
||||
import { useDiff } from './hooks/useDiff'
|
||||
import type { PaneMeta } from './components/DiffView'
|
||||
import type { SideCell } from './diff/diffEngine'
|
||||
|
||||
@@ -31,6 +31,9 @@ function stamp(): string {
|
||||
/** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */
|
||||
const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 }
|
||||
|
||||
interface PaneState {
|
||||
meta: PaneMeta
|
||||
path: string
|
||||
@@ -125,11 +128,11 @@ export default function App(): ReactElement {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 超多行预警:行数超过阈值时提示计算耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身)
|
||||
// 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面)
|
||||
const warnHeavyLines = useCallback(
|
||||
(text: string): void => {
|
||||
const lines = countLines(text)
|
||||
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),计算与首次加载可能耗时`)
|
||||
if (lines > HEAVY_LINES) showToast(`行数较多(${lines} 行),差异将在后台计算,可能需要一些时间`)
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
@@ -205,28 +208,28 @@ export default function App(): ReactElement {
|
||||
[showToast, warnHeavyLines]
|
||||
)
|
||||
|
||||
const diff: DiffResult = useMemo(() => {
|
||||
return computeDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
||||
}, [paneL, paneR, options])
|
||||
// diff 计算已 worker 化:大输入后台计算(computing 期间 diff 为 null),小输入同步快路径
|
||||
const { diff, computing } = useDiff(paneL?.text ?? '', paneR?.text ?? '', options)
|
||||
|
||||
const changedRows = useMemo(() => diff.rows.filter((r) => r.isChanged), [diff])
|
||||
const summary: DiffSummary = diff.summary
|
||||
const rows = useMemo(() => diff?.rows ?? [], [diff])
|
||||
const changedRows = useMemo(() => rows.filter((r) => r.isChanged), [rows])
|
||||
const summary: DiffSummary = diff?.summary ?? EMPTY_SUMMARY
|
||||
|
||||
// “仅看差异”视图行集:过滤未更改行,在连续省略处插入折叠提示行(占一个虚拟行位)
|
||||
const displayRows = useMemo(() => {
|
||||
if (!onlyDiff) return diff.rows as DisplayItem[]
|
||||
if (diff.rows.length === 0) return [] as DisplayItem[]
|
||||
if (!onlyDiff) return rows as DisplayItem[]
|
||||
if (rows.length === 0) return [] as DisplayItem[]
|
||||
if (changedRows.length === 0) {
|
||||
const m: FoldMarker = {
|
||||
fold: true,
|
||||
skipped: diff.rows.length,
|
||||
skipped: rows.length,
|
||||
note: '两文件内容完全一致,已折叠全部未更改行'
|
||||
}
|
||||
return [m]
|
||||
}
|
||||
const out: DisplayItem[] = []
|
||||
let skipped = 0
|
||||
for (const r of diff.rows) {
|
||||
for (const r of rows) {
|
||||
if (r.isChanged) {
|
||||
if (skipped > 0) {
|
||||
out.push({ fold: true, skipped })
|
||||
@@ -239,7 +242,7 @@ export default function App(): ReactElement {
|
||||
}
|
||||
if (skipped > 0) out.push({ fold: true, skipped })
|
||||
return out
|
||||
}, [diff, onlyDiff, changedRows.length])
|
||||
}, [rows, onlyDiff, changedRows.length])
|
||||
|
||||
// 交换左右两侧(React 18 事件回调内自动批处理,两态一同更新)
|
||||
const swapPanes = useCallback((): void => {
|
||||
@@ -308,6 +311,8 @@ export default function App(): ReactElement {
|
||||
|
||||
const exportReport = useCallback(
|
||||
async (fmt: ReportFormat) => {
|
||||
// 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御
|
||||
if (!diff) return
|
||||
const ctx: ReportContext = {
|
||||
leftName: paneL?.meta.name ?? '',
|
||||
rightName: paneR?.meta.name ?? '',
|
||||
@@ -316,7 +321,7 @@ export default function App(): ReactElement {
|
||||
leftPath: paneL?.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]}`
|
||||
let res: { ok: boolean; path: string | null }
|
||||
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
|
||||
@@ -438,6 +443,7 @@ export default function App(): ReactElement {
|
||||
navCount={changedRows.length}
|
||||
onNav={go}
|
||||
onExport={(fmt) => void exportReport(fmt)}
|
||||
computing={computing}
|
||||
onlyDiff={onlyDiff}
|
||||
onOnlyDiffChange={setOnlyDiff}
|
||||
onSwap={swapPanes}
|
||||
@@ -464,7 +470,9 @@ export default function App(): ReactElement {
|
||||
</div>
|
||||
<div className="status-spacer" />
|
||||
<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 diff">有差异</span>
|
||||
@@ -472,6 +480,13 @@ export default function App(): ReactElement {
|
||||
</div>
|
||||
</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)} />}
|
||||
{pasteSide && (
|
||||
<TextInputModal
|
||||
|
||||
@@ -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 - 粘贴下拉交互', () => {
|
||||
it('按 Esc 关闭粘贴下拉', () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DiffSummary } from '../diff/diffEngine'
|
||||
|
||||
const summary: DiffSummary = { changedLines: 3, inserted: 1, deleted: 1, modified: 1 }
|
||||
|
||||
function setup() {
|
||||
function setup(extra?: { computing?: boolean }) {
|
||||
const onOptionsChange = vi.fn()
|
||||
const onNav = vi.fn()
|
||||
const onExport = vi.fn()
|
||||
@@ -20,6 +20,7 @@ function setup() {
|
||||
navCount={3}
|
||||
onNav={onNav}
|
||||
onExport={onExport}
|
||||
computing={extra?.computing ?? false}
|
||||
onlyDiff={false}
|
||||
onOnlyDiffChange={onOnlyDiffChange}
|
||||
onSwap={onSwap}
|
||||
@@ -100,4 +101,9 @@ describe('Toolbar', () => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByText('HTML 报告')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('后台计算中禁用导出按钮', () => {
|
||||
setup({ computing: true })
|
||||
expect(screen.getByText('导出报告')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@ interface ToolbarProps {
|
||||
navCount: number
|
||||
onNav: (dir: 1 | -1) => void
|
||||
onExport: (fmt: ReportFormat) => void
|
||||
/** 后台差异计算中(导出不可用) */
|
||||
computing: boolean
|
||||
/** 仅看差异视图开关 */
|
||||
onlyDiff: boolean
|
||||
onOnlyDiffChange: (v: boolean) => void
|
||||
@@ -32,6 +34,7 @@ export default function Toolbar({
|
||||
navCount,
|
||||
onNav,
|
||||
onExport,
|
||||
computing,
|
||||
onlyDiff,
|
||||
onOnlyDiffChange,
|
||||
onSwap
|
||||
@@ -113,7 +116,12 @@ export default function Toolbar({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{exportOpen && (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { createDiffWorker } from './createDiffWorker'
|
||||
|
||||
describe('createDiffWorker', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('环境不支持 Worker 时返回 null(jsdom 回退路径)', () => {
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
})
|
||||
@@ -75,7 +75,7 @@ export function isTextFile(name: string): boolean {
|
||||
return TEXT_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
/** 超多行预警阈值:超过该行数时提示计算与首次加载可能耗时(虚拟化已解决渲染卡顿,剩余瓶颈在 diff 计算本身) */
|
||||
/** 超多行预警阈值:超过该行数时提示后台计算可能耗时(diff 计算 worker 化后不阻塞界面) */
|
||||
export const HEAVY_LINES = 50000
|
||||
|
||||
/** 统计文本行数,切分规则与 diff 引擎一致(结尾换行不计数) */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -89,6 +89,7 @@ button:disabled {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@@ -739,4 +740,40 @@ button:disabled {
|
||||
.eq-badge.diff {
|
||||
color: var(--muted);
|
||||
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
@@ -19,7 +19,13 @@ export default defineConfig({
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'text-summary'],
|
||||
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,
|
||||
// CI 门禁:四项覆盖率不得低于 80%,否则测试失败
|
||||
thresholds: {
|
||||
|
||||
Reference in New Issue
Block a user