我来自机票BU,目前负责机票营销的业务开发,众所周知营销业务的普遍特点是:访问量很大。每次营销活动,对于不同角色的同学,关注的点也不一样。
运营/产品同学更为关注产品的转化、拉新、留存、用户行为。
普通用户更为关注产品的新特性、吸引力、性价比、产品体验等。
开发同学更为关注页面性能和异常情况。
归纳上述诉求:
第一部分属于行为监控
的范畴,产品一般会让我们在关键的节点埋点,然后产品上线后,通过BI
拉数据来进行分析。我们能做的并不多。
第三部分,用户使用快照这里我们接入了组内其他同学开发的coffeebean
系统,可对用户的操作行为进行录制,在排查线上问题时能给予很大的帮助。页面报错(js error、promise 错误、自定义错误等)我们可在cdata
查看。
唯独第二部分,用户体验这部分的数据(对应性能监控
)对我们影响很大,也是我们着力在提升的。
本文就分享一下我们在用户体验优化和页面性能提升上做的一系列改造工作,希望能给看文章的你一些启发。
首先来看一下关于性能监控
的一些基础知识。
上面这张图梳理了性能监控需要关注的几个核心指标。
衡量一个Web
页面的体验和质量一直有非常多的工具和指标。
如果我们全面的了解了 Web 性能标准,就能知道性能指标是如何定义的、性能指标的计算方式以及有哪些浏览器支持的优化策略。基于这些了解,我们才能说如何去排查性能问题、如何去优化性能以及如何去采集这些性能数据来做性能监控。
上图中的指标都会基于下面这张图:
我估计你也会有同样的疑惑:怎么这么多指标。。。
每次我们去关注这些指标的时候都会非常痛苦,因为这些指标真的是又多又难理解,测量这些指标的工具也非常多。但毕竟我们不是性能工程师
,我们只是前端工程师
。😂
但不用着急,在之前发布的Chrome 83
中,Google
认为不用每个人都成为网站性能方面的专家,大家只需要关注那些最核心最有价值的指标即可,于是提出了 Core Web Vitals
,它是 Web Vitals
的子集。那Web Vitals
又是什么呢?
什么是 Web Vitals
,Google
给的定义是一个良好网站的基本指标 (Essential metrics for a healthy site
)。由于过去要衡量一个好的网站,需要使用的指标太多,于是推出了 Web Vitals
,也是为了简化学习的曲线,开发者只需要关注 Web Vitals
指标表现即可。
而在 Web Vitals
指标中,Core Web Vitals
是其中的核心,目前包含三个指标:
LCP
:(Largest Contentful Paint) 从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间(衡量网站初次载入速度)FID
:(First Input Delay) 首次输入延迟时间 (衡量网站互动顺畅程度)CLS
:(Cumulative Layout Shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数(衡量视觉稳定性)指标标准如下图:
在过去的设计中,常用 load
和 DOMContentLoaded
事件反映页面完成载入,但为了更精准地抓到页面完成渲染的持续时间,需要使用 FCP
指标。不过在 SPA
时代,页面常常一开始是先显示一个加载图标,此时,FCP
就很难反映出页面初次载入直到 Web 能够提供使用的那个时间点。
后来WICG
上孵化了一个新的指标 LCP
,可以简单清楚地以网页 Viewport
最大 Element
载入为基准点,衡量标准如下图所示,在 2.5
秒内加载完最大 Element
为良好的网页载入速度:
FID
指标用来衡量用户交互上的体验,即用户第一次输入后经过多久得到了响应,这也能很好的体现出网站的互动流畅程度。如图示,页面的FID
应该小于100ms
。
视觉稳定性这个比较好解释:你在访问一个web
页面的时候,有没有碰到阅读文章时页面突然滚动或者本应点击按钮却点到了别的区块:
出现这种情况的罪魁祸首通常是由于异步加载资源或将 DOM 元素动态添加到现有内容上方导致的,一般是图片尺寸未知或者动态调整自身大小的第三方广告或小部件。
如图示,为了更好的用户体验,CLS
分数应小于0.1
。
web-vitals
现在你可以使用标准的 Web API
在 JavaScript
中测量每个指标。Google
提供了一个 npm
包:web-vitals
,这个库提供了非常简单的 API
,测量每个指标就像调用一个普通函数一样简单:
import {getCLS, getFID, getLCP} from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify(metric);
// Use `navigator.sendBeacon()` if available, falling back to `fetch()`.
(navigator.sendBeacon && navigator.sendBeacon('/analytics', body)) ||
fetch('/analytics', {body, method: 'POST', keepalive: true});
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
白屏会在页面加载之前触发,在这段时间里,不会呈现任何内容和信息给用户。虽然背景色会很快完成绘制,但是实际的内容和交互可能要花很长的时间去加载,因此,白屏时间过长,会让用户认为我们的页面不能用或可用性差。
那白屏时间如何计算呢?
我们通常认为浏览器开始渲染 <body>
标签或者解析完 <head>
标签的时刻就是页面白屏结束的时间点。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>白屏</title>
<script type="text/javascript">
// 不兼容performance.timing 的浏览器,如IE8
window.pageStartTime = Date.now();
</script>
<!-- 页面 CSS 资源 -->
<link rel="stylesheet" href="common.css">
<link rel="stylesheet" href="page.css">
<script type="text/javascript">
// 白屏时间结束点
window.firstPaint = Date.now();
</script>
</head>
<body>
<!-- 页面内容 -->
</body>
因此白屏时间则可以这样计算出:
Performance API
时:firstPaint - performance.timing.navigationStart
;Performance API
时firstPaint - pageStartTime
;首屏这个概念目前来说没有一个官方的定义,一般来说都以约定俗成的说法为准即 从输入 URL 开始到第一屏(可视区域)的内容加载完毕的时间。根据业务场景的不同,也有不同的指标和规范。以我们目前的业务场景来看就是首屏最大的一张图片加载完成的时间
。
首屏时间计算主要是基于getBoundingClientRect
和MutationObserver
,通过观察在页面一段时间内DOM
变化的情况,然后通过判断是否在首屏显示进行数据过滤,找出最大一张图片的加载时间。
class FCP {
static details = [];
static ignoreEleList = ["script", "style", "link", "br"];
constructor() {}
static isEleInArray(target, arr) {
if (!target || target === document.documentElement) {
return false;
} else if (arr.indexOf(target) !== -1) {
return true;
} else {
return this.isEleInArray(target.parentElement, arr);
}
}
// 判断元素是否在首屏内
static isInFirstScreen(target) {
if (!target || !target.getBoundingClientRect) return false;
var rect = target.getBoundingClientRect(),
screenHeight = window.innerHeight,
screenWidth = window.innerWidth;
return (
rect.left >= 0 &&
rect.left < screenWidth &&
rect.top >= 0 &&
rect.top < screenHeight
);
}
static getFCP() {
return new Promise((resolve, reject) => {
// 5s之内先收集所有的dom变化,并以key(时间戳)、value(dom list)的结构存起来。
var observeDom = new MutationObserver((mutations) => {
if (!mutations || !mutations.forEach) return;
var detail = {
time: performance.now(),
roots: [],
};
mutations.forEach((mutation) => {
if (!mutation || !mutation.addedNodes || !mutation.addedNodes.forEach)
return;
mutation.addedNodes.forEach((ele) => {
if (
// nodeType = 1 代表元素节点
ele.nodeType === 1 &&
this.ignoreEleList.indexOf(ele.nodeName.toLocaleLowerCase()) ===
-1
) {
if (!this.isEleInArray(ele, detail.roots)) {
detail.roots.push(ele);
}
}
});
});
if (detail.roots.length) {
this.details.push(detail);
}
});
observeDom.observe(document, {
childList: true,
subtree: true,
});
setTimeout(() => {
observeDom.disconnect();
resolve(this.details);
}, 5000);
}).then((details) => {
// 分析上面收集到的数据,返回最终的结果
var result;
details.forEach((detail) => {
for (var i = 0; i < detail.roots.length; i++) {
if (this.isInFirstScreen(detail.roots[i])) {
result = detail.time;
break;
}
}
});
// 遍历当前请求的图片中,如果有开始请求时间在首屏dom渲染期间的,则表明该图片是首屏渲染中的一部分,
// 所以dom渲染时间和图片返回时间中大的为首屏渲染时间
window.performance
.getEntriesByType("resource")
.forEach(function (resource) {
if (
resource.initiatorType === "img" &&
(resource.fetchStart < result || resource.startTime < result) &&
resource.responseEnd > result
) {
result = resource.responseEnd;
}
});
return result;
});
}
}
FPS
一般用来检测页面卡顿。通过如何监控网页的卡顿?,我们知道可以通过浏览器的requestAnimationFrame
来计算其值:
const next = window.requestAnimationFrame
? requestAnimationFrame
: (callback) => {
setTimeout(callback, 1000 / 60);
};
const frames = [];
export default function fps() {
let frame = 0;
let lastTime = performance.now();
function calculateFPS() {
frame++;
const now = performance.now();
if (lastTime + 1000 <= now) {
const fps = Math.round((frame * 1000) / (now - lastTime));
frames.push(fps);
frame = 0;
lastTime = now;
}
next(calculateFPS);
}
calculateFPS();
}
得到了FPS
,那么怎么通过它来监控页面的卡顿呢?
一般当连续出现三个低于 20 的FPS
时,就可断定页面出现了卡顿:
export function isBlocking(fpsList, below = 20, last = 3) {
let count = 0;
for (let i = 0; i < fpsList.length; i++) {
if (fpsList[i] && fpsList[i] < below) {
count++;
} else {
count = 0;
}
if (count >= last) return true;
}
return false;
}
接口请求耗时需要对 XMLHttpRequest
和 fetch
进行监听。
XMLHttpRequest
接口耗时可根据请求前后的时间差计算。
const originalProto = XMLHttpRequest.prototype;
const originalOpen = originalProto.open;
const originalSend = originalProto.send;
originalProto.open = function newOpen(...args) {
// ...省略
return originalOpen.call(this, args);
};
originalProto.send = function newSend(...args) {
// ...省略
this.addEventListener("readystatechange", function () {
if (this.readyState === 4) {
if (this.status >= 200 && this.status < 300) {
// ...省略
} else {
// ...省略
}
}
});
return originalSend.apply(this, args);
};
fetch
对于 fetch
,可以通过Object.defineProperty
来进行拦截。并根据返回数据中的的 ok 字段判断请求是否成功,如果为 true 则请求成功,否则失败:
const originalFetch = window.fetch;
Object.defineProperty(window, "fetch", {
configurable: true,
enumerable: true,
get() {
return (url: string, options: any = {}) => {
return originalFetch(url, options)
.then((res) => {
// ...
})
};
}
});
除了在接口请求成功/失败时上报耗时情况,也应分别添加自定义数据(request、response 信息),方便问题排查。
通过 PerformanceObserver
可以监听 resource
和 navigation
事件,如果浏览器不支持 PerformanceObserver
,还可以通过 performance.getEntriesByType(entryType)
来进行降级处理。
上面啰啰嗦嗦说了这么多,都是为了在业务中实践打基础。
话不多说,拿一个产线上已有的营销页面来跑跑分吧:
好家伙,只有 17 分,,,
结合上文提到的性能优化指标和评测给出的建议,我们得出几个可改进的点,
知道病因所在,下一步就是逐步去优化改进了。
上面也分析了,营销页面存在比较多的图片,但是都没有提前给到宽高,页面加载时会出现明显的偏移抖动效果,用户体验非常不好,进而严重拉低了 CLS 评分。
实现方案很简单,配置活动上传图片后就存储图片的宽高,在页面加载时获取对应图片宽高。为了防止图片变形,宽度取当前屏幕的宽度,高度则用当前屏幕宽度乘以原图片宽高比:
<ImageComponent
v-show="!loading"
style="display: block; width: 100%;"
:width="Width"
:height="Height"
:src="
layerInfo.topImgs && layerInfo.topImgs[0] && layerInfo.topImgs[0].imgUrl
"
/>
Width() {
return window.screen.width;
},
Height() {
const { topImgs = [] } = this.layerInfo;
const { height = 0, width = 0 } = topImgs[0] || {};
let imgHeght = 0;
if (width > 0 && height > 0) {
imgHeght = (height / width) * window.screen.width;
}
return imgHeght;
}
这个是因为图片 size 都比较大,很大程度上影响了渲染时长。
最开始是借鉴了NFES-ServiceWorker-webp。
但一番试用下来,发现nfes-serviceworker-webp
工具依赖于延迟加载 sw 文件,无法在图片加载的时机保证 sw 注册完成,对于二次缓存有较强的帮助,不适用与我们改善首次加载的场景。
既然这种方法不适合我们的业务场景,我们就自己做了调研,最终改为项目内部判断 webp 的支持性,并直接加载 webp 图片,以原图作为兜底展示。
首先,我们要检测客户端是否支持 webp
,这里参考了how_can_i_detect_browser_supportWebp_using_javascript
// check_webp_feature:
// 'feature' can be one of 'lossy', 'lossless', 'alpha' or 'animation'.
// 'callback(feature, result)' will be passed back the detection result (in an asynchronous way!)
function check_webp_feature(feature, callback) {
var kTestImages = {
lossy: "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA",
lossless: "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==",
alpha: "UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAARBxAR/Q9ERP8DAABWUDggGAAAABQBAJ0BKgEAAQAAAP4AAA3AAP7mtQAAAA==",
animation: "UklGRlIAAABXRUJQVlA4WAoAAAASAAAAAAAAAAAAQU5JTQYAAAD/////AABBTk1GJgAAAAAAAAAAAAAAAAAAAGQAAABWUDhMDQAAAC8AAAAQBxAREYiI/gcA"
};
var img = new Image();
img.onload = function () {
var result = (img.width > 0) && (img.height > 0);
callback(feature, result);
};
img.onerror = function () {
callback(feature, false);
};
img.src = "data:image/webp;base64," + kTestImages[feature];
}
这种方式检测地比较全面,可以检测客户端对多个 webp 压缩级别的支持度测试。
然后在页面加载时根据对 webp 支持程度来区分加载何种类型的图片:
this.check_webp_feature("lossless",(feature, result) => {
if(result) {
this.imageSrc = this.src + ".webp"
} else {
this.imageSrc = this.src
}
})
除了图片格式采用 webp 的外,我们还采用了图片懒加载的方式。
这里没有采用传统的方式来实现(性能考虑),而是采用IntersectionObserver
IntersectionObserver API
,可以自动"观察"元素是否可见,Chrome 51+
已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做交叉观察器
。
它的用法也非常简单。
var io = new IntersectionObserver(callback, option);
上面代码中,IntersectionObserver
是浏览器原生提供的构造函数,接受两个参数:callback
是可见性变化时的回调函数,option
是配置对象(该参数可选)。
构造函数的返回值是一个观察器实例。实例的observe
方法可以指定观察哪个 DOM
节点。
// 开始观察
io.observe(document.getElementById('container'));
// 停止观察
io.unobserve(element);
// 关闭观察器
io.disconnect();
上面代码中,observe
的参数是一个 DOM
节点对象。
如果要观察多个节点,就要多次调用这个方法。
io.observe(elementA);
io.observe(elementB);
了解了IntersectionObserver
的使用,我们就来看下基于IntersectionObserver
实现图片懒加载的思路:
对应实现就是:
const imgs = document.querySelectorAll('img') //获取所有待观察的目标元素
var options = {}
function lazyLoad(target) {
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entrie => {
if (entrie.isIntersecting) {
const img = entrie.target;
const src = img.getAttribute('data-src');
img.setAttribute('src', src)
observer.unobserve(img); // 停止监听已开始加载的图片
}
})
}, options);
observer.observe(target)
}
imgs.forEach(lazyLoad)
其实关于图片懒加载还有一种更简单的方式:img.loading=lazy
。但其实支持程度还不是特别好,所以我们就没采用这种方式。
这块对应的是白屏时间,也就是用户的等待时间,对用户体验来说至关重要的。针对这块我们也做了很多工作:
做完上面这些优化工作,我们对原来的页面又做了一次评测:
这个结果显然还不是我们最终想要的,不过做到现在,也算有提升了😂
后续会继续致力于页面性能优化,也有考虑在内的方案: