fix(editor): 修复 IdleTask 在 requestIdleCallback timeout 时队列空转的问题

主线程持续繁忙时回调因 timeout 触发,timeRemaining() 恒为 0 导致任务无法执行且依赖收集卡住;改用时间预算分批推进并补充单测。
This commit is contained in:
roymondchen 2026-08-04 12:19:39 +08:00
parent d2a02e16d2
commit b544f05917
2 changed files with 183 additions and 28 deletions

View File

@ -13,6 +13,18 @@ type TaskList<T> = {
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<T = any> 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<T = any> 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<T = any> 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;
}
}
}
/**
*
*

View File

@ -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<number>();
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('剩余时间 85-10 范围)单批最多 100', () => {
test('剩余时间 85-10 范围)单批 100', () => {
const task = new IdleTask<number>();
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('剩余时间 1210-15 范围)单批 300', () => {
const task = new IdleTask<number>();
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<number>();
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<number>();
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<number>();
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<number>();
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<string>();
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<number>();
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<number>();
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<number>();
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();
});
});
});