mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-05 12:28:33 +00:00
fix(editor): 修复 IdleTask 在 requestIdleCallback timeout 时队列空转的问题
主线程持续繁忙时回调因 timeout 触发,timeRemaining() 恒为 0 导致任务无法执行且依赖收集卡住;改用时间预算分批推进并补充单测。
This commit is contained in:
parent
d2a02e16d2
commit
b544f05917
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个任务失败不能中断整个队列,否则后续任务永远不会被执行,
|
||||
* 依赖收集会停在半路(收集中状态与剩余任务数都不再变化)
|
||||
|
||||
@ -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('剩余时间 8(5-10 范围)单批最多 100', () => {
|
||||
test('剩余时间 8(5-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('剩余时间 12(10-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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user