你的PageSpeed Insights显示所有指标都是绿色:LCP 1.8秒、FID 12毫秒、CLS 0.02。Lighthouse给了你100分。你觉得性能问题已经解决了。

但用户反馈依然说"页面感觉卡"、“点按钮没反应”。产品经理质问为什么用户体验还是不好,你指着性能报告说"指标都达标了"。双方各执一词,谁也说服不了谁。

这不是一个沟通问题,而是一个深刻的测量学问题。**性能指标测量的是机器的行为,用户感知的是自己的体验。**这两者之间的鸿沟,远比大多数开发者想象的要宽。

三个时间阈值:人类感知的基本规律

在讨论Web性能之前,我们需要理解人类如何感知时间。这不是Web领域的发明,而是认知心理学的研究成果。

Jakob Nielsen在1993年的经典文章中总结了三个关键阈值,这些数字来自1968年Miller的研究和1991年Card等人的工作:

延迟时间 用户感知 设计建议
0-100毫秒 即时响应 无需特殊反馈,直接显示结果
100-1000毫秒 注意到延迟,但保持专注 可以继续操作,不需要进度指示
超过1000毫秒 注意力开始涣散 必须提供进度反馈

这些数字背后有神经科学的基础。人类的视觉系统处理一帧图像需要约13毫秒,运动视觉的时间分辨率约为16毫秒(对应60fps)。100毫秒大约是人类"感觉即时"的上限——超过这个时间,大脑就会意识到"等待"的存在。

1982年,IBM的研究员Walter Doherty提出了更具挑战性的目标:400毫秒。他的研究表明,当系统响应时间低于400毫秒时,用户的生产效率会显著提高,因为他们的思维流程不会被中断。这就是著名的"Doherty阈值"。

这些研究成果告诉我们一个关键事实:用户对时间的感知是分段的,而不是连续的。0-100毫秒是一个段(即时),100-1000毫秒是另一个段(可接受),超过1000毫秒又是一个段(需要反馈)。在同一个段内的优化,用户几乎感知不到差异。

Core Web Vitals测量的是什么?

Google在2020年推出Core Web Vitals,试图建立一套"以用户为中心"的性能指标。但仔细分析这些指标,你会发现它们测量的是特定时刻的特定事件,而不是用户的完整体验。

LCP(Largest Contentful Paint):加载的"第一印象"

LCP测量的是视口中最大内容元素的渲染时间。它试图回答"用户什么时候看到主要内容"。

但LCP有一个关键限制:它只测量一个时间点。一个页面可能在1.8秒时渲染了最大的图片,但用户可能还在等待字体加载、等待JavaScript执行、等待数据从API返回。LCP告诉你是"什么时候用户看到了主要内容",但不告诉你"什么时候用户能够使用这些内容"。

更复杂的是,LCP元素可能因用户而异。个性化内容、A/B测试、不同的屏幕尺寸都可能导致不同的元素成为"LCP元素"。你在实验室测试中看到的LCP,可能和真实用户经历的LCP完全不同。

INP(Interaction to Next Paint):响应性的新标准

2024年3月,INP正式取代FID成为Core Web Vitals的响应性指标。这个变化反映了对"响应性"理解的深化。

FID只测量第一次交互的输入延迟。它回答的是"用户第一次点击时,浏览器多久开始处理"。但这个测量存在明显缺陷:

  • 它不关心事件处理器执行了多久
  • 它不关心执行后多久用户看到视觉反馈
  • 它只看第一次交互,忽略后续所有交互

Chrome的数据显示,用户在页面上90%的时间是在页面加载完成后度过的。FID完全无法反映这90%时间内的响应性。

INP试图解决这个问题。它测量的是:从用户开始交互,到浏览器渲染下一帧的完整时间。这个定义包含了三个部分:

INP延迟 = 输入延迟 + 处理时间 + 呈现延迟
  • 输入延迟:从用户输入到事件处理器开始执行
  • 处理时间:事件处理器执行的总时间
  • 呈现延迟:从处理完成到下一帧渲染完成

INP的阈值也反映了用户感知的分段特性:

INP值 评级
0-200毫秒 良好
200-500毫秒 需要改进
超过500毫秒 较差

为什么是200毫秒?因为这略高于"即时感知"的100毫秒阈值,给复杂的交互留出了余量,但仍在"保持专注"的1000毫秒窗口内。

