优化一下博客的图片加载

标题口气这么大,实际上只是优化了一下图片懒加载。

因为腾讯 CDN 免费策略收紧,我之前将博客移到了自己的服务器上并且没再上 CDN。

服务器带宽有点小水管,为了体验就开启了图片懒加载。

但是用了才发现,这懒加载问题还不少。

那光有肯定不够,还得想办法优啊。

我虽然不会前端,但是我有个万能的同事法爷呀!

于是我抓着法爷来帮我搞这个图片懒加载的优化……

2023.12.25 更新:修正 Fancybox 会处理占位图的问题。

那么都有什么问题呢?

其实在抓同事之前,我是在互联网里冲浪过的。

但是发现网络上我能找到的所有的图片懒加载项目,实现的需求都比较简单,处于一个有就行的阶段。

这些懒加载,都是使用同一张占位图,不可能总和加载完成后的图尺寸一样。

于是在图片加载完成后,页面布局就会发生抖动。

尤其是图片较多的博文中,经常会出现内容反卷或跳跃,十分影响体验。

此外,图片加载完成后的切换是直接切换,过于生硬,观感较差。

如果有一个过渡特效来切换,应该会让观感好上不少。

制定需求

为了解决上面发现的两个问题,我给法爷抛出了两个需求:

  • 占位图尺寸要根据实际图片的长宽比例来决定。

  • 图片加载完成后需要有一个过渡效果来切换。

第一个需求保证加载前后占位图和图片在页面上使用的空间一致,即在图片加载前就把位置提前空好了,这样最大限度避免加载完成后页面抖动的问题。

第二个我为了自己想要的效果,提出了不要统一的占位 Loading 图,而是每个图都生成一个体积很小的缩略图,用这个图进行占位并打上高斯模糊,等图片加载完成使用一个模糊逐渐变清晰的过渡效果切换过去。

法爷不愧是法爷,做起来那是挺快的。

但测试中又碰到一些预料外的问题,以及最初我的想法也并不是刚才上面那样,是后来逐渐完善才形成的,所以中间又有修修改改,整个时间跨度有了半年多。

成果公布

首先说明,我用的是 Fluid 主题,所以是直接以这个主题为基础进行修改的。

<2023.12.25 更新>

修正 Fancybox 会处理占位图的问题。

需要修改的文件有四处,起始目录均为 Fluid 主题根目录。

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
104
'use strict';

const path = require('path')
const urlJoin = require('../../utils/url-join');
const sizeOf = require('image-size');

module.exports = (hexo) => {
const config = hexo.theme.config;
const loadingImage = urlJoin(hexo.config.root, config.lazyload.loading_img
|| urlJoin(config.static_prefix.internal_img, 'loading.gif'));
if (!config.lazyload || !config.lazyload.enable || !loadingImage) {
return;
}
if (config.lazyload.onlypost) {
hexo.extend.filter.register('after_post_render', (page) => {
if (page.layout !== 'post' && !page.lazyload) {
return;
}
if (page.lazyload !== false) {
collectImages(page.content)
getImageSize()
page.content = lazyImages(page.content, loadingImage);
page.content = lazyComments(page.content);
}
return page;
});
} else {
hexo.extend.filter.register('after_render:html', (html, data) => {
if (!data.page || data.page.lazyload !== false) {
collectImages(html)
getImageSize()
html = lazyImages(html, loadingImage);
html = lazyComments(html);
return html;
}
});
}
};

const imageSet = new Set();
const imageMap = new Map();

function getImagePath(image) {
if (/^https?:\/\/[^\/]+/.test(image)) {
const str = image.replace(/^https?:\/\/[^\/]+/, '')
const result = str.startsWith('/images/') ? str : '/images' + str
return result
}
return image
}

