标题口气这么大,实际上只是优化了一下图片懒加载。
因为腾讯 CDN 免费策略收紧,我之前将博客移到了自己的服务器上并且没再上 CDN。
服务器带宽有点小水管,为了体验就开启了图片懒加载。
但是用了才发现,这懒加载问题还不少。
那光有肯定不够,还得想办法优啊。
我虽然不会前端,但是我有个万能的同事法爷呀!
于是我抓着法爷来帮我搞这个图片懒加载的优化……
2023.12.25 更新:修正 Fancybox 会处理占位图的问题。
那么都有什么问题呢?
其实在抓同事之前,我是在互联网里冲浪过的。
但是发现网络上我能找到的所有的图片懒加载项目,实现的需求都比较简单,处于一个有就行的阶段。
这些懒加载,都是使用同一张占位图,不可能总和加载完成后的图尺寸一样。
于是在图片加载完成后,页面布局就会发生抖动。
尤其是图片较多的博文中,经常会出现内容反卷或跳跃,十分影响体验。
此外,图片加载完成后的切换是直接切换,过于生硬,观感较差。
如果有一个过渡特效来切换,应该会让观感好上不少。
制定需求
为了解决上面发现的两个问题,我给法爷抛出了两个需求:
占位图尺寸要根据实际图片的长宽比例来决定。
图片加载完成后需要有一个过渡效果来切换。
第一个需求保证加载前后占位图和图片在页面上使用的空间一致,即在图片加载前就把位置提前空好了,这样最大限度避免加载完成后页面抖动的问题。
第二个我为了自己想要的效果,提出了不要统一的占位 Loading 图,而是每个图都生成一个体积很小的缩略图,用这个图进行占位并打上高斯模糊,等图片加载完成使用一个模糊逐渐变清晰的过渡效果切换过去。
法爷不愧是法爷,做起来那是挺快的。
但测试中又碰到一些预料外的问题,以及最初我的想法也并不是刚才上面那样,是后来逐渐完善才形成的,所以中间又有修修改改,整个时间跨度有了半年多。
成果公布
首先说明,我用的是 Fluid 主题,所以是直接以这个主题为基础进行修改的。
<2023.12.25 更新>
修正 Fancybox 会处理占位图的问题。
需要修改的文件有四处,起始目录均为 Fluid 主题根目录。
▶
scripts\events\lib\lazyload.js
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) } } }
|
▶
source\css\_pages\_base\color-schema.styl
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"
|
▶
source\js\img-lazyload.js
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
|
(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
|
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