CLS(Cumulative Layout Shift):视觉稳定性

CLS测量的是页面布局的意外偏移。它试图回答"用户是否曾经点错了位置"。

一个经典的例子:用户想点击"确定"按钮,但广告突然加载,按钮位置偏移,用户点到了广告。CLS试图量化这类问题。

但CLS同样存在盲点:它只测量"意外"的布局偏移。如果布局偏移是由用户交互触发的(比如展开一个手风琴组件),这不算"意外"。问题在于,“意外"的定义并不总是与用户感受一致。用户可能预期某个操作会改变布局,但改变的方式或时机出乎意料。

Lab数据 vs Field数据:测量环境的根本差异

很多开发者的困惑来自同一个页面在Lighthouse(实验室数据)和PageSpeed Insights的CrUX数据(真实用户数据)中显示不同的分数。理解这种差异是诊断性能问题的关键。

实验室数据的控制环境

Lighthouse运行在一个受控环境中:

  • 固定的设备配置(通常是模拟的中端移动设备)
  • 固定的网络条件(通常是模拟的3G或4G)
  • 没有用户交互(除非编写脚本模拟)
  • 冷缓存状态

这种控制使得结果可重复、可调试,但也意味着它代表的是一种"理想化"的测试条件。真实用户可能使用着五年前的低端Android手机,连着拥挤的咖啡馆WiFi,同时还运行着十几个其他应用。

真实用户数据的多样性

CrUX数据来自真实Chrome用户的聚合统计,反映的是28天内的性能分布。它展示了用户群体的多样性:

  • 有人用最新款iPhone,有人用低端设备
  • 有人用光纤网络,有人用移动数据
  • 有人第一次访问,有人带着完整缓存

CrUX报告的是75百分位数的值。这意味着如果你的INP在75百分位数是200毫秒,那么75%的用户体验到的INP等于或低于200毫秒。但剩下的25%呢?他们的体验可能远差于此。

为什么两者会差异巨大?

一个页面可能有完美的实验室数据,但糟糕的真实用户数据,原因包括:

1. 缓存状态的差异

实验室测试通常使用冷缓存。但真实用户可能是回访者,带着完整的HTTP缓存、Service Worker缓存,甚至bfcache(back-forward cache)。如果你的页面从bfcache恢复,LCP几乎是瞬间的——这会显著改善真实用户数据,但实验室测试无法模拟。

2. 个性化内容的影响

A/B测试、个性化推荐、地理位置相关内容都会导致不同用户看到不同的页面元素。实验室测试看到的是一个"标准版本”,但真实用户看到的可能是任何版本。

3. 交互时机的不确定性

INP需要用户交互才能测量。实验室测试无法预测用户何时会与页面交互。有些用户的第一次交互发生在页面完全加载后,此时主线程已经空闲;有些用户的交互发生在JavaScript正在执行时,此时主线程正忙。这两种场景会产生完全不同的INP值。

4. 用户行为的影响

用户行为会影响某些指标。例如,如果用户在页面还在加载时滚动,浏览器可能停止监测LCP元素的变化。如果用户在交互后立即滚动,可能错过某些布局偏移。这些都是实验室测试无法复现的。

主线程阻塞:被指标忽略的"冻结时刻"

让我们回到开头的问题:为什么性能指标正常,用户却感觉卡顿?

答案往往在主线程阻塞

什么是"长任务"?

在浏览器性能模型中,任何超过50毫秒的任务被称为"长任务"。为什么是50毫秒?因为RAIL模型建议在100毫秒内响应用户输入,而如果主线程正执行一个50毫秒的任务,加上输入处理时间,总响应时间就可能超过100毫秒。

当主线程被长任务阻塞时:

  • 用户点击按钮,没有任何反应
  • 动画卡顿,帧率下降
  • 滚动不流畅,出现"冻结"感
  • 输入框打字延迟,字符显示滞后

问题的关键在于:这些现象不一定反映在Core Web Vitals中。

INP的测量时机

INP测量的是交互发生时的响应速度。如果用户没有在那个"糟糕时刻"交互,INP就不会捕捉到问题。

想象这样一个场景:

  1. 页面加载完成,LCP 1.5秒,一切正常
  2. 页面运行了一个数据同步任务,持续300毫秒
  3. 在这300毫秒内,用户点击了一个按钮
  4. 点击事件被延迟300毫秒才开始处理
  5. INP记录了一个糟糕的值