const lazyImages = (htmlContent, loadingImage) => {
return htmlContent.replace(/<img[^>]+?src=(".*?")[^>]*?>/gims, (str, p1) => {
if (/loading=/i.test(str)) {
return str;
}
let widthExist
if (/width="[^"]+"/.test(str)) {
widthExist = str.match(/width="([^"]+)"/)[1]
}
const imagePath = getImagePath(p1.replace(/"/g, ''))
const info = imageMap.get(imagePath)
const thumb = p1.replace(/"/g, '').replace(/\.[^.]+$/, '_proc.jpg')
if (info) {
let style = `aspect-ratio: ${info.width} / ${info.height}`
if (widthExist) {
style += `;width:${widthExist}`
} else {
style += `;width:90%`
}
style += `;max-width:${info.width}px`
return str.replace(p1, `${p1} style="${style}" srcset="${thumb}" lazyload loading="lazy"`);
}
return str.replace(p1, `${p1} srcset="${thumb}" lazyload loading="lazy"`);
});
};

const lazyComments = (htmlContent) => {
return htmlContent.replace(/<[^>]+?id="comments"[^>]*?>/gims, (str) => {
if (/lazyload/i.test(str)) {
return str;
}
return str.replace('id="comments"', 'id="comments" lazyload');
});
};

const collectImages = (htmlContent) => {
const images = htmlContent.match(/(?<=<img[^>]+?src=").*?(?="[^>]*?>)/gims)
if (images) {
images.forEach(image => {
imageSet.add(getImagePath(image))
})
}
}

const getImageSize = () => {
for (let imagePath of imageSet) {
if (!imageMap.has(imagePath)) {
const info = sizeOf(path.resolve(process.cwd(), 'source/', imagePath.replace(/^[\\\/]/, '')))
imageMap.set(imagePath, info)
}
}
}

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
:root
--color-mode "light"
--body-bg-color $body-bg-color
--board-bg-color $board-bg-color
--text-color $text-color
--sec-text-color $sec-text-color
--post-text-color $post-text-color
--post-heading-color $post-heading-color
--post-link-color $post-link-color
--link-hover-color $link-hover-color
--link-hover-bg-color $link-hover-bg-color
--line-color $line-color
--navbar-bg-color $navbar-bg-color
--navbar-text-color $navbar-text-color
--subtitle-color $subtitle-color
--scrollbar-color $scrollbar-color
--scrollbar-hover-color $scrollbar-hover-color
--button-bg-color $button-bg-color
--button-hover-bg-color $button-hover-bg-color
--highlight-bg-color $highlight-bg-color
--inlinecode-bg-color $inlinecode-bg-color
--fold-title-color $text-color
--fold-border-color $line-color

dark-colors()
--body-bg-color $body-bg-color-dark
--board-bg-color $board-bg-color-dark
--text-color $text-color-dark
--sec-text-color $sec-text-color-dark
--post-text-color $post-text-color-dark
--post-heading-color $post-heading-color-dark
--post-link-color $post-link-color-dark
--link-hover-color $link-hover-color-dark
--link-hover-bg-color $link-hover-bg-color-dark
--line-color $line-color-dark
--navbar-bg-color $navbar-bg-color-dark
--navbar-text-color $navbar-text-color-dark
--subtitle-color $subtitle-color-dark
--scrollbar-color $scrollbar-color-dark
--scrollbar-hover-color $scrollbar-hover-color-dark
--button-bg-color $button-bg-color-dark
--button-hover-bg-color $button-hover-bg-color-dark
--highlight-bg-color $highlight-bg-color-dark
--inlinecode-bg-color $inlinecode-bg-color-dark
--fold-title-color $text-color
--fold-border-color $line-color

img:not(.img-loaded,.img-blur,.blur-loading)
-webkit-filter brightness(.9)
filter brightness(.9)
transition filter .2s ease-in-out

.navbar .dropdown-collapse, .top-nav-collapse, .navbar-col-show
if $navbar-glass-enable
ground-glass($navbar-glass-px, $navbar-bg-color-dark, $navbar-glass-alpha)

.license-box
background-color rgba(#3e4b5e, .35)
transition background-color .2s ease-in-out

.gt-comment-admin .gt-comment-content
background-color transparent
transition background-color .2s ease-in-out

if (hexo-config("dark_mode.enable"))
@media (prefers-color-scheme: dark)
:root
--color-mode "dark"

:root:not([data-user-color-scheme])
dark-colors()

@media not print
[data-user-color-scheme="dark"]
dark-colors()

@media print
:root
--color-mode "light"

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
/* global Fluid, CONFIG */

// (function(window, document) {
// for (const each of document.querySelectorAll('img[lazyload]')) {
// Fluid.utils.waitElementVisible(each, function() {
// each.removeAttribute('srcset');
// each.removeAttribute('lazyload');
// }, CONFIG.lazyload.offset_factor);
// }
// })(window, document);

(function(){
const className = `img-box-${Math.floor(Math.random() * 100)}`

jQuery('head').append(`<style>
.${className} {
position: relative;
margin: 1.5rem auto;
overflow: hidden;
box-shadow: 0 5px 11px 0 rgb(0 0 0 / 18%), 0 4px 15px 0 rgb(0 0 0 / 15%);
border-radius: 4px;
}
.${className} .img-blur {
filter: blur(32px);
transition: filter 0.7s;
opacity: 0;
}
.${className} .img-loaded {
filter: none;
opacity: 1;
}
.${className} img {
border-radius: 4px;
}
.${className} .blur-loading {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
filter: blur(32px);
object-fit: cover !important;
}
</style>`)
jQuery('img[lazyload]').each(function() {
const elem = $(this)
const src = elem.attr('src')
const mini = src.replace(/\.[^.]+$/, '_proc.jpg')
const style = elem.attr('style')
elem.replaceWith(`<div class="${className}" style="${style}">
<img class="img-blur" data-src="${src}" loading="lazy">
<img class="blur-loading" src="${mini}">
</div>`)
})
jQuery('.img-blur').on('load', function() {
const elem = $(this)
elem.addClass('img-loaded')
elem.closest(`.${className}`).find('.blur-loading').remove()
})
jQuery('.blur-loading').on('load', function() {
const elem = $(this)
const img = elem.closest(`.${className}`).find('.img-blur')
img.attr('src', img.data('src'))
})
})()
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/* global Fluid, CONFIG */

HTMLElement.prototype.wrap = function(wrapper) {
this.parentNode.insertBefore(wrapper, this);
this.parentNode.removeChild(this);
wrapper.appendChild(this);
};

Fluid.plugins = {

typing: function(text) {
if (!('Typed' in window)) { return; }

var typed = new window.Typed('#subtitle', {
strings: [
' ',
text
],
cursorChar: CONFIG.typing.cursorChar,
typeSpeed : CONFIG.typing.typeSpeed,
loop : CONFIG.typing.loop
});
typed.stop();
var subtitle = document.getElementById('subtitle');
if (subtitle) {
subtitle.innerText = '';
}
jQuery(document).ready(function() {
typed.start();
});
},

fancyBox: function(selector) {
if (!CONFIG.image_zoom.enable || !('fancybox' in jQuery)) { return; }

jQuery(selector || '.markdown-body :not(a) > img:not(.blur-loading), .markdown-body > img').each(function() {
var $image = jQuery(this);
var imageUrl = $image.attr('data-src') || $image.attr('src') || '';
if (CONFIG.image_zoom.img_url_replace) {
var rep = CONFIG.image_zoom.img_url_replace;
var r1 = rep[0] || '';
var r2 = rep[1] || '';
if (r1) {
if (/^re:/.test(r1)) {
r1 = r1.replace(/^re:/, '');
var reg = new RegExp(r1, 'gi');
imageUrl = imageUrl.replace(reg, r2);
} else {
imageUrl = imageUrl.replace(r1, r2);
}
}
}
var $imageWrap = $image.wrap(`
<a class="fancybox fancybox.image" href="${imageUrl}"
itemscope itemtype="http://schema.org/ImageObject" itemprop="url"></a>`
).parent('a');
if ($imageWrap.length !== 0) {
if ($image.is('.group-image-container img')) {
$imageWrap.attr('data-fancybox', 'group').attr('rel', 'group');
} else {
$imageWrap.attr('data-fancybox', 'default').attr('rel', 'default');
}

var imageTitle = $image.attr('title') || $image.attr('alt');
if (imageTitle) {
$imageWrap.attr('title', imageTitle).attr('data-caption', imageTitle);
}
}
});

jQuery.fancybox.defaults.hash = false;
jQuery('.fancybox').fancybox({
loop : true,
helpers: {
overlay: {
locked: false
}
}
});
},

imageCaption: function(selector) {
if (!CONFIG.image_caption.enable) { return; }

jQuery(selector || `.markdown-body > p > img, .markdown-body > figure > img,
.markdown-body > p > a.fancybox, .markdown-body > figure > a.fancybox`).each(function() {
var $target = jQuery(this);
var $figcaption = $target.next('figcaption');
if ($figcaption.length !== 0) {
$figcaption.addClass('image-caption');
} else {
var imageTitle = $target.attr('title') || $target.attr('alt');
if (imageTitle) {
$target.after(`<figcaption aria-hidden="true" class="image-caption">${imageTitle}</figcaption>`);
}
}
});
},

codeWidget() {
var enableLang = CONFIG.code_language.enable && CONFIG.code_language.default;
var enableCopy = CONFIG.copy_btn && 'ClipboardJS' in window;
if (!enableLang && !enableCopy) {
return;
}

function getBgClass(ele) {
return Fluid.utils.getBackgroundLightness(ele) >= 0 ? 'code-widget-light' : 'code-widget-dark';
}

var copyTmpl = '';
copyTmpl += '<div class="code-widget">';
copyTmpl += 'LANG';
copyTmpl += '</div>';
jQuery('.markdown-body pre').each(function() {
var $pre = jQuery(this);
if ($pre.find('code.mermaid').length > 0) {
return;
}
if ($pre.find('span.line').length > 0) {
return;
}

var lang = '';

if (enableLang) {
lang = CONFIG.code_language.default;
if ($pre[0].children.length > 0 && $pre[0].children[0].classList.length >= 2 && $pre.children().hasClass('hljs')) {
lang = $pre[0].children[0].classList[1];
} else if ($pre[0].getAttribute('data-language')) {
lang = $pre[0].getAttribute('data-language');
} else if ($pre.parent().hasClass('sourceCode') && $pre[0].children.length > 0 && $pre[0].children[0].classList.length >= 2) {
lang = $pre[0].children[0].classList[1];
$pre.parent().addClass('code-wrapper');
} else if ($pre.parent().hasClass('markdown-body') && $pre[0].classList.length === 0) {
$pre.wrap('<div class="code-wrapper"></div>');
}
lang = lang.toUpperCase().replace('NONE', CONFIG.code_language.default);
}
$pre.append(copyTmpl.replace('LANG', lang).replace('code-widget">',
getBgClass($pre[0]) + (enableCopy ? ' code-widget copy-btn" data-clipboard-snippet><i class="iconfont icon-copy"></i>' : ' code-widget">')));

if (enableCopy) {
var clipboard = new ClipboardJS('.copy-btn', {
target: function(trigger) {
var nodes = trigger.parentNode.childNodes;
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].tagName === 'CODE') {
return nodes[i];
}
}
}
});
clipboard.on('success', function(e) {
e.clearSelection();
e.trigger.innerHTML = e.trigger.innerHTML.replace('icon-copy', 'icon-success');
setTimeout(function() {
e.trigger.innerHTML = e.trigger.innerHTML.replace('icon-success', 'icon-copy');
}, 2000);
});
}
});
}
};

