2023年9月,土耳其电商平台Trendyol的产品详情页INP指标高达963毫秒,处于"差"评级。用户点击商品后,页面近乎冻结。六个月后,这个数字降到了481毫秒——INP改善50%,点击率提升1%。转折点只改动了了几行代码:用scheduler.yield()替换了setTimeout

这不是个例。JavaScript运行了二十五年,开发者一直用setTimeout(fn, 0)让出主线程。这个方法有效,但不完美。HTML规范规定,嵌套超过四层的setTimeout会被强制加入至少4毫秒的延迟。更关键的是,让出后的代码被排到任务队列末尾——你给浏览器让了路,却失去了自己的位置。

50毫秒的红线

浏览器的主线程是独木桥。渲染、JavaScript执行、用户输入处理、网络请求回调,全部挤在这条窄道上。任何一个任务霸占太久,其他工作就得等待。

Chrome团队在2019年的研究中划定了一条红线:50毫秒。超过这个阈值的任务被称为"长任务"(Long Task)。为什么是50毫秒?因为人类对延迟的感知起点约为100毫秒,而浏览器需要在用户感知到卡顿之前完成渲染帧。以60帧每秒计算,每帧约16.67毫秒,三帧就是50毫秒——这是保证界面流畅的最低要求。

Chrome DevTools性能面板中显示的长任务,红色斜线区域表示超过50毫秒的阻塞部分

图片来源: web.dev - Optimize long tasks

长任务的危害远超"卡顿"二字。当主线程被占用时,用户的点击、滚动、键盘输入全部被阻塞。表单提交按钮点击后毫无反应,用户可能会重复点击,触发意外的多次提交。搜索引擎优化的核心指标INP(Interaction to Next Paint)会直接崩塌。

2024年3月,INP正式取代首次输入延迟(FID)成为Core Web Vitals的一部分。Google的搜索排名算法开始将INP作为衡量页面质量的硬性指标。INP测量的是用户交互到浏览器完成下一次绘制的时间,包括输入延迟、处理时间和渲染延迟三个部分。一个长任务可以直接把INP推入"差"区间(超过500毫秒)。

setTimeout的隐形成本

传统方案很简单:把大任务切成小块,中间插入让出。

