用户在群里甩来一张截图,说点了提交按钮没反应。你问他什么机型、什么网络、报了什么错,对面回一句「就是没反应啊」。然后你打开自己的 Chrome,一切正常。
这种对话我经历过太多次了。没有监控的时候,线上对前端来说就是一个黑盒,能不能复现全看运气。这篇是我把前端监控这套东西从指标定义、采集、上报、存储一直到告警和看板整个走了一遍之后的笔记合集,里面有能直接抄走的采集代码,也有踩过的坑。读完你至少能自己搭出一个跑得起来的最小监控系统,而不是只会说「我们接了 Sentry」。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 一套完整前端监控系统的骨架长什么样,从采集到看板一共几层
- 首屏时间(FMP)在 SPA 场景下怎么算,包含图片的页面为什么会算错
- 白屏、卡顿、网络环境这三类指标各自的采集口径和代码
- 性能 SDK 怎么设计才好接入,抽样、过滤、上报时机怎么定
- 数据接入层为什么必须过一层 Kafka,清洗和计算分别做什么
- 告警规则怎么配,什么样的问题值得叫醒人
- JS 错误、接口异常、资源加载失败三条线的捕获方式
- 用 Node.js + Logstash + Elasticsearch 手搭一套监控后端的完整流程
- 首屏秒开的四重保障和白屏 300ms 的具体优化手段
- 上线前该过一遍的 checklist
一、先把整套系统的骨架摆出来
很多人一上来就问「用什么 SDK」,这个问题问早了。前端监控不是一个 SDK 的事,它是一条从浏览器一直通到告警群的链路,任何一环断了,前面做得再精细都白搭。
先看这块,把整条链路画出来大概是这样:
浏览器 / WebView 里跑的那段 JS |
这五层的落地顺序,我的建议是这样排:
- Step 1:先定指标口径。首屏算到哪一刻、白屏用哪两个时间点相减、卡顿的阈值是多少,这些不定死,后面所有数据都没法横向比。
- Step 2:写采集 SDK,先只做错误捕获这一条线。错误是最容易看到收益的,接上去当天就能捞到线上问题。
- Step 3:搭接入层。一个 Node 服务 + 落本地日志就够了,先别想着上大数据。
- Step 4:接存储和查询。日志用 Logstash 送进 ES,配个 Kibana 或者 es-head 就能开始查。
- Step 5:补性能采集。等错误这条线跑顺了再加性能,因为性能指标的口径争议远比错误多。
- Step 6:最后做告警和看板。有了稳定的数据才谈得上定阈值。
我一开始也是反过来做的,先花两周做了一个很漂亮的大盘,结果数据口径改了三次,图表全部返工。回到这个顺序上,先有数据,再有展示,返工成本会低很多。
后面的章节就按这条链路一层一层往下拆。
二、性能优化方法论
在动手写采集代码之前,得先想清楚一件事:我们到底在优化什么。性能不是一个数,它是一串可以拆开的时间段,只有拆开了才知道该在哪一段下手。

首屏时间可以拆分为白屏时间、数据接口响应时间、图片加载资源等
这句话是整篇的地基。用户抱怨「慢」的时候,慢在哪一段是完全不同的问题:白屏长通常是 DNS、连接和首字节的问题;接口响应慢要找后端;图片加载慢那是资源体积和 CDN 的事。拿不到分段数据,优化就只能靠猜。


