前端性能优化方法与实战 - 前 58 集团技术副总监 - 拉勾教育

记得那是 2014 年我刚入职 58,负责整个集团前端团队,有一天公司老板突然发话,希望从整体上优化一下 M 站性能。

根据过往经验,我先是和资深前端工程师 F 同学说了下我的想法,然后让他来给出具体方案。

半个小时后,F 同学就找到我说:“老大,我感觉你想多了,我们只要把首屏慢的问题给解决就行了,还需要做啥首屏指标采集,本质上还是要解决问题嘛。”

我说:“你如果能解决当然更好了。” 很快 F 同学又给了我一个结论:我们列表页的首屏时间没问题。

为什么会得出这个结论呢?

原来,F 同学是在内网通过 Chrome DevTools 观察首屏时间的,这样内外网络环境就不同,首屏时间也会受到影响;还有,他使用的是调试工具,而用户是直接访问的,访问方式不同;最后,他观察首屏时间的手机只有几种,而真实用户人不同,手机型号也有很大不同。

如此复杂的环境,我们到底该如何了解用户的首屏时间?大量用户的首屏时间分布又是怎样的?性能差的用户首屏时间是多少?想要了解这些并对首屏时间进行优化,我们首先要做的就是首屏指标采集。

那么,具体都有哪些采集方式呢?

在实际当中,首屏指标采集有手动采集和自动化采集两种,接下来我就来为你分别介绍下。

手动采集办法及优缺点

所谓手动采集,一般是通过埋点的方式进行, 比如在页面开始位置打上 FMP.Start(),在首屏结束位置打上 FMP.End(),利用 FMP.End()-FMP.Start() 获取到首屏时间。

以电商平台为例,如果是电商类商品详情页,首屏包括头图、购买、商品信息、下单按钮等,就在这些内容加载完毕的位置打上首屏结束的点。

如果是电商列表页,瀑布流型的页面,需要根据各个机型下的首屏位置,估算一个平均的首屏位置,然后打上点。

如果是直播型的页面,页面核心是一个直播框,就需要在直播框的结束位置,打上点。

手动采集都有哪些优点和缺点呢?

首先是它兼容性强,业务同学知道在这个业务场景下首屏结束点在哪里,可以随情况变动。其次是去中心化,各个业务负责自己的打点代码,有问题时业务同学去排查即可,假如一条业务出现问题,并不会影响其他业务。

缺点方面,手动采集会和业务代码严重耦合,如果首屏采集逻辑调整,业务代码也需要修改;还有,它的覆盖率不足,因为要手动采集,业务一旦忙起来,性能优化方案就会延迟排后。

最后,手动采集的统计结果并不精确,因为依赖于人,每个人对首屏的理解有偏差,经常打错或者忘记打点。

自动化采集优势及办法

接下来我们看自动化采集。获取首屏时间,目前业界还是以自动化采集为主。所谓自动化采集,即引入一段通用的代码来做首屏时间自动化采集,引入过程中,除了必要的配置不需要做其他事情

自动化采集的好处是独立性更强,接入过程更自动化。具体的自动化采集代码,可以由一个公共团队来开发,试点后,推广到各个业务团队。而且统计结果更标准化,同一段统计代码,标准更统一,业务侧同学也更认可这个统计结果。

当然,它也有缺点,最明显的是,有些个性化需求无法满足,毕竟在工作中,总会有一些特殊业务场景。所以,采用自动化采集方案必须做一些取舍。

既然是自动化采集,具体怎么采集呢?都有哪些办法?

首屏指标自动化采集,需要考虑是服务端模板业务,还是单页面(SPA)应用开发业务,业务场景不同,对应的采集方法也不同。下面我来分别介绍下。

服务端模板业务下的采集办法

提到服务端模板业务,很多人可能会问,现在不都是 Vue 和 React 这些单页面应用的天下了吗?其实在一些 B 端业务的公司用的还是服务端模板,如 Velocity、Smarty 等。另外大名鼎鼎的 SSR 用的也是服务端模板。

这些业务后端比较重,前端偏配合,出于效率方面的考虑,前后端并没有解耦。因此,公司内部研发同学既做前端又做后端,这时候如果使用现在流行的 Vue/React,无疑会增加学习成本。

那服务端模板项目的加载流程是怎样的呢?

