字节前端监控SDK体积与性能优化实践原创
背景
字节各类业务拥有众多用户群,作为字节前端性能监控 SDK,自身若存在性能问题,则会影响到数以亿计的真实用户的体验。所以此类 SDK 自身的性能在设计之初,就必须达到一个非常极致的水准。
与此同时,随着业务不断迭代,功能变得越来越多,对监控的需求也会变得越来越多。例如,今天 A 业务更新了架构,想要自定义性能指标的获取规则,明天 B 业务接入了微前端框架,需要监控子应用的性能。在解决这些业务需求的同时,我们会不断加入额外的判断逻辑、配置项。同时由于用户的电脑性能、浏览器环境的不同,我们又要解决各种兼容性问题,加入 polyfill 等代码,不可避免地造成 SDK 体积膨胀,性能劣化。那么我们是如何在需求和功能不断迭代的情况下,持续追踪和优化 SDK 的体积和性能的呢?
SDK 体积优化
通常而言,体积的优化是最容易拿到收益的一项。
由于监控 SDK 通常作为第一个脚本被加载到页面中,体积的膨胀不仅会增加用户的下载时间,还会增加浏览器解析脚本的时间。对于体积优化,我们可以从宏观和微观两个角度去实现。
微观上,我们会去尽可能去精简所有的表达,剥离冗余重复代码,同时尽可能减少以下写法的出现:
1. 过多的 class 和过长的属性方法名
Class 的定义会被转换成 function 声明 + prototype 赋值,以及常用代码压缩工具无法对 object 属性名压缩,过多的面向对象写法会让编译后的 js 代码体积膨胀得非常快。例如下列代码
class ClassWithLongName {
methodWithALongLongName() {}
}
var ClassWithLongName = /** @class */ (function () {
function ClassWithLongName() {
}
ClassWithLongName.prototype.methodWithALongLongName = function () { };
return ClassWithLongName;
}());
var ClassWithLongName=function(){function n(){}return n.prototype.methodWithALongLongName=function(){},n}();
可以看到以上长命名都无法被压缩
如果使用函数式编程来代替面向对象编程,能够很好的避免代码无法被压缩的情况:
function functionWithLongName() {
return function MethodWithALongLongName(){}
}
function n(){return function(){}}
相较于 class 的版本,压缩后的代码减小了50%以上。
原理同上,对象中的字段名通常不会被代码压缩工具压缩。同时合理使用 TS named tuple 类型可以保证代码可维护性
function report(event, {optionA, optionB, optionC, optionD}: ObjectType){
}
function report(event, [optionA, optionB, optionC, optionD]: NamedTupleType){
}
?.
??
??=
等操作符的出现。同理,尽可能避免一些例如 spread 操作符、generator 等新语法,这些语法在编译成 es5 后通常会引入额外的 polyfill。a?.b
会被转换成:a === null || a === void 0 ? void 0 : a.b
过多的 nullish 操作符也是代码体积增加的一个原因。
当然,以上只列举了部分体积优化措施,还有更多优化方法要结合具体代码而议。对于我们的前端监控 SDK,为了性能和体积是可以牺牲一些开发体验的,并且由于使用 TS 类型系统,并不会对代码维护增加很多负担。
-
拆分文件
我们可以分离出 SDK 中不是必须提前执行的逻辑,拆分成异步加载的文件,仅将必须提前执行的逻辑加入初始脚本。同时将不同功能拆分成不同文件,业务按需加载,这样可以最大程度减少对首屏加载时间的影响。
-
尽可能避免 polyfill 的使用
polyfill 会显著增加产物体积,我们尽可能不使用存在兼容性的方法。甚至在不需要兼容低端浏览器环境时,我们可以不使用 polyfill。
-
减少重复的常量字符串的出现次数
对于多次重复出现的常量字符串,提取成公共变量。例如
a.addEventListener('load', cb)
b.addEventListener('load', cb)
c.addEventListener('load', cb)
我们可以将 addEventListener
和 load
提取公共变量:
let ADD_EVENT_LISTENER = 'addEventLister'
let LOAD = 'load'
a[ADD_EVENT_LISTENER](LOAD, cb)
b[ADD_EVENT_LISTENER](LOAD, cb)
c[ADD_EVENT_LISTENER](LOAD, cb)
此段代码压缩后会变成
let d="addEventLister",e="load";a[d](e,cb),b[d](e,cb),c[d](e,cb);
我们还可以使用 TSTransformer 或者 babel plugin 来帮我们自动地完成上述过程。
💡 值得注意的是,这个方法在 web 端并不能取得很好的收益,因为浏览器在传输数据时会做 gzip 压缩,已经将重复信息用最高效的算法压缩了,我们做的并不会比 gzip 更好。但是在需要嵌入移动端 app 的监控 SDK 来说,这一做法能减少约 10 ~ 15% 产物体积。
除了体积优化以外,随着需求不断增加,功能不断完善,不可避免的会影响到 SDK 的性能。接下来,我们介绍如何测量并优化 SDK 的性能。
使用工具进行性能衡量
通常来说,监控类 SDK 最有可能影响性能的地方为:
-
监控初始化时执行各类监听的过程
-
监控事件上报请求对业务的影响
-
SDK 维护数据缓存时的内存使用情况
接下来,我们着重从以上几个维度来衡量并优化 SDK 的性能。
性能衡量过程
使用 Benchmark 性能衡量工具的目的便是为了知道 SDK 运行过程中每一个函数执行的耗时,给业务带来多大的影响,是否会引起 longtask。由于我们的监控 SDK 包含了性能、请求、资源等各类前端监控能力,这些功能的实现依赖对页面各类事件的监听、性能指标的获取、请求对象的包装。除此之外,SDK还提供给用户(开发者)调用的方法,例如配置页面信息、自定义埋点、更改监控行为等能力。根据 SDK 以上行为和能力,我们将测试分为两个模块:
-
接入 SDK 后自动运行的各类监控,这些行为大部分会在页面加载之初执行,若此部分性能劣化,会严重影响到所有前端业务用户的首屏加载。 -
用户端(开发者)调用的方法,我们会将此类方法包装成 client 对象以 npm 包的形式给开发者调用,这部分方法的执行由用户控制,可能存在频繁调用的情况,因此也应避免耗时过长的调用出现。
下面我们以使用 benny 这一开源工具为例,展示一段方便理解 benchmark 过程的伪代码,仅作参考:
💡 benny 是一个非常简单易用的 benchmark 工具,通过
suite
方法创建测试用例组合,通过add
方法添加需要测试的函数,cycle
方法用于多次循环执行测试用例,complete
用于添加测试完成之后的回调函数。更多详细的使用说明可以查阅官方文档。
const { suite, add, cycle, complete, save } = require('benny')
// 衡量 SDK 各类监控初始化运行性能
suite(
'collectors setup',
add('route', () => route(context)),
add('exception', () => exception(context)),
add('ajax', () => ajax(context)),
add('FCP', getFCP),
add('LCP', getLCP),
add('longtask', getLongtask),
cycle(),
complete(),
)
// 衡量 Client 实例方法耗时
suite(
'npm client',
add('set config', () => client.config({pid})),
add('set context', () => client.context.set({ something })),
add('send custom pv', () => client.sendPageView(pid)),
add('send custom event', () => client.sendCustom(ev)),
// ...
cycle(),
complete(),
)
通常这类 benchmark 工具都是在 Node 上执行的,但是我们的 SDK 是个前端监控 SDK,依赖了非常多的浏览器环境对象,我们几乎不可能在 Node 环境去创造或模拟这些对象,我们有没有办法在浏览器里去运行这段脚本,做性能自动化测试呢?
利用 Puppeteer 在浏览器环境中执行 Benchmark
Puppeteer 是一个 Node 模块,提供了通过 Devtool Protocol 控制 Chrome 或者 Chromium 的能力。Puppeteer 默认运行 Chrome 的无头版本,也可以通过设置运行 Chrome 用户界面版。
const browser = await puppeteer.launch()
const page = await browser.newPage()
const cdp = await page.target().createCDPSession()
// 用于 benchmark 脚本和 puppeteer 之间的通信,用以收集结果
await page.evaluate(() => (window.benchmarks = []))
// 将 pushResult 方法暴露给浏览器,来将结果收集到 node 端
await page.exposeFunction(
'pushResult',
(result: any) => benchmark.results.push(result)
)
await cdp.send('Profiler.enable')
await cdp.send('Profiler.start')
// 开始执行 benchmark
await page.addScriptTag({
content: file.toString(),
})
await Promise.race([timeout, allBenchmarksDone()])
// profile 可用于绘制火焰图
const { profile } = await cdp.send('Profiler.stop')
await page.close()
除此之外利用 puppeteer 的能力,我们不仅可以得到 benchmark 的结果,还可以获取到整个 benchmark 过程的 profile 数据,利用 speedscope 绘制出函数执行过程中的火焰图:
💡 绘制火焰图的具体实现不在本文讨论范围内,感兴趣的同学可以参考 speedscope 官方文档
如何衡量异步任务性能?
-
性能监控逻辑分片运行,将各项性能指标的监听同步拆为异步,用 requestIdleCallback 做调度并区分优先级。 -
多个性能指标监听同一事件的公用监听器,例如 CLS 和 LCP 都需要监听 onBFCacheRestore,让他们只做一次 addEventListener。 -
可以延迟执行的方法延迟执行,例如在高版本的 Chrome 中 PerformanceObserver 是有 buffer 的,可以直接获取到调用之前的性能指标,这些方法调用就可以等待页面完全加载完成之后执行,从而尽可能减少对业务页面首屏影响。
通过 Perfsee 的 Lab 结果分析性能问题
Perfsee 是一个针对前端 web 应用在整个研发流程中的性能分析平台。提供性能分析报告、产物分析报告、源码分析、竞品分析等模块,定位与梳理性能问题,提供专业的优化方案来渐进地优化产品性能。 Lab 模块性能分析的依据是,使用 headless 浏览器运行用户指定的页面,通过运行时数据的收集,分析并产出关键性能指标分数、网络请求信息、主线程 JS/渲染/Longtask 信息供业务方参考优化。具体使用说明请查看 perfsee.com
💡 注意,本文所展示 Perfsee 功能示例为早期版本,并不与开源版本功能和界面完全一致。
准备基准页面作为对照组
-
容易搭建,一个命令就能跑起来。 -
自身逻辑简单,性能好,SDK 所造成的影响容易被放大观察。 -
SPA 应用,含有异步加载的逻辑,更容易探测到监控 SDK 对页面 FCP、LCP 等指标影响。 -
无外部网络请求,页面结果稳定不易波动。
查看 Lab 性能报告
问题分析与性能优化
onBFCacheRestore
都占用了超过 15ms 的时间,我们在源码里搜索这个函数,此部分伪代码如下:const onBFCacheRestore = (cb) => {
addEventListener('pageshow', (e) => {
if (e.persisted) cb(e)
}, true)
}
BFCache 即 back-forward cache,可称为“往返缓存”,可以在用户使用浏览器的“后退”和“前进”按钮时加快页面的转换速度。这个缓存不仅保存页面数据,还保存了 DOM 和 JS 的状态,实际上是将整个页面都保存在内存里。如果页面位于 BFCache 中,那么再次打开该页面就不会触发 on-load 事件。
1. 监控任务切片运行,区分优先级
2. 减少重复监听次数
3. 请求数量的优化
-
浏览器对请求并发量有限制,所以存在网络资源竞争的可能性 -
浏览器在页面卸载时会忽略异步ajax请求,而同步 ajax 通常在现代浏览器中已被禁用
我们可以通过使用 navigator.sendBeacon 方法解决上述问题。
这个方法主要用于满足统计和诊断代码的需要,这些代码通常尝试在卸载(unload)文档之前向 Web 服务器发送数据。过早的发送数据可能导致错过收集数据的机会。然而,对于开发者来说保证在文档卸载期间发送数据一直是一个困难。因为用户代理通常会忽略在
unload (en-US)
事件处理器中产生的异步XMLHttpRequest
经过以上优化后,我们注入优化过后的 SDK 再次跑分。
优化后的 SDK 对业务 FCP、LCP、LOAD 等性能的影响已经降到了最低,已经达到了非常高的性能标准。
了解更多
字节内部众多业务方使用的前端监控解决方案已同步在火山引擎上,无论是外部企业开发者或个人开发者,均可通过接入该服务提升性能优化的效率。扫一下,立即申请30天免费试用。