async function saveSettings() {
  validateForm();
  showSpinner();
  
  // 让出主线程
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

这招有效了几十年,但存在三个致命缺陷。

缺陷一:嵌套延迟陷阱

HTML规范第8.6节明确规定:当setTimeout嵌套调用超过五次时,浏览器会强制执行至少4毫秒的最小延迟。这是为了防止恶意脚本通过无限循环占用CPU。

规范原文写道:“Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.”

这意味着,如果你在循环中使用setTimeout来分批处理数据,第五次迭代后每次都会被延迟至少4毫秒。处理1000个元素,可能凭空增加近4秒的总延迟。

缺陷二:队尾插入

setTimeout的回调被添加到任务队列的末尾。假设你的代码结构如下:

setTimeout(myJob);           // 你的任务
setTimeout(someoneElsesJob); // 其他脚本的任务

即使myJob先注册,如果你在myJob内部用setTimeout让出:

function myJob() {
  myJobPart1();
  setTimeout(myJobPart2, 0); // 让出
}

执行顺序变成:myJobPart1someoneElsesJobmyJobPart2。你的后半部分工作被其他任务插队了。

使用setTimeout让出后,任务续行被排到队列末尾,其他任务获得执行机会

图片来源: Chrome Developers - Use scheduler.yield()

在真实场景中,这可能导致严重后果。你的关键业务逻辑被第三方分析脚本、广告代码、框架更新任务抢占,延迟变得不可预测。

缺陷三:无优先级概念

setTimeout无法表达任务的紧急程度。用户正在等待的界面更新,和后台发送的分析数据,被同等对待。开发者只能通过调整延迟值来模拟优先级,但这只是曲线救国。

isInputPending:被废弃的尝试

2019年,Facebook向Chromium贡献了一个新API:navigator.scheduling.isInputPending()。这个API允许开发者检查是否有用户输入等待处理,只在必要时才让出主线程。

while (workQueue.length > 0) {
  if (navigator.scheduling.isInputPending()) {
    setTimeout(processRemaining, 0);
    break;
  }
  processNextItem();
}

理论上,这比无条件让出更高效。如果用户没有交互,代码可以持续执行,避免上下文切换的开销。

但这个API在2023年被正式废弃。原因有三:

第一,isInputPending可能在用户交互后返回false。浏览器内部状态更新存在延迟,API的判断不够准确。

第二,用户输入不是唯一需要让出的场景。动画帧更新、渲染任务、网络回调都可能比当前任务更重要。只检查输入会导致动画掉帧。

第三,更完善的API已经出现。scheduler.yield()scheduler.postTask()提供了更优雅的解决方案。

Chrome官方文档明确写道:“We no longer recommend using this API, and instead recommend yielding regardless of whether input is pending or not.”

Scheduler API的设计哲学

优先任务调度API(Prioritized Task Scheduling API)不是一个方法,而是一套完整的任务调度体系。它包含三个核心组件:

  • scheduler.postTask():按优先级调度新任务
  • scheduler.yield():让出主线程并优先续行
  • TaskController/TaskSignal:控制任务的优先级和取消

这套API的设计理念源自一个朴素的认识:浏览器是全局协调者。页面中可能存在多个调度器——框架自己的调度器(如React)、业务代码的手动调度、第三方脚本的内部调度。每个调度器只能控制自己管辖的任务,对其他任务一无所知。而浏览器拥有全局视角,知道哪些任务是用户正在等待的,哪些可以延后。

三级优先级体系

W3C规范定义了三个任务优先级:

优先级 名称 用途 有效优先级值
最高 user-blocking 用户直接感知的交互响应、视口内UI更新 4(任务)/ 5(续行)
中等 user-visible 用户可见但不紧急的更新,默认优先级 2(任务)/ 3(续行)
最低 background 后台分析、日志、非关键初始化 0(任务)/ 1(续行)

user-blocking任务会在其他所有任务之前执行。但注意,它不是"阻塞渲染"——阻塞渲染的是同步代码。user-blocking任务是异步的,可以被更高优先级的输入事件打断。

续行(Continuation)的任务会比同优先级的新任务更高一级。这是关键设计:你让出了主线程,但不应该因此受到惩罚。你的后续工作应该在同优先级任务之前继续。

scheduler.postTask()详解

postTask是调度新任务的主要入口:

// 基本用法
scheduler.postTask(callback, options);

// 立即执行高优先级任务
scheduler.postTask(() => {
  updateUI();
}, { priority: 'user-blocking' });

// 延迟执行后台任务
scheduler.postTask(() => {
  sendAnalytics();
}, { priority: 'background', delay: 1000 });

options对象支持以下参数:

  • priority:固定优先级,一旦设置不可更改
  • signalAbortSignalTaskSignal,用于取消或动态调整优先级
  • delay:延迟执行时间(毫秒)

返回值是Promise,可以用await等待结果:

const result = await scheduler.postTask(async () => {
  return await fetchData();
}, { priority: 'user-visible' });

TaskController与动态优先级

当任务需要动态调整优先级时,使用TaskController

const controller = new TaskController({ priority: 'background' });

// 初始为后台任务
scheduler.postTask(() => {
  // 当用户滚动到该区域时,提升优先级
  observer.onIntersection = () => {
    controller.setPriority('user-blocking');
  };
  
  loadVisibleContent();
}, { signal: controller.signal });

TaskController继承自AbortController,所以它的signal既可以用于取消,也可以用于优先级控制。调用controller.abort()会取消所有关联的任务。

动态优先级的典型场景:图片懒加载。图片在视口外时,加载任务优先级为background;用户滚动接近时,提升为user-visible;图片进入视口时,进一步提升为user-blocking

scheduler.yield():优先级续行的核心

scheduler.yield()是整个API中最常用的方法。它的行为看似简单——返回一个Promise,在下一个事件循环任务中resolve——但实现了一个关键语义:续行优先。

async function processLargeDataset(data) {
  for (let i = 0; i < data.length; i++) {
    processItem(data[i]);
    
    // 每处理50毫秒让出一次
    if (performance.now() - startTime > 50) {
      await scheduler.yield();
      startTime = performance.now();
    }
  }
}

使用scheduler.yield()让出后,任务续行保持优先执行,其他任务无法插队

图片来源: Chrome Developers - Use scheduler.yield()

与setTimeout的本质区别

特性 setTimeout(fn, 0) scheduler.yield()
最小延迟 4ms(嵌套后)
任务位置 队列末尾 同优先级队列前端
优先级继承 继承父任务优先级
可取消性 需要手动存储ID 自动通过Signal取消

最关键的区别在"优先级继承"。如果你在background优先级的任务中调用scheduler.yield(),续行仍然是background优先级,但它会在所有background新任务之前执行。

async function lowPriorityJob() {
  part1();
  await scheduler.yield(); // 续行仍是background,但优先于其他background新任务
  part2();
}

scheduler.postTask(lowPriorityJob, { priority: 'background' });

批量处理的最佳实践

无脑让出效率低下。每次yield()都有上下文切换开销,如果单次迭代耗时极短,开销会超过收益。

推荐的策略是按时间预算让出:

async function processJobs(jobs, budget = 50) {
  let lastYield = performance.now();
  
  for (const job of jobs) {
    job();
    
    if (performance.now() - lastYield >= budget) {
      await scheduler.yield();
      lastYield = performance.now();
    }
  }
}

预算值通常设为50毫秒,与长任务阈值一致。这样既能保证不超过长任务限制,又能最小化让出次数。

生产案例:Trendyol的INP优化

Trendyol是土耳其最大的电商平台,日均访问量数千万。他们的商品列表页(PLP)使用React 16.9.0,通过Intersection Observer实现图片懒加载。

问题出在Observer回调中。当商品滚动进入视口时,回调触发setState,导致React重新渲染。在低端设备上,这个回调执行时间超过700毫秒,形成巨大的长任务。

Trendyol商品列表页的737毫秒长任务,由Intersection Observer回调触发

图片来源: web.dev - Trendyol INP Case Study

Trendyol团队的修复方案:

async function yieldToMain() {
  if ('scheduler' in window && 'yield' in scheduler) {
    return await scheduler.yield();
  }
  return new Promise(resolve => setTimeout(resolve, 0));
}

const observer = new IntersectionObserver((entries) => {
  entries.forEach(handleIntersection);
  
  if (maxEntries > 0) {
    await yieldToMain(); // 关键改动
    this.setState({ count: maxEntries });
  }
}, { threshold: 0.5 });

改动只有三行,效果立竿见影:

Trendyol的长任务被拆分成多个小任务,INP降低50%

图片来源: web.dev - Trendyol INP Case Study

  • INP从963毫秒降至481毫秒,改善50%
  • 商品详情页点击率提升1%
  • 用户感知的响应速度显著提升

这个案例的意义在于:不需要重写架构,不需要升级框架版本,只需要在关键位置插入一行await scheduler.yield()

Airbnb的postTask实践

Airbnb与Chrome团队合作,在2021年就开始探索postTask的应用场景。他们的主要优化目标不是初始加载,而是页面加载后的交互响应。

场景一:图片预加载

Airbnb的房源列表页有图片轮播。当用户在第一张图片停留超过一秒时,预加载后续图片。但预加载不能影响滚动性能。

function setupPreloader(carousel) {
  const controller = new TaskController({ priority: 'background' });
  
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.intersectionRatio > 0.5) {
        // 延迟1秒后开始预加载,如果用户还在看
        scheduler.postTask(() => {
          preloadNextImages(carousel);
        }, { 
          signal: controller.signal,
          delay: 1000,
          priority: 'background'
        });
      } else {
        // 用户离开,取消预加载
        controller.abort();
      }
    });
  }, { threshold: 0.5 });
}