大致流程是这样的:HTTP 请求 → HTML 文档加载解析完成 → 加载样式和脚本文件 → 完成页面渲染。

其中,HTML 文档加载解析完成的时间点,就是首屏时间点,而要采集这个首屏时间,可以用浏览器提供的 DOMContentLoaded 接口来实现。

我们来直观看一下什么是 DOMContentLoaded。打开 Chrome 浏览器调试工具,进入 Network 选项,重新加载网页,我们就会得到这么一张图。

04 | 指标采集:首屏时间指标采集具体办法 - 图1

DOMContentLoaded 示意图

右侧中间竖向的一条蓝线,代表了 DOMContentLoaded 这个事件触发的时间,而下面的蓝色文字(DOMContentLoaded 1.02s),代表 HTML 元素加载解析完成用了 1.02 秒。根据服务端模板项目加载流程,我们就知道这个时间就是首屏时间。

那么,DOMContentLoaded 时间具体的采集思路是怎样的呢?

当页面中的 HTML 元素被加载和解析完成(不需要等待样式表、图片和一些脚本的加载过程),DOMContentLoaded 事件触发。此时我们记录下当前时间 domContentLoadedEventEnd,再减去页面初始进入的时间 fetchStart,就是 DOMContentLoaded 的时间,也就是我们要采集的首屏时间。
即首屏时间 = DOMContentLoaded 时间 = domContentLoadedEventEnd-fetchStart。

那么,这种采集方法可以照搬到单页面应用下吗?答案是不可以。

单页面(SPA)应用业务下的采集办法

SPA 页面首屏时间采集会有什么不同?如果也使用 Performance API 会有什么问题?

我举个例子,在 2018 年 6 月的 GMTC 大会上,阿里云曾分享了他们的一个首屏指标采集结果:

使用 Performance API 接口采集的首屏时间是 1106ms
实际的首屏时间是 1976ms

为什么偏差如此大呢?

原来在 Vue 页面中,整体加载流程是这样的。

用户请求一个页面时,页面会先加载 index.html,加载完成后,就会触发 DOMContentLoaded 和 load。而这个时候,页面展示的只是个空白页。此时根本不算真正意义的首屏。接下来,页面会加载相关脚本资源并通过 axios 异步请求数据,使用数据渲染页面主题部分,这个时候首屏才渲染完成。

正是这个原因造成了用 Performance 接口取得的时间是 1106ms,实际时间则是 1976ms,差距如此之大。可以说,SPA 的流行让 Performance 接口失去了原来的意义。那么,这种情况下怎么采集首屏指标呢?可以使用 MutationObserver 采集首屏时间。

SPA 页面因为无法基于 DOMContentLoaded 做首屏指标采集,最初我们想过使用技术栈的生命周期来解决这个问题。

比如,我们以 Vue 为例,记录首屏各个组件 mounted 的时间,最终在 onload 时,统计出最后一个组件 mounted 的时间,做为首屏时间。但很快,我就发现这个方案存在以下问题。

  1. 如果一个首屏页面的内容没有被组件化,那么首屏时间无法被统计到,除非各个业务都定一套组件标准,首屏内容必须封装成组件。
  2. 前面也提过 onload 的时间并非最终时间,可能 onload 时,首屏还没加载完。
  3. 没有考虑首屏是张图片的情况,在这种情况,首屏虽然加载完成了,可是图片是异步的,图片并没有加载,试想你会在看不到商品图片的情况下,直接下单吗?

当时我们就想,如果能在首屏渲染过程中,把各个资源的加载时间记录到日志中,后续再通过分析,确定某一个资源加载完的时间,就是首屏时间。而 MutationObserver 恰恰可以做到这些。

MutationObserver 是什么意思呢?请看 MDN 上关于它的定义:

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 等框架层。

最后,根据前面的得分,计算元素的分数变化率,获取变化率最大点对应的分数。然后找到该分数对应的时间,即为首屏时间。

