篇幅较长,废话较多,点 这里 直接查看代码实现部分

使用 Vue + tsx 实现图片渐进式加载

最近在开发的网页中,需要实现从 api 接口获取随机图片的地址,并在页面中展示

由于图片的加载速度可能较慢,决定使用渐进式加载的方式,即先展示一张低分辨率的图片,等高分辨率图片加载完成后替换

最终效果

20240719161942

遇到的问题

在原本的方案中,使用 ref 创建图片地址的响应式变量,在通过接口获取到图片地址后,将地址赋值给变量

img 元素中使用 :src 属性将变量绑定到图片元素 src 属性上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';

const bgImg = ref(""); // 图片地址

axios.get("RANDOM_IMG_URL").then((res: {data: string}) => {
bgImg.value = res.data;
})
</script>

<template>
<img :src="bgImg" />
</template>

但是,在实际使用时发现图片无法正常加载,这是因为 Cloudflare 的 Hotlink Protection 阻止了图片文件的跨域热加载

由于后端限制(至少在遇到问题的时候)没有通过添加 /hotlink-ok/ 的方式来允许图片热加载,所以需要寻找其他方案

图片资源过大,导致页面加载缓慢

尝试直接将接口地址填入 img 元素的 src 属性中(接口支持302重定向到图片文件),发现图片可以正常加载

但后端的图库存储的全部都是原图,加载图片需要长达 15 秒左右 (已经暴揍过后端同学了

解决方案

由于 Cloudflare hotlink protection 的限制,如果不使用直接 302 重定向资源文件的方法,就无法跨域请求图片资源

既然没法动态更改 img 元素的 src 属性,那么就使用 tsx 动态 “创建” 一个 img 元素,将图片地址直接填入 src 属性中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script lang="tsx">

import { defineComponent, ref } from 'vue';
import axios from 'axios';

export default defineComponent({
setup() {
const imgurl = ref("");
axios.get("RANDOM_IMG_URL").then((res: {data: string}) => {
imgurl.value = res.data;
})
return () => <img src={imgurl.value} />
}
})

</script>

压缩图片资源

接着解决图片资源过大的问题,用 honeyview 的转换工具将原图压缩成等比例缩小的 webp 文件

原本到这里就结束了,但是哪怕是压缩后的图片,仍然需要 2 秒左右进行加载

所以,决定使用渐进式加载的方式,即先展示一张低分辨率的图片,等高分辨率图片加载完成后替换

渐进式加载

创建低分辨率资源文件

用同样的方法压缩更低分辨率的图片资源 平均大小不超过 5KB (其实可以更小)

20240719144328

创建 img 元素与 onload 事件

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
<script lang="tsx">

import { defineComponent, ref } from 'vue';
import axios from 'axios';

export default defineComponent({
setup() {
const imgurl = ref("");
const previewurl = ref("");
const imgload = ref(false)

const onIMGload = () => {
setTimeout(() => {
imgload.value = true;
}, 100)
}

return () => (
<div style="position: relative" class="img-box">
<img src={previewurl.value} class={imgload.value ? "preview hide" : "preview"} />
<img src={imgurl.value} onload={onIMGload} class="img" />
</div>
)
}
})

</script>

<style scoped>
.img-box img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}

.preview {
z-index: -1; /* preview 的 z-index 要高于 img */
transition: opacity 1s ease 0s;
opacity: 1;
filter: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='$'%3E%3CfeGaussianBlur stdDeviation='10'/%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0,0 1 0 0 0,0 0 1 0 0,0 0 0 9 0'/%3E%3CfeComposite in2='SourceGraphic' operator='in'/%3E%3C/filter%3E%3C/svg%3E#$"); /* 模糊滤镜 */
}

.img {
z-index: -2;
}

.hide {
opacity: 0;
}


</style>

随机图片并实现渐进式加载

代码源自 desu-life/desu-life commit 5cabf9

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<script lang="tsx">
import { defineComponent, ref } from "vue";
import defaultbg from "@/assets/main/background.webp";
import axios from "axios";
export default defineComponent({
name: "randombg",
props: {
viewType: {
type: String,
default: "h",
},
},
setup(props) {
const bg = ref("");
const bg_preview = ref("");
const bgload = ref(false);
const bgpreviewload = ref(false);
axios
.get(
`https://rdmimg.desu.life/utils/execute?type=json&viewType=${props.viewType}`
)
.then((res: { data: { url: string; url_preview: string } }) => {
bg_preview.value = res.data.url_preview;
bg.value = res.data.url;
})
.catch(() => {
bgpreviewload.value = true;
bg.value = defaultbg;
});
const onBGload = () => {
setTimeout(() => {
bgload.value = true;
}, 100);
};
const onPreviewLoad = () => {
setTimeout(() => {
bgpreviewload.value = true;
}, 100);
};
const onBGLoadFail = () => {
if (bgpreviewload.value) {
bg.value = defaultbg;
}
};
return () => (
<div>
<div
class={
bgpreviewload.value
? "bg-base bg-default hide"
: "bg-base bg-default"
}
/>
<img
src={bg_preview.value}
class={
bgload.value ? "bg-base bg-preview hide" : "bg-base bg-preview"
}
alt=""
onload={onPreviewLoad}
/>
<img
src={bg.value}
alt=""
class={!bgpreviewload.value ? "bg-base bg hide" : "bg-base bg"}
onload={onBGload}
onerror={onBGLoadFail}
/>
</div>
);
},
});
</script>

<style scoped lang="scss">
.bg-base {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.bg {
z-index: -3;
filter: brightness(70%);
}
.bg-preview {
z-index: -2;
transition: opacity 1s ease 0s;
opacity: 1;
filter: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='$'%3E%3CfeGaussianBlur stdDeviation='10'/%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0,0 1 0 0 0,0 0 1 0 0,0 0 0 9 0'/%3E%3CfeComposite in2='SourceGraphic' operator='in'/%3E%3C/filter%3E%3C/svg%3E#$");
}
.bg-default {
z-index: -1;
transition: opacity 1s ease;
background-color: var(--vt-c-black);
opacity: 1;
}
.hide {
opacity: 0;
}
</style>