优化首屏体验——Lazy-Load初探
Lazy-Load是什么
Lazy-Load,翻译过来是“懒加载”。它是针对图片加载时机的优化:在一些图片量比较大的网站(比如电商网站首页,或者团购网站、小游戏首页等),如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象,因为图片真的太多了,一口气处理这么多任务,浏览器做不到啊!
但我们再想,用户真的需要这么多图片吗?不对,用户点开页面的瞬间,呈现给他的只有屏幕的一部分(我们称之为首屏)。只要我们可以在页面打开的时候把首屏的图片资源加载出来,用户就会认为页面是没问题的。至于下面的图片,我们完全可以等用户下拉的瞬间再即时去请求、即时呈现给他。这样一来,性能的压力小了,用户的体验却没有变差——这个延迟加载的过程,就是 Lazy-Load。
Lazy-Load的实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Lazy-Load</title>
<style>
.img {
width: 200px;
height:200px;
background-color: gray;
}
.pic {
// 必要的img样式
}
</style>
</head>
<body>
<div class="container">
<div class="img">
// 注意我们并没有为它引入真实的src
<img class="pic" alt="加载中" data-src="./images/1.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/2.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/3.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/4.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/5.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/6.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/7.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/8.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/9.png">
</div>
<div class="img">
<img class="pic" alt="加载中" data-src="./images/10.png">
</div>
</div>
</body>
</html>在懒加载的实现中,有两个关键的数值:一个是当前可视区域的高度,另一个是元素距离可视区域顶部的高度。
当前可视区域的高度, 在和现代浏览器及 IE9 以上的浏览器中,可以用 window.innerHeight 属性获取。在低版本 IE 的标准模式中,可以用 document.documentElement.clientHeight 获取,这里我们兼容两种情况:··
const viewHeight = window.innerHeight || document.documentElement.clientHeight 元素距离可视区域顶部的高度,我们这里选用 getBoundingClientRect() 方法来获取返回元素的大小及其相对于视口的位置。对此 MDN 给出了非常清晰的解释:
该方法的返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合, 即:是与该元素相关的 CSS 边框集合 。
DOMRect 对象包含了一组用于描述边框的只读属性——left、top、right 和 bottom,单位为像素。除了 width 和 height 外的属性都是相对于视口的左上角位置而言的。top 属性代表了元素距离可视区域顶部的高度,正好可以为我们所用!
Lazy-Load 方法开工啦!
<script>
// 获取所有的图片标签
const imgs = document.getElementsByTagName('img')
// 获取可视区域的高度
const viewHeight = window.innerHeight || document.documentElement.clientHeight
// num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
let num = 0
function lazyload(){
for(let i=num; i<imgs.length; i++) {
// 用可视区域高度减去元素顶部距离可视区域顶部的高度
let distance = viewHeight - imgs[i].getBoundingClientRect().top
// 如果可视区域高度大于等于元素顶部距离可视区域顶部的高度,说明元素露出
if(distance >= 0 ){
// 给元素写入真实的src,展示图片
imgs[i].src = imgs[i].getAttribute('data-src')
// 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
num = i + 1
}
}
}
// 监听Scroll事件
window.addEventListener('scroll', lazyload, false);
</script>事件的节流(throttle)与防抖(debounce)
节流与防抖的本质
这两个东西都以闭包的形式存在。
它们通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率。
节流Throttle:第一个人说了算
throttle 的中心思想在于:在某段时间内,不管你触发了多少次回调,我都只认第一次,并在计时结束时给予响应。
//第一个人说了算 节流
//定义一个函数,这个函数会传入需要执行的函数,以及执行的间隔时长。
function throttle(fn, interval) {
//函数内部定义一个最后执行的时间,初始化为 0;
let lastTime = 0;
//定义一个_throttle 函数,并返回这个函数,这个函数就是事件触发时需要执行的函数,比如点击事件。
const _throttle = function (...args) {
const nowTime = new Date().getTime();
//通过闭包的技术,获取到这个_throttle 函数执行的时间,
const remainTime = interval - (nowTime - lastTime);
if (remainTime <= 0) {
//如果执行的间隔小于当次执行时间nowTime和上一次执行时间lastTime的间隔,那么就执行传入的函数,并更改上一次执行时间(lastTime)。
fn.apply(this, args);
lastTime = nowTime;
}
};
return _throttle;
}防抖Debounce:最后一个人说了算
防抖的中心思想在于:我会等你到底。在某段时间内,不管你触发了多少次回调,我都只认最后一次。
//定义一个函数debounce,这个函数会传入一个会被执行的方法,和防抖的时长
function debounce(fn, delay) {
//通过闭包的技术,在外部先定义一个 timer,然后执行防抖函数时先判断 timer 函数存在不存在,存在就无效化定时器。
let timer = null;
const _debounce = function (...args) {
if (timer) clearTimeout(timer);
//创建一个定时器,并赋值给 timer 变量,这是为了避免 this 指向被改变,所以我们需要使用 apply 来制定 this
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
//返回一个函数,这个函数就是处理好的防抖函数,可以传入被执行的函数所需要的参数
return _debounce;
}用节流Throttle来优化防抖Debounce
debounce 的问题在于它“太有耐心了”。试想,如果用户的操作十分频繁——他每次都不等 debounce 设置的 delay 时间结束就进行下一次操作,于是每次 debounce 都为该用户重新生成定时器,回调函数被延迟了不计其数次。频繁的延迟会导致用户迟迟得不到响应,用户同样会产生“这个页面卡死了”的观感。
为了避免弄巧成拙,我们需要借力 throttle 的思想,打造一个“有底线”的 debounce——等你可以,但我有我的原则:delay 时间内,我可以为你重新生成定时器;但只要delay的时间到了,我必须要给用户一个响应。这个 throttle 与 debounce “合体”思路,已经被很多成熟的前端库应用到了它们的加强版 throttle 函数的实现中:
/**
* 创建一个带有最大等待时间的防抖函数。
* 在高频触发下,它既能像普通防抖一样推迟执行,
* 又能保证在指定的最大等待时间(maxWait)后至少执行一次。
*
* @param {Function} func 需要防抖的函数。
* @param {number} wait 延迟执行的毫秒数。
* @param {number} maxWait 允许延迟执行的最长毫秒数。
* @returns {Function} 返回新的防抖函数。
*/
function debounceMax(func, wait, maxWait) {
let timeoutId = null;
let lastArgs = null;
let lastThis = null;
let result = null;
let lastInvokeTime = 0; // 上次执行 func 的时间戳
// 确保 wait 和 maxWait 是数字
wait = Number(wait) || 0;
maxWait = Number(maxWait) || 0;
// 真正执行函数的地方
function invokeFunc() {
const time = Date.now();
lastInvokeTime = time; // 记录本次执行时间
result = func.apply(lastThis, lastArgs);
// 执行后清空,以便垃圾回收
lastThis = null;
lastArgs = null;
}
// 定时器到期后调用的函数
function timerExpired() {
timeoutId = null; // 定时器已触发,清空ID
// 如果有最后一次的调用信息,则执行
if (lastArgs) {
invokeFunc();
}
}
// 返回给用户使用的函数
const debounced = function(...args) {
const time = Date.now();
lastArgs = args; // 保存最新的参数
lastThis = this; // 保存最新的 this 上下文
// 如果这是第一次触发,或者距离上次执行已经超过了 maxWait
if (lastInvokeTime === 0) {
lastInvokeTime = time;
}
// 计算距离上次执行 func 过去了多久
const timeSinceLastInvoke = time - lastInvokeTime;
// 清除之前设置的定时器,这是“防抖”的核心
if (timeoutId) {
clearTimeout(timeoutId);
}
// 检查是否满足最大等待时间,如果满足则立即执行
if (timeSinceLastInvoke >= maxWait && maxWait > 0) {
invokeFunc();
} else {
// 否则,设置一个新的定时器,在 wait 时间后执行
timeoutId = setTimeout(timerExpired, wait);
}
return result;
};
return debounced;
}