前端性能优化方法与实战 - 前 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 选项,重新加载网页,我们就会得到这么一张图。
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 的时间,做为首屏时间。但很快,我就发现这个方案存在以下问题。
- 如果一个首屏页面的内容没有被组件化,那么首屏时间无法被统计到,除非各个业务都定一套组件标准,首屏内容必须封装成组件。
- 前面也提过 onload 的时间并非最终时间,可能 onload 时,首屏还没加载完。
- 没有考虑首屏是张图片的情况,在这种情况,首屏虽然加载完成了,可是图片是异步的,图片并没有加载,试想你会在看不到商品图片的情况下,直接下单吗?
当时我们就想,如果能在首屏渲染过程中,把各个资源的加载时间记录到日志中,后续再通过分析,确定某一个资源加载完的时间,就是首屏时间。而 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 的权重,具体请看下面代码示例。
function CScor(el, tiers, parentScore) {
let score = 0;
const tagName = el.tagName;
if ("SCRIPT" !== tagName && "STYLE" !== tagName && "META" !== tagName && "HEAD" !== tagName) {
const childrenLen = el.children ? el.children.length : 0;
if (childrenLen > 0) for (let childs = el.children, len = childrenLen - 1; len >= 0; len--) {
score += calculateScore(childs[len], tiers + 1, score > 0);
}
if (score <= 0 && !parentScore) {
if (!(el.getBoundingClientRect && el.getBoundingClientRect().top < WH)) return 0;
}
score += 1 + .5 * tiers;
}
return score;
}
变化率部分核心计算逻辑是获取 DOM 变化最大时对应的时间,代码如下所示。
calFinallScore() {
try {
if (this.sendMark) return;
const time = Date.now() - performance.timing.fetchStart;
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);
if (this.observer && isCheckFmp) {
this.observer.disconnect();
window.SCORE_ITEMS_CHART = JSON.parse(JSON.stringify(SCORE_ITEMS));
let fmps = getFmp(SCORE_ITEMS);
let record = null
for (let o = 1; o < fmps.length; o++) {
if (fmps[o].t >= fmps[o - 1].t) {
let l = fmps[o].score - fmps[o - 1].score;
(!record || record.rate <= l) && (record = {
t: fmps[o].t,
rate: l
});
}
}
this.fmp = record && record.t || 30001;
try {
this.checkImgs(document.body)
let max = Math.max(...this.imgs.map(element => {
if(/^(\/\/)/.test(element)) element = 'https:' + element;
try {
return performance.getEntriesByName(element)[0].responseEnd || 0
} catch (error) {
return 0
}
}))
record && record.t > 0 && record.t < 36e5 ? this.setPerformance({
fmpImg: parseInt(Math.max(record.t , max))
}) : this.setPerformance({});
} catch (error) {
this.setPerformance({});
}
} else {
setTimeout(() => {
this.calFinallScore();
}, CHECK_INTERVAL);
}
} catch (error) {
}
}
这个就是首屏计算的部分流程。
看完前面的流程,不知道你有没有这样的疑问:如果页面里包含图片,使用上面的首屏指标采集方案,结果准确吗?
结论是:不准确。上述计算逻辑主要是针对 DOM 元素来做的,图片加载过程是异步,图片容器(图片的 DOM 元素)和内容的加载是分开的,当容器加载出来时,内容还没出来,一定要确保内容加载出来,才算首屏。
这就需要增加一些策略了,以下是包含图片页面的首屏计算 demo。
<!doctype html><body><img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
<img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
<style type=text/css>
background-image:url('https://www.baidu.com/img/dong_8f1d47bcb77d74a1e029d8cbb3b33854.gif);
</style>
</body>
<html>
<script type="text/javascript">
(() => {
const imgs = []
const getImageDomSrc = {
_getImgSrcFromBgImg: function (bgImg) {
var imgSrc;
var matches = bgImg.match(/url\(.*?\)/g);
if (matches && matches.length) {
var urlStr = matches[matches.length - 1];
var innerUrl = urlStr.replace(/^url\([\'\"]?/, '').replace(/[\'\"]?\)$/, '');
if (((/^http/.test(innerUrl) || /^\/\//.test(innerUrl)))) {
imgSrc = innerUrl;
}
}
return imgSrc;
},
getImgSrcFromDom: function (dom, imgFilter) {
if (!(dom.getBoundingClientRect && dom.getBoundingClientRect().top < window.innerHeight))
return false;
imgFilter = [/(\.)(png|jpg|jpeg|gif|webp|ico|bmp|tiff|svg)/i]
var src;
if (dom.nodeName.toUpperCase() == 'IMG') {
src = dom.getAttribute('src');
} else {
var computedStyle = window.getComputedStyle(dom);
var bgImg = computedStyle.getPropertyValue('background-image') || computedStyle.getPropertyValue('background');
var tempSrc = this._getImgSrcFromBgImg(bgImg, imgFilter);
if (tempSrc && this._isImg(tempSrc, imgFilter)) {
src = tempSrc;
}
}
return src;
},
_isImg: function (src, imgFilter) {
for (var i = 0, len = imgFilter.length; i < len; i++) {
if (imgFilter[i].test(src)) {
return true;
}
}
return false;
},
traverse(e) {
var _this = this
, tName = e.tagName;
if ("SCRIPT" !== tName && "STYLE" !== tName && "META" !== tName && "HEAD" !== tName) {
var el = this.getImgSrcFromDom(e)
if (el && !imgs.includes(el))
imgs.push(el)
var len = e.children ? e.children.length : 0;
if (len > 0)
for (var child = e.children, _len = len - 1; _len >= 0; _len--)
_this.traverse(child[_len]);
}
}
}
getImageDomSrc.traverse(document.body);
window.onload=function(){
var max = Math.max(...imgs.map(element => {
if (/^(\/\/)/.test(element))
element = 'https:' + element;
try {
return performance.getEntriesByName(element)[0].responseEnd || 0
} catch (error) {
return 0
}
}
))
console.log(max);
}
}
)()
</script>
它的计算逻辑是这样的。
首先,获取页面所有的图片路径。在这里,图片类型分两种,一种是带 IMG 标签的,一种是带 DIV 标签的。前者可以直接通过 src 值得到图片路径,后者可以使用 window.getComputedStyle(dom) 方式获取它的样式集合。
接下来,通过正则获取图片的路径即可。
然后通过 performance.getEntriesByName(element)[0].responseEnd 的方式获取到对应图片路径的下载时间,最后与使用 MutationObserver 获得的 DOM 首屏时间相比较,哪个更长,哪个就是最终的首屏时间。
以上就是首屏采集的完整流程。
小结
这一讲我主要介绍了首屏指标采集相关的内容。不知道你看完后有没有这样的疑惑:这种性能采集方案靠谱吗?目前一线大厂有谁在使用这种采集方案?采集过程中会不会有什么坑?
先说靠不靠谱,目前来说,这是市面中最好的首屏指标采集方案,它兼容了单页面应用和服务端模板的页面。我们反复做了几个月的数据实验,并借助它完成了一个全公司的性能优化项目,用实验和实践结果证明这种方案的靠谱程度。
第二个问题,一线大厂里,阿里云、淘宝、阿里飞猪、得到 App、微店等公司都广泛在使用这个方案。
最后一个问题,首屏指标采集中会不会有坑。实践中确实有不少的坑。比如,一个单页面应用,我们需要采集它的首屏时间,当我们采集首页的首屏指标时,用户恰好输入了一些东西导致页面跳转到了搜索结果页。此时首屏采集脚本继续在执行,那最终统计的就是搜索结果页的首屏数据而不是首页的
类似这种问题,你想过怎么解决吗?欢迎在评论区和我留言进行交流。
上面就是首屏指标采集和优化手段相关的内容,接下来看看其他的指标如何采集。
源码地址:https://github.com/lagoueduCol/WebPerformanceOptimization-xifeng