普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月13日首页

告别字体闪烁 / 首屏卡顿!preload 让关键资源 “高优先级” 提前到

2025年12月12日 23:43

⚡️ 浏览器“未卜先知”的秘密:资源提示符,让你的页面加载速度快人一步!

前端性能优化专栏 - 第四篇

在前端性能优化的战场上,时间就是金钱,尤其是在页面加载的关键时刻。我们上一篇讲到 PerformanceObserver 可以精准地测量性能,但测量只是第一步,更重要的是主动出击,让浏览器在用户需要资源之前,就提前做好准备。

今天,我们就来揭秘浏览器“未卜先知”的秘密武器——资源提示符(Resource Hints)


💡 什么是资源提示符?

资源提示符(Resource Hints)是 <link> 标签 rel 属性的一组特殊值,用于告诉浏览器未来即将发生的资源处理策略,让它提前做准备

简单来说,它们是开发者给浏览器下达的“预处理指令”,让浏览器在空闲或关键时刻,提前完成一些耗时的网络操作,从而:

  • 提高网页的首屏加载性能
  • 减少 DNS、TCP、TLS 等连接延迟
  • 预加载关键或预测性资源
<!-- 资源提示符示例 -->
<link rel="preconnect" href="//cdn.example.com">

🔧 四大金刚:资源提示符的家族成员

资源提示符家族主要有四个核心成员,它们各有神通,针对不同的优化场景:

1. dns-prefetch:最小开销的“打听”

<link rel="dns-prefetch" href="//api.example.com">
  • 作用: 仅提前解析 DNS,将域名解析为 IP 地址,不建立连接

  • 开销: 最小,兼容性最好。

  • 使用场景:

    • 非关键的第三方资源(如分析脚本、广告、插件)。
    • 可作为 preconnect降级方案

专业名词解释:DNS 解析 DNS(Domain Name System)解析是将人类可读的域名(如 www.google.com)转换为机器可读的 IP 地址(如 142.250.190.14)的过程。这是一个网络请求的起点,通常需要几十到几百毫秒。

2. preconnect:提前握手的“老朋友”

<link rel="preconnect" href="//cdn.example.com" crossorigin>
  • 作用: 完成 DNS 解析 + TCP 握手 + TLS 加密握手,全流程建立连接。

  • 效果: 极大地消除了后续资源请求的网络延迟。

  • 使用时机:

    • 字体库核心 APICDN 静态资源关键第三方域名
    • 注意: 建立连接会消耗资源,建议控制数量(一般建议 ≤6 个)。

Preconnect 提前握手过程示意图

3. preload:高优先级的“快递”

<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
  • 作用: 直接以高优先级下载关键资源,但下载后暂不执行

  • 特点: 提前触发关键资源的加载,确保资源在需要时立即可用。

  • 常见场景:

    • CSS 定义的字体文件(避免文本闪烁 FOUT/FOIT)。
    • 背景图或 LCP 元素图片(加速最大内容绘制)。
    • 首屏必需的动态脚本

注意: preload 必须配合 as 属性指定资源类型,否则浏览器会重复下载。

4. prefetch:空闲时的“下一站”

<link rel="prefetch" href="next-page.js">
  • 作用:当前页加载完成后,利用浏览器空闲时间请求资源。

  • 特点: 优先级最低,不会与当前页面的关键资源竞争带宽。

  • 使用场景:

    • 优化“下一个页面”的加载体验
    • SPA 路由中,预取用户可能访问的下一个 chunk
    • 基于用户行为预测的预加载。

💡 总结:让资源“早一步”准备好

资源提示符家族的目标一致:让资源“早一步”准备好

它们的核心区别在于时机与深度

提示符 深度(提前到哪一步) 时机(何时触发) 优先级 适用场景
dns-prefetch 仅 DNS 解析 尽早 非关键第三方资源
preconnect DNS + TCP + TLS 尽早 关键第三方域名
preload 下载资源 尽早(高优先级) 当前页面的关键资源
prefetch 下载资源 页面空闲时 最低 下一个页面的资源

资源提示符概览图

