开发一个懒加载图片 npm 插件

懒加载图片

懒加载图片是 web 中常见的性能优化手段,可以有效的减少非首屏的图片请求,带宽消耗

实现方案

  1. img 元素定义一个 data-src 属性存放图片地址或者普通元素定义一个 data-background-src 属性
  2. 获取屏幕可视区域的尺寸
  3. 获取元素到窗口边缘的距离
  4. 判断元素是否在可视边缘内,如果是则把 data-src 赋值给 src 或者 data-background-src 赋值给 background-image: url()

使用的 API

IntersectionObserver:提供了一种异步观察目标元素与其祖先元素或顶级文档视窗(viewport)交叉状态的方法,具体使用参考 mdn

scroll:监听滚动事件,判断目标元素是否在视窗内

编码

目录结构

image.png

  1. 先写一个页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<!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>lazy</title>
<style>
img{
width: 300px;
min-height: 300px;
border-radius: 5px;
background-color: #ddd;
margin: 10px;
}
.bg{
width: 300px;
height: 300px;
border-radius: 5px;
background-color: #eee;
margin: 10px;
}
.data{
width: 100%;
overflow: scroll;
}
#data{
display: flex;
width: 10000px;
}
</style>
</head>
<body>
<div id="app"></div>
<div class="data">
<div id="data"></div>
</div>
<script>
window.onload = function() {
let app = document.getElementById("app");
let data = document.getElementById("data");
let src = 'https://p9-passport.byteacctimg.com/img/user-avatar/fbf0dad2f64546b5718cb7e502e92c7b~1000x1000.image'
let lazyHtml = new Array(20).fill(0).reduce((prev, cur, index) => {
return prev + `<img class='img' index=${index} data-src='${src}?index=${+new Date() + index}'>`
}, '')
let lazyBackgroundHtml = new Array(20).fill(0).reduce((prev, cur, index) => {
return prev + `<div class='bg' index=${index} style='background-position: center' data-background-image='${src}?index=${+new Date() + index}'></div>`
}, '')
app.innerHTML = lazyHtml + lazyBackgroundHtml
data.innerHTML = lazyHtml + lazyBackgroundHtml
}
</script>
<script src="./index.js"></script>
</body>
</html>

image.png

  1. 先写如何调用
1
2
3
4
5
6
7
8
9
setTimeout(() => {
// 确保节点插入后使用懒加载工具
window.lazyImage = LazyImage({
el: '.data'
})
window.lazyImage = LazyImage({
el: '#app',
})
})
  1. 开始来写 LazyImage 的实现,先把代码骨架写好
1
2
3
4
5
6
7
8
9
// index.js
class Lazy {

}

function LazyImage(...arg) {
return new Lazy(...arg)
}

  1. 构造函数中需要去对参数进行提取合并,初始化操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
constructor(options) {
options = options || {}
// 从哪个元素下获取节点
const el = options.el || ''
this.el = util.querySelector(el)
// 懒加载 N 秒后执行
this.wait = options.wait || 500
// 偏移量
this.diffTop = 0
this.diffLeft = 0
// 容器,是否父容器为滚动元素,相对父容器还是window
this.isScrollContainer =
options.isScrollContainer ||
!(this.el.scrollWidth <= this.el.clientWidth) ||
!(this.el.scrollHeight <= this.el.clientHeight)
this.container = this.isScrollContainer ? this.el : window
// 合并 observerOption IntersectionObserver 使用的配置
this.observerOption = options.observerOption || {
thresholds: [1],
root: this.isScrollContainer ? this.el : null,
}
// 待观察图片数组
this.images = []
// 初始化监听方式,使用单例模式
this.initEvents = getEventFunc().bind(this)
// 初始化销毁函数,避免报错,先使用空函数
this.destroyEvent = () => { }
// 更新插件
this.update()
}
  1. getEventFunc 的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
let func = null
function getEventFunc() {
// 如果已经判断过,直接返回
if (func) return func
// 优先使用 IntersectionObserver
if (window.IntersectionObserver) {
func = function () {
// 这里使用缓存数组,缓存激活元素
let activeImages = []
// 使用 setTimeout 做一个防抖
let timeout
// 监听
let observer = new IntersectionObserver((images) => {
activeImages.push(...images)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
activeImages
.filter((image) => image.isIntersecting)
.map((image) => image.target)
.forEach((image) => {
if (this.initImages(image)) {
// 完成图片加载后移除监听
observer.unobserve(image)
}
})
// 清空本次激活数组
activeImages = []
}, this.wait)
}, this.observerOption)
// 对每一项进行监听
this.images.forEach((image) => observer.observe(image))
// 返回一个销毁事件监听方法
return () => {
this.images.forEach((image) => observer.unobserve(image))
}
}
} else {
// 否则降级使用 scroll
func = function () {
// 这里也做一个防抖
let timeout = null
let load = () => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
this.images.forEach((image) => this.initImages(image))
}, this.wait)
}
if (this.container !== window) {
// fix 横向滚动时上下滚动无法触发更新问题
window.addEventListener('scroll', load)
}
this.container.addEventListener('scroll', load)
// 同样返回一个销毁函数
return () => {
this.container.removeEventListener('scroll', load)
window.removeEventListener('scroll', load)
}
}
}
return func
}
  1. update_init 方法实现