这两张图把「指标 → 归因 → 手段」串了起来。看图的时候重点不是记住每个格子,而是记住这个链条本身:任何一个优化动作,都应该能回答「它改善的是哪个指标的哪一段」。答不上来的优化,多半是在做无用功。
顺着上面聊,指标从哪儿来?这就到采集环节了。
三、指标采集:首屏时间指标采集具体办法
首屏时间(FMP,First Meaningful Paint)是所有性能指标里最难算准的一个,因为「有意义的内容出现了」这句话本身就没有统一定义。业界的做法分两派,手动打点和自动化采集,各有各的问题。
手动采集办法及优缺点
所谓手动采集,一般是通过埋点的方式进行,比如在页面开始位置打上 FMP.Start(),在首屏结束位置打上 FMP.End(),利用 FMP.End()-FMP.Start() 获取到首屏时间。
听起来很直观,做起来全是麻烦。手动采集的统计结果并不精确,因为它依赖于人,每个人对首屏的理解有偏差,经常打错或者忘记打点。更麻烦的是页面改版之后,那个 FMP.End() 常常还留在原来的组件里,数据看着挺稳定,其实早就量错了地方。
这个我踩过,一个列表页改成了骨架屏方案,结算点还挂在旧的 loading 隐藏逻辑上,数据一夜之间「优化」了 400ms,实际用户体验一点没变。
自动化采集优势及办法
所谓自动化采集,即引入一段通用的代码来做首屏时间自动化采集,引入过程中,除了必要的配置不需要做其他事情。
自动化采集的好处是独立性更强,接入过程更自动化。具体的自动化采集代码,可以由一个公共团队来开发,试点后,推广到各个业务团队。而且统计结果更标准化,同一段统计代码,标准更统一,业务侧同学也更认可这个统计结果。
标准统一这一点特别重要。监控数据的价值不在绝对值,而在可比较:A 页面 1.2s、B 页面 2.8s,这个对比成立的前提是两边用同一把尺子。手动埋点做不到这件事。
当然,它也有缺点,最明显的是,有些个性化需求无法满足,毕竟在工作中,总会有一些特殊业务场景。所以,采用自动化采集方案必须做一些取舍。我的取舍是:主指标一律走自动化,个别页面确实需要精确到某个业务节点的,再额外开一个自定义埋点,两套数据分开存,不要混在一起算平均。
单页面(SPA)应用业务下的采集办法
SPA 页面因为无法基于 DOMContentLoaded 做首屏指标采集,可以使用 MutationObserver 采集首屏时间。
MutationObserver接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是 DOM3 Events 规范的一部分。
简单来说, 使用 MutationObserver 能监控页面信息的变化,当页面 body 变化最剧烈的时候,我们拿到的时间数据,就是首屏时间。
首先,在用户进入页面时,我们可以使用 MutationObserver 监控 DOM 元素 (Document Object Model,文档对象模型)。当 DOM 元素发生变化时,程序会标记变化的元素,记录时间点和分数,存储到数组中。数据的格式类似于 [200ms,18.5]。
为了提升计算的效率,我们认为首屏指标采集到某些条件时,首屏渲染已经结束,我们需要考虑首屏采集终止的条件,即计算时间超过 30 秒还没有结束;计算了 4 轮且 1 秒内分数不再变化;计算了 9 次且分数不再变化。
接下来,设定元素权重计算分数。
递归遍历 DOM 元素及其子元素,根据子元素所在层数设定元素权重,比如第一层元素权重是 1,当它被渲染时得 1 分,每增加一层权重增加 0.5,比如第五层元素权重是 3.5,渲染时给出对应分数。
为什么需要权重呢?
因为页面中每个 DOM 元素对于首屏的意义是不同的,越往内层越接近真实的首屏内容,如图片和文字,越往外层越接近 body 等框架层。
最后,根据前面的得分,计算元素的分数变化率,获取变化率最大点对应的分数。然后找到该分数对应的时间,即为首屏时间。
这套打分法的思路,说到底是用 DOM 结构的复杂度变化来近似「内容出现了」这件事。它不完美,但比人肉打点稳定得多。
下面这段是分数部分的核心计算逻辑,做三件事:把 SCRIPT、STYLE、META、HEAD 这类不产生视觉的标签排除掉;用 getBoundingClientRect().top < WH 判断元素是否在首屏可视范围内,超出的直接返回 0 分;每往下一层,权重加 0.5。
function calculateScore(el, tiers, parentScore) { |
这里有个细节容易被忽略:parentScore 这个参数是用来做剪枝的。如果父节点已经拿到了分数,子节点即便超出可视区也不再单独判断,避免整棵子树被误判成 0 分。
有了每个时刻的分数,接下来要找的是分数增长最猛的那一刻。变化率部分的核心逻辑就是遍历分数序列,两两相减,找出增量最大的那个时间点,那个点就是首屏。同时这段代码还负责收尾:断开 MutationObserver、把图片时间和 DOM 时间取大值、写入最终性能对象。
calFinallScore() { |
这个就是首屏计算的部分流程。代码里 30001 这个兜底值是个约定,表示「算超时了,没算出来」,落库之后按无效数据处理,别让它混进平均值。
看完前面的流程,不知道你有没有这样的疑问:如果页面里包含图片,使用上面的首屏指标采集方案,结果准确吗?
结论是:不准确。上述计算逻辑主要是针对 DOM元素来做的,图片加载过程是异步,图片容器(图片的 DOM 元素)和内容的加载是分开的,当容器加载出来时,内容还没出来,一定要确保内容加载出来,才算首屏。
这一点在电商列表页、图片流这类页面上尤其明显。DOM 树早就稳定了,分数曲线也不再变化,但屏幕上还是一片灰色占位,用户看到的仍然是空的。这时候算出来的首屏时间会比真实体感快一大截,看着很好看,其实是在自欺欺人。
所以要给图片单独补一条时间线。以下是包含图片页面的首屏计算 demo。
<body><img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super"> |
它的计算逻辑是这样的。
首先,获取页面所有的图片路径。在这里,图片类型分两种,一种是带 IMG 标签的,一种是带 DIV 标签的。前者可以直接通过 src 值得到图片路径,后者可以使用 window.getComputedStyle(dom) 方式获取它的样式集合。
接下来,通过正则获取图片的路径即可。
然后通过 performance.getEntriesByName(element)[0].responseEnd 的方式获取到对应图片路径的下载时间,最后与使用 MutationObserver 获得的 DOM 首屏时间相比较,哪个更长,哪个就是最终的首屏时间。
以上就是首屏采集的完整流程
注意:计算首屏时间用到的
getBoundingClientRect和getComputedStyle会引起强制回流
这条注意事项分量很重。监控代码本身是有成本的,递归遍历整棵 DOM 树 + 每个节点调一次 getBoundingClientRect(),在节点上千的页面里能实打实拖慢主线程。实际用的时候要控制两点:一是采集频率,CHECK_INTERVAL 别设太小;二是抽样,不必每个用户都跑这套计算。
这里还要补一句时效性。上面这套自研 FMP 是 2021 年的写法,当时浏览器没有统一的「有意义内容」指标,只能靠 MutationObserver 打分近似。后来 Core Web Vitals 把 LCP(Largest Contentful Paint)标准化了,浏览器直接通过 PerformanceObserver 暴露 largest-contentful-paint 条目,口径由浏览器保证,比自己打分稳定得多。各项指标的阈值和统计口径 Google 调整过若干次,PerformanceObserver 支持哪些 entryType 也随版本变化,具体数值以 web.dev 和 MDN 官方文档为准,我这里就不写死了。
新项目我的建议是:主指标用 LCP,自研 FMP 作为辅助对照。老项目已经积累了一年以上 FMP 历史数据的,别急着换口径,两套并行跑一段时间再切,不然趋势图会断层。

四、指标采集:白屏、卡顿、网络环境指标采集方法
白屏指标采集
白屏时间是指从输入内容回车(包括刷新、跳转等方式)后,到页面开始出现第一个字符的时间。白屏时间的长短会影响用户对 App 或站点的第一印象
白屏指标怎么采集呢?我们先来回顾一下浏览器的页面加载过程:
客户端发起请求 -> 下载 HTML 及 JS/CSS 资源 -> 解析 JS 执行 -> JS 请求数据 -> 客户端解析 DOM 并渲染 -> 下载渲染图片-> 完成渲整体染
在这个过程中,客户端解析 DOM 并渲染之前的时间,都算白屏时间。所以,白屏时间的采集思路如下:白屏时间 = 页面开始展示时间点 - 开始请求时间点。如果你是借助浏览器的 Performance API 工具来采集,那么可以使用公式:白屏时间 FP = domLoading - navigationStart。
这个公式在 2021 年是通用写法,靠的是 performance.timing 这套老 API。有两件事得说清楚:domLoading 只在旧的 PerformanceTiming 接口上有,新的 PerformanceNavigationTiming 里没有对应字段;navigationStart 在新接口下的等价物是 startTime,通常为 0。现在更常见的做法是直接取 paint 类型的 first-paint 条目,也就是浏览器自己认定的首次绘制时刻,比自己推算准。哪些字段在哪个接口上可用,以 MDN 官方文档为准,别照着某篇博客的表格硬抄。
不过老 API 也别急着删。国内还有相当比例的低版本 WebView,新接口拿不到值的时候,回退到 performance.timing 依然是有效兜底。我自己的 SDK 里是两套都取,优先用新的,取不到再降级。
这是浏览器页面加载过程,如果放在 App场景下,就不太一样了,App下的页面加载过程:
初始化 WebView -> 客户端发起请求 -> 下载 HTML 及 JS/CSS 资源 -> 解析 JS 执行 -> JS 请求数据 -> 服务端处理并返回数据 -> 客户端解析 DOM 并渲染 -> 下载渲染图片 -> 完成整体渲染。
App下的白屏时间,多了启动浏览器内核,也就是 Webview 初始化的时间。这个时间必须通过手动采集的方式来获得,而且因为线上线下时间差别不大,线下采集即可。具体来说,在 App 测试版本中,程序在 App 创建 WebView 时打一个点,然后在开始建立网络连接打一个点,这两个点的时间差就是 Webview 初始化的时间。
卡顿指标采集
所谓卡顿,简单来说就是页面出现卡住了的不流畅的情况。 提到它的指标,你是不是会一下就想到 FPS(Frames Per Second,每秒显示帧数)?FPS 多少算卡顿?网上有很多资料,大多提到 FPS 在 60 以上,页面流畅,不卡顿。但事实上并非如此,比如我们看电影或者动画时,素虽然 FPS 是 30 (低于60),但我们觉得很流畅,并不卡顿。
FPS 低于 60 并不意味着卡顿,那 FPS 高于 60 是否意味着一定不卡顿呢?比如前 60 帧渲染很快(10ms 渲染 1 帧),后面的 3 帧渲染很慢( 20ms 渲染 1 帧),这样平均起来 FPS 为95,高于 60 的标准。这种情况会不会卡顿呢?实际效果是卡顿的。因为卡顿与否的关键点在于单帧渲染耗时是否过长。
但难点在于,在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。
它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。
以 H5 为例,H5 场景下获取 FPS 方案如下:
var fps_compatibility= function () { |
利用
requestAnimationFrame在一秒内执行60次(在不卡顿的情况下)这一点,假设页面加载用时X ms,这期间requestAnimationFrame执行了N次,则帧率为1000* N/X,也就是FPS
由于用户客户端差异很大,我们要考虑兼容性,在这里我们定义 fps_compatibility 表示兼容性方面的处理,在浏览器不支持 requestAnimationFrame 时,利用 setTimeout 来模拟实现,在 fps_loop 里面完成 FPS 的计算,最终通过遍历 fpsList 来判断是否连续三次 fps 小于20。
如果连续判断 3次 FPS 都小于20,就认为是卡顿。
这段代码有两个地方值得单独说。fps_compatibility 里的降级分支用 setTimeout(callback, 1000 / 60) 模拟 requestAnimationFrame,这只是保证代码不崩,测出来的数值参考价值有限,因为 setTimeout 本身就受主线程阻塞影响,主线程越卡它越不准。所以数据落库时最好带上「是否走了降级」这个标记,分析时把降级样本单独看。
另一个是 isBlocking 的 count 归零逻辑。它要求的是连续三帧低于阈值,中间只要有一帧恢复就重新计数。这跟前面说的「关键在单帧渲染耗时」是一回事,偶发的一帧掉队用户是感知不到的,连续掉才叫卡。
顺带提一句网络环境。navigator.connection 上的 effectiveType 能拿到 4g、3g、2g 这类粗粒度的网络类型,用来给性能数据分桶很好用。同一个页面在 4G 下 1.2s、在 3G 下 4s,这是两个完全不同的优化命题,混在一起算平均只会得到一个谁都不认的数字。这个 API 的浏览器支持面一直不算宽,Safari 侧的情况以官方文档为准,取不到就落一个 unknown 分桶,别让整条数据作废。

五、工具实践:性能 SDK 及上报策略设计
由于性能 SDK 最终是给各个业务使用的,所以它的设计要满足在接入性能监控平台时,简单易用和运行平稳高效,这两个要求。
SDK 接入设计
要保证 SDK 接入简单,容易使用,首先要把之前首屏、白屏和卡顿采集的脚本封装在一起,并让脚本自动初始化和运行。

这张图是 SDK 的模块划分,看的时候注意分层:底下是各指标的采集实现,中间是统一的 Perf API,最上面才是业务调用的一行 perfInit()。业务方能看到的东西越少,SDK 越好推。
- 具体来说,首屏采集的
分数计算部分 API(calculateScore)、变化率计算的 API(calFinallScore)和首屏图片时间计算 API(fmpImg)可以一起封装成 FMP API。其中首屏图片计算 API 因为比较独立,可以专门抽离成一个 util,供其他地方调用。白屏和卡顿采集也类似,可以封装成FP API和BLOCK API。 - 还有一个
ExtensionAPI接口,用来封装一些后续需要使用的数据,比如加载瀑布流相关的数据(将首屏时间细分为DNS、TCP连接等时间),这些数据可以通过浏览器提供的performance接口获得。
为了进行首屏、白屏、卡顿的指标采集,我们可以封装
Perf API,调用FMP、FP、BLOCK、ExtensionAPI四个 API 来完成。因为是调用window.performance接口,所以先做环境兼容性的判断,即看看浏览器是否支持window.performance
接入方式要给两种,因为业务的工程形态不一样。有 npm 工程的走包管理,纯静态页面或者不方便改构建的老项目走外链。
npm 方式,装包加一行初始化就完事:
npm install @common/Perf -S |
import { perfInit } from '@common'; |
或者以外链的形式接入。注意脚本要用 src 属性引进来,不能把 URL 直接当成脚本内容写在标签体里,那样浏览器会拿它当 JavaScript 解析,然后抛一个莫名其妙的语法错:
<script type="text/javascript" src="https://s1.static.com/common/perf/static/js/1.0.0/perf.min.js"></script> |
外面那层 try catch 不是可选项。SDK 是被塞进别人页面里跑的,它自己抛错还没兜住,很可能连累宿主页面后面的脚本,这是做监控最不该犯的错。
除了性能 SDK 自身的方案设计之外,提供帮助文档(如示例代码、 QA 列表等),也可以提高性能 SDK 的易用性。
具体来说,我们可以搭建一个简单的性能 SDK 网站,进入站点后,前端工程师可以看到使用文档,包括各种平台下如何接入,接入的示例代码是怎样的,接入性能 SDK 后去哪个 URL 看数据,遇到异常问题时怎么调试,等等。
SDK 运行设计
SDK 如果想运行高效,必须有好的兼容性策略、容错机制和测试方案。
所谓兼容性策略,就是性能 SDK 可以在各个业务下都可以稳定运行。
我们知道,前端性能优化会面临的业务场景大致有:
- 各类页面,如平台型页面、3C 类页面、中后台页面;
- 一些可视化搭建的平台,如用于搭建天猫双十一会场页这种用于交易运行页面的魔方系统;
- 各个终端,如 PC 端,移动端,小程序端等。
这就要求性能 SDK 要能适应这些业务,及时采集性能指标并进行上报。那具体怎么做呢?
一般不同页面和终端,它们的技术栈也会不同,如 PC端页面使用 React,移动端页面使用 VUE 。这个时候,我们可以尽可能用原生 JavaScript 去做性能指标的采集,从而实现跨不同技术栈的采集。
不同终端方面,我设计了一个适配层来抹平采集方面的差异。
具体来说,小程序端可以用有自己的采集 API,如 minaFMP,其他端可以直接用 FMP,这样在性能 SDK 初始化时,根据当前终端类型的不同,去调用各自的性能指标采集 API。
容错方面怎么做呢?
如果是性能 SDK 自身的报错,可以通过
try catch的方式捕获到,然后上报异常监控平台。注意,不要因为 SDK 的报错而影响引入性能 SDK 页面的正常运行。
日志数据过滤
我的建议是,在采集性能指标之后,最好先对异常数据进行过滤。
异常数据分一般有两类,第一类是计算错误导致的异常数据,比如负值或者非数值数据,第二类是合法异常值、极大值、极小值,属于网络断掉或者超时形成的数值,比如 15s 以上的首屏时间。
负值的性能指标数据影响很大,它会严重拖低首屏时间,也会把计算逻辑导致负值的问题给掩盖掉。
负值是怎么来的?多半是两个时间戳来自不同的时钟基准,或者某个字段压根没取到,拿了个 0 去做减法。所以过滤规则里除了「小于 0 直接丢」,还得加一条「参与计算的字段有任意一个为 0 就丢」,不然你会看到一堆等于 navigationStart 的诡异首屏时间。
还有一类容易被忽略的脏数据:页面在后台标签页加载。用户开了一堆标签,你这个页面在后台慢悠悠加载了 30 秒,这数据算进来毫无意义。采集时判一下 document.visibilityState,页面加载期间进过后台的直接打标丢弃。
数据抽样策略
性能 SDK 上报数据是全量还是抽样,需要根据本身 App 或者网站的日活来确定,如果日活10万以下,那抽样就没必要了。如果是一款日活千万的 App,那就需要进行数据抽样了,因为如果上报全量日志的话,会耗费大量用户的流量和请求带宽。
抽样的实现很简单,Math.random() < rate 就完事了,难的是抽样率怎么定,以及哪些数据不能抽样。我的分法是:性能指标可以抽样,因为它看的是分布和趋势,少一部分样本不影响结论;错误上报尽量别抽样,尤其是 JS 报错,一个只在特定机型触发的错误本来量就小,再抽一道可能就永远看不到了。
抽样率还得能远程下发,别写死在 SDK 里。写死的后果是想调一次采样率要发一次版,等业务方全量升级得等到下个月。做法也简单,SDK 初始化时拉一份配置,拉不到就用内置默认值兜底。
上报机制选择
一般,为了节省流量,性能 SDK 也会根据网络能力,选择合适的上报机制。在强网环境(如 4G/WIFI),直接进行上报;在弱网(2G/3G)下,将日志存储到本地,延时到强网下再上报。
除了网络能力,我们还可以让 SDK 根据 App 忙碌状态,选择合适的上报策略。如果 App 处于空闲状态,直接上报;如果处于忙碌状态,等到闲时(比如凌晨 2-3 点)再进行上报。
除此之外,还有一些其他的策略,如批量数据上报,默认消息数量达到 30 条才上报,或者只在 App 启动时上报等策略,等等。你可以根据实际情况进行选择。
说到上报,用什么方式发这个请求也得挑一下。三种常见做法各有各的适用场景:
| 方式 | 跨域 | 页面卸载时可靠性 | 数据量 | 适用场景 |
|---|---|---|---|---|
| 1x1 gif(GET) | 天然不受限 | 中等,可能被中断 | 受 URL 长度限制 | 常规打点,业界主流 |
navigator.sendBeacon |
需要服务端 CORS | 高,浏览器保证发出 | 可带 body | 页面卸载前的兜底上报 |
fetch / XHR POST |
需要服务端 CORS | 低,卸载时容易被 kill | 大 | 复杂结构、批量数据 |
日常打点我还是用 gif,一是不用管跨域,二是服务端返一个 1 字节的图就行,成本极低。但页面关闭这个时机必须用 sendBeacon,因为用户点了关闭按钮之后,浏览器不保证你的异步请求还能发出去,sendBeacon 是专门为这个场景设计的,它会把请求交给浏览器进程排队发送,页面死了也不影响。
这里有个坑要注意:sendBeacon 有单次数据量上限,超了会直接返回 false,而且很多人不去看这个返回值,结果数据静悄悄丢了。写的时候记得判一下返回值,失败就降级到 gif。

采集到数据后,先对数据进行校验,如果发现数据异常则直接上报到数据异常平台(通过邮件或者钉钉通知的方式发送给开发者),反之如果数据是正常范围内的,则结合采样率来看是否需要上报。
六、平台实践:如何从 0 到 1 搭建前端性能平台
数据采上来了,接下来的问题是往哪儿放、怎么算、给谁看。前端性能平台是一个 Web 系统,主要包括后台的性能数据处理和前台的可视化展示两部分
其中,数据处理后台主要是对 SDK 上报后的性能指标进行处理和运算,具体包括数据入库、数据清洗、数据计算,做完这些后,前台会对结果进行可视化展现,我们借助它就可以实时监督前端的性能情况。下图是性能平台大盘页的效果,主要对当前用户关注的性能模块进行展示,内容包括首屏时间、秒开率和采样PV。

大盘页只放三个数是有讲究的。首屏时间看绝对水平,秒开率看达标比例,采样 PV 看这份数据可不可信。少了第三个数,前面两个随时可能因为样本太少而失真,我见过采样 PV 只有几十的页面顶着一个非常漂亮的秒开率,点进去一看是内部测试流量。
那么,我们该如何搭建这样一个性能平台呢?

这是具体的技术架构图,从底层到前台大致情况如下:
- 数据接入层,主要是接收
SDK上报的性能数据,做数据处理后入库,包含的技术有Node.js、Node-sechdule、Node-mailer; - 数据计算层,会对性能数据做计算处理,需要的技术有
Kafka、Spark、Hive、HDFS - 存储层,包括
MySQL + MongoDB,性能平台需要的数据会来这里 - 平台层,也就是展示给用户的部分,需要的技术有
React、Ant design、Antv、Less
性能数据处理后台
想要搭建性能平台,我们先来看它的性能处理后台情况。一般性能 SDK 上报数据的处理过程是这样的:
- 客户端借助
SDK上报性能数据指标,数据接入层(图中绿色部分)接收相应数据,并做协议转换等简单处理后,作为生产者向 Kafka 写入数据; - 数据计算层(图中橙色部分)作为消费者,从 Kafka 读数据存入 Hive(Hadoop平台的存储表),Hadoop 平台借助 Spark 做数据分析计算;
- 借助 Hive 提供的接口,数据计算层使用 SQL 语句从 Hive 拉取计算后的数据到数据库平台(MongoDB),平台层取出数据,准备数据可视化展现的数据。
上述数据流程,对应的性能数据后台的搭建过程如下:
- 第一步是入库,客户端借助 SDK 上报性能数据指标后,需要后端服务层的处理,这里我们选取的是 NodeJS 做后端,利用 Controller 层对数据做处理。
为了避免数据库出现「脏数据」(如空数据、异常数据),影响后续数据处理,我们将 SDK 上报的数据通过 URL 解析成 key-value 格式的数据,对数据进行空数据删除,异常数据舍弃等操作。然后我们让数据写进消息队列 Kafka。
为什么不是直接存入 Hive 呢?
因为客户端上报的性能数据量和用户规模有关。如果直接入库到 Hive,遇到高并发的时候,会因为服务器扛不住而导致数据丢失。与此同时,因为数据下游(数据的使用方,如数据清洗计算平台,性能预警模块)会有多个数据接收端,直接入库的话也会造成数据重复。
所以最好我们选择 Kafka,先让数据写进消息队列。Kafka 能通过缓存,慢慢接收这些数据,降低流量洪峰压力。同时,消息队列还有接收数据后将其删除的特点,可以避免数据重复的问题。
我想强调一下削峰这件事,因为它经常被当成「架构好看」的装饰品。监控数据的流量形态跟业务流量是同步的,业务出故障的时候,错误上报量会瞬间涨几十倍。你想想看,最需要看数据的那一刻,恰恰是写入压力最大的一刻。没有队列挡在前面,监控系统会跟着业务一起挂,那就彻底失去意义了。
如果团队规模没到需要 Kafka 的程度,这一层也可以退化成「Node 服务直接写本地日志文件 + Logstash 监听文件变化」,后面第十一节手搭那套走的就是这条轻量路线。架构选型看的是团队和数据量,不是看着谁的图更唬人。
- 第二步,
对 Kafka 中的数据,做数据清洗和数据计算
数据清洗,是指针对性能上报单条数据进行核对校验的过程。所清洗的数据包括:
- 对重复数据的处理,即同一个用户网络出错时,多次重试导致上传了好几条首屏时间相关的数据;
- 对缺失数据的处理,虽然上报了首屏时间,但白屏时间或者卡顿时间计算时没能给出;
- 对错误数据的处理,即数据超出正常范围,出现负值或者超出极大值的情况。
这几种类型数据问题如果不处理,最终会影响计算结果的准确性。那么该怎么处理呢?
- 遇到重复数据,直接去重删除即可。
- 遇到缺失数据,我们在 Spark 平台上,先根据上报的 Performance 数据进行计算补全,如果无法补全的,就直接舍弃掉,不然会出现后续无法入库的情况。
- 遇到超出正常范围的数据,如负值或者超过 10 秒以上的数据,把它当作无效数据,直接舍弃掉。
做完数据清洗之后,我们还需要
使用 Spark 做数据计算,为可视化展现准备数据。具体需要做以下数据计算:
- 首屏时间分布的计算,
1s ~ 2s占比多少,2s ~ 4s占比多少; - 秒开率的计算,首屏时间小于等于 1 秒的数据占比;
- 页面瀑布流时间的计算。
其中,页面瀑布流时间是对首屏时间的细分,包括 DNS 查询、TCP链接、请求耗时、内容传输、资源解析、DOM 解析和资源加载的时间。这些细分时间点,是我们根据 SDK 上报的 Performance 接口数据指标计算出来的,前端工程师根据页面瀑布流时间,可以快速定位性能瓶颈点出现在哪个环节。
还有一个计算上的坑得提前说:性能指标千万别只看平均值。首屏时间的分布是明显右偏的,一小撮 10s 以上的样本能把平均值拉得很难看,而平均值又掩盖了大多数用户的真实体感。正确的看法是看分位数,P50 代表大多数人的体验,P95 代表最差那批人的体验,两个数一起看才知道该优化谁。秒开率其实就是一种更粗的分位数表达。
回到我们要解决的问题,这一层算出来的东西,最终都是为了让前端能在页面上一眼看出「哪里慢了」。
- 第三步,准备性能前台所需的可视化数据
为了完成前台展现,性能平台需要登录功能,还需要做一些用户关注的模块信息,比如前端开发者添加关注的业务模块。我们可以用关系数据库去存储这些数据,具体可以选择 MySQL完成账号权限系统和关注业务模块对应的数据表。
而性能数据,因为都是单条性能信息,相互之间并没有什么关系,可以用 MongoDB 做存储。具体来说,我们可以用 NodeJS 提供的定时脚本(Node-sechdule)从 Spark 取到数据导入到 MongoDB 中。
前端数据可视化展示前台
前端数据可视化展现前台,整体上只有两个页面,大盘页和详情页。
大盘页包括一个个业务的性能简图。每一个性能简图包括首屏时间、秒开率、采样 PV 数据。点击性能简图上的「进入详情」链接,就可以进入详情页。初次进入大盘页的时候,需要你登录并关注相关的业务,然后就可以在大盘首页看到相关的性能情况。
「关注」这个设计别省。平台上线之后业务方会越接越多,几十上百个模块堆在一个页面上,谁都不会看第二次。让每个人只看自己关注的那几个,页面才有人愿意天天打开。
详情页的设计的初衷是为了对性能简图做进一步的补充,除了展示对应性能简图的秒开率、性能均值细节、白屏均值细节之外,还会展示终端信息,比如多少比例在 iOS 端,多少比例在 Android 端,以方便用户根据不同场景去做优化。

同时,为了解秒开率不达标原因或者首屏时间变慢的细节在哪里,我们会给出页面加载瀑布流,前面数据处理阶段已经提到可以使用的数据(包括 DNS 查询、TCP链接、请求耗时、内容传输、资源解析、DOM 解析和资源加载的时间),套用 AntV (阿里巴巴集团的数据可视化方案)的瀑布流模板即可完成数据展现。

这张瀑布流图是详情页里最有用的一块。它把一个笼统的「首屏慢了」摊开成 DNS、TCP、请求、内容传输、DOM 解析、资源加载这几段,哪一段最宽一眼就能看出来。有了它,性能优化才从玄学变成看图说话。
那么,大盘页和详情页如何实现的呢?
首先是前端展示技术栈的选择,对应技术架构图中的淡黄色部分,因为这两个页面都属于 PC 端后台页面,主要给公司前端开发者使用,功能上更多是数据可视化展示,非常适合用 React 技术栈做开发。
为了更好实现首屏时间、秒开率和采用 PV 的功能效果,我们使用 AntdPro 的模板,相关的配套的数据可视化方案,我推荐 Antv,因为它能够满足我们在首屏时间、秒开率等性能指标的展示需求,用起来比较简单(开箱即用),功能灵活且扩展性强(比如秒开率部分,要自定义一些图形,能够较好满足)。
大盘页和详情页的数据展示效果比较丰富多样,相应的 CSS 代码逻辑就比较复杂,为了让 CSS代码更容易维护和扩展,CSS 方面可以选用 Less 框架。
接下来是前后端交互方面,为了让前后台更独立,大盘页、详情页与后端的通信通过 HTTP 接口来实现,使用 nginx 作为 Web Server。为了让传输更高效,我们采用 compression 对 HTTP 传输内容进行 GZip 压缩处理。
最后是后台服务部分,为了让性能平台开发过程更简单,效率更高,同时平台本身的性能体验更流畅,后台服务方面可以选用 Egg.js(基于 NodeJS 的开发框架)做开发,进行数据处理和存储服务。
为了解决监控预警的问题,我们借助 Node-schedule 做调度和定时任务的处理,通过 node-mailer 进行邮件报警

这里的技术选型都是 2021 年的组合,React + AntdPro + AntV + Less + Egg.js。要是今天重做,Less 我大概率会换成 Tailwind 或者 CSS Modules,Egg.js 也可以考虑用 NestJS 替代。不是说 Egg 不行,而是团队里熟悉 NestJS 的人现在明显更多,维护成本低一截。核心的分层思路没变,变的只是每一层填什么。
七、诊断清单:如何实现监控预警并进行问题诊断
监控预警
监控预警部分,我们借助 Node-schedule 做调度和定时任务的处理,通过 node-mailer 进行邮件报警。具体来说我们通过以下几步来实现。
第一步,准备预警数据
在做完数据清洗之后,一个分支使用 Spark 做计算,另外一个分支使用 Flink 实时数据计算。这两者的区别在于后者的数据是实时处理的,因为监控预警如果不实时的话,就没有意义了。有关数据的处理,我是这样做的:超过 2s 的数据,或者认定为卡顿的数据,直接标记为预警数据。实际当中你也可以根据情况去定义和处理。
第二步,我们借助 Node-schedule,用一批定时任务将预警数据通过 Node.js,拉取数据到 MongoDB 的预警表中。
第三步,预警的展示流程。根据预警方式不同,样式展示也不同。具体来说,预警的方式有三种:企业微信报警通知、邮件报警通知、短信报警通知。
以手机列表页为例,性能标准是首屏时间 1.5s,秒开率 90%,超过这个标准就会在性能平台预警模块展示,按照严重程度倒序排列展示。如果超出 10%,平台上会标红展示,并会发企业微信报警通知;如果超过 20%,会发借助 node-mailer 做邮件报警;如果超出 30%,会发短信报警通知。
注意,预警通知需要用到通信资源,为了避免数据量太大而浪费资源,一般对 App 首页核心的导航位进行页面监控即可。
分级这件事我想多说两句。告警最怕的不是漏报,是滥报。一旦群里每天蹦几十条红色告警,所有人都会把这个群设成免打扰,那么真正的事故来的时候就没人看了。所以阈值宁可定得保守一点,先只让最核心的几个页面进告警,跑稳一个月再逐步放开。
另外一定要做告警收敛。同一个错误在一分钟内触发一千次,你要发的是一条「该错误 1 分钟内出现 1000 次」,而不是一千条通知。实现上就是按「错误指纹」聚合,指纹一般取错误类型 + 消息 + 堆栈首帧的哈希。这块 Sentry 做得很成熟,可以参考它的分组逻辑,我之前整理过一篇 Sentry 的接入和用法总结,想直接用现成方案的可以看看。
问题诊断
当预警功能做好后,前端性能平台就可以对重要指标进行实时监督了。当发现性能问题,不论是我们自己发现还是用户反馈,都需要先对问题进行诊断,然后看情况是否需要进一步采取措施。
一般问题诊断时需要先确认是共性问题还是个例问题。如果是共性问题,那接下来我们就开始诊断和优化;如果是个例问题,是因为偶发性因素导致的(如个人的网络抖动、手机内存占用太多、用户连了代理等),则不需要进行专门优化。
怎么判断是共性还是个例?把同一时间窗口内的数据按机型、系统版本、网络类型、地域这几个维度各切一刀,如果某个维度下的指标明显偏离整体,那大概率是共性问题;如果怎么切都只有零星几个样本,那就是个例,不值得投入。

诊断完了才轮到动手优化。下面两节讲的就是拿到诊断结论之后,具体能用哪些手段落地。
八、优化手段:首屏秒开的 4 重保障
懒加载、缓存、离线化、并行化,这四件事分别对应四个不同的方向:少加载、不重复加载、提前加载、并行加载。它们不冲突,可以叠着用,这也是我把它叫做四重保障的原因。
懒加载
懒加载是性能优化的前头兵。什么叫懒加载呢?懒加载是指在长页面加载过程时,先加载关键内容,延迟加载非关键内容。比如当我们打开一个页面,它的内容超过了浏览器的可视窗口大小,我们可以先加载前端的可视区域内容,剩下的内容等它进入可视区域后再按需加载。
具体怎么做呢?我们可以先根据手机的可视窗口,估算需要多少条数据,比如京东 App 列表页是 4 条数据,这时候,先从后端拉取 4 条数据进行展现,然后超出首屏的内容,可以在页面下拉或者滚动时再发起加载。
那么如果首页当中图片比较多,比如搜索引擎产品的首页,如何保证首屏秒开呢?同样也可以采用懒加载。以百度图片列表页为例,可视区域范围内的图片先请求加载,一般会根据不同手机机型估算一个最大数据,比如 ihone12 Pro 屏幕比较大, 4 行 8 条数据,我们就先请求 8 条数据,用来在可视区域展示,其他位置采用占位符填充,在滑动到目标区域位置后,才使用真实的图片填充。这样,通过使用懒加载,可以最大限度降低数据接口传输阶段的时间。
补一句现在的做法。当年判断元素是否进入可视区,普遍是监听 scroll 事件加 getBoundingClientRect(),还得自己做节流,写起来又长又容易卡。现在用 IntersectionObserver 就够了,它在浏览器内部异步计算,不占主线程,代码量也小很多。图片场景还可以更省事,直接给 img 标签加 loading="lazy",交给浏览器原生处理。这两个特性的兼容范围以 MDN 官方文档为准,需要覆盖老 WebView 的话保留一份 scroll 兜底实现。
缓存
如果说懒加载解决的是首屏后再请求非关键内容,那么缓存解决的就是二次访问不用重复请求。在首屏优化方案中,接口缓存和静态资源缓存起到中流砥柱的作用。
接口缓存
接口缓存的实现,如果是端内的话,所有请求都走 Native 请求,以此来实现接口缓存。为什么要这么做呢?
App 中的页面展现有两种形式,使用 Native 开发的页面展现和使用 H5 开发的页面展现。如果统一使用 Native 做请求的话,已经请求过的数据接口,就不用请求了。而如果使用 H5 请求数据,必须等 WebView 初始化之后才能请求(也就是串行请求),而 Native 请求时,可以在 WebView 初始化之前就开始请求数据(也就是并行请求),这样能有效节省时间。
那么,如何通过 Native 进行接口缓存呢?**我们可以借助 SDK 封装来实现,即修改原来的数据接口请求方法,实现类似 Axios 的请求方法。**具体来说就是,把包括 post、Get 和 Request 功能的接口,封装进 SDK 中。
这样,客户端发起请求时,程序会调用 SDK.axios 方法,WebView 会拦截这个请求,去查看 App 本地是否有数据缓存,如果有的话,就走接口缓存,如果没有的话,先向服务端请求数据接口,获取接口数据后存放到 App 缓存中。
静态资源缓存
数据接口的请求一般来说较少,只有几个,而静态资源(如 JS、CSS、图片和字体等)的请求就太多了。以京东首页为例,177 个请求中除了 1 个文档和 1 个数据接口外,其余都是静态资源请求。
那么,如何做静态缓存方案呢?这里有两种情况,一种是静态资源长期不需要修改,还有一种是静态资源修改频繁的
资源长期不变的话,比如 1 年都不怎么变化,我们可以使用强缓存,如 Cache-Control 来实现。具体来说可以通过设置 Cache-Control:max-age=31536000,来让浏览器在一年内直接使用本地缓存文件,而不是向服务端发出请求。
至于第二种,如果资源本身随时会发生改动的,可以通过设置 Etag 实现协商缓存。具体来说,在初次请求资源时,设置 Etag(比如使用资源的 md5 作为 Etag),并且返回 200 的状态码,之后请求时带上 If-None-Match 字段,来询问服务器当前版本是否可用。如果服务端数据没有变化,会返回一个 304 的状态码给客户端,告诉客户端不需要请求数据,直接使用之前缓存的数据即可。
协商缓存有个代价常被忽略:它省的是下行流量,省不掉那一次往返。弱网下一个 RTT 可能就好几百毫秒,一个页面几十个协商请求排下来,光是问「你变了没」就要花掉一秒多。
所以更好的组合是构建时给文件名加内容哈希(app.8f3c2a.js 这种),然后对带哈希的文件统统上强缓存 Cache-Control: max-age=31536000, immutable,只让入口 HTML 走协商缓存。内容变了文件名就变,天然不存在缓存不更新的问题,也不需要每次都去问服务器。这套现在基本是 Webpack、Vite 的默认输出形态,前端这边几乎不用额外做什么,只要运维侧的 nginx 规则配对就行。资源相关的更多优化手段,我在 前端性能优化的实战总结 里写得更细一些。
离线化
离线化是指线上实时变动的资源数据静态化到本地,访问时走的是本地文件的方案。说到这里,你是不是想到了离线包?离线包是离线化的一种方案,是将静态资源存储到 App 本地的方案,不过,在这里,我重点讲的是离线化的另一个方案,把页面内容静态化到本地。
离线化一般适合首页或者列表页等不需要登录页面的场景,同时能够支持 SEO 功能。那么,如何实现离线化呢?其实,打包构建时预渲染页面,前端请求落到 index.html 上时,已经是渲染过的内容。此时,可以通过 Webpack 的 prerender-spa-plugin 来实现预渲染,进而实现离线化。Webpack 实现预渲染的代码示例如下:
// webpack.conf.js |
这段配置做的事情是:打包结束后启动一个无头浏览器,把列出来的几条路由各跑一遍,把渲染完的 HTML 写回 dist 目录。用户请求过来拿到的就是已经有内容的 HTML,不用等 JS 下载执行完才看到东西。
补一句时效性。prerender-spa-plugin 这个包早就不怎么维护了,现在同样的事情通常直接用框架自带的能力,Next.js 的 SSG、Nuxt 的 nuxt generate、Vite 生态的 vite-plugin-ssr 这一类。原理完全一样,都是构建期把页面渲染成静态 HTML,只是不用自己搭 Puppeteer 那一套了。如果你现在还在用 Vue 2 + Webpack 4 的老工程,原来的方案继续跑没问题,别为了换而换。
并行化
懒加载、缓存和离线化都是在请求本身上下功夫,想尽办法减少请求或者推迟请求,并行化则是在请求通道上下功夫,解决请求阻塞问题,进而减少首屏时间。这就像解决交通阻塞一样,除了限号减少车辆,还可以增加车道数量,我们在处理请求阻塞时,也可以加大请求通道数量,借助于 HTTP 2.0 的多路复用方案来解决。
HTTP 1.1 时代,有两个性能瓶颈点,串行的文件传输和同域名的连接数限制(6个),到了HTTP 2.0 时代,因为提供了多路复用的功能,传输数据不再使用文本传输(文本传输必须按顺序传输,否则接收端不知道字符的顺序),而是采用二进制数据帧和流的方式进行传输。
其中,帧是数据接收的最小单位,流是连接中的一个虚拟通道,它可以承载双向信息。每个流都会有一个唯一的整数 ID 对数据顺序进行标识,这样接收端收到数据后,可以按照顺序对数据进行合并,不会出现顺序出错的情况。所以,在使用流的情况下,不论多少个资源请求,只要建立一个连接即可。
文件传输环节问题解决后,同域名连接数限制问题怎么解决呢?以 Nginx 服务器为例,原先因为每个域名有 6 个连接数限制,最大并发就是 100 个请求,采用 HTTP 2.0 之后,现在则可以做到 600,提升了 6倍。
你一定会问,这不是运维侧要做的事情吗,我们前端开发需要做什么?我们要改变静态文件合并(JS、CSS、图片文件)和静态资源服务器做域名散列这两种开发方式。
具体来说,使用 HTTP 2.0 多路复用之后,单个文件可以单独上线,不需要再做 JS 文件合并了。因为原先遇到由 A 和 B 组成的 C 文件,其中 A 文件稍微有点修改,整个 C 文件就需要重新加载的情况,如今由于没有同域名连接数限制了,也就不需要了。
同样的道理,域名散列(把静态资源分散到 img1.xxx.com、img2.xxx.com 这种多个子域上,绕开单域名连接数限制)在 HTTP 2.0 下不但没收益,还有害。多一个域名就多一次 DNS 解析、多一次 TCP 握手和 TLS 协商,反而把前面省下的时间又还回去了。老项目升到 HTTP 2.0 之后,记得回头把这两个历史包袱一起清掉,不然多路复用的收益会被抵消掉一大半。
顺便说,我这里说的收益都是从原理推出来的,具体到你的项目能提升多少,得自己拿性能平台的数据前后对比,别信任何人给的固定数字。

九、优化手段:白屏 300ms 和界面流畅优化技巧
白屏优化
现在我们假设一个场景,有一天你想要在某电商 App 上买个手机,于是你搜索后进入商品列表页,结果屏幕一片空白,过了好久还是没什么内容出现,这时候你是不是会退出来,换另外一个电商 App 呢?这就是白屏时间过长导致用户跳出的情形。
作为前端开发者,我们遇到这种问题如何解决呢?先去性能平台上查看白屏时间指标,确认是不是白屏问题。问题确认后,我们可以基于影响白屏时间长短的两个主要因素来解决,也就是 DNS 查询和首字符展示。
DNS 查询优化
DNS 查询是指浏览器发起请求时,需要将用户输入的域名地址转换为 IP 地址的过程,这个转换时间长短就会影响页面的白屏时间。
那么如何对 DNS 查询进行优化呢?根据 DNS 查询过程,我们可以从前端和客户端这两部分采取措施。
前端侧,可以通过在页面中加入 dns-prefetch,在静态资源请求之前对域名进行解析,从而减少用户进入页面的等待时间。这两行放在 head 里越靠前越好,因为它要赶在真正的资源请求发出之前生效:
<meta http-equiv="x-dns-prefetch-control" content="on" /> |
其中第一行中的 x-dns-prefetch-control 表示开启 DNS 预解析功能,第二行 dns-prefetch 表示强制对 s.google.com 的域名做预解析。这样在 s.google.com 的资源请求开始前,DNS 解析完成,后续请求就不需要重复做解析了。不要小看这个标签哦,它可以为你减少 150ms 左右的 DNS 解析时间。
这个 150ms 是原文当时给的经验值,实际能省多少完全取决于用户所在网络的 DNS 响应速度,不同地区差别很大,用之前用自己的数据量一下。另外还有一个更进一步的 preconnect,它不只解析 DNS,还会顺手把 TCP 连接和 TLS 握手一起做了,省的时间比 dns-prefetch 更多。代价是每个 preconnect 都会占用一个真实连接,滥用会拖慢别的资源,一般只给最关键的一两个域名用。
客户端侧呢?可以在启动 App 时,同步创建一个肉眼不可见的 WebView(例如 1*1 像素的 webview),将常用的静态资源路径写入这个 WebView 中,然后对它做域名解析并放入缓存中。这样后面需要使用 WebView 打开真正所需的页面时,由于已经做过域名解析了,客户端直接从缓存中获取即可。
当然如果是端外页面,因为没在 App 里面,就没法使用 1*1 WebView 的策略了,我们可以使用 iframe ,也能达到类似效果。
以上是一个轻量级的方案,通过它可以将 DNS 解析时间控制在 400ms 以内(这个算是比较快的)。如果你想要将耗时进一步压缩,比如控制在 200ms,此时就需要一个重量级的方案了。具体来说,可以采用 IP 直连方式,原来是请求 www.google.com,现在我们通过调用 SDK 进行域名解析,拿到对应的 IP(如 6.6.6.6),然后直接请求这个 IP 地址拿到数据。
当然,这个实现起来需要避过许多坑,比如,HTTPS 证书和配置文件。
Https 证书是指当客户端使用 IP 直连时,请求 URL 中的 host 会被替换成对应的 IP,所以在证书验证时,会出现 domain 不匹配的情况,导致 SSL/TLS 握手不成功。
怎么解决呢?在非 SNI(Server Name Indication,表示单 IP多域名)的场景下,可以把证书验证环节独立出来 (如 Hook证书校验环节),然后将 IP 替换为原来的域名。在 SNI 场景下,可以定制 SSLSocketFactory,在 createSocket 时替换为 IP,并进行 SNI/HostNameVerify 配置。
而配置文件方面,一般在域名只有两三个的情况时,我们可以用到它来做 IP 和域名的映射。但随着机房的扩大,每次扩机器都要升级配置文件,后续会非常麻烦。
对此我们可以采用 httpDNS 来解决。这是因为 httpDNS 可以准确调度到对应区域的服务器 IP 地址给用户,同时还可以避免运行商 DNS 劫持。具体来说, SDK 会通过发报文(类似系统向 DNS 运营商发的报文)向 httpDNS 做一个 HTTP 请求(也是通过 IP 直接请求),请求通过后拿到对应域名,然后进行 IP 直连,完成资源或者数据接口请求。
首字符展示优化
所谓首字符展示,通常我们会在页面加载过程中出现一个 loading 图,用来告诉用户页面内容需要加载,请耐心等待。但这样一个 loading 图既无法让用户感受到页面加载到什么程度,也无法给用户视觉上一个焦点,让人们的注意力集中在上面。
如何解决这个问题呢?我们可以使用骨架屏。骨架屏(Skeleton Screen)是指在页面数据加载完成前,先给用户展示出页面的大致结构(灰色占位图),告诉用户页面正在渐进式地加载中,然后在渲染出实际页面后,把这个结构替换掉。骨架屏并没有真正减少白屏时间,但是给了用户一个心理预期,让他可以感受到页面上大致有什么内容。
那么,如何构建骨架屏呢?因为考虑到每次视觉修改或者功能迭代,骨架屏都要配合修改,我建议采用自动化方案,而不是手动骨架屏方案(也就是自己编写骨架屏代码)。骨架屏的实现方法有以下三个步骤。
- 步骤一,确定生成规则,遍历所有的 DOM 元素。针对特定区块(如视频、音频)生成相应的代码块,获取原始页面中 DOM 节点的宽度、高度和距离视窗的位置,计算出当前设备快高对应的大小,转换成相应的百分比,然后来适配不同的设备。
- 步骤二,基于上述规则结合 CLI 工具可以通过脚手架自动生成骨架屏
- 步骤三,将骨架屏自动化注入页面,再利用 Puppeteer 把骨架屏代码注入页面中自动运行。整个过程比较复杂,且有不少坑
说实话我也没把这套自动化骨架屏完整跑通过,只在一个 demo 工程里验证到第二步。最大的麻烦是生成规则很难做到通用,页面上稍微有点绝对定位或者动态高度,生成出来的骨架就会歪。真要上,我的建议是先手写两三个核心页面的骨架屏顶着用,等页面结构稳定了再考虑自动化,不要一上来就搞工程化。
以上就是白屏时间优化方面相关的内容,但即便首屏展示比较快,如果有卡顿的现象,用户操作也会很不流畅,那怎么解决这个问题呢。下面我们就聊聊卡顿治理。
卡顿治理
卡顿现象,一般可以通过用户反馈或性能平台来发现。比如我们接到用户说某页面比较卡,然后在性能平台上查看卡顿指标后,发现页面出现连续 5 帧超过 50ms ,这就属于严重卡顿。如何处理呢?
首先也还是问题的定位,先通过 charles 等工具抓包看一下数据接口,如果是和数据相关的问题,找后端同事,或者用数据缓存的方式解决。如果问题出在前端,一般和以下两种情形有关:浏览器的主线程与合成线程调度不合理,以及计算耗时操作。
浏览器的主线程与合成线程调度不合理
比如,在某电商 App 页面点击抽奖活动时,遇到一个红包移动的效果,在红包位置变化时,页面展现时特别卡,这就是主线程和合成线程调度的问题。怎么解决呢?
一般来说,主线程主要负责运行 JavaScript,计算 CSS 样式,元素布局,然后交给合成线程,合成线程主要负责绘制。当使用 height、width、margin、padding 等作为 transition 值时,会让主线程压力很大。此时我们可以使用 transform 来代替直接设置 margin 等操作。
比如红包元素从 margin-left:-10px 渲染到 margin-left:0,主线程需要计算样式 margin-left:-9px,margin-left:-8px,一直到 margin-left:0,每一次主线程计算样式后,合成线程都需要绘制到 GPU 再渲染到屏幕上,来来回回需要进行 10 次主线程渲染,10 次合成线程渲染,这给浏览器造成很大压力,从而出现卡顿。
如何解决呢?我们可以利用 transform 来做,比如从 transform: translate(-10px, 0) 到 transform: translate(0, 0),主线程只需要交代一次起点和终点,然后由合成线程一次性把 -10px 过渡到 0px。这样能省下中间那些逐帧的主线程计算,具体省多少要看动画时长和帧数,别拿一个固定数字当结论。
这件事的原理,说到底是渲染流水线的分工。margin、width、height 这类属性改动会触发 layout(重排),然后 paint、composite 一路走完;而 transform 和 opacity 可以只走 composite 这一步,layout 和 paint 都跳过了,而 composite 是在合成线程上做的,主线程再忙也不影响它跑。这就是为什么用 CSS 动画做位移一定要选 transform。
需要提前提升为合成层的元素,可以加 will-change: transform。但这个属性别乱撒,每个合成层都要占显存,加多了在低端机上反而更卡。我的用法是只在动画开始前动态加上,动画结束就移除。
顺着上面聊,内存这条线也和卡顿有关,长时间不释放的监听器和闭包会让页面越用越卡,这块我在 JS 内存泄漏的排查方法 里单独写过。
计算耗时操作
除了主线程和合成线程调度不合理导致的卡顿,还有因为计算耗时过大导致的卡顿。遇到这类问题,一般有两种解法:空间换时间和时间换空间。
空间换时间方面,比如你需要频繁增加删除很多 DOM 元素,这时候一定会很卡,在对 DOM 元素增删的过程中最好先在 DocumentFragment (DOM文档碎片)上操作,而不是直接在 DOM上操作。只在最后一步操作完成后,将所有 DocumentFragment 的变动更新到 DOM上,从而解决频繁更新 DOM 带来的卡顿问题。
至于时间换空间,一般是通过将一个复杂的操作细分成一个队列,然后通过多次操作解决复杂操作的问题。
这个「切成队列」的思路现在有更顺手的写法。浏览器侧可以用 requestIdleCallback 把任务塞到空闲帧里跑,主线程有活干的时候它自动让路;纯计算类的重活可以整个挪进 Web Worker,彻底不占主线程。React 的时间切片走的也是同一个思路,把一次长渲染拆成很多个短片段,中间留出机会响应用户输入。requestIdleCallback 的兼容情况以 MDN 官方文档为准,Safari 侧一直有出入,用之前记得做能力检测。

到这里,性能这条线从采集到优化就走完了。下面换一条线,聊错误监控和一个更完整的 SDK 设计。
十、JS SDK 设计

Performance
前面几节聊的是「一个团队怎么把监控做成平台」,这一节回到代码层面,看看 SDK 里那些指标具体是从哪个 API 抠出来的。
网站的性能怎么样,不能单靠某一个工具去检测就得出结论,因为影响它的因素太多了(DNS 解析、网络、缓存等等)。
Performance是一个做前端性能监控离不开的API,最好在页面完全加载完成之后再使用,因为很多值必须在页面完全加载之后才能得到。最简单的办法是在window.onload事件中读取各种数据。
1. 页面加载
一个页面的请求到响应再到显示出来,需要经过下面一些重要过程,当我们在浏览器输入一个
URL或者说点击一个URL开始,会出现如下流程
- 页面准备
- 重定向:在
header定义了重定向才会有这个过程,如果没有重定向,不会产生这个过程。 app cache:会先检查这个域名是否有缓存,如果有缓存就不需要DNS解析域名。这里的app是值应用程序application,不指手机app。DNS解析:把域名解析成IP,如果直接用ip地址访问,不产生这个过程。TCP连接:http协议是经过TCP来传输的,所以产生一个http请求就会有TCP connect,但是依赖于长连接,不会产生这个过程。request header:请求头信息。request body:请求体信息,比如get请求是没有请求体信息的,所以没有这个过程,这就是为什么把头跟体分开写的原因。response header:响应头信息。response body:响应体信息。- 解析
HTML结构 - 加载外部脚本和样式表文件:正常来说
JS、css都是外部加载的,当然有不正常的人啊,比如我。 - 解析并执行脚本代码
- 构建与解析
HTML DOM树:这个过程可以去了解下DOM树是怎样的就明白啦。 - 加载外部图片
- 页面加载完成,显示出来啦
这个流程表看着琐碎,但它是所有性能指标的坐标系。后面每一个 xxxStart / xxxEnd 字段,都能在这张流程上找到对应的位置,记不住字段名没关系,记住流程就能推出来。
2. 重定向分析
重定向的代价是整条链路重来一遍,DNS、TCP、请求头全部再走一次。所以下面这个序列会出现两遍:
app cachDNS解析TCP连接request header- 重定向
app cachDNS解析TCP连接request header
所以监控里如果发现某个页面的重定向时间明显偏高,第一反应应该是去查有没有多余的 301/302 跳转,比如 http 跳 https 又跳 www 这种连跳两次的配置,这个改一行 nginx 就能省掉一整轮往返。
3. performance.timing
这个API能帮我们得到整个页面请求的时间,如下图,在
Chrome的Console是可以直接运行的

先解释下这些时间都是代表什么
timing 对象里边的数据比较多,梳理如下几个关键性的节点
fetchStart:发起获取当前文档的时间点,我的理解是浏览器收到发起页面请求的时间点;domainLookupStart:返回浏览器开始DNS查询的时间,如果此请求没有DNS查询过程,如长连接、资源cache、甚至是本地资源等,那么就返回fetchStart的值;domainLookupEnd:返回浏览器结束DNS查询的时间,如果没有DNS查询过程,同上;connectStart:浏览器向服务器请求文档,开始建立连接的时间,如果此连接是一个长连接,或者无需与服务器连接(命中缓存),则返回domainLookupEnd的值;connectEnd:浏览器向服务器请求文档,建立连接成功的时间;requestStart:开始请求文档的时间(注意没有requestEnd);responseStart:浏览器开始接收第一个字节数据的时间,数据可能来自于服务器、缓存、或本地资源;unloadEventStart:卸载上一个文档开始的时间;unloadEventEnd:卸载上一个文档结束的时间;domLoading:浏览器把document.readyState设置为loading的时间点,也就是开始构建dom树的时间点;responseEnd:浏览器接收最后一个字节数据的时间,或连接被关闭的时间;domInteractive:浏览器把document.readyState设置为interactive的时间点,DOM树创建结束;domContentLoadedEventStart:文档发生DOMContentLoaded事件的时间;domContentLoadedEventEnd:文档的DOMContentLoaded事件结束的时间;domComplete:浏览器把document.readyState设置为complete的时间点;loadEventStart:文档触发load事件的时间;loadEventEnd:文档出发load事件结束后的时间
再来一张图,表示各阶段的开始与结束对应的时间

这张图建议存下来。写采集代码的时候对着它抠字段,比翻文档快得多,也不容易把 responseStart 和 responseEnd 搞反。
从以上的分析,我们就可以得到一些时间的计算
- 准备新页面耗时:
fetchStart - navigationStart - 重定向时间:
redirectEnd - redirectStart App Cache时间:domainLookupStart - fetchStartDNS解析时间:domainLookupEnd -domainLookupStartTCP连接时间:connectEnd - connectStartrequest时间:responseEnd - requestStart这个计算是代表请求响应加起来的时间- 请求完毕到
DOM树加载:domInteractive -responseEnd - 构建与解析
DOM树,加载资源时间:domComplete - domInteractive load时间:loadEventEnd - loadEventStart- 整个页面加载时间:
loadEventEnd -navigationStart - 白屏时间:
responseStart-navigationStart
下面这段就是把上面那张表翻译成代码,SDK 里的性能采集模块基本就长这样。抄的时候注意每个减法前都要判空,某些字段在特定场景下会是 0,直接相减会得到一个巨大的负数:
let timing = performance.timing |
4. performance.getEntries()
这个API能帮我们获得资源的请求时间,包括JS、CSS、图片等

如上图可以看到这个API请求返回的是一个数组,这个数组包括整个页面所有的资源加载,上图打开了一个其中一个资源,可以看到如下信息
entryType:类型为resourcename:资源的urlinitiatorType:资源是link- 资源时间:
duration的值,是responseEnd - startTime得到的
这个 API 在排查「哪个资源拖慢了页面」的时候特别好用,按 duration 倒序排一下,前几名就是嫌疑犯。要注意的是跨域资源默认只能拿到 startTime 和 duration,中间的 DNS、TCP 等细分字段全是 0,想拿到得让对方在响应头里加 Timing-Allow-Origin。这个坑我排查了一下午才想起来,一直以为是自己代码写错了。
performance.memory
这个API主要是得到浏览器内存情况
jsHeapSizeLimit:内存大小上限totalJSHeapSize:当前已分配的堆总量usedJSHeapSize:已使用的内存
usedJSHeapSize表示所有被使用的JS堆内存,totalJSHeapSize是当前已分配的JS堆总量,如果usedJSHeapSize持续逼近jsHeapSizeLimit且一直不回落,就可能出现内存泄漏

提醒一句,performance.memory 是 Chrome 的非标准实现,别的浏览器不一定有,用之前要判空。而且它给的是整个页面的堆内存,粒度很粗,只能用来发现「有没有在涨」,具体是谁泄漏了还得靠 heap snapshot 去比对。
5. getEntriesByType
这个 API 可以让我们通过传入 type 获取一些相应的信息:
- frame:事件循环中帧的时间数据。
- resource:加载应用程序资源的详细网络计时数据
- mark:
performance.mark调用信息 - measure:
performance.measure调用信息 - longtask:长任务(执行时间大于 50ms)信息。这个类型已被废弃(文档未标注,但是在 Chrome 中使用会显示已废弃),我们可以通过别的方式来拿
- navigation:浏览器文档事件的指标的方法和属性
- paint:获取 FP 和 FCP 指标
最后两个 type 是性能检测中获取指标的关键类型。当然你如果还想分析加载资源相关的信息的话,那可以多加上 resource 类型。
这份 type 列表是 2021 年的情况,后来又陆续加进来 largest-contentful-paint、layout-shift、first-input、event 等条目,Core Web Vitals 那几个指标基本都靠它们拿。哪些 type 在哪个浏览器上可用一直在变,代码里最好用 PerformanceObserver.supportedEntryTypes 做一次能力检测,别硬写,具体清单以 MDN 官方文档为准。
6. PerformanceObserver
PerformanceObserver 也是用来获取一些性能指标的 API,用法如下:
const perfObserver = new PerformanceObserver((entryList) => { |
这里的 buffered: true 是个容易漏的参数。它的作用是把订阅之前已经产生的条目也一起回放给你。监控 SDK 加载的时机往往晚于 FP、FCP 这些早期指标,不加这个参数就只能收到订阅之后的数据,早期指标全部丢失。
另外 observe 传 type 只能一次一个,要监听多种就得调多次,或者用 entryTypes 数组形式(但那种形式不支持 buffered)。这个区别我第一次用的时候栽过。
结合
getEntriesByType以及PerformanceObserver,我们就能获取到所有需要的指标了。
错误监控
性能这条线看的是「慢不慢」,错误这条线看的是「坏没坏」。后者的收益来得更快,前面说过接入顺序建议先做错误,原因就在这。
错误捕获一共要铺三张网,它们互相不重叠,缺一张就会有盲区。
js 运行时报错
为了更好的保证网站正常的运行,我们会通过
window.onerror捕获,js具体的堆栈信息和错误行和列。一般我们的js都是打包压缩、混淆后上传到cdn的(无法定位到具体错误)。因此在打包时,同时生产.map文件,用sourcemapjs库(nodejs)来还原具体错误信息
window.onerror 有个必须知道的边界:它抓不到 Promise 里没有 catch 的 reject。异步代码里的报错就这么静悄悄溜走了,看着报表挺干净,其实是漏了。补上这一条:
window.addEventListener('unhandledrejection', (event) => { |
unhandledrejection 现在主流浏览器都支持了,但老版本 Safari 和一些内置 WebView 上的情况我没有逐个验证过,具体支持范围以 MDN 官方文档为准,加个 try catch 兜一下总没错。
还有一类跨域脚本报错,onerror 只会给你一个 Script error.,什么信息都没有。想拿到真实堆栈,得给 script 标签加 crossorigin="anonymous",同时让 CDN 的响应头带上 Access-Control-Allow-Origin。两个条件缺一不可,只加标签不配响应头是没用的,这个我一开始也搞错过。
资源加载出错
为了防止加载资源失败,而导致网站打不开。一般我们会通过
window.addEventListener('error')对资源加载进行监控。
资源加载失败不会冒泡,所以第三个参数必须传 true 走捕获阶段,否则监听不到:
window.addEventListener('error', (event) => { |
这里的 if 判断是为了把 JS 运行时错误和资源错误区分开,两者共用同一个 error 事件,混在一起上报会让数据没法看。
后端api接口监控
一般对于小公司而言,可能连后端都很少会有接口方面的监控。一旦出现问题,却又不好排查问题,因此我们可以通过对浏览器底层的xhr对象进行拦截,上报相关调用数据和接口耗时。一方面可以检测到接口的实时调用情况,同时也方便后期对接口的数据统计。
只劫持 XMLHttpRequest 在今天是不够的,fetch 也得包一层,不然用了 axios 的 fetch adapter 或者原生 fetch 的请求就全漏了。包 fetch 的思路是一样的:存下原函数,替换成自己的实现,记录开始时间,在 then 和 catch 里各上报一次,最后把结果原样返回,绝对不能吞掉异常。

数据处理和展示
用到 es(elasticsearch) 来对数据进行实时查询和分析。可是怎么把数据推到 es 里面呢?这对于前端同学来说,这又是一个难点。别急,「logstash」了解一下。logstash 是对数据进行采集、分析、过滤的工具,处理完推送到 es 里面。数据既然有了,那么怎么展示呢?这时候 Kibana 出来了,用来承托数据展示。这套组合就是后端开源界的日志分析系统「ELK」。
选 ES 而不是 MySQL 的理由很实在:监控数据是典型的写多读少、字段不固定、要按任意条件全文检索。今天你想按错误消息模糊搜,明天想按 UA 里的某个片段筛,用关系型数据库每次都要加索引,ES 天生就干这个。

其实对于数据的展示,可以不用 kibana 或者其他开源产品,也可以自己通过 es 的 restful 接口来搭建数据展示。自己搭的好处是能按团队习惯定制,比如把错误详情和 sourcemap 还原直接做在一个页面里,Kibana 做不到这种业务级的联动。

十一、前端监控系统实战
前面九节偏方案和架构,这一节落到最小可运行的实现上。这套东西一个人一周能搭起来,适合小团队从 0 开始。
没有前端监控下的痛点有哪些
- 用户反馈点击某个按钮没有任何反应。(没办法知道用户点了按钮,为什么没反应呢?是js报错导致,还是其它原因导致?)
- 用户反馈打开页面很慢。(没办法感知用户慢在什么地方)
- 调用后端api接口出错,无法第一时间感知。(只能被动等待用户告诉和自己去发现)
- 网站静态资源加载出错,导致网站打不开。
- 上报的js错误信息,怎么反解析出源代码报错位置。

手动搭建一个前端监控系统
在搭建前端监控系统开始之前,我们先来了解下整个系统的架构是怎么样的。因为你只有了解到流程是怎样走的,才清楚每个步骤我们该做些什么事情。

JS SDK 功能设计
1. 监控js错误信息上报
通过window.onerror方法,对页面js错误进行监控。上报具体的错误信息和堆栈信息(如下图),利用webpack/gulp打包出来的.map文件,来解析出具体的错误信息。

上面这种堆栈直接看是没意义的,行列号指的是压缩后文件里的位置。要还原成源码位置,得拿构建产物对应的 .map 文件反查。下面这段 Node 代码做的就是这件事:读 map 文件,用 source-map 库转成 consumer 对象,传入上报的行列号,拿回源文件名和源码行号,再顺手把出错行前后各一行的源码切出来一起返回。
let sourceMap = require('source-map'); |
这里有几个工程上的注意点。.map 文件绝对不能跟着构建产物一起发到公网 CDN 上,那等于把源码送人了,正确做法是构建完单独上传到内网的监控后台。还有版本对应关系,同一个 app.js 每次发版内容都不一样,map 文件必须按版本号或者构建哈希归档,用错版本还原出来的行号是完全错乱的,这一点比想象中更容易出事。
首先在管理后台,上传对应错误 js 的 map 文件,最终还原效果图如下:

通过上图,我们可以快速定位错误的具体位置,大大节省了排查错误的时间。这个功能是整套监控里最能让人有获得感的一块,接上之后同事才会真的开始用它。
2. api接口调用请求上报
可能有些朋友会好奇,怎么去实时监听ajax异步接口请求情况呢?其实很简单:首先我们先通过代理XMLHttpRequest对象,来实现对ajax异步请求进行数据劫持。如下图:

看图就明白了,我们劫持的是 xhr 对象的 onreadystatechange 事件,在里面拿到后端接口的返回,处理后上报这次调用的情况,这样就能实时看到接口的调用状况。
劫持这类底层对象有条铁律:一定要保存原始实现,处理完照原样调回去,并且整段用 try catch 包住。监控代码把业务请求搞挂了,那是本末倒置。
如有同学想了解这块内容具体的代码,github社区有开源的模块,可自行查看: https://github.com/wendux/Ajax-hook
根据需要决定上报哪些数据,比如 page 的 query 参数、接口请求参数和响应码等等,方便后续出错时定位问题。这里要提醒一句,接口的请求体和响应体里很可能带着手机号、身份证这类敏感信息,上报之前必须脱敏或者干脆只报字段名不报值,别让监控系统变成数据泄露的口子。如下图:

3. 页面性能数据上报
关于页面性能数据,我们一般通过浏览器
performance.timingAPI来进行上报
各阶段耗时图

关键性能指标图

这两张图分别对应前面讲过的瀑布流拆解和关键指标,数据源都是 performance.timing 那一套字段,前面第十节列的计算公式直接就能用上。
4. 能支持自定义数据上报
上述一般都是自动进行错误采集进行上报,但有些情况是需要我们手动调用进行上报(自定义数据的埋点)。因此sdk的设计,最好支持手动调用上报。
const report = new Report({ |
自定义上报这个口子一定要留。自动采集覆盖的是通用场景,但业务侧总有自己的诉求,比如支付流程走到第几步中断了、某个开关命中了哪个分支。留了这个口,业务方才不会绕过 SDK 自己再造一套上报。
关于SDK设计的时候,需要注意的以下几点:
- SDK 上报采用哪种方式比较好。一般来说采用
get(image方式上报,因为它避开了跨域限制,目前也是业界主流的上报方式) - 在SDK上报方式,最好设计
get和post两种方式上报。因为有些复杂的数据结构,采用post上报,在服务端解析会比较方便。 - 当网站每天的pv很大时,要支持抽样上报,减少服务器资源。(一般做法,通过每次随机一个数值,来跟预设定的值做比较)
- 如果有些错误信息不需要上报,要支持配置忽略名单。浏览器插件注入的脚本报错、第三方广告脚本的错误,量非常大而且跟你没关系,不过滤掉会把有用的信号淹掉。
- 如果针对
get上报时,一般是在xx.gif后带参数,一定要对参数进行转义,不然在某些场景下会报错! - 针对采集
page参数时,也要进行encodeURIComponent进行转义,不然在某些场景下会报错! - 要支持站点级别的功能,因为你想要接入不同的项目。就得根据不同的做区分和报警设置。思路很简单通过增加一个站点id值,来作为后续数据的筛选条件。比如:
pid
服务端接收数据,并记录日志
sdk设计好了,上报数据也有了。现在我们开始设计服务怎么接收和处理数据。以Nodejs为例:

看图就能看出来,服务端拿到参数后先 decode 解析,处理完用 nodejs 的 file-stream-rotator 这个库把数据按时间切片写进本地日志。选这个库是因为它自带按天或按大小滚动的能力,不至于让单个日志文件涨到几十 G。
写日志之前记得校验:不存在站点 id 的不写,来自外部网站的请求不写。实际跑下来会遇到一些意想不到的情况,比如 UC 浏览器的部分请求也被抓上来了,这些都得过滤掉。
这一层还得加一道限流。上报接口是完全裸露在公网的,谁都能往里灌数据,没有限流的话,一个脚本就能把你的磁盘打满。按 IP 加站点 id 做个简单的令牌桶就够用了。
数据收集与过滤
当数据写入日志文件中,通过Logstash监测日志变化,收集到数据并对数据进行格式化和过滤处理,并收集推送到ES(数据存储)。
比如利用 geoip 和 useragent 插件,分别对 IP 和浏览器 UA 进行解析,得到对应的地理位置信息和浏览器、系统、设备信息。这一步很关键,前面讲问题诊断时说的「按机型、地域切一刀看是不是共性问题」,靠的就是这里解析出来的字段。如下图:

关于logstash的安装和配置,在这里不多说啦,可自行google安装配置。(建议使用docker版本,简单省事)
数据存储(ES)提供分析和查询能力
通过ES(elasticsearch)提供对应的restful api,手动搭建数据大盘(或者用kibana)。对于es数据管理,前期可以先安装一个es-head的插件,来查看数据情况。

ES 这层还有个索引规划的问题要提前想清楚。监控数据量涨得很快,建议按天建索引(monitor-2021.05.11 这种命名),配上生命周期策略自动删掉过期数据。等到磁盘报警了再回头改索引结构,代价会大很多。
关于修改
es header 5.0 request content-type不对的情况。可以对/usr/src/app/_site/ vendor.js文件进行修改。以docker镜像为例:
Step 1,把镜像里的 vendor.js 拷出来(路径换成你自己的本地目录):
docker cp elasticsearch-head:/usr/src/app/_site/vendor.js /Users/duanliang/logstash |
Step 2,改掉 ajax 请求的 content-type。ES 5.0 之后不再接受表单类型的请求体,而 es-head 这个插件一直没跟上,所以要手动改:
大约在 6886 行 |
Step 3,把改完的文件拷回容器:
docker cp /Users/duanliang/logstash/vendor.js elasticsearch-head:/usr/src/app/_site/vendor.js |
Step 4,重启容器让改动生效:
docker restart elasticsearch-head |
注意这种改法是改在容器里的,容器一旦重建就没了。真要长期用,得把这个改动做进自己的镜像,或者用 volume 挂载出来。
你可能用到的 docker 镜像地址(版本是当时用的 6.5.4,现在 ES 早就迭代了很多个大版本,新装建议直接拉当前稳定版,具体版本以官方文档为准,但要注意 ES 和 Logstash 的大版本必须对齐):
# es |
告警配置
通过后台配置自定义报警规则,来告警开发人员。原理:通过nodejs启动定时任务来读取配置表,根据不同的规则去读取es里面的数据(一分钟查询一次),如果有错误,则通知报警。
把规则做成可配置的表,而不是写死在代码里,这个决定后面会省很多事。规则表至少要有这几列:站点 id、指标类型、比较符、阈值、统计窗口、通知渠道、接收人。加一条新规则就是插一行数据,不用发版。




定时任务这块还有两个细节。一是查询窗口要和执行间隔对齐,一分钟跑一次就查最近一分钟的数据,两边不一致会出现漏查或者重复告警。二是要记录每条规则上次告警的时间,同一条规则在冷却期内不再重复发,这就是前面说的收敛。
最终效果,以首页 Dashboard 为例:

到这里,从浏览器里的一行 window.onerror,到群里弹出的一条告警,整条链路就打通了。
十二、上线前的 checklist
这份清单是我自己每次接入新站点前都要过一遍的,按顺序打勾就行。
SDK 侧:
- SDK 全部逻辑套在
try catch里,自身报错不影响宿主页面 - SDK 体积控制住,同步引入的话尽量压到几 KB 量级,别为了监控拖慢首屏
-
window.onerror、unhandledrejection、资源error(捕获阶段)三条线都接上 -
XMLHttpRequest和fetch都做了劫持,劫持后原样调用原实现 - 跨域脚本加了
crossorigin,CDN 配了对应的 CORS 响应头 - 抽样率支持远程下发,拉不到配置有内置默认值兜底
- 有忽略名单,能过滤浏览器插件和第三方脚本的噪音错误
- 上报数据做过脱敏,接口参数里不含手机号、token 这类敏感信息
- 页面卸载时的上报走
sendBeacon,判断了返回值并有降级路径
服务端与存储侧:
- 上报接口有限流,按 IP 加站点 id 限制
- 校验站点 id,非法来源直接丢弃
- 日志按天或按大小滚动,磁盘有告警
- ES 按天建索引,配了生命周期策略自动清理过期数据
-
.map文件只上传到内网监控后台,没有发到公网 CDN -
.map按版本号或构建哈希归档,能和线上产物一一对应
告警与看板侧:
- 告警规则存在配置表里,加规则不用发版
- 告警按错误指纹聚合,同一问题不重复轰炸
- 每条规则有冷却时间
- 告警分级,只有最高级别才走短信或电话
- 大盘上带采样 PV,能判断数据可不可信
- 性能指标看 P50 和 P95,不是只看平均值
- 监控系统自己也有监控,上报量突然掉零要能被发现
最后一条特别容易被忘掉。监控系统挂了是没人告诉你的,因为它挂了就不会再报警了。给上报量本身配一条「低于阈值就告警」的规则,成本很低,能救命。
总结
回头看这一整套东西,真正决定成败的其实不是技术选型,而是三件事。
第一是指标口径要先定死。首屏算到哪一刻、白屏用哪两个时间点相减、卡顿的阈值是多少,这些没定清楚,后面所有的图表都是自说自话。第二是采集的顺序,先做错误再做性能,错误这条线接上当天就能捞到线上问题,团队才会认这套东西的价值。第三是告警必须收敛和分级,一个每天响几十次的告警群,等于没有告警。
技术上,2021 年那套自研 FMP 打分法今天可以让位给 LCP,performance.timing 可以换成 PerformanceObserver,prerender-spa-plugin 可以换成框架自带的 SSG。但底下这条链路,采集、上报、接入、清洗、存储、告警、看板,这七个环节一个都少不了,换什么框架都一样。
真要动手,别一上来就照着大厂架构图搭 Kafka 加 Spark。一个 Node 服务写本地日志、Logstash 送进 ES、es-head 看数据,这个组合一个人一周能跑起来,先让团队用上,再谈演进。