一个按钮点击的完整旅程

当用户点击网页上的"提交订单"按钮时,他的期待是瞬间得到反馈。但现实往往是:按钮按下后,页面没有任何反应,用户开始怀疑是否真的点击成功了。这种不确定性会驱使用户重复点击,最终可能导致重复提交。

这个"延迟感"从何而来?要回答这个问题,我们需要追踪一次点击从手指触碰到屏幕更新的完整路径。

1982年,Walter J. Doherty在IBM的系统性能研究中发现了一个关键阈值:当系统响应时间低于400毫秒时,用户的生产力会显著提升。这个发现后来被称为"Doherty阈值"。但400毫秒在今天看来只是一个底线——现代用户对响应速度的期望已经远远更高。

Google的RAIL模型给出了更精确的时间窗口:

  • 0-100毫秒:用户感知为即时响应
  • 100-300毫秒:用户察觉到轻微延迟
  • 300-1000毫秒:用户感觉任务正在进行
  • 1000毫秒以上:用户失去耐心

为什么要在50毫秒内处理事件,才能保证100毫秒内的响应?因为浏览器在处理用户输入之前,可能正在执行其他任务。如果一个后台任务占用了50毫秒,而事件处理又需要50毫秒,总延迟就达到了100毫秒。

浏览器事件处理管道:从硬件中断到JavaScript回调

一次点击要经过多长的旅程?让我们从硬件层面开始追踪。

输入事件的诞生

当用户的手指触碰屏幕时,触摸控制器会产生硬件中断。操作系统内核捕获这个中断,将其封装为输入事件,然后传递给浏览器进程。这个过程本身通常在几毫秒内完成。

Chrome的多进程架构在这里发挥了关键作用。浏览器进程负责接收所有输入事件,然后根据命中测试结果将事件路由到正确的渲染进程。如果点击发生在一个跨域iframe内,事件会被转发到对应站点的渲染进程。

主线程的困境

问题出在渲染进程的主线程上。主线程是浏览器中最繁忙的线程,它负责:

  • 执行JavaScript代码
  • 计算样式(Style)
  • 执行布局(Layout)
  • 绘制(Paint)
  • 解析HTML和CSS

主线程一次只能做一件事。当主线程正在执行一个耗时的JavaScript函数时,所有其他任务都必须等待——包括响应用户输入。

这就是**输入延迟(Input Delay)**的来源。根据web.dev的定义,输入延迟是指从用户发起交互到事件回调开始执行之间的时间。

交互延迟 = 输入延迟 + 处理时间 + 呈现延迟

输入延迟本身不是事件处理程序的执行时间,而是用户输入"排队等待"的时间。如果主线程正在处理一个200毫秒的长任务,用户恰好在这期间点击了按钮,那么这个点击就要等200毫秒才能被处理。

Chrome的对齐输入事件机制

Chrome 60引入了一项重要优化:对齐输入事件(Aligned Input Events)

触摸屏的输入频率通常为60-120Hz,鼠标可达100Hz甚至2000Hz,但屏幕刷新率只有60Hz。这意味着浏览器接收输入的速度比它能更新屏幕的速度更快。

Chrome的解决方案是延迟派发连续事件(wheeltouchmovepointermove等),将它们与requestAnimationFrame回调对齐。这样每个渲染帧只处理一次输入事件,避免了不必要的重复工作。

Chrome团队在实验中发现,这项优化减少了35%的命中测试次数,让主线程更频繁地准备好响应输入。

主线程阻塞:长任务的隐形代价

什么是长任务

浏览器将任何执行时间超过50毫秒的任务定义为长任务(Long Task)。超过50毫秒的部分称为阻塞时间(Blocking Time)

为什么是50毫秒?因为浏览器需要在16.67毫秒内完成一帧的渲染(60FPS),但实际的渲染工作需要约6毫秒,留给JavaScript的时间只有10毫秒左右。为了留出安全裕量,RAIL模型建议事件处理在50毫秒内完成,这样即使有其他任务在运行,用户输入最多也只需要等待50毫秒。

长任务的典型来源

1. 大量数据处理

function processLargeArray(items) {
  const results = [];
  for (let i = 0; i < items.length; i++) {
    // 复杂的数据处理
    results.push(heavyComputation(items[i]));
  }
  return results;
}