当轮播滑出视口时,预加载任务自动取消,避免浪费带宽和CPU。

场景二:延迟加载非关键组件

// 等待页面加载完成后,延迟5秒加载服务工作者
scheduler.postTask(() => {
  registerServiceWorker();
}, { priority: 'background', delay: 5000 });

场景三:错开网络请求

当用户快速滑动轮播时,连续预加载多张图片。Airbnb使用postTask的延迟参数错开请求,避免网络拥塞:

async function preloadImages(images, staggerDelay = 100) {
  for (let i = 0; i < images.length; i++) {
    scheduler.postTask(() => {
      loadImage(images[i]);
    }, { 
      priority: 'background',
      delay: i * staggerDelay
    });
  }
}

与其他调度API的对比

requestIdleCallback

requestIdleCallback专门用于空闲时段执行后台任务。它在浏览器确定没有更重要的工作时触发。

requestIdleCallback((deadline) => {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    processTask(tasks.shift());
  }
});

区别在于:

  • requestIdleCallback的执行时机完全由浏览器决定,可能永远不会执行
  • scheduler.postTask({ priority: 'background' })保证执行,只是在所有高优先级任务之后
  • requestIdleCallback提供了deadline.timeRemaining(),可以精确控制执行时长

最佳实践:使用requestIdleCallback做真正的"空闲时执行",使用scheduler做"低优先级但保证执行"。