重要提醒: 资源提示符虽好,但过度使用可能导致浪费带宽或建立过多连接,反而拖慢性能。请务必根据实际的性能数据(比如 RUM 采集的数据)来合理规划和使用。


下一篇预告: 既然资源都提前加载了,如何让它们在下次访问时更快出现呢?下一篇我们将深入探讨前端性能优化的“节流大师”——HTTP 缓存机制。敬请期待!

性能数据别再瞎轮询了!PerformanceObserver 异步捕获 LCP/CLS,不卡主线程

2025年12月12日 23:38

🚀 性能监控的“最强大脑”:PerformanceObserver API,如何让你告别轮询的噩梦?

前端性能优化专栏 - 第三篇

在上一篇中,我们聊到了 RUM(真实用户监控)是如何帮助我们打破“薛定谔的 Bug”魔咒的。既然 RUM 是性能监控的“雷达”,那么谁来负责实时、精准地采集数据呢?

答案就是今天的主角——PerformanceObserver API。它就像是浏览器内置的“高性能数据采集器”,彻底改变了我们获取性能数据的方式。


⚠️ 为什么需要 PerformanceObserver?告别“老黄历”

在 PerformanceObserver 出现之前,我们获取性能数据的方式,简直就是一场“噩梦”:

传统方式:性能监控的“老黄历”

  1. performance.timingperformance.getEntries()

    • 问题: 这些 API 只能获取页面加载完成那一刻的静态数据。对于像 First Input Delay (FID) 这种发生在用户交互过程中的动态指标,它们就无能为力了。
    • 痛点: 想要获取实时数据?你只能轮询(不断地去问:“数据好了吗?好了吗?”)。这种方式不仅时机难以掌握,还会带来额外的性能开销,甚至可能阻塞主线程,让页面更卡!

专业名词解释:轮询 (Polling) 轮询是一种计算机通信技术,指客户端程序或设备不断地向服务器程序或设备发送请求,以查询是否有新的数据或状态更新。在前端性能监控中,轮询意味着需要定时检查性能数据是否生成,效率低下且消耗资源。

✨ 优化方案:事件驱动的“高性能引擎”

PerformanceObserver 的出现,彻底解决了轮询的痛点。它提供了一种事件驱动、异步回调的机制:

  • 高效、非阻塞: 它在浏览器记录到性能事件时,会异步通知你,不会阻塞主线程。
  • 实时性: 能够实时捕获动态指标,如用户首次输入延迟(FID)和布局偏移(CLS)。
  • 可订阅: 你可以像订阅报纸一样,选择你感兴趣的性能事件类型。

🔄 PerformanceObserver 的工作原理:三步走战略

PerformanceObserver 的使用流程非常简洁,可以概括为“创建、指定、接收”三步走战略:

步骤 1:创建观测器(Observer)

首先,我们需要创建一个 PerformanceObserver 实例,并传入一个回调函数 (callback)

const observer = new PerformanceObserver((list) => {
  // 浏览器在记录到性能条目时,会自动异步触发这个回调函数
  // list.getEntries() 包含了所有被观测到的性能数据
})

工作原理揭秘: 浏览器在内部记录性能数据时,会检查是否有 PerformanceObserver 在监听。如果有,它就会将最新的性能条目(Performance Entry)打包,并在下一个空闲时机(异步)调用你提供的回调函数。

步骤 2:指定观测目标(Observe)

创建好观测器后,你需要明确告诉它:“我想看哪些数据? ” 这通过 observer.observe() 方法实现,你需要指定一个或多个 entryTypes

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

常见的核心观测指标:

entryType 对应指标 含义
largest-contentful-paint LCP 最大内容绘制时间,衡量加载速度。
first-input FID 首次输入延迟,衡量交互响应速度。
layout-shift CLS 累积布局偏移,衡量视觉稳定性。
resource Resource Timing 资源加载(图片、CSS、JS)的详细耗时。

PerformanceObserver 与传统方式对比图

步骤 3:接收和处理数据(Callback)

在回调函数中,你可以通过 list.getEntries() 获取到所有新产生的性能条目。每个条目(Entry)都是一个包含详细信息的对象。