但如果用户在步骤2之前之后点击,INP就不会捕捉到这个长任务的影响。

更糟糕的是,如果这个数据同步任务发生在页面加载阶段(比如在DOMContentLoaded事件后),它会影响Total Blocking Time(TBT),但TBT只是一个实验室指标,不是Core Web Vitals的一部分。

动画帧的丢失

另一个被指标忽略的问题是帧率下降

现代显示器的刷新率通常是60Hz(约16.7毫秒每帧)或更高。为了实现流畅的动画,浏览器需要在每一帧内完成JavaScript执行、样式计算、布局、绘制和合成。

如果任何一个阶段超时,就会"丢帧"。用户看到的是动画卡顿、滚动不流畅。

但Core Web Vitals中没有专门测量帧率的指标。CLS测量布局偏移,但它只关心"意外"的偏移,不关心动画是否流畅。INP测量交互响应,但不测量动画性能。

Chrome团队在研究"动画平滑度指标",但目前还没有成为Core Web Vitals的一部分。

RAIL模型:一个更完整的性能框架

为了理解用户感知,我们需要回到RAIL模型。RAIL是一个用户中心的性能框架,将用户体验分解为四个维度:

Response(响应):100毫秒内响应

用户输入后的100毫秒内,必须有可见的反馈。这是"即时感"的边界。

实际含义

  • 点击按钮后,100毫秒内显示视觉反馈(高亮、ripple效果等)
  • 即使实际操作需要更长时间,也要先给出即时反馈
  • 对于复杂操作,可以显示加载指示器

为什么是50毫秒而不是100毫秒?因为要考虑到最坏情况:如果用户交互发生在一个50毫秒长任务的中间,它需要等待最长50毫秒才能开始处理。所以留给事件处理的时间只有50毫秒。

Animation(动画):10毫秒内生成帧

动画帧的预算是16毫秒,但浏览器需要约6毫秒来渲染,所以留给JavaScript的时间只有约10毫秒。

实际含义

  • 避免在动画期间执行复杂计算
  • 使用transformopacity等合成属性做动画
  • 使用requestAnimationFrame而非setTimeoutsetInterval

Idle(空闲):最大化空闲时间

主线程空闲时间越多,响应用户输入的机会就越大。

实际含义

  • 将非关键工作延迟到空闲时执行
  • 使用requestIdleCallback处理低优先级任务
  • 将长任务拆分成多个短任务

Load(加载):5秒内可交互

在慢速3G和中端移动设备上,页面应在5秒内变得可交互。

实际含义

  • 关键路径优化:内联关键CSS,延迟非关键JavaScript
  • 代码分割:只加载当前路由需要的代码
  • 预加载关键资源:使用<link rel="preload">

RAIL模型的四个维度相互关联。加载影响响应(加载期间的JavaScript执行会阻塞交互),动画影响响应(动画期间的主线程占用),空闲时间影响所有其他维度。

分解长任务:从setTimeout到scheduler.yield()

理解了主线程阻塞的问题,解决方案就清晰了:不要让单个任务占用主线程太久

传统方法:setTimeout

最简单的任务是拆分方法是使用setTimeout

function processLargeArray(array) {
  const chunk = array.splice(0, 100);
  processChunk(chunk);
  
  if (array.length > 0) {
    setTimeout(() => processLargeArray(array), 0);
  }
}

这会把一个大任务拆分成多个小任务,每个任务处理100个元素。setTimeout(..., 0)将下一个任务放入宏任务队列,给浏览器机会处理其他工作(比如用户输入)。

但这个方法有一个问题:每个setTimeout任务会被放到任务队列的末尾。如果队列中还有其他任务,它们会先执行。这可能导致"任务饥饿"——你的拆分任务永远得不到执行机会。

现代方案:scheduler.yield()

2025年,Chrome引入了scheduler.yield()API,专门用于任务拆分:

async function processLargeArray(array) {
  for (let i = 0; i < array.length; i += 100) {
    const chunk = array.slice(i, i + 100);
    processChunk(chunk);
    
    // 让出主线程,但优先继续当前工作
    await scheduler.yield();
  }
}

scheduler.yield()的关键特性是优先继续当前工作。当你让出主线程后,你的后续任务会被优先调度,而不是放到队列末尾。这既保证了响应性,又避免了任务饥饿。

任务优先级:scheduler.postTask()