requestAnimationFrame

requestAnimationFrame在下一帧渲染之前调用,用于动画和视觉更新。

requestAnimationFrame((timestamp) => {
  updateAnimation(timestamp);
});

scheduler.yield()requestAnimationFrame服务于不同目的:

  • rAF用于视觉同步,确保动画流畅
  • yield用于任务让出,确保响应性

它们可以组合使用:

async function processAndAnimate() {
  processChunk();
  await scheduler.yield(); // 让出给输入处理
  requestAnimationFrame(render); // 在下一帧渲染
}

Web Workers

Web Workers完全脱离主线程,在独立线程中执行。这是彻底解决长任务的方案,但代价是:

  • 数据传输需要序列化/反序列化
  • 无法直接操作DOM
  • 额外的内存开销

scheduler.yield()是折中方案:保持在主线程,但分批执行。对于纯计算密集型任务,Workers仍然是首选。对于需要操作DOM或使用主线程API的任务,yield更合适。

浏览器兼容性与Polyfill

截至2025年初的浏览器支持情况:

浏览器 scheduler.postTask scheduler.yield
Chrome 87+ 115+
Edge 87+ 115+
Firefox 部分支持 142+
Safari 不支持 不支持

Safari目前不支持Scheduler API。对于不支持的浏览器,需要降级处理。

简单降级

function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  // 降级到setTimeout
  return new Promise(resolve => setTimeout(resolve, 0));
}

这种方案在支持的浏览器中获得优先级续行,在不支持的浏览器中退化到队尾插入。虽然不是最优,但保证了基本功能。

进阶降级:MessageChannel

MessageChannelpostMessagesetTimeout更快,没有最小延迟限制:

function fastYield() {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }
  return new Promise(resolve => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve;
    channel.port2.postMessage(null);
  });
}

这在旧浏览器中性能更好,但仍然没有优先级续行。

Polyfill方案

scheduler-polyfill库提供了更完整的模拟:

import { Scheduler } from 'scheduler-polyfill';

// 在Safari中也能使用scheduler API
if (!window.scheduler) {
  window.scheduler = new Scheduler();
}

Polyfill无法完美模拟优先级续行,因为底层机制依赖浏览器的任务调度器。但它提供了统一的API接口。

设计权衡与最佳实践

何时使用scheduler.yield()

适用场景:

  • 长时间运行的数据处理循环
  • 大量DOM操作分批执行
  • 复杂计算后的UI更新
  • 任何超过50毫秒的同步任务