示例:基础用法

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('指标名称:', entry.name)
    console.log('开始时间:', entry.startTime)
    console.log('持续时间:', entry.duration)

    // 针对不同指标进行特殊处理,例如获取 CLS 的具体值
    if (entry.entryType === 'layout-shift') {
      console.log('CLS 值:', entry.value)
    }
    // 在这里将数据上报到 RUM 服务器
  }
})

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

总结:PerformanceObserver 的核心优势

PerformanceObserver 是前端性能监控领域的一次重大飞跃,它的核心优势在于:

  • 实时性: 事件驱动,性能数据一产生就能被捕获,无需低效的轮询。
  • 低开销: 异步执行,不占用主线程资源,对用户体验影响极小。
  • 可扩展: 通过 entryTypes,可以轻松订阅未来浏览器新增的各种性能事件。
  • 易集成: 它是现代 RUM 监控体系中,最核心、最可靠的数据采集组件。

结论: PerformanceObserver 是构建前端性能可观测性的核心组件,它让我们从“猜测性能”迈向了 “数据驱动的性能优化” ,让性能数据采集变得高效、优雅。


下一篇预告: 既然我们能精准地测量性能了,下一步就是如何主动出击,让浏览器提前加载资源。下一篇我们将深入讲解前端性能优化的“预加载神器”——浏览器资源提示符。敬请期待!

昨天以前首页

用户说卡但我测不出来?RUM 监控:直接去 “用户手机里” 抓薛定谔的 Bug

2025年12月10日 11:36

⚡️ 告别“薛定谔的 Bug”:RUM 如何精准捕获线上卡顿,让性能优化不再靠玄学?

前端性能优化专栏 - 第二篇

在性能优化的路上,我们总会遇到一个让人抓狂的“灵异事件”:用户反馈页面卡得像幻灯片,但你在本地、在公司、在高速网络下测试,它却流畅得像德芙巧克力。

我们把这种现象戏称为“薛定谔的 Bug”——你打开看的时候,它就消失了。那么,如何才能打破这个魔咒,让性能优化从“玄学”变成“科学”呢?答案就是:RUM(真实用户监控)

⚠️ “薛定谔的 Bug”:线上卡顿但无法复现

想象一下这个场景:

  1. 用户反馈: “你们的页面太卡了,点个按钮要等半天!”
  2. 你测试: 刷新、点击、滚动,一切丝滑流畅,耗时不到 100ms。
  3. 你的内心: “是不是用户手机太烂了?”

这种线上卡顿,本地流畅的差异,往往让开发者陷入深深的自我怀疑。问题根源可能隐藏在资源加载、渲染或复杂的交互延迟中,而这些问题,在你的“完美”开发环境中根本无从察觉。

✨ 环境差异的根源:性能的“黑洞”

为什么你的环境是“天堂”,用户的环境却是“地狱”?因为你们的环境差异太大了!

差异维度 你的环境(开发/测试) 用户的环境(真实线上) 性能影响
网络条件 稳定 Wi-Fi / 专线 3G/4G 切换、地铁弱信号 资源加载耗时、TTFB
设备性能 高配笔电、旗舰手机 低端手机、老旧平板 JS 执行速度、渲染速度
浏览器版本 最新 Chrome/Safari 各种版本、不同内核 API 支持、渲染机制差异
地理位置 靠近服务器的 CDN 节点 偏远地区的 CDN 节点 首字节时间(TTFB)

与其在本地一遍遍地尝试“复现问题”,不如换个思路:直接去用户的“案发现场”收集证据! 这正是 RUM(Real User Monitoring) 的核心思想。

环境差异对比图

🔧 什么是 RUM(真实用户监控)?

RUM,全称 Real User Monitoring,顾名思义,就是一套采集真实用户访问数据的监控体系。

它就像一个潜伏在用户浏览器中的“性能侦探”,默默地捕获并回传用户的性能指标、环境信息和异常日志

专业名词解释:RUM 是一种被动式的性能监控方法,它通过在用户浏览器中植入一段 JavaScript 代码,来实时收集用户在页面上的各种性能数据和行为数据,并将数据上报到服务器进行分析。

