From ffa47c7f1c7e4d6ff3158ff8f47995138235d5bf Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Mon, 17 Aug 2026 23:43:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.4.1=20worker=20=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E5=BC=80=E9=94=80=E4=BC=98=E5=8C=96=E4=B8=8E=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20-=20worker=20=E5=BB=B6=E8=BF=9F=E8=87=B3?= =?UTF-8?q?=E5=8E=BB=E6=8A=96=E5=88=B0=E6=9C=9F=E6=89=8D=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=EF=BC=88=E5=8E=BB=E6=8A=96=E6=9C=9F=E5=86=85=E5=8F=96=E6=B6=88?= =?UTF-8?q?=E9=9B=B6=E5=88=9B=E5=BB=BA=E5=BC=80=E9=94=80=E3=80=81=E6=97=A0?= =?UTF-8?q?=20Worker=20=E5=9B=9E=E9=80=80=E5=90=8C=E6=A0=B7=E8=B5=B0?= =?UTF-8?q?=E5=8E=BB=E6=8A=96=EF=BC=89=E3=80=81=E8=B4=A8=E9=87=8F=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E6=96=87=E6=A1=A3=E8=A1=A5=20npm=20test=E3=80=81READM?= =?UTF-8?q?E=20=E6=9E=B6=E6=9E=84=E6=A0=91=E8=A1=A5=20hooks=E3=80=81Markdo?= =?UTF-8?q?wn=20=E6=8A=A5=E5=91=8A=E6=A0=87=E9=A2=98=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E5=9D=97=E3=80=81=E5=AE=89=E8=A3=85=E5=8C=85?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=BC=80=E5=8F=91=E6=9C=9F=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++-- docs/应用开发与版本迭代规范.md | 8 +++-- docs/测试策略与方案.md | 10 +++--- electron-builder.yml | 4 +++ package.json | 2 +- src/renderer/src/__tests__/App.test.tsx | 6 ++-- src/renderer/src/diff/report.test.ts | 5 +-- src/renderer/src/diff/report.ts | 4 ++- src/renderer/src/hooks/useDiff.test.ts | 47 +++++++++++++++++-------- src/renderer/src/hooks/useDiff.ts | 24 +++++++------ 10 files changed, 74 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index ceb4920..8fe818f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐 - **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位 - **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅 -- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(含去抖与过期任务丢弃),界面始终保持流畅响应 +- **后台差异计算**:大文件的差异计算在 Worker 线程后台进行(去抖合并输入、worker 按需创建、过期任务丢弃),界面始终保持流畅响应 - **差异导航**:一键“上一处 / 下一处”在差异之间跳跃,支持 F7 / Shift+F7 快捷键 - **仅看差异**:一键过滤未更改行,连续相同内容折叠为提示行,专注浏览差异 - **交换左右侧**:一键互换两侧内容与视角 @@ -81,7 +81,8 @@ src/ ├── main/ # 主进程:窗口、菜单、文件读取与编码探测、IPC ├── preload/ # 预加载:contextBridge 安全桥,仅暴露最小 API └── renderer/ # 渲染进程:React 界面与全部 diff 逻辑 - ├── diff/ # diff 引擎(行级 + 词级高亮) + ├── diff/ # diff 引擎(行级 + 词级高亮、Worker 后台计算) + ├── hooks/ # 状态 hooks(diff 计算调度、下拉关闭) ├── components/ # 界面组件(DiffView / Toolbar 等) └── styles/ # 全局主题样式 ``` @@ -95,7 +96,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.4.0 +- 版本:0.4.1 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 20b3e18..cd94df2 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.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.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构 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.y.z ← 长期停留,永不进入 1.x @@ -126,10 +127,11 @@ main ```bash npm run typecheck # 类型检查(node + web 双端) +npm test # Vitest 单元 + 组件测试(覆盖率 80% 硬门禁) npm run build # 生产构建(main/preload/renderer 三端) ``` -> 任一命令失败则禁止提交;修复通过后再提交。 +> 任一命令失败则禁止提交;修复通过后再提交。与《测试策略与方案》第 7 节保持一致。 --- diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index bd1f04a..f69cc78 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -105,14 +105,14 @@ src/renderer/src/ - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - 交换左右侧后面板文件互换 -- 大输入(超快路径阈值)在无 Worker 环境回退同步计算,无计算中遮罩 +- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 ### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`) - 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致 -- 重路径:进入 computing 且 diff 置空;200ms 去抖后才派发 worker;响应按 jobId 匹配,错误/过期 jobId 被丢弃 -- 输入变更:旧 worker 终止重建(等价取消);去抖期内变更则旧任务未派发即取消(零浪费计算) -- worker 不可用(环境缺失/工厂返回 null):主线程同步回退 -- 卸载:终止 worker 并清理去抖定时器 +- 重路径:进入 computing 且 diff 置空;200ms 去抖到期后才创建 worker 并派发;响应按 jobId 匹配,错误/过期 jobId 被丢弃 +- 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销) +- worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退 +- 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理 - 工厂:无 Worker 环境返回 null;可用环境返回实例 --- diff --git a/electron-builder.yml b/electron-builder.yml index 9e712eb..63bb0e3 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,9 +13,13 @@ files: - '!**/.vscode/*' - '!src/*' - '!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}' - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' + # 开发期专用文件不进入安装包(assets 仅为打包期图标源,运行时图标在 resources/) + - '!docs/*' + - '!assets/*' asarUnpack: - resources/** diff --git a/package.json b/package.json index 6b81822..805cea2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.4.0", + "version": "0.4.1", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 27fabb7..ce01500 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -362,7 +362,7 @@ describe('App - 提示与容错', () => { }) describe('App - 大输入与 worker 回退', () => { - it('超过快路径阈值的文件在无 Worker 环境回退同步计算', async () => { + it('超过快路径阈值的文件在无 Worker 环境去抖到期后回退同步计算', async () => { const big = Array.from({ length: 2100 }, (_, i) => `row${i}`).join('\n') window.api = mockApi({ openFile: async () => ({ @@ -376,9 +376,9 @@ describe('App - 大输入与 worker 回退', () => { render() fireEvent.click(screen.getByText('打开左侧')) expect(await screen.findByText('导出报告')).toBeInTheDocument() - // jsdom 无 Worker:同步回退完成计算,不出现计算中遮罩,行已渲染 + // jsdom 无 Worker:去抖到期后同步回退完成计算,行渲染且计算中遮罩消失 + expect(await screen.findByText(/row0/)).toBeInTheDocument() expect(screen.queryByText('正在计算差异…')).not.toBeInTheDocument() - expect(screen.getAllByText(/row0/).length).toBeGreaterThan(0) }) }) diff --git a/src/renderer/src/diff/report.test.ts b/src/renderer/src/diff/report.test.ts index 2e9d37b..de82b7a 100644 --- a/src/renderer/src/diff/report.test.ts +++ b/src/renderer/src/diff/report.test.ts @@ -48,10 +48,11 @@ describe('buildTxtReport', () => { }) describe('buildMarkdownReport', () => { - it('输出表格化报告', () => { + it('输出表格化报告,标题独立成行不进引用块', () => { const { rows, summary } = computeDiff('a', 'b') 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('| 左行号 |') }) diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts index 8a90694..2c038a3 100644 --- a/src/renderer/src/diff/report.ts +++ b/src/renderer/src/diff/report.ts @@ -179,7 +179,9 @@ export function buildMarkdownReport( summary: DiffSummary ): string { 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('| --- | --- | --- | --- | --- |') for (const row of rows) { diff --git a/src/renderer/src/hooks/useDiff.test.ts b/src/renderer/src/hooks/useDiff.test.ts index 7a4d189..07f8df4 100644 --- a/src/renderer/src/hooks/useDiff.test.ts +++ b/src/renderer/src/hooks/useDiff.test.ts @@ -66,15 +66,15 @@ describe('useDiff - 快路径', () => { }) describe('useDiff - worker 后台计算', () => { - it('重输入先进入 computing,去抖后派发,回传后更新', () => { + it('重输入先进入 computing,去抖到期才创建 worker 并派发,回传后更新', () => { 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) + // 去抖期内尚未创建 worker(零创建开销) + expect(factory.workers).toHaveLength(0) act(() => { vi.advanceTimersByTime(250) }) @@ -117,11 +117,11 @@ describe('useDiff - worker 后台计算', () => { w1.respond(jobId1, computeDiff('a', 'a', {})) }) expect(hook.result.current.diff).toBeNull() - // 新 worker 的结果生效 - const w2 = factory.workers[1] + // 新 worker 的结果生效(新任务同样在去抖到期才创建 worker) act(() => { vi.advanceTimersByTime(250) }) + const w2 = factory.workers[1] const jobId2 = w2.posted[0].jobId expect(jobId2).toBeGreaterThan(jobId1) const res2 = computeDiff('m', 'n', {}) @@ -132,7 +132,7 @@ describe('useDiff - worker 后台计算', () => { expect(hook.result.current.computing).toBe(false) }) - it('去抖期内输入变更:旧任务未派发即取消(零浪费计算)', () => { + it('去抖期内输入变更:旧任务未创建即取消(零创建与计算开销)', () => { const factory = makeFactory() const hook = renderHook((p: string) => useDiff(p, '', NO_OPTIONS, factory), { initialProps: heavyText(2100) @@ -141,34 +141,51 @@ describe('useDiff - worker 后台计算', () => { vi.advanceTimersByTime(100) }) hook.rerender(heavyText(2300)) - expect(factory.workers[0].posted).toHaveLength(0) - expect(factory.workers[0].terminated).toBe(true) + // 去抖期内取消:worker 从未被创建 + expect(factory.workers).toHaveLength(0) act(() => { 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) // 工厂必须稳定引用:内联箭头函数每次渲染变引用会引发 effect 无限重跑 const nullFactory: DiffWorkerFactory = () => null 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.computing).toBe(false) }) - it('卸载时终止 worker 并清理去抖定时器', () => { + 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) - // 卸载后去抖到期不再派发 + expect(factory.workers).toHaveLength(0) act(() => { 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) }) }) diff --git a/src/renderer/src/hooks/useDiff.ts b/src/renderer/src/hooks/useDiff.ts index 016b232..851bbcf 100644 --- a/src/renderer/src/hooks/useDiff.ts +++ b/src/renderer/src/hooks/useDiff.ts @@ -19,7 +19,8 @@ export interface DiffState { /** * worker 化的 diff 计算 hook。 * 小输入同步快路径(行为与 worker 化之前完全一致); - * 大输入去抖后派发 worker 后台计算,输入变化时终止旧 worker 重建(JS worker 无法中断计算,销毁重建即取消), + * 大输入去抖到期后才创建 worker 并派发后台计算(去抖期内取消的任务零创建开销), + * 输入变化时终止旧 worker 重建(JS worker 无法中断计算,销毁重建即取消), * 响应按 jobId 匹配丢弃过期任务;worker 不可用的环境回退主线程同步计算。 * workerFactory 仅测试注入用,生产调用保持缺省;注入时需保证引用稳定。 */ @@ -49,16 +50,19 @@ export function useDiff( 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) + let w: Worker | null = null + // worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译, + // 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销 const timer = window.setTimeout(() => { + w = factory() + if (!w) { + // 无 Worker 支持(jsdom/老环境):主线程同步回退 + setAsyncDiff(computeDiff(left, right, options)) + setComputing(false) + return + } + const jobId = ++jobIdRef.current w.onmessage = (e: MessageEvent): void => { // 丢弃过期任务:输入已变化时旧计算结果不再生效 if (e.data.jobId !== jobId) return @@ -69,7 +73,7 @@ export function useDiff( }, DEBOUNCE_MS) return () => { window.clearTimeout(timer) - w.terminate() + w?.terminate() } }, [left, right, options, heavy, factory])