后台字节拥有众多用户群体,适用于各种业务。作为字节前端性能监控SDK,一旦出现性能问题,将影响亿万真实用户的体验。因此,这种SDK-like本身的性能在设计之初就必须达到一个非常极致的水平。同时,随着业务的不断迭代,功能越来越多,对监控的需求也会越来越多。比如今天业务A更新了架构,想自定义性能指标的获取规则。明天业务B接入微前端框架,需要监控子应用的性能。在解决这些业务需求的同时,我们会不断增加额外的判断逻辑和配置项。同时,由于用户的电脑性能和浏览器环境不同,我们要解决各种兼容性问题,加上polyfill等代码,必然会造成SDK体积膨胀和性能下降。那么我们如何在需求和功能不断迭代的背景下,持续跟踪和优化SDK的大小和性能呢?SDK大小优化一般来说,大小优化是最容易获得收益的项目。由于监控SDK通常作为第一个脚本加载到页面中,体积的扩大不仅会增加用户的下载时间,也会增加浏览器解析脚本的时间。对于体积优化,我们可以从宏观和微观两个角度来实现。微观上,我们会尽量简化所有的表达方式,剥离冗余和重复的代码,尽可能减少以下写法的出现:1.类的定义过多,属性方法名过长Class会转化为函数声明+原型赋值,普通代码压缩工具无法压缩对象属性名。过多的面向对象的写法会让编译后的js代码膨胀得非常快。比如下面的代码:classClassWithLongName{methodWithALongLongName(){}}经过ts改造后会变成:varClassWithLongName=/**@class*/(function(){functionClassWithLongName(){}ClassWithLongName.prototype.methodWithALongLongName=function(){};返回ClassWithLongName;}());压缩后的代码为:varClassWithLongName=function(){functionn(){}returnn.prototype.methodWithALongLongName=function(){},n}();可以看到到上面的长名字是不能压缩的。如果使用函数式编程而不是面向对象编程,就可以避免代码无法压缩的情况:(){}}与类版本相比,压缩代码减少了50%以上。2、在函数内部使用数组而不是对象传递参数的原理同上,对象中的字段名通常不会被代码压缩工具压缩。同时,合理使用TS命名元组类型可以保证代码的可维护性。functionreport(event,{optionA,optionB,optionC,optionD}:ObjectType){}改为:functionreport(event,[optionA,optionB,optionC,optionD]:NamedTupleType){}3.当不需要判断可为空,尽量避免?.????=等运算符。同理,尽量避免一些新的语法,比如spreadoperator和generator,这些语法通常在编译到es5后引入额外的polyfill。TS会将这些运算符转换成很长的代码。例如,a?.b将被转换为:a===null||一个===无效0?void0:a.b无效运算符太多也是代码量增加的原因之一。当然,以上只是列举了一些体积优化措施,还有更多的优化方法需要结合具体代码进行讨论。对于我们的前端监控SDK来说,可以为了性能和体积牺牲一些开发经验,而且因为使用了TS类型的系统,所以并没有给代码维护增加很多负担。从宏观上,我们应该思考如何减少SDK所依赖的模块,减少产品包含的内容,提高产品的“信噪比”。有以下几种方式:1、我们可以通过拆分的方式将SDK中的文件分开。执行的逻辑被拆分成异步加载的文件,只有必须提前执行的逻辑才会被添加到初始脚本中。同时,将不同的功能拆分到不同的文件中,按需加载业务,可以将对首屏加载时间的影响降到最低。2、尽量避免使用polyfill。Polyfill会显着增加产品的体积。我们尽量不使用兼容的方法。甚至当我们不需要兼容低端浏览器环境时,我们也不需要使用polyfill。3、减少常量字符串重复出现的次数。对于重复出现的常量字符串,提取到公共变量中。例如a.addEventListener('load',cb)b.addEventListener('load',cb)c.addEventListener('load',cb)我们可以从addEventListener中提取公共变量并加载:letADD_EVENT_LISTENER='addEventLister'letLOAD='load'a[ADD_EVENT_LISTENER](LOAD,cb)b[ADD_EVENT_LISTENER](LOAD,cb)c[ADD_EVENT_LISTENER](LOAD,cb)压缩后这段代码会变成:letd="addEventLister",e="load";a[d](e,cb),b[d](e,cb),c[d](e,cb);我们也可以使用TSTransformer或者babel插件来帮助我们自动完成上述过程。值得注意的是,这种方式在web端并没有取得很好的回报,因为浏览器在传输数据的时候会做gzip压缩,重复的信息已经用最高效的算法进行了压缩。我们所做的并不比gzip更好。但对于需要嵌入到手机APP中的监控SDK,这种方式可以减少产品体积约10-15%。除了尺寸优化,随着需求的不断增加,功能的不断完善,势必会影响到SDK的性能。接下来,我们将介绍如何衡量和优化SDK的性能。使用工具进行性能测量一般来说,监控SDK最容易影响性能的地方是:监控初始化时进行各种监控的过程。监控事件报告请求对业务的影响。SDK维护数据缓存时的内存占用。接下来,我们重点从以上几个维度来衡量和优化SDK的性能。在性能测量过程中使用Benchmark性能测量工具的目的是了解SDK运行过程中各个函数的执行耗时,对业务的影响有多大,是否会造成longtask。由于我们的监控SDK包含了性能、请求、资源等各种前端监控能力,这些功能的实现依赖于对页面各种事件的监控、性能指标的获取、请求对象的封装。此外,SDK还提供了方法供用户(开发者)调用,如配置页面信息、自定义埋点、改变监控行为等。根据SDK的上述行为和能力,我们将测试分为两个模块:接入SDK后自动运行的各类监控。大多数这些行为将在页面加载开始时执行。如果这部分性能变差,将严重影响所有前端业务用户的首屏加载。客户端(开发者)调用的方法,我们会将这类方法包装成一个客户端对象,以npm包的形式调用给开发者。这部分方法的执行是由用户控制的,可能会出现频繁调用的情况,所以也应该避免发生耗时过长的调用。在之前的文章前端监控系列一|字节跳动的前端监控SDK是如何设计的,我们提到我们的SDK在设计上尽可能做到了解耦,各个模块各司其职。这个特性非常方便我们对每个模块方法进行单独的性能测量。下面以开源工具benny(https://github.com/caderek/benny)为例,展示一段伪代码,方便理解benchmark过程,仅供参考:benny是一个非常简单易用的-使用基准工具。通过suite方法创建测试用例组合,通过add方法添加待测函数,cycle方法用于多次执行测试用例,complete用于添加测试完成后的回调函数。更详细的说明请参考官方文档。const{suite,add,cycle,complete,save}=require('benny')//衡量SDK各种监控套件的初始运行性能('collectorssetup',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('npmclient',add('setconfig',()=>client.config({pid})),add('设置上下文',()=>client.context.set({something})),add('发送自定义pv',()=>client.sendPageView(pid)),add('发送自定义事件',()=>client.sendCustom(ev)),//...cycle(),complete(),)通常这样的benchmark工具是在Node上执行的,但是我们的SDK呢是一个前端监控SDK,依赖了很多浏览器环境对象。我们几乎不可能在Node环境中创建或模拟这些对象。有没有办法让我们在浏览器中运行这个脚本来进行性能自动化测试呢?在浏览器环境使用Puppeteer执行Benchmark由于我们的前端监控依赖于浏览器环境,所以我们可以将上面的benchmark测试代码打包成commonjs在headlesschrome浏览器中执行,通过puppeteer收集执行结果。Puppeteer是一个Node模块,它提供了通过Devtool协议控制Chrome或Chromium的能力。Puppeteer默认运行无头版本的Chrome,也可以配置为运行ChromeUI版本。下面是一段伪代码,方便理解操作puppeteer的过程。仅供参考。实际情况比较复杂,需要等待未完成的异步请求等:constbrowser=awaitpuppeteer.launch()constpage=awaitbrowser.newPage()constcdp=awaitpage.target().createCDPSession()//用于benchmark脚本和puppeteer之间的通信,收集结果awaitpage.evaluate(()=>(window.benchmarks=[]))//将pushResult方法暴露给浏览器,收集结果到节点awaitpage.exposeFunction('pushResult',(result:any)=>benchmark.results.push(result))awaitcdp.send('Profiler.enable')awaitcdp.send('Profiler.start')//开始基准测试awaitpage.addScriptTag({content:file.toString(),})awaitPromise.race([timeout,allBenchmarksDone()])//profile可以用来引火烧图const{profile}=awaitcdp.send('Profiler.stop')awaitpage.close()通过运行上面的脚本,我们就可以在无头浏览器中运行我们的性能测试脚本,并在测试脚本中输出结果然后添加pushResult方法收集测试结果。在实际的benchmark测试中,我们发现开启性能监控(即运行每次性能监控的PerformanceObserver.observe方法)耗时高达21ms。虽然看起来不长,但如果和其他监控同时执行,加上业务代码引入的复杂性和移动端较弱的CPU性能,极有可能是带来longtask的罪魁祸首商业。性能监控性能成为瓶颈。接下来,我们将性能监视器一一拆分,以同样的方式分别测试每个性能监视器的耗时。在实际的benchmark结果中,我们发现fp、fcp、lcp、cls的监控耗时最多,加起来超过了10ms,占比超过了一半,这也是我们后续需要重点优化的地方。另外利用puppeteer的能力,我们不仅可以得到benchmark的结果,还可以得到整个benchmark过程的profile数据,使用speedscope(https://github.com/jlfwong/speedscope/blob/main/README-zh_CN.md)函数执行过程中绘制火焰图:绘制火焰图的具体实现不在本文讨论范围内。感兴趣的同学可以参考speedscope官方文档。这里显示的时间是用例的总执行时间(单次消费Hours*Times)如何衡量异步任务的性能?Benny的api支持异步测试用例,并测量每个异步函数从执行到解析的时间。但通常这并不是我们要衡量的数据,因为异步任务的执行并不总是占用主线程。对于一些异步定时任务(如SDK崩溃检测、卡顿检测、白屏检测),将其拆解成一系列可测量的同步任务,可以更直观地展示每个阶段的性能和耗时。比如我们SDK的前端白屏检测,由一个mutationObserver和一个触发白屏检测的函数组成。我们可以分别测量mutationObserver的回调函数和触发函数的性能。这两种方法都没有得到很好的优化。但是根据benchmark结果和源码可以发现,性能监控的所有指标都是同步开启的,每个指标都会监控页面的事件或者PerformanceObserver,而这些原生的监控耗时都是以毫秒为单位的。所以我们对性能做了如下优化:性能监控逻辑分片运行,将各种性能指标的监控拆分为异步和requestIdleCallback(https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback)用于调度和优先级排序。对于CLS、LCP等多个性能指标监听同一个事件的普通监听器,需要监听onBFCacheRestore,这样只做一次addEventListener。可以延迟执行的方法可以延迟。比如在高版本的Chrome中,PerformanceObserver有一个buffer(https://www.w3.org/TR/performance-timeline-2/#dom-performanceobserverinit-buffered),可以直接获取到之前的性能指标调用,这些方法调用可以在页面完全加载后执行,尽量减少对业务页面首屏的影响。通过Perfsee的实验室结果分析性能问题。上述基准测试过程得到的结果是理想化的简单方法调用性能。但是,在实际的浏览器环境中,我们的前端监控SDK对性能的影响有多大呢?对于这一类在页面初始化时加载的SDK,可以通过Perfsee(https://perfsee.com/)的Lab函数来衡量。Perfsee是一个用于前端Web应用程序整个开发过程的性能分析平台。提供性能分析报告、产品分析报告、源码分析、竞品分析等模块,定位梳理性能问题,提供专业的优化方案,逐步优化产品性能。Lab模块性能分析的基础是使用无头浏览器运行用户指定的页面,通过运行时数据的收集,分析产生关键性能指标得分、网络请求信息、主线程JS/rendering/Longtask信息供业务端优化参考。具体使用说明请参考perfsee.com(https://perfsee.com/docs/cn/lab/get-started)。注意,本文展示的Perfsee功能示例是早期版本,与开源版本并不完全相同。准备一个基准页面作为对照组。我们的目的是衡量SDK对业务性能的影响,所以需要找一个benchmarkpage进行对比。这里我们以ReactServerComponentDemo(https://github.com/reactjs/server-components-demo)为例作为基准页面。该应用程序具有以下特点:易于构建,一条命令即可运行。其自身逻辑简单,性能好,SDK造成的影响容易被放大观察。SPA应用包含异步加载逻辑,更容易检测监控SDK对页面FCP、LCP等指标的影响。无外网请求,页面结果稳定不易波动。我们修改一下应用的逻辑,通过url参数注入监控sdk脚本,部署到服务器上。接下来,我们在perfsee平台上配置了基准页面和SDK注入页面,并触发了性能扫描。查看Lab性能报告,我们将未注入SDK的页面设为空白组(empty),将注入SDK的页面设为实验组(with-sdk)。首先,我们需要配置空白组和实验组的页面和配置文件。触发快照后,我们会得到多个报告。我们可以点击比较,比较空白组和实验组的数据。在实际的实验室性能扫描结果中,我们可以看到两个页面所有性能指标的对比。我们发现sdk的注入在mobileprofile下依然给业务带来了fcp70ms、lcp90ms、load200ms的降级(降频4倍)。同时我们也可以观察到注入sdk之后,fmp和lcp之前只多了一个request,符合预期。但是,这仍然是我们持续观察的指标之一,因为在一些低端环境下,在页面加载完成之前发送的每个请求可能会延迟业务的更高优先级请求,导致页面性能指标下降。衰退。切换到BreakdownTab,我们也可以看到页面的首屏时间线。我们需要关注线程占用前几个关键指标(load、fcp、lcp)。悬停在加载前的黄色块上。我们发现sdk在加载前执行了30ms,导致业务指标变慢。原因之一。此处的屏幕截图省略了一些内部信息。一般来说,如果需要更多的信息,可以使用Source模块来查找导致主线程密集计算的代码位置。在这个例子中,这个调用并没有触发longtask,我们不难发现这是SDK初始化的逻辑,也是接下来需要优化的地方。问题分析及性能优化通过以上的benchmark工具和perfseelab的性能分析结果,我们可以看出SDK的初始化逻辑和大量的事件监听确实对业务性能产生了一定的影响。比如上图火焰图中的每个onBFCacheRestore耗时超过15ms。我们在源码中搜索这个函数。这部分的伪代码如下:constonBFCacheRestore=(cb)=>{addEventListener('pageshow',(e)=>{if(e.persisted)cb(e)},true)}BFCache(https://web.dev/bfcache/)是前后向缓存,可以称为“往返缓存”,可以让用户在使用浏览器的“后退”和“前进”按钮时加速页面转换。这个缓存不仅保存了页面数据,还保存了DOM和JS的状态,实际上是将整个页面保存在内存中。如果页面在BFCache中,再次打开页面将不会触发onload事件。可以看出耗时主要是onBFCacheRestore和onHidden这两个方法中原生的addEventListener造成的。本身监控是毫秒级的,回调函数没有优化空间。考虑到实际场景,这两个回调是监听用户页面的进度和返回,并不是最高优先级的任务。我们可以从以下几个方面降低对业务的影响:1.监控任务分片运行,区分优先级。对于监控SDK,除了必要的监控和事件预采集任务外,其他任务不应妨碍业务代码的执行。对于字节前端的监控需求,异常和请求监控是必须提前执行的任务,其他所有的事件监控可以拆分成单独的任务。所有的数据后处理逻辑,如采样、数据计算、上报请求等,只有在空闲状态下被requestIdleCallback调用时才会执行。2.减少重复监测的次数。多个性能指标监控同一事件。比如CLS和LCP这两个指标都需要监听onBFCacheRestore,这样它们只做一次addEventListener。3.请求数的优化我们SDK的脚本由一个必须先执行的主脚本(包括预采集、请求钩子、错误监听等逻辑)和多个异步插件脚本(性能、资源、白屏等),主脚本的请求不能省略,插件脚本可以通过接入CDN组合服务或自行搭建组合服务,将多个请求组合为一个。对于事件上报请求,我们内部会维护一个缓存,只有在间隔达到一定时间或者累积一定量后才会统一上报。在这种场景下,我们还需要考虑两个问题:浏览器对并发请求数有限制,存在网络资源竞争的可能。浏览器会在页面卸载时忽略异步ajax请求,而同步ajax在现代浏览器中通常是可用的。hasbeendisabled我们可以使用navigator.sendBeacon方法来解决上面的问题。该方法主要用于满足统计和诊断代码的需要,通常在卸载(unloading)文档之前尝试向web服务器发送数据。过早发送数据会导致错失收集数据的机会。但是,开发人员很难确保在文档卸载期间发送数据。因为用户代理通常会忽略在unload(en-US)事件处理程序中生成的异步XMLHttpRequest。经过上面的优化,我们注入优化后的SDK,再次跑分。优化后的SDK对业务FCP、LCP、LOAD的性能影响降到了最低,达到了非常高的性能标准。
