fix: v0.4.1 worker 创建开销优化与文档对齐 - worker 延迟至去抖到期才创建(去抖期内取消零创建开销、无 Worker 回退同样走去抖)、质量门禁文档补 npm test、README 架构树补 hooks、Markdown 报告标题移出引用块、安装包排除开发期文件

This commit is contained in:
2026-08-17 23:43:05 +08:00
parent 1ecf5ae7d3
commit ffa47c7f1c
10 changed files with 74 additions and 43 deletions
+4 -3
View File
@@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖过期任务丢弃),界面始终保持流畅响应 - **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃),界面始终保持流畅响应
- **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键
- **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异
- **交换左右侧**:一键互换两侧内容与视角 - **交换左右侧**:一键互换两侧内容与视角
@@ -81,7 +81,8 @@ src/
├── main/ # 主进程:窗口、菜单、文件读取与编码探测、IPC ├── main/ # 主进程:窗口、菜单、文件读取与编码探测、IPC
├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API ├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API
└── renderer/ # 渲染进程:React 界面与全部 diff 逻辑 └── renderer/ # 渲染进程:React 界面与全部 diff 逻辑
├── diff/ # diff 引擎(行级 + 词级高亮) ├── diff/ # diff 引擎(行级 + 词级高亮、Worker 后台计算
├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭)
├── components/ # 界面组件(DiffView / Toolbar 等) ├── components/ # 界面组件(DiffView / Toolbar 等)
└── styles/ # 全局主题样式 └── styles/ # 全局主题样式
``` ```
@@ -95,7 +96,7 @@ src/
- 作者:thzxx - 作者:thzxx
- 组织:MetonaTeam - 组织:MetonaTeam
- 许可证:MIT License(见 [LICENSE](./LICENSE) - 许可证:MIT License(见 [LICENSE](./LICENSE)
- 版本:0.4.0 - 版本:0.4.1
--- ---
+5 -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.4.0`** 当前基线版本:**`0.4.1`**
--- ---
@@ -66,7 +66,8 @@ z = 补丁版本号(Patch
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 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用 0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
0.4.1 ← worker 创建开销优化与文档对齐(当前):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) 0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
... ...
0.y.z ← 长期停留,永不进入 1.x 0.y.z ← 长期停留,永不进入 1.x
@@ -126,10 +127,11 @@ main
```bash ```bash
npm run typecheck # 类型检查(node + web 双端) npm run typecheck # 类型检查(node + web 双端)
npm test # Vitest 单元 + 组件测试(覆盖率 80% 硬门禁)
npm run build # 生产构建(main/preload/renderer 三端) npm run build # 生产构建(main/preload/renderer 三端)
``` ```
> 任一命令失败则禁止提交;修复通过后再提交。 > 任一命令失败则禁止提交;修复通过后再提交。与《测试策略与方案》第 7 节保持一致。
--- ---
+5 -5
View File
@@ -105,14 +105,14 @@ src/renderer/src/
- 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复
- F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占
- 交换左右侧后面板文件互换 - 交换左右侧后面板文件互换
- 大输入(超快路径阈值)在无 Worker 环境回退同步计算,无计算中遮罩 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts` ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
- 重路径:进入 computing 且 diff 置空;200ms 去抖后才派发 worker;响应按 jobId 匹配,错误/过期 jobId 被丢弃 - 重路径:进入 computing 且 diff 置空;200ms 去抖到期后才创建 worker 并派发;响应按 jobId 匹配,错误/过期 jobId 被丢弃
- 输入变更:旧 worker 终止重建(等价取消);去抖期内变更则旧任务未派发即取消(零浪费计算 - 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销
- worker 不可用(环境缺失/工厂返回 null):主线程同步回退 - worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退
- 卸载:终止 worker 并清理去抖定时器 - 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker去抖定时器均被清理
- 工厂:无 Worker 环境返回 null;可用环境返回实例 - 工厂:无 Worker 环境返回 null;可用环境返回实例
--- ---
+4
View File
@@ -13,9 +13,13 @@ files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}' - '!electron.vite.config.{js,ts,mjs,cjs}'
- '!vitest.config.{js,ts,mjs,cjs}'
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
# 开发期专用文件不进入安装包(assets 仅为打包期图标源,运行时图标在 resources/)
- '!docs/*'
- '!assets/*'
asarUnpack: asarUnpack:
- resources/** - resources/**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "DiffLens", "name": "DiffLens",
"version": "0.4.0", "version": "0.4.1",
"description": "DiffLens — 精美酷炫的文本对比桌面应用", "description": "DiffLens — 精美酷炫的文本对比桌面应用",
"author": "thzxx", "author": "thzxx",
"license": "MIT", "license": "MIT",
+3 -3
View File
@@ -362,7 +362,7 @@ describe('App - 提示与容错', () => {
}) })
describe('App - 大输入与 worker 回退', () => { describe('App - 大输入与 worker 回退', () => {
it('超过快路径阈值的文件在无 Worker 环境回退同步计算', async () => { it('超过快路径阈值的文件在无 Worker 环境去抖到期后回退同步计算', async () => {
const big = Array.from({ length: 2100 }, (_, i) => `row${i}`).join('\n') const big = Array.from({ length: 2100 }, (_, i) => `row${i}`).join('\n')
window.api = mockApi({ window.api = mockApi({
openFile: async () => ({ openFile: async () => ({
@@ -376,9 +376,9 @@ describe('App - 大输入与 worker 回退', () => {
render(<App />) render(<App />)
fireEvent.click(screen.getByText('打开左侧')) fireEvent.click(screen.getByText('打开左侧'))
expect(await screen.findByText('导出报告')).toBeInTheDocument() expect(await screen.findByText('导出报告')).toBeInTheDocument()
// jsdom 无 Worker:同步回退完成计算,不出现计算中遮罩,行已渲染 // jsdom 无 Worker去抖到期后同步回退完成计算,行渲染且计算中遮罩消失
expect(await screen.findByText(/row0/)).toBeInTheDocument()
expect(screen.queryByText('正在计算差异…')).not.toBeInTheDocument() expect(screen.queryByText('正在计算差异…')).not.toBeInTheDocument()
expect(screen.getAllByText(/row0/).length).toBeGreaterThan(0)
}) })
}) })
+3 -2
View File
@@ -48,10 +48,11 @@ describe('buildTxtReport', () => {
}) })
describe('buildMarkdownReport', () => { describe('buildMarkdownReport', () => {
it('输出表格化报告', () => { it('输出表格化报告,标题独立成行不进引用块', () => {
const { rows, summary } = computeDiff('a', 'b') const { rows, summary } = computeDiff('a', 'b')
const md = buildMarkdownReport(rows, ctx, summary) const md = buildMarkdownReport(rows, ctx, summary)
expect(md).toContain('# DiffLens 差异报告') expect(md.startsWith('# DiffLens 差异报告')).toBe(true)
expect(md).not.toContain('> # DiffLens 差异报告')
expect(md).toContain('| 类型 |') expect(md).toContain('| 类型 |')
expect(md).toContain('| 左行号 |') expect(md).toContain('| 左行号 |')
}) })
+3 -1
View File
@@ -179,7 +179,9 @@ export function buildMarkdownReport(
summary: DiffSummary summary: DiffSummary
): string { ): string {
const head = headLines(ctx, summary, '# DiffLens 差异报告') const head = headLines(ctx, summary, '# DiffLens 差异报告')
const out: string[] = [...head.map((h) => `> ${h}`), ''] // 标题独立成行(不套进引用块),其余元信息行以引用块展示
const [title, ...meta] = head
const out: string[] = [title, '', ...meta.map((h) => `> ${h}`), '']
out.push('| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |') out.push('| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |')
out.push('| --- | --- | --- | --- | --- |') out.push('| --- | --- | --- | --- | --- |')
for (const row of rows) { for (const row of rows) {
+32 -15
View File
@@ -66,15 +66,15 @@ describe('useDiff - 快路径', () => {
}) })
describe('useDiff - worker 后台计算', () => { describe('useDiff - worker 后台计算', () => {
it('重输入先进入 computing,去抖派发,回传后更新', () => { it('重输入先进入 computing,去抖到期才创建 worker 并派发,回传后更新', () => {
const factory = makeFactory() const factory = makeFactory()
const left = heavyText(1100) const left = heavyText(1100)
const right = heavyText(1100) const right = heavyText(1100)
const { result } = renderHook(() => useDiff(left, right, NO_OPTIONS, factory)) const { result } = renderHook(() => useDiff(left, right, NO_OPTIONS, factory))
expect(result.current.computing).toBe(true) expect(result.current.computing).toBe(true)
expect(result.current.diff).toBeNull() expect(result.current.diff).toBeNull()
// 去抖期内尚未派发 // 去抖期内尚未创建 worker(零创建开销)
expect(factory.workers[0].posted).toHaveLength(0) expect(factory.workers).toHaveLength(0)
act(() => { act(() => {
vi.advanceTimersByTime(250) vi.advanceTimersByTime(250)
}) })
@@ -117,11 +117,11 @@ describe('useDiff - worker 后台计算', () => {
w1.respond(jobId1, computeDiff('a', 'a', {})) w1.respond(jobId1, computeDiff('a', 'a', {}))
}) })
expect(hook.result.current.diff).toBeNull() expect(hook.result.current.diff).toBeNull()
// 新 worker 的结果生效 // 新 worker 的结果生效(新任务同样在去抖到期才创建 worker)
const w2 = factory.workers[1]
act(() => { act(() => {
vi.advanceTimersByTime(250) vi.advanceTimersByTime(250)
}) })
const w2 = factory.workers[1]
const jobId2 = w2.posted[0].jobId const jobId2 = w2.posted[0].jobId
expect(jobId2).toBeGreaterThan(jobId1) expect(jobId2).toBeGreaterThan(jobId1)
const res2 = computeDiff('m', 'n', {}) const res2 = computeDiff('m', 'n', {})
@@ -132,7 +132,7 @@ describe('useDiff - worker 后台计算', () => {
expect(hook.result.current.computing).toBe(false) expect(hook.result.current.computing).toBe(false)
}) })
it('去抖期内输入变更:旧任务未派发即取消(零浪费计算', () => { it('去抖期内输入变更:旧任务未创建即取消(零创建与计算开销', () => {
const factory = makeFactory() const factory = makeFactory()
const hook = renderHook((p: string) => useDiff(p, '', NO_OPTIONS, factory), { const hook = renderHook((p: string) => useDiff(p, '', NO_OPTIONS, factory), {
initialProps: heavyText(2100) initialProps: heavyText(2100)
@@ -141,34 +141,51 @@ describe('useDiff - worker 后台计算', () => {
vi.advanceTimersByTime(100) vi.advanceTimersByTime(100)
}) })
hook.rerender(heavyText(2300)) hook.rerender(heavyText(2300))
expect(factory.workers[0].posted).toHaveLength(0) // 去抖期内取消:worker 从未被创建
expect(factory.workers[0].terminated).toBe(true) expect(factory.workers).toHaveLength(0)
act(() => { act(() => {
vi.advanceTimersByTime(300) vi.advanceTimersByTime(300)
}) })
expect(factory.workers[1].posted).toHaveLength(1) expect(factory.workers).toHaveLength(1)
expect(factory.workers[0].posted).toHaveLength(1)
}) })
it('worker 不可用时回退主线程同步计算', () => { it('worker 不可用时去抖到期回退主线程同步计算', () => {
const left = heavyText(2100) const left = heavyText(2100)
// 工厂必须稳定引用:内联箭头函数每次渲染变引用会引发 effect 无限重跑 // 工厂必须稳定引用:内联箭头函数每次渲染变引用会引发 effect 无限重跑
const nullFactory: DiffWorkerFactory = () => null const nullFactory: DiffWorkerFactory = () => null
const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, nullFactory)) const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, nullFactory))
// 去抖期内同样处于 computing(无 Worker 回退不豁免去抖)
expect(result.current.computing).toBe(true)
act(() => {
vi.advanceTimersByTime(250)
})
expect(result.current.diff).toEqual(computeDiff(left, 'x', {})) expect(result.current.diff).toEqual(computeDiff(left, 'x', {}))
expect(result.current.computing).toBe(false) expect(result.current.computing).toBe(false)
}) })
it('卸载时终止 worker 并清理去抖定时器', () => { it('去抖期内卸载:worker 从未创建且不再派发', () => {
const factory = makeFactory() const factory = makeFactory()
const left = heavyText(2100) const left = heavyText(2100)
const hook = renderHook(() => useDiff(left, '', NO_OPTIONS, factory)) const hook = renderHook(() => useDiff(left, '', NO_OPTIONS, factory))
const w = factory.workers[0]
hook.unmount() hook.unmount()
expect(w.terminated).toBe(true) expect(factory.workers).toHaveLength(0)
// 卸载后去抖到期不再派发
act(() => { act(() => {
vi.advanceTimersByTime(300) vi.advanceTimersByTime(300)
}) })
expect(w.posted).toHaveLength(0) expect(factory.workers).toHaveLength(0)
})
it('去抖到期派发后卸载:worker 被终止', () => {
const factory = makeFactory()
const left = heavyText(2100)
const hook = renderHook(() => useDiff(left, '', NO_OPTIONS, factory))
act(() => {
vi.advanceTimersByTime(250)
})
const w = factory.workers[0]
expect(w.posted).toHaveLength(1)
hook.unmount()
expect(w.terminated).toBe(true)
}) })
}) })
+9 -5
View File
@@ -19,7 +19,8 @@ export interface DiffState {
/** /**
* worker 化的 diff 计算 hook。 * worker 化的 diff 计算 hook。
* 小输入同步快路径(行为与 worker 化之前完全一致); * 小输入同步快路径(行为与 worker 化之前完全一致);
* 大输入去抖后派发 worker 后台计算,输入变化时终止旧 worker 重建(JS worker 无法中断计算,销毁重建即取消), * 大输入去抖到期后才创建 worker 并派发后台计算(去抖期内取消的任务零创建开销),
* 输入变化时终止旧 worker 重建(JS worker 无法中断计算,销毁重建即取消),
* 响应按 jobId 匹配丢弃过期任务;worker 不可用的环境回退主线程同步计算。 * 响应按 jobId 匹配丢弃过期任务;worker 不可用的环境回退主线程同步计算。
* workerFactory 仅测试注入用,生产调用保持缺省;注入时需保证引用稳定。 * workerFactory 仅测试注入用,生产调用保持缺省;注入时需保证引用稳定。
*/ */
@@ -49,7 +50,12 @@ export function useDiff(
if (!heavy) return if (!heavy) return
// 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异 // 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异
setAsyncDiff(null) setAsyncDiff(null)
const w = factory() setComputing(true)
let w: Worker | null = null
// worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译,
// 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销
const timer = window.setTimeout(() => {
w = factory()
if (!w) { if (!w) {
// 无 Worker 支持(jsdom/老环境):主线程同步回退 // 无 Worker 支持(jsdom/老环境):主线程同步回退
setAsyncDiff(computeDiff(left, right, options)) setAsyncDiff(computeDiff(left, right, options))
@@ -57,8 +63,6 @@ export function useDiff(
return return
} }
const jobId = ++jobIdRef.current const jobId = ++jobIdRef.current
setComputing(true)
const timer = window.setTimeout(() => {
w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => { w.onmessage = (e: MessageEvent<DiffWorkerResponse>): void => {
// 丢弃过期任务:输入已变化时旧计算结果不再生效 // 丢弃过期任务:输入已变化时旧计算结果不再生效
if (e.data.jobId !== jobId) return if (e.data.jobId !== jobId) return
@@ -69,7 +73,7 @@ export function useDiff(
}, DEBOUNCE_MS) }, DEBOUNCE_MS)
return () => { return () => {
window.clearTimeout(timer) window.clearTimeout(timer)
w.terminate() w?.terminate()
} }
}, [left, right, options, heavy, factory]) }, [left, right, options, heavy, factory])