对于需要显式控制优先级的场景,可以使用scheduler.postTask()

// 高优先级任务(如用户交互响应)
scheduler.postTask(
  () => respondToUserClick(),
  { priority: 'user-blocking' }
);

// 正常优先级任务
scheduler.postTask(
  () => updateUI(),
  { priority: 'user-visible' }
);

// 后台任务(如数据分析上报)
scheduler.postTask(
  () => sendAnalytics(),
  { priority: 'background' }
);

React的并发模式

React 18引入的并发特性,本质上也是在框架层面实现任务拆分:

// 使用startTransition标记非紧急更新
startTransition(() => {
  setSearchResults(heavyResults);
});

// 使用useDeferredValue延迟渲染
const deferredQuery = useDeferredValue(query);

React的调度器会将这些标记为"过渡"的更新拆分成多个单元,在每一帧的空闲时间执行。如果有更高优先级的更新(如用户输入),React会暂停当前渲染,优先处理紧急更新。

感知性能优化:让等待感觉更短

当技术优化达到瓶颈时,还有一个维度可以提升用户体验:感知优化

骨架屏:建立正确预期

研究表明,骨架屏可以让用户感知的等待时间减少约30%。这不是魔法,而是心理学原理的应用。

当用户看到一个空白屏幕时,他们不知道会发生什么,焦虑感上升。当他们看到一个骨架屏时,他们知道内容即将出现,并能够预判内容的结构。这种"预期建立"降低了认知负荷,让等待感觉更短。

<!-- 骨架屏示例 -->
<div class="skeleton">
  <div class="skeleton-header"></div>
  <div class="skeleton-line"></div>
  <div class="skeleton-line short"></div>
  <div class="skeleton-image"></div>
</div>

Nielsen Norman Group的研究指出,骨架屏最适合加载时间在1-10秒的场景。超过10秒,应该使用进度条而不是骨架屏,因为用户需要知道还需要等多久。

乐观UI:先行动,后验证

乐观UI是一种"先斩后奏"的策略:在用户操作后立即更新UI,同时异步发送请求到服务器。如果请求失败,再回滚UI。

async function likePost(postId) {
  // 立即更新UI
  setIsLiked(true);
  setLikesCount(prev => prev + 1);
  
  try {
    // 异步发送请求
    await api.likePost(postId);
  } catch (error) {
    // 失败时回滚
    setIsLiked(false);
    setLikesCount(prev => prev - 1);
    showErrorToast('操作失败,请重试');
  }
}

这种策略让用户感觉操作是"即时"的,因为视觉反馈发生在用户预期的100毫秒内。绝大多数操作都会成功,所以大多数用户永远不会体验"回滚"。

进度指示器:给等待一个形状

对于无法快速完成的操作,进度指示器是必要的。研究发现,有进度指示器的等待感觉比没有的短,即使实际等待时间相同。

关键是要提供足够的信息:

  • 确定性进度条:告诉用户还需要等多久
  • 步骤指示器:告诉用户现在进行到哪一步
  • 不确定进度指示器:至少告诉用户"系统在工作"

Nielsen建议,对于超过10秒的操作,必须提供进度反馈。对于2-10秒的操作,可以使用不确定进度指示器(spinner或骨架屏)。对于低于2秒的操作,通常不需要进度指示器。

从指标到体验:一个诊断框架

当用户报告"感觉慢"时,应该如何排查?以下是一个诊断框架:

第一步:区分问题类型

用户说的"慢"可能指不同的事情:

用户描述 可能的问题 关键指标
“打开页面慢” 加载性能 LCP, FCP
“点按钮没反应” 交互响应 INP
“滚动卡顿” 动画性能 帧率
“内容乱跳” 视觉稳定性 CLS
“打字延迟” 输入响应 INP

第二步:对比Lab和Field数据

查看PageSpeed Insights,对比Lighthouse结果和CrUX数据:

  • 如果两者都差:优先优化加载性能
  • 如果Lab好Field差:关注真实用户的设备和网络条件
  • 如果Lab差Field好:可能是缓存或预加载优化在起作用

第三步:分析主线程活动

使用Chrome DevTools的Performance面板:

  1. 录制用户交互
  2. 查看主线程上的任务
  3. 识别超过50毫秒的长任务
  4. 分析长任务的调用栈

第四步:检查交互时机