1
2
3
4
5
6
7
8
9
10
11
_init() {
// 先销毁
this.destroyEvent()
// 获取元素
this.queryImage()
// 监听,并到下一次销毁的方法
this.destroyEvent = this.initEvents()
}
update() {
return this._init()
}
  1. 编写工具方法 util
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const util = {
// 获取容器宽高
getVwVh(container) {
return {
vw:
container.innerWidth ||
window.innerWidth ||
document.documentElement.clientWidth,
vh:
container.innerHeight ||
window.innerHeight ||
document.documentElement.clientHeight,
}
},
// 获取元素
querySelector(el = '') {
return el ? document.querySelector(el) : document.body
},
// 获取元素属性值
getAttribute(el, name) {
return el.getAttribute(name)
},
// 获取全部元素
querySelectorAll(el, search) {
return el && search ? el.querySelectorAll(search) : []
},
}
// 初始化工具函数
;[
['src', 'getSrc', util.getAttribute],
['data-src', 'getDataSrc', util.getAttribute],
['data-background-image', 'getBackgroundSrc', util.getAttribute],
['style', 'getStyle', util.getAttribute],
['[data-src]', 'queryAllSrc', util.querySelectorAll],
['[data-background-image]', 'queryAllImage', util.querySelectorAll],
].forEach(([key, val, fn]) => {
util[val] = (el) => fn(el, key)
})
  1. queryImage 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
queryImage() {
if (!this.el) return
// 获取所有符合标准的元素
this.images = [
...util.queryAllSrc(this.el),
...util.queryAllImage(this.el),
].filter((el) => {
// 过滤漏网之鱼
return !!(
!util.getSrc(el) &&
(util.getDataSrc(el) || util.getBackgroundSrc(el))
)
})
}
  1. inViewport 判断是否处于视窗内
1
2
3
4
5
6
7
8
9
10
11
12
13
inViewport(el) {
// 获取容器宽高
const { vw, vh } = util.getVwVh(this.container)
// 获取元素的位置
const { top, right, bottom, left } = el.getBoundingClientRect()
// 计算判断是否满足条件
return (
top - vh < this.diffTop &&
bottom > this.diffTop &&
left - vw < this.diffLeft &&
right > this.diffLeft
)
}
  1. initImages 加载图片方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
initImages(image) {
// 如果不在视窗内,直接返回
if (!this.inViewport(image)) return null
// 获取内容
const src = util.getSrc(image)
const dataSrc = util.getDataSrc(image)
const dataBackground = util.getBackgroundSrc(image)
// 判断是否加载过
if (src || (!dataSrc && !dataBackground)) return image
// 存在src
if (dataSrc) {
// 修改src
image.setAttribute('src', dataSrc)
let load = () => {
// 初始化结束后从数组中清除该节点
this.images = this.images.filter((img) => img !== image)
image.removeAttribute('data-src')
// 删除事件监听
image.removeEventListener('load', load)
}
image.addEventListener('load', load)
}
if (dataBackground) {
// 获取原有的 style 进行拼接
image.style = `${util.getStyle(
image
)}; background-image:url(${dataBackground});`
this.images = this.images.filter((img) => img !== image)
image.removeAttribute('data-background-image')
}
return image
}
  1. 看看效果

GIF.gif

打包

  1. 使用 rollup 打包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// rollup.config.js
import babel from "rollup-plugin-babel";
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import { terser } from 'rollup-plugin-terser';

export default {
input: 'src/index.js',
output: {
name: 'LazyImage',
file: 'xy-lazyimage.min.js',
format: 'umd'
},
plugins: [
resolve(),
commonjs(),
babel({
exclude: 'node_modules/**', // 防止打包node_modules下的文件
runtimeHelpers: true, // 使plugin-transform-runtime生效
}),
terser()
]
}
  1. package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
{
"name": "xy-lazyimage",
"version": "1.0.6",
"description": "懒加载图片插件",
"main": "src/index.js",
"module": "src/index.js",
"browser": "xy-lazyimage.min.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rollup -c"
},
"homepage": "https://github.com/Luoyuda/xy-lazyimage",
"author": "xiayuchen",
"license": "ISC",
"devDependencies": {
"rollup-plugin-babel": "^4.4.0",
"@babel/core": "^7.17.8",
"@babel/plugin-transform-runtime": "^7.17.0",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.16.11",
"core-js": "^3.21.1",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^7.0.2"
},
"keywords": ["lazy", "lazy image", "lazyload"],
"dependencies": {
},
"files": [
"xy-lazyimage.min.js"
]
}

image.png

image.png

源码地址

npm包地址