RUM 的目标非常明确: 帮助开发者还原现场、定位瓶颈、验证优化效果。它将“用户说卡”这个模糊的定性描述,转化为可量化的数据指标。

🚀 RUM 的核心组成:三大法宝

一个完整的 RUM 体系,通常由以下三个核心部分组成:

1. 性能数据采集:量化“卡顿”的体感

“卡顿”是一种主观感受,但 RUM 能用客观指标来量化它。我们主要关注 Google 推荐的 Core Web Vitals(核心 Web 指标)以及其他关键指标:

指标名称 英文缩写 衡量目标 对应“卡顿”体感
最大内容绘制 LCP 页面加载速度(最大元素出现时间) “页面白屏很久”
首次输入延迟 FID 页面交互响应速度(首次点击到响应) “点按钮没反应”
累积布局偏移 CLS 页面视觉稳定性 “页面元素乱跳”
首次绘制 FP/FCP 页面开始渲染的时间 “页面开始有东西了”
首字节时间 TTFB 服务器响应速度 “网络慢不慢”

技术实现:PerformanceObserver API

现代浏览器提供了强大的 PerformanceObserver API,让我们能够实时监听这些关键性能指标的变化,并将其上报。

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    // 收集 LCP, FID, CLS 等数据
    console.log(entry.name, entry.startTime, entry.duration)
    // report(entry) // 上报到 RUM 服务器
  })
})

// 监听关键指标
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] })

2. 异常日志收集:捕获“意外”现场

性能问题不只是慢,还包括“崩”。RUM 体系必须能捕获各种意料之外的错误,防止用户体验彻底中断。

  • JS 执行错误: 通过 window.onerror 捕获未被 try...catch 的同步错误。
  • 资源加载失败: 监听全局的 error 事件,捕获图片、CSS、JS 等资源加载失败的情况。
  • Promise 未处理异常: 通过 unhandledrejection 捕获 Promise 链中没有 catch 的错误。
// 捕获 JS 错误
window.addEventListener('error', e => {
  report({ type: 'js-error', message: e.message })
})

// 捕获 Promise 异常
window.addEventListener('unhandledrejection', e => {
  report({ type: 'promise-rejection', reason: e.reason })
})

3. 环境信息:还原用户的“案发现场”

光有性能数据还不够,我们还需要知道是谁、在哪里、用什么遇到了问题。这些环境信息是数据聚合分析的关键,能帮助我们快速定位共性问题(比如“所有华为手机用户都卡顿”)。

  • 🖥️ 设备信息: 设备型号、内存、屏幕分辨率。
  • ⏱️ 操作系统: 操作系统类型与版本(如 iOS 17.0, Android 14)。
  • 🌐 浏览器信息: 浏览器类型与版本(如 Chrome 120, Safari 17)。
  • 📊 网络类型: 用户的网络连接类型(如 4G, Wi-Fi)。
  • 🗺️ 地理位置: 用户的国家/城市,用于分析地域性 CDN 差异。

RUM 数据流示意图

💡 总结:让数据成为你最可靠的依据

“薛定谔的 Bug”并不可怕,可怕的是我们没有工具去揭开它的面纱。

无法复现 ≠ 无法解决。

RUM 体系将性能优化从“凭感觉”和“靠运气”的阶段,带入了数据驱动的科学时代。建立一个系统性的 RUM 体系,让每一次“卡顿反馈”都能被精准捕获、分析和优化。

性能优化是一场持久战,而 RUM 就是我们最可靠的雷达。


下一篇预告: 既然我们已经了解了 RUM 的重要性,那么下一篇我们将深入讲解 RUM 的核心采集利器——PerformanceObserver API,手把手教你如何用它来精准监控页面性能指标。敬请期待!

别让页面 “鬼畜跳”!Google 钦点的 3 个性能指标,治好了我 80% 的用户投诉

2025年12月9日 23:48

💥告别卡顿!前端性能优化第一课:Google钦点的三大核心指标,你真的懂吗?