但是,这个改完之后想让懒加载正常工作,还是需要做一些额外的工作的。

使用方法

上面也说了,每个图片都要有自己的占位图,占位图的链接均为原图的链接在扩展名前加入 _proc 且格式均为 jpg

为什么会这样呢?因为之前占位图都是自己手动用 JPEGView 修改的,修改后的文件名默认是这种格式。

后来觉得自己手动改的大小还是太保守了一点,还需要更小的体积,这时候再重新手动改一遍就太懒了,改不动。

于是找了 ChatGPT 帮我搓了个批处理,调用 ffmpeg 来帮我干活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@echo off
setlocal enabledelayedexpansion

set "inputFolder=%cd%"
set "outputFolder=%cd%"

for /f "tokens=*" %%F in ('dir /b /o-d /tc "%inputFolder%\*.png" "%inputFolder%\*.jpg"') do (
set "inputFile=%inputFolder%\%%F"
set "fileName=%%~nF"

echo !fileName! | findstr /i /c:"banner" /c:"index" /c:"proc" >nul && (
echo Skipping: !inputFile! - File name contains "banner", "index" or "proc"
) || (
set "outputFile=!outputFolder!\!fileName!_proc.jpg"
ffmpeg -i "!inputFile!" -vf "scale=17:-1" -q:v 2 -compression_level 50 "!outputFile!" -y
echo Processed: !inputFile! -^> !outputFile!
)
)

虽然逻辑上还有些问题,但是能正常工作就不管它了。

需要环境变量中有 ffmpeg 才能使用,会正常跳过(实际用下来好像并不会跳过)一些不需要处理的图片。

把批处理往各个图片文件夹里都跑了一遍,新的占位图就全部生成完毕了。

再偷懒一点

现在还有个问题,每次 Fluid 更新后我需要把修改过的文件手动再覆盖进去。

这个修改不具有普适性,也不好往 Fluid 那边提 PR。

于是想了想办法,用 GitHub Ation 来帮我自动处理,处理完后再发布个 Release,这样还有邮件提醒,不用隔三岔五去瞅 Fluid 那边更新了没。

项目地址:https://github.com/huaxianyan/custom_hexo_fluid