分数部分核心计算逻辑是递归遍历元素,将一些无用的标签排除,如果元素超过可视范围返回 0 分,每一层增加 0.5 的权重,具体请看下面代码示例。

  1. function CScor(el, tiers, parentScore) {
  2. let score = 0;
  3. const tagName = el.tagName;
  4. if ("SCRIPT" !== tagName && "STYLE" !== tagName && "META" !== tagName && "HEAD" !== tagName) {
  5. const childrenLen = el.children ? el.children.length : 0;
  6. if (childrenLen > 0) for (let childs = el.children, len = childrenLen - 1; len >= 0; len--) {
  7. score += calculateScore(childs[len], tiers + 1, score > 0);
  8. }
  9. if (score <= 0 && !parentScore) {
  10. if (!(el.getBoundingClientRect && el.getBoundingClientRect().top < WH)) return 0;
  11. }
  12. score += 1 + .5 * tiers;
  13. }
  14. return score;
  15. }

变化率部分核心计算逻辑是获取 DOM 变化最大时对应的时间,代码如下所示。

  1. calFinallScore() {
  2. try {
  3. if (this.sendMark) return;
  4. const time = Date.now() - performance.timing.fetchStart;
  5. var isCheckFmp = time > 30000 || SCORE_ITEMS && SCORE_ITEMS.length > 4 && time - (SCORE_ITEMS && SCORE_ITEMS.length && SCORE_ITEMS[SCORE_ITEMS.length - 1].t || 0) > 2 * CHECK_INTERVAL || (SCORE_ITEMS.length > 10 && window.performance.timing.loadEventEnd !== 0 && SCORE_ITEMS[SCORE_ITEMS.length - 1].score === SCORE_ITEMS[SCORE_ITEMS.length - 9].score);
  6. if (this.observer && isCheckFmp) {
  7. this.observer.disconnect();
  8. window.SCORE_ITEMS_CHART = JSON.parse(JSON.stringify(SCORE_ITEMS));
  9. let fmps = getFmp(SCORE_ITEMS);
  10. let record = null
  11. for (let o = 1; o < fmps.length; o++) {
  12. if (fmps[o].t >= fmps[o - 1].t) {
  13. let l = fmps[o].score - fmps[o - 1].score;
  14. (!record || record.rate <= l) && (record = {
  15. t: fmps[o].t,
  16. rate: l
  17. });
  18. }
  19. }
  20. this.fmp = record && record.t || 30001;
  21. try {
  22. this.checkImgs(document.body)
  23. let max = Math.max(...this.imgs.map(element => {
  24. if(/^(\/\/)/.test(element)) element = 'https:' + element;
  25. try {
  26. return performance.getEntriesByName(element)[0].responseEnd || 0
  27. } catch (error) {
  28. return 0
  29. }
  30. }))
  31. record && record.t > 0 && record.t < 36e5 ? this.setPerformance({
  32. fmpImg: parseInt(Math.max(record.t , max))
  33. }) : this.setPerformance({});
  34. } catch (error) {
  35. this.setPerformance({});
  36. }
  37. } else {
  38. setTimeout(() => {
  39. this.calFinallScore();
  40. }, CHECK_INTERVAL);
  41. }
  42. } catch (error) {
  43. }
  44. }

这个就是首屏计算的部分流程。

看完前面的流程,不知道你有没有这样的疑问:如果页面里包含图片,使用上面的首屏指标采集方案,结果准确吗?

结论是:不准确。上述计算逻辑主要是针对 DOM 元素来做的,图片加载过程是异步,图片容器(图片的 DOM 元素)和内容的加载是分开的,当容器加载出来时,内容还没出来,一定要确保内容加载出来,才算首屏。