不适用场景:

  • 单次操作极短(<1ms)的循环——yield开销更大
  • 可以完全移到Web Worker的纯计算任务
  • 必须同步完成的关键路径操作

优先级选择指南

任务类型 推荐优先级
用户点击响应、表单验证、关键UI更新 user-blocking
数据加载后的渲染、非紧急更新 user-visible(默认)
分析发送、日志记录、预加载 background

避免过度让出

// 错误:每个迭代都让出,开销巨大
for (const item of items) {
  process(item);
  await scheduler.yield(); // 如果process只有1ms,这是浪费
}

// 正确:按时间预算让出
let lastYield = performance.now();
for (const item of items) {
  process(item);
  if (performance.now() - lastYield > 50) {
    await scheduler.yield();
    lastYield = performance.now();
  }
}

与框架集成

React 18的并发模式内部实现了自己的调度器。在React应用中,优先使用框架提供的API(如startTransitionuseDeferredValue)。scheduler.yield()适用于React之外的代码,或需要更底层控制的场景。

Vue 3和Angular没有内置的任务调度机制,可以直接使用scheduler.yield()优化长任务。

技术演进的脉络

JavaScript任务调度的演进反映了一个趋势:从开发者手工管理,到框架封装,再到浏览器原生支持。

早期的网页交互简单,setTimeout足够用。单页应用兴起后,复杂交互带来性能挑战,框架开始实现自己的调度器。React的Fiber架构、Vue的异步更新队列,都是在用户空间解决调度问题。

但用户空间调度器有天然局限:它们看不到其他脚本的任务。一个React应用可能调度得很完美,但引入一个第三方分析脚本后,调度策略就会被打乱。

Scheduler API的意义在于将调度能力下沉到浏览器层。浏览器拥有全局视角,可以协调页面中所有脚本的执行顺序。这是对JavaScript运行时模型的一次重要扩展。

W3C规范明确写道:“The browser is the ideal coordination point since the browser has global information, and the event loop is responsible for running tasks.”

这不是终点。Scheduler API仍在演进中。scheduler.wait()提案将支持等待特定事件后再执行任务。更细粒度的帧预算控制也在讨论中。

结语

scheduler.yield()的引入,标志着JavaScript任务调度进入新阶段。它不是银弹——对于真正计算密集型任务,Web Workers仍然是最佳选择。但对于需要在主线程执行、又不能阻塞交互的任务,它提供了一个简单、高效、符合直觉的解决方案。

Trendyol案例证明,几行代码的改动就能带来可观的业务收益。这不是技术炫技,而是对用户体验本质理解的体现:让用户的交互永远优先于代码的执行。

核心原则只有一条:当代码需要长时间运行时,定期让出主线程,让浏览器有机会响应用户。scheduler.yield()让这件事变得前所未有的简单。


参考资料

  1. W3C. Prioritized Task Scheduling API Specification. https://wicg.github.io/scheduling-apis/
  2. Chrome Developers. Use scheduler.yield() to break up long tasks. https://developer.chrome.com/blog/use-scheduler-yield
  3. MDN Web Docs. Scheduler: yield() method. https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
  4. web.dev. Optimize long tasks. https://web.dev/articles/optimize-long-tasks
  5. web.dev. How Trendyol reduced INP by 50%. https://web.dev/case-studies/trendyol-inp
  6. Airbnb Engineering. Building a Faster Web Experience with the postTask Scheduler. https://medium.com/airbnb-engineering/building-a-faster-web-experience-with-the-posttask-scheduler-276b83454e91
  7. HTML Standard. 8.6 Timers. https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html
  8. web.dev. Interaction to Next Paint (INP). https://web.dev/articles/inp
  9. GitHub WICG. scheduling-apis - yield-and-continuation.md. https://github.com/WICG/scheduling-apis/blob/main/explainers/yield-and-continuation.md
  10. MDN Web Docs. TaskController. https://developer.mozilla.org/en-US/docs/Web/API/TaskController