这个月,我把下班后的碎片时间全砸进了一个代号叫Apollo-11的项目里。它不是登月计划,而是一个属于自己的前端性能监控 SDK。为什么起这么唬人的名字?因为我的目标很直白:像登月一样,从零把一块石头送上天。具体来说,就是写一个足够轻量、无侵入、可私有化部署的监控小工具,解决“页面白屏了没数据”“用户说按钮点了没反应我们一无所知”这类日常痛点。它采集页面性能指标、用户核心行为和运行时错误,通过 Beacon 优先的方式异步上报到自己搭的采集服务里。如果你也维护着几个业务页面,被“看不见的故障”折磨过,这篇拆解应该能帮上忙。
1. 项目定位与整体设计:为什么叫 Apollo-11
1.1 要解决的痛点
我最早是被一个反馈逼出来的:某页面在低端手机上白屏,用户群里有人截图,但我们没有任何日志。常规监控平台虽然能查报错,但重链路、高成本,数据也得经过第三方服务。对于需要数据链路自控的团队来说,自己维护一套小型采集器非常常见。
于是我给这个项目定了三条硬指标:
- 接入成本低:一行
Apollo.init()搞定,不污染业务代码。 - 体积可控:压缩后目标小于 8KB,不成为页面上最重的资源。
- 可二次开发:模块之间解耦,后续能扩展自定义指标和告警逻辑。
这不只是一个采集脚本,而是一个“从页面到服务端”的完整闭环。最初的 MVP 版只包含性能统计和错误采集,后面才逐步加入用户行为队列与采样策略。每个模块单独抽象,就是为了避免以后想换上报协议时要把整个 SDK 推倒重来。
1.2 技术选型:为什么自己写而不是直接上现成的
很多人会问:“现成的监控服务不香吗?”香,但对我来说不够。私有化部署就卡住了很多诉求,更别说定制业务指标时的二次开发成本。自己写 SDK 本质上是把一个通用能力做薄,整体可控性反而更高。
核心选型如下:
- 语言:TypeScript。采集端会暴露给业务方使用,类型提示能避免大量低级错误,也方便其他人直接看方法签名维护。
- 浏览器 API:PerformanceObserver + Navigation Timing + Resource Timing。这套原生能力基本覆盖了核心 Web Vitals,不用再手动埋点去算 FP、FCP、LCP。
- 上报:优先
navigator.sendBeacon,页面卸载时也能把数据送出去;不支持时降级到fetch的keepalive,再不行退成普通 XMLHttpRequest。 - 构建:Rollup 或 tsup 输出 ESM 和 UMD 双格式。现代项目用 ESM 可以摇树,老页面直接
<script>引入也完全兼容。
选型的原则只有一个:尽量贴近浏览器原生能力,减少依赖。监控代码本身如果引入了大量第三方库,反而会拖慢页面,甚至成为被监控的“异常源”。
1.3 模块化架构
Apollo-11 的目录结构按职责切分,不搞大泥球:
config/:配置解析和默认值合并。collectors/:性能采集、错误采集、行为采集三个采集器。transport/:上报队列、数据格式化、协议封装。core/:初始化流程、生命周期、公共类型。
采集器只负责“拿到原始数据”,不关心怎么上报;transport 只负责“把数据送到服务端”,不关心数据从哪来。这样每次浏览器 API 更新、新增指标时,只改对应采集器就行。
初始化时Apollo.init()会先合并默认配置,然后依次注册各采集器,再启动一个定时器来 flush 队列。整个流程非常像一个小型消息管道:采集器产生事件,事件进入队列,队列按策略上报,上报结果反馈到统计计数里,方便以后扩展重试。
2. 核心细节解析与实操要点:从采集到上报
2.1 性能指标的采集真相
性能数据的核心来源是PerformanceObserver。很多人担心浏览器兼容性,其实现代浏览器已经支持得不错,而且我们可以通过buffered: true读取页面加载早期产生但来不及监听的 entry。
LCP 的监听代码大概是这样的:
const lcpObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); const latestEntry = entries[entries.length - 1] as PerformanceEntry; reportMetric('lcp', latestEntry.startTime); }); try { lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); } catch (e) { // 当前浏览器不支持 LCP,跳过 }CLS 需要监听layout-shift类型,累加所有非预期布局偏移的分数。INP 则要监听event类型的 PerformanceEntry,取每次交互的延迟值。还要注意 Web Vitals 的口径会迭代,因此 SDK 里最好带上vitalVersion字段,方便后续分析数据口径变化。
TTFB 的计算也不能省:responseStart - requestStart。DOMContentLoaded 和 load 时间可以直接从performance.getEntriesByType('navigation')里拿。这些指标虽然老,但排查首屏问题很管用。
有一个容易踩坑的细节:LCP 元素可能在用户离开页面那一刻才确定,也没有稳定触发点。可靠做法是监听visibilitychange,当页面进入 hidden 状态时,取当前记录里的最终 LCP 值一次性上报。否则很可能永远等不到最后结果。
2.2 无侵入采集用户行为
光有性能指标还不够,白屏时常伴随某些操作路径。行为采集要做的是“无侵入”,不能为了监控给业务代码塞一堆埋点。
我采用了两种方式:
- 通过事件委托,捕获
click、input、submit等交互事件,记录元素标签、类名、文本片段和坐标。 - 监听
history.pushState与popstate,配合hashchange,记录路由变化。
代码上要注意不要重复绑定同一实例。每个采集器都有started标志位,防止init()被调用两次后事件回调叠加。
行为数据在内存里维护一个环形队列,最多保留 100 条:新数据进来,最老的数据被覆盖。当发生错误时,把当前队列和错误事件一起打包。这个“黑匣子”设计让每一份错误报告都自带“事故前因后果”。
行为采集最大的坑是隐私。千万不要把 input 的真实内容录进队列,尤其是密码框。我在采集元素信息时会过滤掉type="password"的节点,同时对可能包含手机号、身份证的文本字段做脱敏,用正则替换成掩码。这不只是为了合规,也是不让数据变成监控人员的负担。
2.3 上报策略:sendBeacon 是首选,但不是万能
上报最关键的目标是:不阻塞主线程、不丢数据。sendBeacon天然适合这个场景,它在浏览器内部调度,在页面卸载时也能尽量发出请求。
但我要说,它并不是银弹。
- 不同浏览器对 Beacon 请求体大小有限制,常见上限是 64KB。
- 部分老旧的 WebView 里
sendBeacon不可用。 - Beacon 的 Content-Type 默认是
text/plain,后端如果按 JSON 解析可能会踩坑。
所以 transport 层要做分层降级:
function send(data: string): boolean { if (typeof navigator !== 'undefined' && navigator.sendBeacon) { const blob = new Blob([data], { type: 'application/json' }); return navigator.sendBeacon(endpoint, blob); } if (typeof fetch === 'function') { fetch(endpoint, { method: 'POST', body: data, keepalive: true }).catch(() => {}); return true; } const xhr = new XMLHttpRequest(); xhr.open('POST', endpoint); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(data); return true; }使用 Blob 显式指定 JSON 类型,是为了避免服务端因为text/plain不识别。批量上报也能控制请求个数:队列攒够 10 条,或达到 10 秒间隔,就 flush 一次。这样既减少连接开销,又能保证实时性。
上报数据的队列要综合考虑成功率。成功后清空对应数据,失败则保留,但最多重试 3 次,超过后丢弃并计数。避免失败请求无限重试把页面拖垮,这在弱网环境尤其重要。
3. 实操过程与核心环节实现:把 Apollo-11 跑起来
3.1 SDK 初始化配置
一个监控 SDK 的初始化体验基本决定了它能否被推广到团队里。我提供的核心接口很简单:
export interface ApolloOptions { dsn: string; // 采集服务端接口地址 sampleRate?: number; // 抽样率,0 到 1,默认 1 maxQueueSize?: number; // 内存队列最大长度,默认 10 flushInterval?: number; // 上报间隔,默认 10_000 ms enablePerformance?: boolean; // 是否采集性能指标 enableBehavior?: boolean; // 是否采集用户行为 enableError?: boolean; // 是否采集 JS 错误 ignoreErrors?: string[]; // 忽略的错误关键词 }内部合并默认值的方式很简单:
const defaultOptions: ApolloOptions = { dsn: '', sampleRate: 1, maxQueueSize: 10, flushInterval: 10000, enablePerformance: true, enableBehavior: true, enableError: true, ignoreErrors: [], }; const finalOptions = { ...defaultOptions, ...userOptions };抽样率不是越高越好。假设一个日活 10 万的页面,你什么都采集,一天的原始数据量可能超过几百万条;个人搭建的采集服务大概率扛不住。抽样率 0.1,也就是只放行 10% 的用户,已经足够覆盖大多数稳定性问题。当然,错误采集通常不抽样,因为错误数据量的量级远低于 PV 类事件。
3.2 打包发布:UMD 和 ESM 双重导出
SDK 的消费场景有两类:一类是 Vite/Webpack 项目,喜欢import Apollo from 'apollo-11';另一类是 CMS 或传统页面,直接想在<script>标签后用全局变量window.Apollo。为了兼容,构建产物得同时输出 ESM 和 UMD。
我的构建脚本里用的是 tsup,配置很短:
export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'umd'], dts: true, minify: true, target: 'es2018', outExtension({ format }) { return { js: format === 'esm' ? '.js' : '.min.js' }; }, });有一点要特别注意:默认 UMD 导出可能叫Apollo,如果页面里已经有同名全局变量,会被覆盖。稳妥做法是 SDK 内部支持通过window.APOLLO_SDK_NAME自定义全局名称,或者挂到window.__APOLLO_11__这种不易冲突的路径上。实际使用里,全局变量冲突比想象中常见。
3.3 本地联调与数据链路验证
拿到 SDK 后,第一步不是接业务,而是先验证数据链路。我在本地起了一个最小的 Node.js 采集服务,纯原生实现,不依赖框架:
const http = require('http'); http.createServer((req, res) => { if (req.method !== 'POST') { res.statusCode = 405; return res.end('Method Not Allowed'); } let body = ''; req.on('data', (chunk) => { body += chunk; }); req.on('end', () => { try { const payload = JSON.parse(body); console.log(JSON.stringify(payload, null, 2)); res.statusCode = 200; res.end('ok'); } catch (e) { res.statusCode = 400; res.end('bad request'); } }); }).listen(3000, () => { console.log('collector listening on 3000'); });然后在页面上初始化:
<script src="./dist/apollo-11.min.js"></script> <script> Apollo.init({ dsn: 'http://localhost:3000/ingest' }); </script>打开浏览器开发者工具的 Network 面板,正常加载后应该能看到 POST 请求,请求体里是 JSON 数组,里面包含页面性能指标。如果没有上报,优先看控制台有没有报错、dsn 地址是否可跨域访问。本地开发要处理跨域的话,把Access-Control-Allow-Origin: *挂在响应头上即可,但生产环境不能这么做。
联调还得验证页面刷新、关闭时数据是否来得及发送。最简单的方法是在页面加载后立即关闭标签页,然后看采集服务日志。如果日志里什么都没有,大概率是 Beacon 降级逻辑没生效,或者初始化时机太晚。
4. 常见问题与排查技巧实录
4.1 sendBeacon 数据“丢失”了
这个问题我排查了很久。页面正常上报,但采集服务收到的数量明显少于预期。最后发现是发送请求体的 Content-Type 问题:服务端用了比较严格的 JSON 解析中间件,只接受application/json,而sendBeacon默认发送的是text/plain。虽然后端可以兼容,但我还是选择在前端把数据包成 Blob 并显式指定 JSON 类型。
另一个丢失原因是数据量超过了 Beacon 的单次上限。如果你把整天的所有数据一次性打包发送,很容易触发限制。解决方案是每次 flush 时拆包,单个请求体控制在 32KB 以内,超过就分多个请求发送。
这里给出一个简单的拆包逻辑:
const SLICE_SIZE = 32 * 1024; // 32KB function splitPayload(items: unknown[]): unknown[][] { const result: unknown[][] = []; let current: unknown[] = []; let currentSize = 0; for (const item of items) { const itemSize = JSON.stringify(item).length; if (currentSize + itemSize > SLICE_SIZE && current.length > 0) { result.push(current); current = []; currentSize = 0; } current.push(item); currentSize += itemSize; } if (current.length > 0) result.push(current); return result; }请求大小要提前预估,别到了上报链路报错才想起来。
4.2 监控 SDK 自身的错误导致循环上报
错误监控最怕的就是“捕到自己”。比如 SDK 内部某个兼容性检查抛错,被全局 error 监听器捕获,然后上报,上报过程又因为网络异常抛错,再次被捕获,直接形成死循环。
我在错误采集器里加了两个防护:
- 错误堆栈的关键文件名中包含 SDK 自身的 chunk 名或
apollo关键字,就直接忽略。 - 上报函数内部用 try-catch 包裹,并且在 catch 后置一个
suppressError标志,避免同一错误被递归捕获。
let inReport = false; function safeReport(payload: unknown): void { if (inReport) return; inReport = true; try { transport.send(payload); } catch (e) { // 静默处理 } finally { inReport = false; } }这个细节看起来小,但如果漏了,生产环境很可能出现“上报风暴”,直接把采集服务打挂。
4.3 性能数据上报前页面已经关闭
做性能采集时有一个天然矛盾:核心指标 LCP 可能要等页面完全加载后才能算准,但用户可能提前关闭页面,监听回调还没触发。
我的处理方法是:
- 初始化时设置
buffered: true,让PerformanceObserver尽可能读取既有数据。 - 尽早调用
Apollo.init(),例如在<head>中的内联脚本里,而不是等到 DOMContentLoaded。 - 监听
visibilitychange,当页面进入 hidden 状态时,立即手动汇总当前已知指标并 flush。
用这种方式,“没等到 LCP 最终值”被“上报当前已观测到的最优值”替换。后续聚合时可以对缺少 LCP 的记录单独标记,而不是当成完全丢数据。
4.4 跨浏览器指标口径不一致
不同浏览器对性能 API 的支持差别不小。Safari 以前不支持event类型 PerformanceEntry,因此计算不了 INP;部分 WebView 的 CLS 值还会因为浏览器版本不同出现偏差。
不能指望一个 polyfill 解决所有问题。我采用的方法是特性检测:
- 如果浏览器不支持某个 Observer,采集器直接返回空数据,但在上报体里带上
browser.ua和supportedMetrics。 - 后端统计时按浏览器分组看,而不是把所有浏览器混在一起算均值。
比如 LCP 在不同浏览器的触发时间本身有差异,混包很容易得出错误结论。按浏览器维度拆开看,比调优页面更能还原真实用户体验。
5. 影响范围与后续扩展
5.1 这套方案能覆盖什么业务
Apollo-11 目前最适合中小团队、对数据所有权敏感、希望快速接入前端监控的场景。它的覆盖面不是“企业级全链路监控”,而是“前端运行时最核心的三大件:性能、错误、行为”。
它解决不了以下问题:后端服务器指标、数据库慢查询、接口依赖链路。前端 SDK 只能看到请求从发起到响应的耗时,无法知道网关后面发生了什么。如果团队需要的是后端全链路追踪,那 Apollo-11 只能作为补充,不能替代。
但如果是排查白屏、按钮无响应、首屏加载慢、上线后错误率突增这些问题,这套方案足够了。它能把响应时间从“找用户聊、猜原因”压缩到“看数据、定位代码影响范围”。
5.2 后续扩展方向
当前版本的扩展重点有三个:
- 告警:采集服务端收到数据后,对错误率做滑动窗口计算,超过阈值触发通知。
- 面板可视化:把上报指标存入时间序列数据库,用详情页展示某个版本上线后的 LCP 趋势。
- Source Map 还原:发布时把 source map 传到服务端,前端只上报压缩后的堆栈,后端结合 source map 还原出原始文件、行列号和函数名。
尤其最后一项,可以说是错误监控从“能用”到“好用”的关键。没有 source map,你看到的是apollo-11.min.js:1:23456,有了 source map,你直接看到的是src/utils/schema.ts:15:10,排查效率完全不同。
扩展性能采集时还可以加入资源加载瀑布图。PerformanceResourceTiming里有每个静态资源的 DNS、TCP、TLS、请求和响应耗时,能做资源级分析。把这些数据存下来,是可以直接指导优化策略的。
最后分享一个我在这个项目里踩坑后想通的经验:做监控,不要只盯平均值,要看 P75、P95 分位数。均值很容易被极端值拉高,或者被大量正常样本掩盖问题。Apollo-11 之后还没跑完,但每一步都值得记录下来。如果你也要做类似的工具,建议先把“数据链路能通”放在第一位,再谈指标多准确、界面多好看。