这就需要增加一些策略了,以下是包含图片页面的首屏计算 demo。

  1. <!doctype html><body><img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
  2. <img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
  3. <style type=text/css>
  4. background-image:url('https://www.baidu.com/img/dong_8f1d47bcb77d74a1e029d8cbb3b33854.gif);
  5. </style>
  6. </body>
  7. <html>
  8. <script type="text/javascript">
  9. (() => {
  10. const imgs = []
  11. const getImageDomSrc = {
  12. _getImgSrcFromBgImg: function (bgImg) {
  13. var imgSrc;
  14. var matches = bgImg.match(/url\(.*?\)/g);
  15. if (matches && matches.length) {
  16. var urlStr = matches[matches.length - 1];
  17. var innerUrl = urlStr.replace(/^url\([\'\"]?/, '').replace(/[\'\"]?\)$/, '');
  18. if (((/^http/.test(innerUrl) || /^\/\//.test(innerUrl)))) {
  19. imgSrc = innerUrl;
  20. }
  21. }
  22. return imgSrc;
  23. },
  24. getImgSrcFromDom: function (dom, imgFilter) {
  25. if (!(dom.getBoundingClientRect && dom.getBoundingClientRect().top < window.innerHeight))
  26. return false;
  27. imgFilter = [/(\.)(png|jpg|jpeg|gif|webp|ico|bmp|tiff|svg)/i]
  28. var src;
  29. if (dom.nodeName.toUpperCase() == 'IMG') {
  30. src = dom.getAttribute('src');
  31. } else {
  32. var computedStyle = window.getComputedStyle(dom);
  33. var bgImg = computedStyle.getPropertyValue('background-image') || computedStyle.getPropertyValue('background');
  34. var tempSrc = this._getImgSrcFromBgImg(bgImg, imgFilter);
  35. if (tempSrc && this._isImg(tempSrc, imgFilter)) {
  36. src = tempSrc;
  37. }
  38. }
  39. return src;
  40. },
  41. _isImg: function (src, imgFilter) {
  42. for (var i = 0, len = imgFilter.length; i < len; i++) {
  43. if (imgFilter[i].test(src)) {
  44. return true;
  45. }
  46. }
  47. return false;
  48. },
  49. traverse(e) {
  50. var _this = this
  51. , tName = e.tagName;
  52. if ("SCRIPT" !== tName && "STYLE" !== tName && "META" !== tName && "HEAD" !== tName) {
  53. var el = this.getImgSrcFromDom(e)
  54. if (el && !imgs.includes(el))
  55. imgs.push(el)
  56. var len = e.children ? e.children.length : 0;
  57. if (len > 0)
  58. for (var child = e.children, _len = len - 1; _len >= 0; _len--)
  59. _this.traverse(child[_len]);
  60. }
  61. }
  62. }
  63. getImageDomSrc.traverse(document.body);
  64. window.onload=function(){
  65. var max = Math.max(...imgs.map(element => {
  66. if (/^(\/\/)/.test(element))
  67. element = 'https:' + element;
  68. try {
  69. return performance.getEntriesByName(element)[0].responseEnd || 0
  70. } catch (error) {
  71. return 0
  72. }
  73. }
  74. ))
  75. console.log(max);
  76. }
  77. }
  78. )()
  79. </script>

它的计算逻辑是这样的。

首先,获取页面所有的图片路径。在这里,图片类型分两种,一种是带 IMG 标签的,一种是带 DIV 标签的。前者可以直接通过 src 值得到图片路径,后者可以使用 window.getComputedStyle(dom) 方式获取它的样式集合。

接下来,通过正则获取图片的路径即可。

然后通过 performance.getEntriesByName(element)[0].responseEnd 的方式获取到对应图片路径的下载时间,最后与使用 MutationObserver 获得的 DOM 首屏时间相比较,哪个更长,哪个就是最终的首屏时间。

以上就是首屏采集的完整流程。

小结

04 | 指标采集:首屏时间指标采集具体办法 - 图2

这一讲我主要介绍了首屏指标采集相关的内容。不知道你看完后有没有这样的疑惑:这种性能采集方案靠谱吗?目前一线大厂有谁在使用这种采集方案?采集过程中会不会有什么坑?

先说靠不靠谱,目前来说,这是市面中最好的首屏指标采集方案,它兼容了单页面应用和服务端模板的页面。我们反复做了几个月的数据实验,并借助它完成了一个全公司的性能优化项目,用实验和实践结果证明这种方案的靠谱程度。

第二个问题,一线大厂里,阿里云、淘宝、阿里飞猪、得到 App、微店等公司都广泛在使用这个方案。

最后一个问题,首屏指标采集中会不会有坑。实践中确实有不少的坑。比如,一个单页面应用,我们需要采集它的首屏时间,当我们采集首页的首屏指标时,用户恰好输入了一些东西导致页面跳转到了搜索结果页。此时首屏采集脚本继续在执行,那最终统计的就是搜索结果页的首屏数据而不是首页的

类似这种问题,你想过怎么解决吗?欢迎在评论区和我留言进行交流。

上面就是首屏指标采集和优化手段相关的内容,接下来看看其他的指标如何采集。

源码地址:https://github.com/lagoueduCol/WebPerformanceOptimization-xifeng