字节各类业务拥有众多用户群,作为字节前端性能监控 SDK,自身若存在性能问题,则会影响到数以亿计的真实用户的体验。所以此类 SDK 自身的性能在设计之初,就必须达到一个非常极致的水准。
与此同时,随着业务不断迭代,功能变得越来越多,对监控的需求也会变得越来越多。例如,今天 A 业务更新了架构,想要自定义性能指标的获取规则,明天 B 业务接入了微前端框架,需要监控子应用的性能。在解决这些业务需求的同时,我们会不断加入额外的判断逻辑、配置项。同时由于用户的电脑性能、浏览器环境的不同,我们又要解决各种兼容性问题,加入 polyfill 等代码,不可避免地造成 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。
对于多次重复出现的常量字符串,提取成公共变量。例如
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 以上行为和能力,我们将测试分为两个模块:
下面我们以使用 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 是一个 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 官方文档
Perfsee 是一个针对前端 web 应用在整个研发流程中的性能分析平台。提供性能分析报告、产物分析报告、源码分析、竞品分析等模块,定位与梳理性能问题,提供专业的优化方案来渐进地优化产品性能。 Lab 模块性能分析的依据是,使用 headless 浏览器运行用户指定的页面,通过运行时数据的收集,分析并产出关键性能指标分数、网络请求信息、主线程 JS/渲染/Longtask 信息供业务方参考优化。具体使用说明请查看 perfsee.com
💡 注意,本文所展示 Perfsee 功能示例为早期版本,并不与开源版本功能和界面完全一致。
onBFCacheRestore
都占用了超过 15ms 的时间,我们在源码里搜索这个函数,此部分伪代码如下:const onBFCacheRestore = (cb) => {
addEventListener('pageshow', (e) => {
if (e.persisted) cb(e)
}, true)
}
BFCache 即 back-forward cache,可称为“往返缓存”,可以在用户使用浏览器的“后退”和“前进”按钮时加快页面的转换速度。这个缓存不仅保存页面数据,还保存了 DOM 和 JS 的状态,实际上是将整个页面都保存在内存里。如果页面位于 BFCache 中,那么再次打开该页面就不会触发 onload 事件。
我们可以通过使用 navigator.sendBeacon 方法解决上述问题。
这个方法主要用于满足统计和诊断代码的需要,这些代码通常尝试在卸载(unload)文档之前向 Web 服务器发送数据。过早的发送数据可能导致错过收集数据的机会。然而,对于开发者来说保证在文档卸载期间发送数据一直是一个困难。因为用户代理通常会忽略在
unload (en-US)
事件处理器中产生的异步XMLHttpRequest
经过以上优化后,我们注入优化过后的 SDK 再次跑分。
优化后的 SDK 对业务 FCP、LCP、LOAD 等性能的影响已经降到了最低,已经达到了非常高的性能标准。
字节内部众多业务方使用的前端监控解决方案已同步在火山引擎上,无论是外部企业开发者或个人开发者,均可通过接入该服务提升性能优化的效率。
扫描下方二维码,立即申请30天免费使用⬇️
💡【阅读原文】了解Perfsee 性能分析平台