INP取决于交互发生的时机。在DevTools中模拟交互:

  1. 在不同时间点触发交互
  2. 比较INP值的差异
  3. 识别"危险时段"——主线程忙碌的时间段

第五步:验证动画性能

检查动画帧率:

  1. 打开DevTools的Rendering面板
  2. 启用"Frame rendering stats"
  3. 观察FPS计数器
  4. 识别帧率下降的时刻

写在最后

性能优化的终极目标不是让指标变绿,而是让用户满意。指标只是工具,不是目的。

Core Web Vitals是目前最接近用户感知的性能指标体系,但它仍然存在盲点。它测量的是特定时刻的特定事件,而用户体验是一个连续的过程。一次糟糕的交互可能被INP捕捉,也可能恰好发生在两次测量之间。

理解这种差距,需要理解三层知识:

  • 人类感知的基本规律:100毫秒、1秒、10秒这些阈值来自认知心理学,它们定义了"即时"、“流畅”、“可忍受"的边界
  • 浏览器的工作原理:主线程、事件循环、渲染管道——这些机制决定了什么时候用户会感到"卡顿”
  • 指标的设计意图:每个指标测量什么、不测量什么、在什么场景下有效

当你下次看到"Lighthouse满分但用户抱怨慢"的情况时,不要困惑于指标的"正确性"。指标没有错,它只是测量了它被设计测量的东西。问题在于,用户的体验比任何单一指标都更复杂

优化性能,从理解用户感知开始,而不是从理解指标开始。


参考资料

  1. Nielsen, J. (1993). Response Times: The 3 Important Limits. https://www.nngroup.com/articles/response-times-3-important-limits/
  2. Miller, R. B. (1968). Response time in man-computer conversational transactions. Proc. AFIPS Fall Joint Computer Conference, 33, 267-277.
  3. web.dev. Interaction to Next Paint (INP). https://web.dev/articles/inp
  4. web.dev. Why lab and field data can be different. https://web.dev/articles/lab-and-field-data-differences
  5. web.dev. Measure performance with the RAIL model. https://web.dev/articles/rail
  6. Laws of UX. Doherty Threshold. https://lawsofux.com/doherty-threshold/
  7. web.dev. Optimize long tasks. https://web.dev/articles/optimize-long-tasks
  8. Chrome Developers. Use scheduler.yield() to break up long tasks. https://developer.chrome.com/blog/use-scheduler-yield
  9. web.dev. Towards an animation smoothness metric. https://web.dev/articles/smoothness
  10. web.dev. First Input Delay (FID). https://web.dev/articles/fid
  11. Nielsen Norman Group. Skeleton Screens 101. https://www.nngroup.com/articles/skeleton-screens/
  12. WPO Stats. Case studies on the business impact of web performance. https://wpostats.com/
  13. NitroPack. How Web Performance Affects Business Results (22 Case Studies). https://nitropack.io/blog/web-performance-matters-case-studies/
  14. Calibre. Psychology of Speed: A Guide to Perceived Performance. https://calibreapp.com/blog/perceived-performance
  15. SpeedCurve. The psychology of site speed and human happiness. https://www.speedcurve.com/blog/psychology-site-speed/
  16. web.dev. Largest Contentful Paint (LCP). https://web.dev/articles/lcp
  17. web.dev. Cumulative Layout Shift (CLS). https://web.dev/articles/cls
  18. web.dev. Total Blocking Time (TBT). https://web.dev/articles/tbt
  19. React Documentation. useDeferredValue. https://react.dev/reference/react/useDeferredValue
  20. MDN. Animation performance and frame rate. https://developer.mozilla.org/en-US/docs/Web/Performance/Guides/Animation_performance_and_frame_rate
  21. Google Search Central. Introducing INP to Core Web Vitals. https://developers.google.com/search/blog/2023/05/introducing-inp
  22. Chrome Developers. INP in frameworks. https://developer.chrome.com/docs/aurora/inp-in-frameworks
  23. Macarthur.me. There are a lot of ways to break up long tasks in JavaScript. https://macarthur.me/posts/long-tasks
  24. SimonHearne.com. Optimistic UI Patterns for Improved Perceived Performance. https://simonhearne.com/2021/optimistic-ui-patterns/
  25. Medium. The Event Loop Lie: Why Your Async Code Still Blocks. https://medium.com/@sonampatel_97163/the-event-loop-lie-why-your-async-code-still-blocks-dbc19a71af1e