欢迎来到前端性能优化专栏的第一课!在这个“用户体验至上”的时代,一个卡顿、缓慢、乱跳的网站,就像一辆抛锚在高速公路上的跑车,再酷炫也只会让人抓狂。别担心,Google已经为你准备好了一份“体检报告”——核心Web指标(Core Web Vitals)

今天,我们就来揭开这份报告的神秘面纱,用最通俗易懂的方式,让你彻底搞懂这三大指标,迈出性能优化的第一步!

✨ LCP(Largest Contentful Paint):最大内容绘制

🚀 衡量:加载性能(你的“第一印象”)

LCP,直译过来是最大内容绘制。它衡量的是用户在访问页面时,视口中最大的那块可见内容(图片或文本块)完成渲染所需的时间

简单来说,它回答了一个核心问题:用户觉得你的页面“加载完成”了吗?

想象一下,你打开一个电商网站,最想看到的是商品大图和价格。LCP就是衡量这个“最重要的东西”多久能出现在你面前。它直接反映了用户对页面加载速度的感知。

指标 衡量维度 优秀标准
LCP 用户感知的加载速度 <= 2.5秒

如果你的 LCP 超过 2.5 秒,用户可能就开始感到不耐烦了。优化 LCP,就是让你的“门面”以最快的速度展示给客人!

LCP 示意图

⚠️ CLS(Cumulative Layout Shift):累积布局偏移

🛡️ 衡量:视觉稳定性(告别“鬼畜”跳动)

CLS,累积布局偏移,听起来有点拗口,但它的作用非常直观:它衡量页面加载过程中元素的非预期移动

你一定遇到过这种情况:正准备点击一个按钮,结果它上面的广告突然加载出来,把按钮挤下去了,你点了个空!这就是布局偏移(Layout Shift)。

CLS 的分数就是用来量化这种“鬼畜”跳动有多严重的。分数越低,代表你的页面布局越稳定,用户体验越丝滑。

指标 衡量维度 理想值
CLS 页面布局稳定性 < 0.1

小贴士: 布局偏移通常是由没有设置尺寸的图片、动态插入的广告或内容导致的。想要 CLS 达标,请给你的元素预留好“坑位”!

⚡️ INP(Interaction to Next Paint):交互到下次绘制

🔄 衡量:整体交互响应性(从“第一印象”到“全程流畅”)

INP,交互到下次绘制,是性能指标家族的新晋“网红”。它衡量的是用户进行点击、触摸或键盘输入后,浏览器需要多长时间才能在屏幕上绘制出视觉反馈。

它取代了老前辈 FID(First Input Delay,首次输入延迟) ,为什么呢?

为什么用 INP 替代 FID? INP 的优势
FID 的局限性 仅测量首次输入延迟,忽略了用户在后续操作中遇到的卡顿。
INP 的全面性 监控用户整个访问周期内的所有交互,更全面。
更真实的用户体验 INP 选取最慢的一次交互作为代表值,反映了“整个使用过程中”的流畅度,而不是仅仅看“第一印象”。

简单来说,FID 就像面试官只看你的简历,而 INP 则是全程跟拍你的工作表现。一个真正流畅的网站,不应该只是第一次点击快,而是从头到尾都快!

INP 与 FID 比较图

🔬 实验室数据 vs. 🌍 现场数据:性能优化的“双重奏”

搞懂了三大指标,接下来我们聊聊如何获取这些数据。性能数据主要分为两大类:实验室数据现场数据

类型 来源(数据渠道) 优点 缺点
实验室数据 (Lab Data) Lighthouse 可控、快速、方便复现问题 非真实用户环境,可能与实际体验有偏差
现场数据 (Field Data) CrUX/Web Vitals 真实用户体验,数据最可靠 不易复现问题,需要时间积累数据

🔧 实验室数据工具

实验室数据就像你在实验室里用精密仪器做的测试,环境是固定的。

  • Lighthouse (Chrome 内置) :Chrome 开发者工具里就能找到,它能快速给你打分,支持 LCP/CLS/INP 评分。
  • PageSpeed Insights:Google 官方工具,它会结合实验室数据(Lighthouse)和现场数据(CrUX),给你一份一站式的性能报告。

🛠️ 现场数据工具