如果items有10000个元素,每个元素处理需要0.1毫秒,整个循环就要执行1000毫秒。

2. 同步网络请求

虽然现代Web开发已经很少使用同步XHR,但某些遗留代码或第三方库可能仍在使用。

3. 复杂的DOM操作

批量修改DOM后强制同步布局(Layout Thrashing):

function badPattern() {
  const elements = document.querySelectorAll('.item');
  elements.forEach(el => {
    const height = el.offsetHeight; // 触发布局
    el.style.height = height * 2 + 'px'; // 修改样式
  });
}

每次读取offsetHeight都会强制浏览器执行布局,然后修改样式又会触发新的布局计算。

4. 第三方脚本

分析工具、广告脚本、聊天组件等第三方代码经常在页面加载后继续执行,可能随时阻塞主线程。

长任务对交互的影响

当一个长任务正在执行时,主线程无法响应任何输入。用户点击按钮后,事件被放入任务队列,但必须等长任务完成后才能被处理。

下图展示了长任务如何影响交互延迟:

时间线:
|----长任务(200ms)----|用户点击|---等待---|事件处理|
                      ↑                    ↑
                   点击时刻             开始处理
                   输入延迟 ≈ 200ms

INP:测量交互响应的新标准

从FID到INP

2024年3月12日,INP(Interaction to Next Paint)正式取代FID(First Input Delay),成为Core Web Vitals的新指标。

FID只测量页面首次交互的输入延迟,这是一个严重的设计缺陷。一个页面可能有很快的首次响应,但后续交互却异常缓慢。更重要的是,FID只测量输入延迟,不考虑事件处理时间和呈现延迟。

INP则观察页面整个生命周期中的所有交互,测量从用户交互到屏幕呈现下一帧的完整时间。

INP如何计算

INP的计算逻辑如下:

  1. 记录页面所有交互(点击、触摸、键盘)的延迟
  2. 对于交互次数超过50次的页面,忽略每50次交互中最差的那一次
  3. 最终取75百分位值作为页面的INP

INP的阈值:

  • 良好:200毫秒或以下
  • 需要改进:200-500毫秒
  • 较差:超过500毫秒

一次交互的完整测量

INP将一次交互分解为三个阶段:

  1. 输入延迟(Input Delay):从用户输入到事件回调开始执行
  2. 处理时间(Processing Duration):事件回调的执行时间
  3. 呈现延迟(Presentation Delay):从回调完成到下一帧绘制
用户点击
    ↓
[输入延迟] → 事件回调排队等待
    ↓
[处理时间] → 事件回调执行
    ↓
[呈现延迟] → 样式计算、布局、绘制
    ↓
屏幕更新

这三个阶段的总和就是INP测量的交互延迟。

框架层面的响应机制

不同前端框架对交互响应的处理方式各有特点,理解这些差异有助于做出正确的技术选择。

React Fiber:可中断的渲染

React 16引入的Fiber架构是前端响应式渲染的里程碑。在Fiber之前,React的渲染是同步的——一旦开始渲染整个组件树,就无法中断。这导致大型应用的更新会阻塞主线程长达数百毫秒。

Fiber的核心思想是将渲染工作拆分成小单元,每个Fiber节点代表一个组件的工作单元。React可以:

  • 暂停当前渲染,让出主线程
  • 恢复之前暂停的渲染
  • 根据优先级决定先处理哪个更新
  • 废弃不再需要的渲染工作

优先级调度(Lanes)

React使用31位的位掩码来表示更新优先级,称为Lanes。每个更新被分配到一个Lane:

// 不同优先级的Lane
const SyncLane = 0b0000000000000000000000000000001; // 同步,最高优先级
const InputContinuousHydrationLane = 0b0000000000000000000000000000010; // 连续输入
const DefaultLane = 0b0000000000000000000000000000100; // 默认
const TransitionLane = 0b0000000000000000000000000010000; // 过渡更新
const IdleLane = 0b0100000000000000000000000000000; // 空闲

用户交互(点击、输入)产生的是高优先级更新,而数据获取、大型列表渲染等可以是低优先级。

startTransition的使用

