From 77dff9842568d1c721a7073718d0116315de0880 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Tue, 18 Aug 2026 11:39:26 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.5.2=20=E7=A8=B3=E5=81=A5=E6=80=A7?= =?UTF-8?q?=E6=94=B6=E5=B0=BE=E4=B8=8E=E5=81=8F=E5=A5=BD=E8=AE=B0=E5=BF=86?= =?UTF-8?q?=20-=20diff=20worker=20=E5=B4=A9=E6=BA=83=20onerror=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=EF=BC=88=E7=BB=88=E6=AD=A2=E5=AE=9E=E4=BE=8B=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E4=B8=BB=E7=BA=BF=E7=A8=8B=E5=90=8C=E6=AD=A5=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E3=80=81effect=20=E5=B7=B2=E5=8F=96=E6=B6=88/jobId=20?= =?UTF-8?q?=E5=B7=B2=E6=8E=A8=E8=BF=9B=E7=9A=84=E8=BF=9F=E5=88=B0=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E4=B8=8D=E5=9B=9E=E5=A1=AB=EF=BC=89=E3=80=81=E4=B8=BB?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=20will-navigate=20=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E5=8A=A0=E5=9B=BA=E3=80=81nativeThe?= =?UTF-8?q?me=20=E6=9A=97=E8=89=B2=E6=A0=87=E9=A2=98=E6=A0=8F=E3=80=81?= =?UTF-8?q?=E6=AF=94=E8=BE=83=E9=80=89=E9=A1=B9=E4=B8=8E=E4=BB=85=E7=9C=8B?= =?UTF-8?q?=E5=B7=AE=E5=BC=82=E5=BC=80=E5=85=B3=20localStorage=20=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=EF=BC=88=E6=8D=9F=E5=9D=8F=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E6=80=A7=E5=9B=9E=E9=80=80=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=EF=BC=89=E3=80=81=E5=AD=97=E7=AC=A6=E7=BA=A7=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E9=80=89=E9=A1=B9=E6=9E=84=E9=80=A0=E4=B8=8E?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E9=99=8D=E7=BA=A7=E5=88=86=E6=94=AF=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- docs/应用开发与版本迭代规范.md | 5 +- docs/测试策略与方案.md | 2 + package.json | 2 +- src/main/index.ts | 11 +++- src/renderer/src/App.tsx | 65 +++++++++++++++++---- src/renderer/src/__tests__/App.test.tsx | 75 +++++++++++++++++++++++++ src/renderer/src/hooks/useDiff.test.ts | 55 +++++++++++++++++- src/renderer/src/hooks/useDiff.ts | 13 ++++- 9 files changed, 215 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index b321aa2..e12828c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”“忽略空行”“忽略所有空白”(空格/制表符/全角空格不参与判等,仅比较实际文字),适配弱差异场景 - **字符级对比**:空白与换行结构全部不参与判等,两侧归一化为字符流 diff——跨行重组(如排版重排、换行位置变化)也能判等;字符差异精确回映射到行内高亮,超 20 万字符自动降级行级对比并提示 - **手动粘贴对比**:无需文件,直接粘贴两侧文本即可开始对比 +- **偏好记忆**:比较选项与"仅看差异"开关自动保存,下次启动自动恢复(损坏数据自动回退默认) +- **原生暗色适配**:系统标题栏等原生控件跟随应用暗色科幻主题 - **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿 - **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 @@ -97,7 +99,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 许可证:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.5.1 +- 版本:0.5.2 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index c010a3d..c3987e0 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -20,7 +20,7 @@ z = 补丁版本号(Patch) > **`x` 永远是 `0`,永远不要提升到 `1.0.0`。** > 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。 -当前基线版本:**`0.5.1`** +当前基线版本:**`0.5.2`** --- @@ -71,7 +71,8 @@ z = 补丁版本号(Patch) 0.4.2 ← 导出菜单溢出修复与报告可读性重设计(已发布):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论 0.4.3 ← 忽略所有空白比较选项与报告选项说明(已发布):新增“忽略所有空白”开关(行内空格/制表符/全角空格不参与判等,展示仍为原文,换行结构仍参与对比) · 三种报告头部新增“比较选项”说明行(HTML 为 Hero 区徽标) 0.5.0 ← 字符级对比模式(已发布):新增“字符级对比”开关(空白与换行结构全部不参与判等,两侧归一化为字符流 diff,跨行重组也能判等) · 字符差异回映射到行(跨界行配对、单侧行回拉、锚行 zip 配对,输出仍为 DiffRow,视图/报告/导航零改动) · 归一化流超 20 万字符自动降级行级对比并提示(报告头部按实际语义描述) · 字符级开启时三个空白选项置灰(语义已包含) -0.5.1 ← 字符级同行多块变更重复输出修复(当前):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化) +0.5.1 ← 字符级同行多块变更重复输出修复(已发布):字符换位专项测试暴露并修复两处回映射缺陷——同行内多处变更(ne-eq-ne 同行,如换位、同行两处替换)不再重复输出多个 modified · 回拉跨界判定与对齐行判定锚定同一 eq 块,多行换位不再把全 common 行误判为 added · 新增字符换位专项测试组(对齐任意性下的不变量固化) +0.5.2 ← 稳健性收尾与偏好记忆(当前):diff worker 运行崩溃 onerror 兜底(终止实例回退主线程同步计算,effect 取消/jobId 已推进的迟到错误不回填) · 主进程 will-navigate 导航白名单加固(仅放行回到应用首页) · nativeTheme 暗色主题(Windows 标题栏等系统控件跟随应用风格) · 比较选项与仅看差异开关持久化(localStorage 记忆用户偏好,损坏数据防御性回退默认) · 字符级降级报告选项构造与引擎降级分支对齐(补 trimWhitespace 置否) 0.6.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比) ... 0.y.z ← 长期停留,永不进入 1.x diff --git a/docs/测试策略与方案.md b/docs/测试策略与方案.md index e3ec59d..47e4307 100644 --- a/docs/测试策略与方案.md +++ b/docs/测试策略与方案.md @@ -109,6 +109,7 @@ src/renderer/src/ - 仅看差异视图:未更改行折叠、折叠提示出现、关闭后恢复 - F7 / Shift+F7 导航快捷键;粘贴弹窗打开时快捷键不抢占 - 交换左右侧后面板文件互换 +- 偏好持久化(比较选项 + 仅看差异开关):启动时恢复上次保存的状态(选项勾选与视图折叠生效)/ 损坏 JSON 回退默认且不崩溃 / 非布尔字段防御性忽略 / 切换选项后写回存储(用例间 beforeEach 清理 localStorage 防串扰) - 字符级对比:跨行重组开启后判为完全一致;开启时三个空白选项禁用;内容超限自动降级并 toast 提示;导出报告携带字符级说明;降级后报告按实际生效的行级语义描述选项 - 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明 - 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩 @@ -119,6 +120,7 @@ src/renderer/src/ - 输入变更:已派发的旧 worker 终止重建(等价取消);去抖期内变更则旧任务未创建即取消(零创建与计算开销) - worker 不可用(环境缺失/工厂返回 null):去抖到期后主线程同步回退 - 卸载:去抖期内卸载则 worker 从未创建;去抖到期后卸载则终止 worker,去抖定时器均被清理 +- worker 运行崩溃(onerror 兜底):终止实例并回退主线程同步计算(computing 不再永久卡死);迟到错误(effect 已取消 / jobId 已推进)只终止不回填,新任务结果正常生效 - 工厂:无 Worker 环境返回 null;可用环境返回实例 --- diff --git a/package.json b/package.json index 340a97f..6ef0058 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.5.1", + "version": "0.5.2", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 35bcf23..93959f2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,5 @@ import { join } from 'path' -import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard } from 'electron' +import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme } from 'electron' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import iconv from 'iconv-lite' import fs from 'fs' @@ -33,6 +33,12 @@ function createWindow(): void { return { action: 'deny' } }) + // 窗口内导航白名单:仅放行回到当前应用地址(dev 首页 / 打包产物首页,等价于刷新), + // 阻断其余一切页面内跳转(新窗口已在上方拦截,此处补齐页面内导航防线) + mainWindow.webContents.on('will-navigate', (e, url) => { + if (url !== mainWindow.webContents.getURL()) e.preventDefault() + }) + // 开发模式加载 dev server,生产加载打包后的 html if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) @@ -154,6 +160,9 @@ ipcMain.handle('clipboard:write', (_event, text: string) => { app.whenReady().then(() => { electronApp.setAppUserModelId('com.metonateam.difflens') + // 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格 + nativeTheme.themeSource = 'dark' + app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9bdd994..7fa6827 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,6 +31,47 @@ function stamp(): string { /** 拖拽导入的文件大小上限(与主进程 file:open 的 10MB 限制保持一致) */ const MAX_FILE_SIZE = 10 * 1024 * 1024 +/** 用户偏好(比较选项 + 视图开关)持久化键 */ +const PREFS_KEY = 'difflens.prefs.v1' + +/** 全部比较选项的默认值(严格对比) */ +const DEFAULT_OPTIONS: DiffOptions = { + trimWhitespace: false, + ignoreCase: false, + ignoreBlankLines: false, + ignoreAllWhitespace: false, + charMode: false +} + +/** + * 读取持久化偏好:只接受已知字段的严格 boolean 值; + * 数据损坏(JSON 非法 / 字段类型异常 / 存储不可用)一律回退默认,保证启动不受影响。 + */ +function loadPrefs(): { options: DiffOptions; onlyDiff: boolean } { + const fallback = { options: { ...DEFAULT_OPTIONS }, onlyDiff: false } + let raw: string | null = null + try { + raw = window.localStorage.getItem(PREFS_KEY) + } catch { + return fallback + } + if (!raw) return fallback + try { + const obj = JSON.parse(raw) as { + options?: Partial> + onlyDiff?: unknown + } + const bool = (v: unknown): boolean => v === true + const options: DiffOptions = { ...DEFAULT_OPTIONS } + for (const k of Object.keys(DEFAULT_OPTIONS) as (keyof DiffOptions)[]) { + options[k] = bool(obj.options?.[k]) + } + return { options, onlyDiff: bool(obj.onlyDiff) } + } catch { + return fallback + } +} + /** 计算中/无内容时的空统计(保持状态栏与徽章渲染稳定) */ const EMPTY_SUMMARY: DiffSummary = { changedLines: 0, inserted: 0, deleted: 0, modified: 0 } @@ -91,15 +132,10 @@ function EmptyPane({ export default function App(): ReactElement { const [paneL, setPaneL] = useState(null) const [paneR, setPaneR] = useState(null) - const [options, setOptions] = useState({ - trimWhitespace: false, - ignoreCase: false, - ignoreBlankLines: false, - ignoreAllWhitespace: false, - charMode: false - }) + // 比较选项与视图开关:初始值从持久化偏好恢复(惰性求值,每次挂载重新读取) + const [options, setOptions] = useState(() => loadPrefs().options) // 仅看差异:视图层过滤选项(不影响比较语义,也与报告导出无关,报告始终全量行) - const [onlyDiff, setOnlyDiff] = useState(false) + const [onlyDiff, setOnlyDiff] = useState(() => loadPrefs().onlyDiff) const [activeRowId, setActiveRowId] = useState(null) const [navIndex, setNavIndex] = useState(0) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) @@ -130,6 +166,15 @@ export default function App(): ReactElement { } }, []) + // 偏好写回:比较选项与视图开关变更即持久化(存储不可用时静默忽略,不影响本次会话) + useEffect(() => { + try { + window.localStorage.setItem(PREFS_KEY, JSON.stringify({ options, onlyDiff })) + } catch { + // 忽略写入失败(隐私模式/配额满等) + } + }, [options, onlyDiff]) + // 超多行预警:行数超过阈值时提示后台计算耗时(渲染已虚拟化、计算已 worker 化,均不阻塞界面) const warnHeavyLines = useCallback( (text: string): void => { @@ -322,9 +367,9 @@ export default function App(): ReactElement { async (fmt: ReportFormat) => { // 计算中(diff 为 null)不导出:正常情况下导出按钮已禁用,此处兜底防御 if (!diff) return - // 字符级对比降级时,报告头部按实际生效的行级语义描述(全部空白忽略 + 空行忽略) + // 字符级对比降级时,报告头部按实际生效的行级语义描述(与引擎降级分支的选项构造完全一致) const reportOptions: DiffOptions = diff.charModeDowngraded - ? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true } + ? { ...options, charMode: false, ignoreAllWhitespace: true, ignoreBlankLines: true, trimWhitespace: false } : options const ctx: ReportContext = { leftName: paneL?.meta.name ?? '', diff --git a/src/renderer/src/__tests__/App.test.tsx b/src/renderer/src/__tests__/App.test.tsx index 1a3d05b..0284167 100644 --- a/src/renderer/src/__tests__/App.test.tsx +++ b/src/renderer/src/__tests__/App.test.tsx @@ -26,6 +26,8 @@ function mockApi(overrides?: Partial): typeof window.api { beforeEach(() => { vi.restoreAllMocks() window.api = mockApi() + // 清除偏好持久化(比较选项 + 视图开关),避免用例间互相污染初始状态 + window.localStorage.clear() // jsdom 未实现滚动定位,导航定位(虚拟化受控 scrollTop)需要打桩 Element.prototype.scrollIntoView = vi.fn() Element.prototype.scrollTo = vi.fn() @@ -693,4 +695,77 @@ describe('App - 交换左右侧', () => { fireEvent.click(screen.getByText('⇄ 交换左右')) expect(files()).toEqual(['b.txt', 'a.txt']) }) +}) + +describe('App - 偏好持久化(比较选项 + 仅看差异)', () => { + const KEY = 'difflens.prefs.v1' + + it('启动时恢复上次保存的比较选项与仅看差异开关', async () => { + window.localStorage.setItem( + KEY, + JSON.stringify({ + options: { + trimWhitespace: false, + ignoreCase: false, + ignoreBlankLines: false, + ignoreAllWhitespace: true, + charMode: false + }, + onlyDiff: true + }) + ) + window.api = mockApi({ + openFile: async (side) => + side === 'left' + ? { path: '/tmp/a.txt', name: 'a.txt', text: 'same\ndiffL', encoding: 'UTF-8', binary: false } + : { path: '/tmp/b.txt', name: 'b.txt', text: 'same\ndiffR', encoding: 'UTF-8', binary: false } + }) + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByText('打开右侧')) + await screen.findAllByText('b.txt') + // 比较选项恢复:忽略所有空白已勾选 + expect(screen.getByLabelText('忽略所有空白')).toBeChecked() + // 仅看差异恢复:未更改行被折叠(same 不再展示) + expect(screen.queryAllByText('same')).toHaveLength(0) + // 关闭开关后恢复全量视图 + fireEvent.click(screen.getByLabelText('仅看差异')) + expect(screen.getAllByText('same').length).toBeGreaterThan(0) + }) + + it('损坏的持久化数据回退默认选项且不崩溃', async () => { + window.localStorage.setItem(KEY, '{broken json!!') + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText('导出报告')).toBeInTheDocument() + expect(screen.getByLabelText('忽略所有空白')).not.toBeChecked() + expect(screen.getByLabelText('字符级对比')).not.toBeChecked() + expect(screen.getByLabelText('仅看差异')).not.toBeChecked() + }) + + it('非布尔字段被防御性忽略(回退关闭)', async () => { + window.localStorage.setItem( + KEY, + JSON.stringify({ options: { ignoreCase: 'yes', charMode: 1 }, onlyDiff: 'true' }) + ) + render() + fireEvent.click(screen.getByText('打开左侧')) + expect(await screen.findByText('导出报告')).toBeInTheDocument() + expect(screen.getByLabelText('忽略大小写')).not.toBeChecked() + expect(screen.getByLabelText('字符级对比')).not.toBeChecked() + expect(screen.getByLabelText('仅看差异')).not.toBeChecked() + }) + + it('切换选项后写回持久化存储', async () => { + render() + fireEvent.click(screen.getByText('打开左侧')) + await screen.findByText('导出报告') + fireEvent.click(screen.getByLabelText('忽略大小写')) + const saved1 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}') + expect(saved1.options.ignoreCase).toBe(true) + fireEvent.click(screen.getByLabelText('仅看差异')) + const saved2 = JSON.parse(window.localStorage.getItem(KEY) ?? '{}') + expect(saved2.onlyDiff).toBe(true) + }) }) \ No newline at end of file diff --git a/src/renderer/src/hooks/useDiff.test.ts b/src/renderer/src/hooks/useDiff.test.ts index 07f8df4..fdedbf8 100644 --- a/src/renderer/src/hooks/useDiff.test.ts +++ b/src/renderer/src/hooks/useDiff.test.ts @@ -4,9 +4,10 @@ import { useDiff } from './useDiff' import { computeDiff, type DiffResult, type DiffOptions } from '../diff/diffEngine' import type { DiffWorkerFactory } from '../diff/createDiffWorker' -/** 模拟 Worker:记录派发请求,可手动回传结果;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */ +/** 模拟 Worker:记录派发请求,可手动回传结果或触发崩溃;terminated 后丢弃回传(对齐真实 worker 终止后不再触发事件) */ class FakeWorker { onmessage: ((e: { data: { jobId: number; result: DiffResult } }) => void) | null = null + onerror: (() => void) | null = null terminated = false posted: { jobId: number }[] = [] postMessage(data: { jobId: number }): void { @@ -19,6 +20,11 @@ class FakeWorker { if (this.terminated) return this.onmessage?.({ data: { jobId, result } }) } + /** 模拟运行中崩溃:触发 onerror(终止后不再触发,对齐真实 worker) */ + fail(): void { + if (this.terminated) return + this.onerror?.() + } } /** 每次调用产出新 FakeWorker 的工厂,并记录全部实例 */ @@ -188,4 +194,51 @@ describe('useDiff - worker 后台计算', () => { hook.unmount() expect(w.terminated).toBe(true) }) + + it('worker 运行中崩溃:终止实例并回退主线程同步计算', () => { + const factory = makeFactory() + const left = heavyText(2100) + const { result } = renderHook(() => useDiff(left, 'x', NO_OPTIONS, factory)) + act(() => { + vi.advanceTimersByTime(250) + }) + const w = factory.workers[0] + expect(result.current.computing).toBe(true) + act(() => { + w.fail() + }) + expect(w.terminated).toBe(true) + expect(result.current.diff).toEqual(computeDiff(left, 'x', {})) + expect(result.current.computing).toBe(false) + }) + + it('迟到错误(输入已变化)只终止不回填,新任务结果正常生效', () => { + const factory = makeFactory() + const hook = renderHook((p: string) => useDiff(p, 'x', NO_OPTIONS, factory), { + initialProps: heavyText(2100) + }) + act(() => { + vi.advanceTimersByTime(250) + }) + const w1 = factory.workers[0] + hook.rerender(heavyText(2300)) + // 模拟 terminate 前已排入事件队列的迟到 error(绕过 terminated 守卫直接触发) + act(() => { + w1.onerror?.() + }) + // 过期错误不回填:仍处于 computing 且无结果 + expect(hook.result.current.diff).toBeNull() + expect(hook.result.current.computing).toBe(true) + // 新任务正常完成 + act(() => { + vi.advanceTimersByTime(250) + }) + const w2 = factory.workers[1] + const res = computeDiff('m', 'n', {}) + act(() => { + w2.respond(w2.posted[0].jobId, res) + }) + expect(hook.result.current.diff).toEqual(res) + expect(hook.result.current.computing).toBe(false) + }) }) diff --git a/src/renderer/src/hooks/useDiff.ts b/src/renderer/src/hooks/useDiff.ts index 851bbcf..90e6b83 100644 --- a/src/renderer/src/hooks/useDiff.ts +++ b/src/renderer/src/hooks/useDiff.ts @@ -51,6 +51,8 @@ export function useDiff( // 重输入先清空旧结果:避免交换左右等场景下展示与新面板不对应的过期差异 setAsyncDiff(null) setComputing(true) + // effect 世代标志:cleanup 后置位,迟到回调(含去抖期内输入已变、jobId 尚未推进的窗口)一律丢弃 + let cancelled = false let w: Worker | null = null // worker 延迟到去抖到期才创建:内联 worker 实例化含 base64 解码与脚本编译, // 去抖期内输入再变时旧任务未创建即取消,零创建与计算开销 @@ -65,13 +67,22 @@ export function useDiff( const jobId = ++jobIdRef.current w.onmessage = (e: MessageEvent): void => { // 丢弃过期任务:输入已变化时旧计算结果不再生效 - if (e.data.jobId !== jobId) return + if (e.data.jobId !== jobId || cancelled) return setAsyncDiff(e.data.result) setComputing(false) } + // worker 运行中崩溃(未捕获异常/极端输入):终止实例后回退主线程同步计算, + // 避免 computing 永久卡死;迟到错误(effect 已取消或 jobId 已推进)只终止不回填 + w.onerror = (): void => { + w?.terminate() + if (cancelled || jobIdRef.current !== jobId) return + setAsyncDiff(computeDiff(left, right, options)) + setComputing(false) + } w.postMessage({ jobId, leftText: left, rightText: right, options }) }, DEBOUNCE_MS) return () => { + cancelled = true window.clearTimeout(timer) w?.terminate() }