diff --git a/packages/editor/src/utils/dep/idle-task.ts b/packages/editor/src/utils/dep/idle-task.ts index 7051d3f0..c0182fbe 100644 --- a/packages/editor/src/utils/dep/idle-task.ts +++ b/packages/editor/src/utils/dep/idle-task.ts @@ -13,6 +13,18 @@ type TaskList = { data: T; }[]; +/** + * 回调因 timeout 触发(主线程一直没有空闲)时,单次回调执行任务的时间预算,单位 ms + * 参考一帧内可让出的余量:既保证队列有进展,又不长时间阻塞主线程 + */ +const TIMEOUT_RUN_BUDGET = 5; + +/** + * 回调因 timeout 触发时,每两次读取时钟之间执行的任务数 + * 与空闲时间 <=5ms 时的批量保持一致,避免每个任务都读一次时钟 + */ +const TIMEOUT_BATCH_SIZE = 10; + globalThis.requestIdleCallback = globalThis.requestIdleCallback || function (cb) { @@ -94,6 +106,19 @@ export class IdleTask extends EventEmitter { this.taskHandle = null; try { + // 主线程一直没有空闲时,回调只会因 timeout 触发,此时 deadline.timeRemaining() 恒为 0(规范行为)。 + // 若仍以它作为循环条件,一个任务都执行不了,而 finishRun 又会接着重新调度, + // 队列就会无限空转下去:依赖收集永远不结束,collecting 卡在 true,画布也不再更新。 + // 这种情况下改用自有时间预算推进,既保证有进展,又不会像放开循环那样长时间阻塞主线程。 + if (deadline.didTimeout) { + const start = Date.now(); + do { + this.runTaskBatch(TIMEOUT_BATCH_SIZE); + } while (this.getTaskLength() && Date.now() - start < TIMEOUT_RUN_BUDGET); + + return; + } + // 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放 // 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久 // 执行不完不会影响配置,但是会影响画布渲染 @@ -110,17 +135,7 @@ export class IdleTask extends EventEmitter { times = 600; } - for (let i = 0; i < times; i++) { - // 每次都从实例上取队列,任务执行过程中调用 clearTasks 能立即生效,不会继续消费已被清空的旧队列 - const task = this.hightLevelTaskList.length > 0 ? this.hightLevelTaskList.shift() : this.taskList.shift(); - if (task) { - this.runTask(task); - } - - if (!this.getTaskLength()) { - break; - } - } + this.runTaskBatch(times); } } finally { // 必须放在 finally 中:一旦这里被跳过,taskHandle 会一直是真值, @@ -129,6 +144,23 @@ export class IdleTask extends EventEmitter { } } + /** + * 执行一批任务,队列被清空时提前结束 + */ + private runTaskBatch(times: number) { + for (let i = 0; i < times; i++) { + // 每次都从实例上取队列,任务执行过程中调用 clearTasks 能立即生效,不会继续消费已被清空的旧队列 + const task = this.hightLevelTaskList.length > 0 ? this.hightLevelTaskList.shift() : this.taskList.shift(); + if (task) { + this.runTask(task); + } + + if (!this.getTaskLength()) { + break; + } + } + } + /** * 单个任务失败不能中断整个队列,否则后续任务永远不会被执行, * 依赖收集会停在半路(收集中状态与剩余任务数都不再变化) diff --git a/packages/editor/tests/unit/utils/dep/idle-task.spec.ts b/packages/editor/tests/unit/utils/dep/idle-task.spec.ts index 1a442793..3d283a80 100644 --- a/packages/editor/tests/unit/utils/dep/idle-task.spec.ts +++ b/packages/editor/tests/unit/utils/dep/idle-task.spec.ts @@ -20,6 +20,15 @@ const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadl }; }; +/** + * 主线程一直没有空闲时浏览器的真实行为:回调因 timeout 触发, + * didTimeout 为 true 且 timeRemaining() 恒为 0 + */ +const timedOutIdleDeadline = (): IdleDeadline => ({ + didTimeout: true, + timeRemaining: () => 0, +}); + describe('IdleTask', () => { let originalRic: any; let originalCancel: any; @@ -40,6 +49,7 @@ describe('IdleTask', () => { }); afterEach(() => { + vi.useRealTimers(); globalThis.requestIdleCallback = originalRic; globalThis.cancelIdleCallback = originalCancel; }); @@ -69,38 +79,38 @@ describe('IdleTask', () => { expect(order[1]).toBe('low'); }); - test('剩余空闲时间 <=5 时单批最多 10 个任务', () => { + // callsBeforeZero 需为 2:第一次读取用于 while 判断,第二次读取才是决定单批任务数的值 + test('剩余空闲时间 <=5 时单批 10 个任务', () => { const task = new IdleTask(); const handler = vi.fn(); for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); - scheduled[0].cb(fakeIdleDeadline(3, 1)); - expect(handler.mock.calls.length).toBeGreaterThan(0); - expect(handler.mock.calls.length).toBeLessThanOrEqual(10); + scheduled[0].cb(fakeIdleDeadline(3, 2)); + expect(handler.mock.calls.length).toBe(10); }); - test('剩余时间 8(5-10 范围)单批最多 100', () => { + test('剩余时间 8(5-10 范围)单批 100', () => { const task = new IdleTask(); const handler = vi.fn(); for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); - scheduled[0].cb(fakeIdleDeadline(8, 1)); - expect(handler.mock.calls.length).toBeLessThanOrEqual(100); + scheduled[0].cb(fakeIdleDeadline(8, 2)); + expect(handler.mock.calls.length).toBe(100); }); - test('剩余时间 12 单批最多 300', () => { + test('剩余时间 12(10-15 范围)单批 300', () => { const task = new IdleTask(); const handler = vi.fn(); for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); - scheduled[0].cb(fakeIdleDeadline(12, 1)); - expect(handler.mock.calls.length).toBeLessThanOrEqual(300); + scheduled[0].cb(fakeIdleDeadline(12, 2)); + expect(handler.mock.calls.length).toBe(300); }); - test('剩余时间 50 单批最多 600', () => { + test('剩余时间 50(>15)单批 600', () => { const task = new IdleTask(); const handler = vi.fn(); for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); - scheduled[0].cb(fakeIdleDeadline(50, 1)); - expect(handler.mock.calls.length).toBeLessThanOrEqual(600); + scheduled[0].cb(fakeIdleDeadline(50, 2)); + expect(handler.mock.calls.length).toBe(600); }); test('完成所有任务后触发 finish 与 hight-level-finish 事件', () => { @@ -199,6 +209,96 @@ describe('IdleTask', () => { expect(afterHandler).toHaveBeenCalled(); }); + test('因 timeout 触发(无空闲时间)时任务仍会被执行,队列不会永久停滞', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 5; i++) task.enqueueTask(handler, i); + + scheduled[0].cb(timedOutIdleDeadline()); + + expect(handler).toHaveBeenCalledTimes(5); + expect(scheduled).toHaveLength(1); + }); + + test('因 timeout 触发时受时间预算约束,不会一次跑完巨大队列阻塞主线程', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); + + // 每读一次时钟推进 3ms:预算 5ms 时最多跑两批 + let now = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 3; + return now; + }); + + scheduled[0].cb(timedOutIdleDeadline()); + nowSpy.mockRestore(); + + expect(handler.mock.calls.length).toBe(20); + }); + + test('因 timeout 触发且仍有剩余任务时继续调度下一轮', () => { + const task = new IdleTask(); + const handler = vi.fn(); + for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i); + + let now = 0; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => { + now += 10; + return now; + }); + + scheduled[0].cb(timedOutIdleDeadline()); + nowSpy.mockRestore(); + + expect(scheduled.length).toBeGreaterThan(1); + expect(handler.mock.calls.length).toBeLessThan(1000); + }); + + test('因 timeout 触发时高优先级任务仍优先执行', () => { + const task = new IdleTask(); + const order: string[] = []; + task.enqueueTask(() => order.push('low'), 'low'); + task.enqueueTask(() => order.push('high'), 'high', true); + + scheduled[0].cb(timedOutIdleDeadline()); + + expect(order).toEqual(['high', 'low']); + }); + + test('因 timeout 触发时任务中调用 clearTasks 立即生效', () => { + const task = new IdleTask(); + const handler = vi.fn((n: number) => { + if (n === 0) { + task.clearTasks(); + } + }); + for (let i = 0; i < 200; i++) task.enqueueTask(handler, i); + + scheduled[0].cb(timedOutIdleDeadline()); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('因 timeout 触发时单个任务抛错不会中断队列', () => { + const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined); + const task = new IdleTask(); + const done: number[] = []; + + for (let i = 0; i < 5; i++) { + task.enqueueTask((n) => { + if (n === 2) throw new Error('boom'); + done.push(n); + }, i); + } + + expect(() => scheduled[0].cb(timedOutIdleDeadline())).not.toThrow(); + expect(done).toEqual([0, 1, 3, 4]); + expect(errorSpy).toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + test('clearTasks - 取消挂起任务并重置队列', () => { const task = new IdleTask(); task.enqueueTask(() => undefined, 1); @@ -222,19 +322,42 @@ describe('IdleTask', () => { test('全局 requestIdleCallback polyfill 在浏览器无原生时降级到 setTimeout', () => { vi.useFakeTimers(); - const original = globalThis.requestIdleCallback; delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; // 重新加载模块以触发 polyfill 注册 vi.resetModules(); return import('@editor/utils/dep/idle-task').then(() => { expect(typeof globalThis.requestIdleCallback).toBe('function'); - const cb = vi.fn(); + expect(typeof globalThis.cancelIdleCallback).toBe('function'); + + let deadline: IdleDeadline | undefined; + const cb = vi.fn((current: IdleDeadline) => { + deadline = current; + }); const id = globalThis.requestIdleCallback(cb); expect(id).toBeDefined(); vi.runAllTimers(); + expect(cb).toHaveBeenCalled(); - vi.useRealTimers(); - globalThis.requestIdleCallback = original; + // polyfill 走不到原生的 timeout 语义,didTimeout 恒为 false,并给出一帧内的剩余时间 + expect(deadline?.didTimeout).toBe(false); + expect(deadline?.timeRemaining()).toBeLessThanOrEqual(50); + expect(deadline?.timeRemaining()).toBeGreaterThanOrEqual(0); + }); + }); + + test('全局 cancelIdleCallback polyfill 取消后回调不再触发', () => { + vi.useFakeTimers(); + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + vi.resetModules(); + return import('@editor/utils/dep/idle-task').then(() => { + const cb = vi.fn(); + const id = globalThis.requestIdleCallback(cb); + globalThis.cancelIdleCallback(id); + vi.runAllTimers(); + + expect(cb).not.toHaveBeenCalled(); }); }); });