import { startTransition, useState } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    // 高优先级:立即更新输入框
    setQuery(e.target.value);
    
    // 低优先级:延迟更新搜索结果
    startTransition(() => {
      const filtered = filterLargeDataset(e.target.value);
      setResults(filtered);
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultList results={results} />
    </>
  );
}

startTransition将其内部的更新标记为过渡更新,React会在高优先级更新完成后再处理它们。

Vue的响应式系统与nextTick

Vue的响应式系统采用依赖追踪机制。当组件状态改变时,Vue不会立即更新DOM,而是将更新加入队列,在下一个"tick"统一执行。

异步更新队列

// Vue内部的更新队列
const queue = [];
let isFlushing = false;

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job);
    if (!isFlushing) {
      isFlushing = true;
      Promise.resolve().then(flushJobs);
    }
  }
}

function flushJobs() {
  while (queue.length) {
    const job = queue.shift();
    job();
  }
  isFlushing = false;
}

这意味着多次状态修改只会触发一次DOM更新:

this.count++;
this.message = 'updated';
this.items.push(newItem);
// DOM只会更新一次

nextTick的作用

nextTick允许在DOM更新完成后执行代码:

this.showModal = true;
// 此时DOM可能还没更新
await this.$nextTick();
// 现在可以安全地操作更新后的DOM
this.$refs.modalInput.focus();

Vue 3使用Promise.resolve()作为nextTick的实现,这会将回调推迟到微任务队列执行。

Angular的Zone.js与变更检测

Angular通过Zone.js实现了自动变更检测。Zone.js修补了所有异步API(setTimeout、Promise、事件监听器等),在异步操作完成后触发变更检测。

Zone.js的性能代价

Zone.js的问题在于它会在每次异步操作后都触发变更检测,即使这次操作并没有改变组件状态。这导致了不必要的检测开销。

@Component({...})
class MyComponent {
  ngOnInit() {
    // 每次定时器触发都会触发变更检测
    setInterval(() => {
      console.log('tick');
    }, 1000);
  }
}

OnPush策略

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})
class OptimizedComponent {
  // 只有在输入属性变化或手动触发时才检测
}

NgZone.runOutsideAngular

constructor(private ngZone: NgZone) {}

startHeavyTask() {
  this.ngZone.runOutsideAngular(() => {
    // 此处执行不触发变更检测
    setInterval(() => {
      // 更新一些不需要Angular检测的状态
    }, 100);
  });
}

Angular 20.2将Zoneless模式从开发者预览升级为稳定版,开发者可以完全禁用Zone.js,使用信号(Signals)来精确控制变更检测。

优化策略:从代码到架构

1. 分解长任务:scheduler.yield()

setTimeout的问题

传统的任务分解使用setTimeout

function processItems(items) {
  for (const item of items) {
    processItem(item);
    setTimeout(() => {}, 0); // 让出主线程
  }
}

这有几个问题:

  • 嵌套5层以上的setTimeout会被强制延迟至少4毫秒
  • 让出的任务会被添加到队列末尾,其他任务可能插队
  • 无法保证执行顺序

scheduler.yield()的优势

Chrome 115引入了scheduler.yield()

async function processItems(items) {
  for (const item of items) {
    processItem(item);
    await scheduler.yield(); // 让出主线程,但延续优先
  }
}

scheduler.yield()的关键优势是延续优先(Yield and Continue):让出的工作会在其他待处理任务之前继续执行,不会被第三方脚本打断。

兼容性处理

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

2. 时间切片策略

不应该在每个迭代都让出主线程,这会产生过多开销。更好的策略是设置时间截止:

async function processItems(items, deadline = 50) {
  let lastYield = performance.now();
  
  for (const item of items) {
    processItem(item);
    
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

这样只有在连续工作超过50毫秒后才让出主线程,平衡了响应性和效率。

3. 事件处理优化

防抖(Debounce)

适用于连续触发但只需最后一次结果的场景:

function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const handleSearch = debounce((query) => {
  fetchResults(query);
}, 300);

节流(Throttle)

适用于需要固定频率执行的场景:

function throttle(fn, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

const handleScroll = throttle(() => {
  updateScrollIndicator();
}, 100);

Passive Event Listeners

对于touchstartwheel等可能阻塞滚动的事件,使用被动监听器:

element.addEventListener('wheel', handleWheel, { passive: true });

被动监听器告诉浏览器该回调不会调用preventDefault(),浏览器可以立即开始滚动而不必等待回调执行完成。

4. Web Worker分担计算

将CPU密集型任务转移到Web Worker:

// main.js
const worker = new Worker('compute.js');

function performHeavyCalculation(data) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage(data);
  });
}