现场数据则是你的网站在真实用户、真实网络、真实设备上跑出来的“实战成绩”。

  • Google Search Console:提供网站整体的核心指标健康报告,是 SEO 优化的重要参考。
  • web-vitals JavaScript 库:这是前端工程师的“秘密武器”。它是一个轻量级的库,可以让你在用户浏览器中实时收集 LCP、CLS、INP 数据,并上报到你的分析后台。

💡 使用 web-vitals 收集性能数据(实战代码)

通过这个库,你可以将真实用户的性能数据发送到你的服务器进行分析,建立自己的性能监测体系(RUM,Real User Monitoring)。

import { onLCP, onCLS, onINP } from 'web-vitals'

function sendToAnalytics(metric) {
  // 将性能指标数据转换为 JSON 字符串
  const body = JSON.stringify(metric)
  
  // 使用 navigator.sendBeacon 或 fetch 发送数据,确保在页面关闭前发送成功
  ;(navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
    fetch('/analytics', { body, method: 'POST', keepalive: true })
}

// 监听并上报三大核心指标
onLCP(sendToAnalytics)
onCLS(sendToAnalytics)
onINP(sendToAnalytics)

✅ 总结:性能优化的“三字真经”

好了,总结一下我们今天学到的前端性能优化的“三字真经”:

  1. LCP:核心内容尽快可见(加载速度要快!)
  2. CLS:页面布局稳定(别再乱跳了!)
  3. INP:交互响应及时(操作要流畅!)

通过 LighthouseSearch Console 以及 Web Vitals 库,我们不仅能建立起一套完善的性能监测体系,还能精准地识别并修复那些让用户抓狂的体验瓶颈。

提升网页质量,不仅能让用户开心,还能让你的网站在 Google 搜索中获得更好的排名。

前端页面崩溃监控全攻略:心跳判定 + Service Worker 接管

作者 Sthenia
2025年12月9日 17:34

背景

在浏览器环境中,“页面崩溃(Page Crash)”并不是一个浏览器主动抛出的可监听事件。 页面可能因为以下原因被动终止:

  1. 内存 OOM 导致 tab 崩溃

  2. 浏览器内部的 renderer 进程挂掉

  3. 业务代码死循环、长任务阻塞导致页面卡死

  4. 页面在后台被系统杀进程(尤其移动端)

  5. 浏览器关闭 / 标签页关闭但执行不到 beforeunload(常见)

由于浏览器没有提供“页面是否异常退出”的 API,因此前端监控体系通常只能间接推断崩溃

本次调研希望解决以下两个问题:

🎯 目标

  1. 如何在单标签页场景中准确推断页面是否异常退出?

  2. 多标签页环境中,一个页面崩溃后,如何被其他页面检测并上报?

  3. 是否能做到不依赖页面再次打开 —— 即实时上报?(如 Service Worker)

  4. 最终方案应尽量稳定、低侵入、可扩展并减少误报率

调研方案

1. 基于「退出打标 + 心跳检测」的崩溃推断方案

这是目前业内最常见的思路,例如不少监控 SDK 都采用类似机制。

1.1 核心思路

  1. 页面正常退出时(beforeunload / pagehide / visibilitychange)写入 normalExit = true

  2. 如果是崩溃,则正常退出钩子不会触发 → normalExit 保持 false

  3. 下次启动页面时读取存储(localStorage),若发现上次 normalExit=false,则认为存在异常退出

单页面流程示意图

正常退出 → normalExit = true → 下次打开不告警
崩溃 → normalExit = false 且心跳断更 → 下次打开上报崩溃

// 单页面检测崩溃代码

// 伪代码,还需要处理beforeunload / pagehide / visibilitychange
window.addEventListener('beforeunload', () => {
  localStorage.setItem('normalExit', 'true');
});
function checkCrash() {
  const normalExit = localStorage.getItem('normalExit');
  if (normalExit !== 'true') {
    reportCrash();
  }
  localStorage.setItem('normalExit', 'false');
}

上面只是讨论到当页面单开的情况,那么如果是多标签页的场景下该如何设计呢?要知道localStorage是在同一个域名下各个标签页共享的。

根据上面的检测原理,会想到,给每个页面都设置一个独立的 tabId,并在其中一个页面获取所有的页面normalExit,判断normalExit 是不是 false,false则认为是发生了崩溃。

但是不行,当我们打开多页面时,因为页面并还没有退出,获取到的normalExit其实都是false的,那我们就需要多一个字段去判断,心跳时间;

1.2 多标签页场景的完整设计

针对多页面场景,每个页面需要心跳 + tabId,判断页面是否还存活

为什么需要心跳?

因为多页面时,每个页面运行中时 normalExit 本来就是 false。 所以不能只看 normalExit,需要结合“最后心跳时间”:

normalExit = false + 心跳超过阈值未更新 → 判定崩溃

👇 关键逻辑代码(精简示例)

** 为每个 tab 创建唯一 ID **

// 
function getTabId() {
  let id = sessionStorage.getItem('**tab_id**');
  if (!id) {
    id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    sessionStorage.setItem('**tab_id**', id);
  }
  return id;
}

心跳写入(运行中 normalExit = false)

function saveAll(map: Record<string, HeartbeatRecord>) {
  try {
    localStorage.setItem(KEY, JSON.stringify(map));
  } catch {}
}

export function writeHeartbeat(rec: Omit<HeartbeatRecord, 'ts' | 'tabId'>) {
  const tabId = getTabId();
  const map = loadAll();
  map[tabId] = { ...map[tabId], ...rec, ts: Date.now(), tabId };
  saveAll(map);
}

export function startSessionHeartbeat(intervalMs = 3000, recBase?: Omit<HeartbeatRecord, 'ts' | 'tabId'>) {
  const page = `${window.location.pathname}${window.location.hash || ''}`;
  let timer: number | undefined;

  function beat() {
    writeHeartbeat({ page, version: recBase?.version, env: recBase?.env, meta: recBase?.meta, normalExit: false });
  }

  beat();
  timer = setInterval(beat, intervalMs);

  window.addEventListener('beforeunload', () => {
    markNormalExit();
    if (timer) clearInterval(timer);
    clearTabHeartbeat();
  });

  return () => {
    if (timer) clearInterval(timer);
  };
}

正常退出钩子(beforeunload)


export function markNormalExit() {
  const tabId = getTabId();
  const map = loadAll();
  if (map[tabId]) {
    map[tabId].normalExit = true;
    saveAll(map);
  }
}

window.addEventListener('beforeunload', () => {
  markNormalExit(); // 标记正常退出
  clearTabHeartbeat(); // 清除心跳
});

// 重新打开页面时判断历史页面是否崩溃
if (!rec.normalExit && diff > timeoutMs) {
  reportCrash(rec);
}

下次打开时检测崩溃(核心)

判断崩溃逻辑:

  1. 不是正常退出时,normalExit 为 false

  2. 当崩溃时,normalExit 为false 或 undefined

  3. diff > timeoutMs 主要是为了当多开页面时,正常的页面心跳时间一直在滚动更新,不会少于timeoutMs,防止误报

export function checkPreviousAbnormalExit(
  timeoutMs: number,
  report: (payload) => void
) {
  const now = Date.now();
  const currentTab = getTabId();
  const map = loadAll();

  let changed = false;
  Object.values(map).forEach((rec) => {
    if (!rec || rec.tabId === currentTab) return;
    const diff = now - (rec.ts || 0);

    if (!rec.normalExit && diff > timeoutMs) {
      //// diff > timeoutMs 有个弊端,当用户崩溃后,刚好设了一个时间戳,并且马上打开一个新标签页,这时diff可能还没超过timeoutMs,这种情况会漏报
      report({ ...rec, diff });
      delete map[rec.tabId];
      changed = true;
    } else if (rec.normalExit) {
      delete map[rec.tabId];
      changed = true;
    }
  });

  if (changed) saveAll(map);
}

1.3 多页面心跳的隐藏坑点(必须处理)

🔥 1. 页面隐藏时定时器会被延迟

浏览器切后台后,setInterval 会被降频,甚至几秒才执行一次。 这会误伤心跳逻辑。

处理方式:

页面隐藏时直接标记 normalExit=true,避免误报。

但为什么可行?因为后台页面本身不应计入崩溃统计(用户没在看)。

🔥 2. 心跳的时间不准

如果是使用定时器更新心跳时间,心跳更新时间并不会特别准可以了解下setInterval原理,比如设了3000毫秒更新一次,有可能是3000+-N000毫秒才执行更新,也可能是主线程有大计算导致更新时间更慢,所以阈值不能设置和心跳时间一样的时间,得有一定的宽容度。 建议阈值为:

阈值 = 心跳间隔 \* 2

1.4 方案一的优劣总结

优点

  • 实现简单,不依赖 Service Worker

  • 能检测页面是否在上一次会话中异常退出

  • 多标签页可准确判断单页崩溃

缺点(重点)

  • 无法实时上报,必须等待下次打开页面

  • 页面进程在后台被系统杀死,无法触发页面生命周期事件导致无法打标记,下次启动页面时会存在误报。但是误报笔者是觉得允许的,采集到样本大的页面崩溃路径才是最有可能导致崩溃的页面;

2. 基于 Service Worker 的实时心跳监控

既然方案一无法实时上报,那么是否能借助Worker 实现实时上报呢?worker 独立于页面运行,可以在页面崩溃后继续存活,从而实现实时上报。

笔者一开始是想到用 Web Worker 来实现的,但是后来发现 Web Worker 生命周期和页面是绑定的,页面崩溃后,Web Worker 也不可用,所以无法实现实时上报。只能是用service Worker 来实现。

Service Worker(SW)可以在页面崩溃后继续存活,只要浏览器进程未关闭。 利用 SW 作为“监控总控”,页面与 SW 双向通信,从而实现:

✔ 页面实时心跳发送

✔ SW 主动判断某 tab 心跳超时

✔ 立即上报崩溃事件(无需等待下次进入)

2.1 方案架构图

页面 A/B/C
  ↓ (heartbeat)
Service Worker(独立线程)
  ↓ (report)
监控服务(如 Sentry)

2.2 SW 的核心逻辑

  1. 收到心跳,更新 tab 的 时间搓
if (data.type === 'heartbeat') {
  tabLastBeat.set(tabId, { ts: now() });
  ensureCrashChecker();
}
  1. 定时检查哪些 tab 心跳超时
if (nowTs - ts > CRASH_TIMEOUT_MS) {
sendReport();
tabLastBeat.delete(tabId);
}
  1. 页面向 SW 发送 exit 消息,避免误报
postToSW({ type: 'exit', tabId: this.tabId });

2.3 页面侧的心跳通知

this.timer = setInterval(() => {
  this.postToSW({
    type: 'heartbeat',
    tabId: this.tabId,
    ts: Date.now()
  });
}, this.heartbeatIntervalMs);

同时绑定生命周期:

  • beforeunload

  • visibilitychange(hidden → exit)

  • pagehide

2.4 方案二的优劣

优点

  • 支持实时上报

  • 监控逻辑不依赖浏览器是否回到页面

  • 多标签页信息共享更自然(SW 本来就是共享运行时)

缺点(非常关键)

  • 浏览器窗口关闭时,SW 也会消失 → 无法上报

  • 仅适用于开启 SW 的站点(需 HTTPS + 同源)

  • SW 更新策略复杂(需处理 skipWaiting、claim 等)

最终结论与推荐方案

场景 最佳方案 原因
简单不复杂 方案一(localStorage + 心跳) 下次打开可判断所有异常退出,覆盖范围最大
想实时上报崩溃(非浏览器关闭情况) 方案二(Service Worker) 页面崩溃后 SW 仍可运行并上报
希望误报率最低 结合两者 SW 实时 & 下次打开兜底

附录

  1. 完整方案一代码示例见:页面崩溃上报实现代码
  2. 方案二 Service Worker 示例代码见:Service Worker 页面崩溃监控代码Service Worker 代码
  3. 完整demo 见:页面崩溃监控 Demo

如果觉得代码有用麻烦点个小星星。

❌
❌