作者:京东科技 孙凯
相信很多前端开发者在做项目时同时也都做过页面性能优化,这不单是前端的必备职业技能,也是考验一个前端基础是否扎实的考点,而性能指标也通常是每一个开发者的绩效之一。尤其马上接近年关,页面白屏时间是否过长、首屏加载速度是否达标、动画是否能流畅运行,诸如此类关于性能更具体的指标和感受,很可能也是决定着年底你能拿多少年终奖回家过年的晴雨表。
关于性能优化,我们一般从以下四个方面考虑:
开发时性能优化
编译时性能优化
加载时性能优化
运行时性能优化
而本文将从第三个方面展开,讲一讲哪些因素将影响到页面加载总时长,谈到总时长,那总是避免不了要谈及window.onload
,这不但是本文的重点,也是常见页面性能监控工具中必要的 API 之一,如果你对自己页面加载的总时长不满意,欢迎读完本文后在评论区交流。
这个挂载到window
上的方法,是我刚接触前端时就掌握的技能,我记得尤为深刻,当时老师说,“对于初学者,只要在这个方法里写逻辑,一定没错儿,它是整个文档加载完毕后执行的生命周期函数”,于是从那之后,几乎所有的练习 demo,我都写在这里,也确实没出过错。
在MDN
上,关于onload
的解释是这样的:load 事件在整个页面及所有依赖资源如样式表和图片都已完成加载时触发。它与DOMContentLoaded
不同,后者只要页面 DOM 加载完成就触发,无需等待依赖资源的加载。该事件不可取消,也不会冒泡。
后来随着前端知识的不断扩充,这个方法后来因为有了 “更先进” 的DOMContentLoaded
,在我的代码里而逐渐被替代了,目前除了一些极其特殊的情况,否则我几乎很难用到window.onload
这个 API,直到认识到它影响到页面加载的整体时长指标,我才又一次拾起来它。
本章节主要会通过几个常用的业务场景展开描述,但是有个前提,就是如何准确记录各种类型资源加载耗时对页面整体加载的影响,为此,有必要先介绍一下前提。
为了准确描述资源加载耗时,我在本地环境启动了一个用于资源请求的node
服务,所有的资源都会从这个服务中获取,之所以不用远程服务器资源的有主要原因是,使用本地服务的资源可以在访问的资源链接中设置延迟时间,如访问脚本资源http://localhost:3010/index.js?delay=300
,因链接中存在delay=300
,即可使资源在 300 毫秒后返回,这样即可准确控制每个资源加载的时间。
以下是node
资源请求服务延迟相关代码,仅仅是一个中间件:
const express = require("express")
const app = express()
app.use(function (req, res, next) {
Number(req.query.delay) > 0
? setTimeout(next, req.query.delay)
: next()
})
场景一: 使用 async 异步加载脚本场景对 onload 的影响
示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 请求时长为1秒的js资源 -->
<script src="http://localhost:3010/index.js?delay=1000" async></script>
</head>
<body>
</body>
</html>
浏览器表现如下:
通过上图可以看到,瀑布图中深蓝色竖线表示触发了DOMContentLoaded
事件,而红色竖线表示触发了window.onload
事件(下文中无特殊情况,不会再进行特殊标识),由图可以得知使用了 async 属性进行脚本的异步加载,仍会影响页面加载总体时长。
场景二:使用 defer 异步加载脚本场景对 onload 的影响
示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 请求时长为1秒的js资源 -->
<script src="http://localhost:3010/index.js?delay=1000" defer></script>
</head>
<body>
</body>
</html>
浏览器表现如下:
由图可以得知使用了 defer 属性进行脚本的异步加载,除了正常的在DOMContentLoaded
之后触发脚本执行,也影响页面加载总体时长。
场景三:异步脚本中再次加载脚本,也就是常见的动态加载脚本、样式资源的情况
html 代码保持不变,index.js
内示例代码:
const script = document.createElement('script')
// 请求时长为0.6秒的js资源
script.src = 'http://localhost:3010/index2.js?delay=600'
script.onload = () => {
console.log('js 2 异步加载完毕')
}
document.body.appendChild(script)
结果如下:
从瀑布图可以看出,资源的连续加载,导致了 onload 事件整体延后了,这也是我们再页面中非常常见的一种操作,通常懒加载一些不重要或者首屏外的资源,其实这样也会导致页面整体指标的下降。
不过值得强调的一点是,这里有个有意思的地方,如果我们把上述代码进行改造,删除最后一行的document.body.appendChild(script)
,发现 index2 的资源请求并没有发出,也就是说,脚本元素不向页面中插入,脚本的请求是不会发出的,但是也会有反例,这个我们下面再说。
在本示例中,后来我又把脚本请求换成了 css 请求,结果是一致的。
场景四:图片的懒加载/预加载
html 保持不变,index.js 用于加载图片,内容如下:
const img = document.createElement('img')
// 请求时长为0.5秒的图片资源
img.src = 'http://localhost:3010/index.png?delay=500'
document.body.appendChild(img)
结果示意:
表现是与场景三一样的,这个不再多说,但是有意思的来了,不一样的是,经过测试发现,哪怕删除最后一行代码:document.body.appendChild(img)
,不向页面中插入元素,图片也会发出请求,也同样延长了页面加载时长,所以部分同学就要注意了,这是一把双刃剑:当你真的需要懒加载图片时,可以少写最后一行插入元素的代码了,但是如果大量的图片加载请求发出,哪怕不向页面插入图片,也真的会拖慢页面的时长。
趁着这个场景,再多说一句,一些埋点数据的上报,也正是借着图片有不需要插入 dom 即可发送请求的特性,实现成功上传的。
场景五:普通接口请求
html 保持不变,index.js 内容如下:
// 请求时长为500毫秒的请求接口
fetch('http://localhost:3010/api?delay=500')
结果如下图:
可以发现普通接口请求的发出,并不会影响页面加载,但是我们再把场景弄复杂一些,见场景六。
场景六:同时加载样式、脚本,脚本加载完成后,内部 http 接口请求,等请求结果返回后,再发出图片请求或修改 dom,这也是更贴近生产环境的真实场景
html 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 请求时长为1.2秒的css -->
<link rel="stylesheet" href="http://localhost:3010/index.css?delay=1200">
<!-- 请求时长为0.4秒的js -->
<script src="http://localhost:3010/index.js?delay=400" async></script>
</head>
<body>
</body>
</html>
index.js 代码:
async function getImage () {
// 请求时长为0.5秒的接口请求
await fetch('http://localhost:3010/api?delay=500')
const img = document.createElement('img')
// 请求时长为0.5秒的图片资源
img.src = 'http://localhost:3010/index.png?delay=500'
document.body.appendChild(img)
}
getImage()
结果图如下:
如图所示,结合场景五记的结果,虽然普通的 api 请求并不会影响页面加载时长,但是因为 api 请求过后,重新请求了图片资源(或大量操作 dom),依然会导致页面加载时间变长。这也是我们日常开发中最常见的场景,页面加载了 js,js 发出网络请求,用于获取页面渲染数据,页面渲染时加载图片或进行 dom 操作。
场景七:页面多媒体资源的加载
示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
</head>
<body>
<video src="http://localhost:3010/video.mp4?delay=500" controls></video>
</body>
</html>
结果如图:
对于视频这种多媒体资源的加载比较有意思,video 标签对于资源的加载是默认开启 preload 的,所以资源会默认进行网络请求(如需关闭,要把 preload 设置为 none ),可以看到红色竖线基本处于图中绿色条和蓝色条中间(实际上更偏右一些),图片绿色部分代表资源等待时长,蓝色部分代表资源真正的加载时长,且蓝色加载条在 onload 的竖线右侧,这说明多媒体的资源确实影响了 onload 时长,但是又没完全影响,因为设置了 500ms 的延迟返回资源,所以 onload 也被延迟了 500ms 左右,但一旦视频真正开始下载,这段时长已经不记录在 onload 的时长中了。
其实这种行为也算合理,毕竟多媒体资源通常很大,占用的带宽也多,如果一直延迟 onload,意味着很多依赖 onload 的事件都无法及时触发。
接下来我们把这种情况再复杂一些,贴近实际的生产场景,通常 video 元素是包含封面图 poster 属性的,我们设置一张延迟 1 秒的封面图,看看会发生什么,结果如下:
不出意外,果然封面图影响了整体的加载时长,魔鬼都在细节中,封面图也需要注意优化压缩。
场景八:异步脚本和样式资源一同请求
示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 请求时长为1秒的css -->
<link rel="stylesheet" href="http://localhost:3010/index.css?delay=1000">
<!-- 请求时长为0.5秒的js -->
<script src="http://localhost:3010/index.js?delay=500" async></script>
</head>
<body>
</body>
</html>
浏览器表现如下:
可以看出 css 资源虽然没有阻塞脚本的加载,但是却延迟了整体页面加载时长,其中原因是 css 资源的加载会影响 render tree 的生成,导致页面迟迟不能完成渲染。
如果尝试把 async 换成 defer,或者干脆使用同步的方式加载脚本,结果也是一样,因结果相同,本处不再举例。
场景九:样式资源先请求,再执行内联脚本逻辑,最后加载异步脚本
我们把场景八的代码做一个改造,在样式标签和异步脚本标签之间,加上一个只包含空格的内联脚本,让我们看看会发生什么,代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<script>
console.log('页面js 开始执行')
</script>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<!-- 请求时长为1秒的css -->
<link rel="stylesheet" href="http://localhost:3010/index.css?delay=2000">
<!-- 此标签仅有一个空格 -->
<script> </script>
<!-- 请求时长为0.5秒的js -->
<script src="http://localhost:3010/index.js?delay=500" async></script>
</head>
<body>
</body>
</html>
index.js 中的内容如下:
console.log("脚本 js 开始执行");
结果如下,这是一张 GIF,加载可能有点慢:
这个结果非常有意思,他到底发生了什么呢?
1. 脚本请求是 0.5 秒的延迟,样式请求是 2 秒
2. 脚本资源是 async 的请求,异步发出,应该什么时候加载完什么时候执行
3. 但是图中的结果却是等待样式资源加载完毕后才执行
答案就在那个仅有一个空格的脚本标签中,经反复测试,如果把标签换成注释,也会出现一样的现象,如果是一个完全空的标签,或者根本没有这个脚本标签,那下方的 index.js 通过 async 异步加载,并不会违反直觉,加载完毕后直接执行了,所以这是为什么呢?
这其实是因为样式资源下方的 script 虽然仅有一个空格,但是被浏览器认为了它内部可能是包含逻辑,一定概率会存在样式的修改、更新 dom 结构等操作,因为样式资源没有加载完(被延迟了 2 秒),导致同步 js(只有一个空格的脚本)的执行被阻塞了,众所周知页面的渲染和运行是单线程的,既然前面已经有了一个未执行完成的 js,所以也导致了后面异步加载的 js 需要在队列中等待。这也就是为什么 async 虽然异步加载了,但是没有在加载后立即执行的原因。
场景十:字体资源的加载
示例代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>test</title>
<style>
@font-face {
font-family: font-custom;
src: url('http://localhost:3010/font.ttf?delay=500');
}
body {
font-family: font-custom;
}
</style>
</head>
<body></body>
</html>
结果如下:
可以看到,此情况下字体的加载是对 onload 有影响的,然后我们又测试了一下只声明字体、不使用的情况,也就是删除上面代码中 body 设置的字体,发现这种情况下,字体是不会发出请求的,仅仅是造成了代码的冗余。
前面列举了大量的案例,接下来我们做个总结,实质性影响 onload 其实就是几个方面。
图片资源的影响毋庸置疑,无论是在页面中直接加载,还是通过 js 懒加载,只要加载过程是在 onload 之前,都会导致页面 onload 时长增加。
多媒体资源的等待时长会被记入 onload,但是实际加载过程不会。
字体资源的加载会影响 onload。
网络接口请求,不会影响 onload,但需要注意的是接口返回后,如果此时页面还未 onload,又进行了图片或者 dom 操作,是会导致 onload 延后的。
样式不会影响脚本的加载和解析,只会阻塞脚本的执行。
异步脚本请求不会影响页面解析,但是脚本的执行同样影响 onload。
图片或其他资源的预加载可以通过 preload 或 prefetch 请求,这两种方式都不会影响 onload 时长。
一定注意压缩图片,页面中图片的加载速度可能对整体时长有决定性影响。
尽量不要做串行请求,没有依赖关系的情况下,推荐并行。
中文字体包非常大,可以使用字蛛压缩、或用图片代替。
静态资源上 cdn 很重要,压缩也很重要。
删除你认为可有可无的代码,没准哪一行代码就会影响加载速度,并且可能很难排查。
视频资源如果在首屏以外,不要开启预加载,合理使用视频的 preload 属性。
async 和 defer 记得用,很好用。
非必要的内容,可以在 onload 之后执行,是时候重新拾起来这个 api 了。