// compute.js
self.onmessage = (e) => {
  const result = heavyComputation(e.data);
  self.postMessage(result);
};

Web Worker在独立线程中运行,不会阻塞主线程。但要注意,Worker之间的数据传输需要序列化,大量数据传输本身可能成为瓶颈。

5. 使用requestIdleCallback处理低优先级任务

function processBackgroundTasks(tasks) {
  requestIdleCallback((deadline) => {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
      const task = tasks.shift();
      task();
    }
    
    if (tasks.length > 0) {
      requestIdleCallback(processBackgroundTasks);
    }
  });
}

requestIdleCallback在浏览器空闲时执行回调,deadline.timeRemaining()返回当前帧剩余的空闲时间。

6. 避免布局抖动

批量读取,批量写入

// 错误方式
elements.forEach(el => {
  const height = el.offsetHeight; // 读
  el.style.height = height * 2 + 'px'; // 写
});

// 正确方式
const heights = elements.map(el => el.offsetHeight); // 批量读
elements.forEach((el, i) => {
  el.style.height = heights[i] * 2 + 'px'; // 批量写
});

使用FastDOM模式

const reads = [];
const writes = [];

function measure(fn) {
  reads.push(fn);
  scheduleUpdate();
}

function mutate(fn) {
  writes.push(fn);
  scheduleUpdate();
}

let scheduled = false;
function scheduleUpdate() {
  if (!scheduled) {
    scheduled = true;
    requestAnimationFrame(() => {
      reads.forEach(fn => fn());
      writes.forEach(fn => fn());
      reads.length = 0;
      writes.length = 0;
      scheduled = false;
    });
  }
}

总结:构建响应式前端的核心原则

前端交互延迟的本质是主线程资源竞争。用户输入、JavaScript执行、样式计算、布局、绘制都在同一线程上串行执行。要实现流畅的交互体验,核心原则是:

1. 尊重时间预算

每个帧只有约10毫秒可用于JavaScript执行。任何超过50毫秒的任务都会被标记为长任务,可能导致可感知的延迟。

2. 让出控制权

使用scheduler.yield()或时间切片策略,让长任务变为可中断的短任务序列。这确保主线程能够及时响应用户输入。

3. 分离优先级

将用户可见的即时更新(输入框内容、按钮状态)与延迟更新(搜索结果、数据同步)分开处理。React的startTransition、Vue的nextTick、Angular的NgZone.runOutsideAngular都是这个思路的体现。

4. 利用现代API

  • scheduler.yield():让出主线程并保持执行顺序
  • Passive Event Listeners:避免滚动阻塞
  • Web Workers:将计算移出主线程
  • requestIdleCallback:利用空闲时间

5. 持续监控INP

使用Chrome DevTools的Performance面板分析长任务,使用web-vitals库收集真实用户的INP数据。优化是一个持续的过程,不是一次性工作。

200毫秒的延迟不是单一原因造成的,而是输入延迟、处理时间、呈现延迟三者的叠加。理解这个完整链路,才能在正确的位置做出正确的优化。


参考资料

  1. Interaction to Next Paint (INP) | web.dev
  2. Measure performance with the RAIL model | web.dev
  3. Optimize long tasks | web.dev
  4. Optimize input delay | web.dev
  5. RenderingNG architecture | Chrome Developers
  6. Passive Event Listeners | Chrome Developers
  7. A Deep Dive into React Fiber | CodeWithSeb
  8. Vue.js Reactivity Fundamentals | Vue.js
  9. Response Time Limits | Nielsen Norman Group
  10. Doherty Threshold | Laws of UX
  11. requestIdleCallback | W3C Specification
  12. Critical rendering path | MDN
  13. How browsers work | MDN
  14. Cooperative Scheduling of Background Tasks | W3C
  15. Aligned Input Events | Chrome Developers