Hugo 优化:为图片添加正在加载动画

一直以来都想在图片加载时添加一个加载动画,以增强用户体验。使用 Hugo 以来,关于图片的添加方式也进行了自定义。针对历史数据,没功夫一遍遍改了,就采用 Markdown 原生语法进行图片的插入,这样形成的图片就是一行一个简单进行罗列。后来为了让自己的照片显示 exif 信息,以及实现一行多图的方式,通过自定义短代码的方式实现,详细可见 通过Hugo短代码功能实现图片及其EXIF信息展示

因此,本站加入图片的方式除了 Markdown 语法以外,还有 figure 和 figure-group 短代码的方式进行插入。此外,还有近期做的足迹地图里面的坐标卡片中,也有图片,当然已经在 Github 中实现了卡片内图片加载动画效果。在这次 Hugo 的优化中,也一并将对应的效果应用进来。

目标场景

我的博客中有两种主要的图片展示场景:

  1. 文章页面:Markdown 图片、figure 短代码、figure-group 图片组
  2. 足迹地图:地图卡片中的缩略图、点击缩略图后的全屏查看

实现方式

1. HTML 层:添加懒加载属性

对于 Markdown 图片,使用 Hugo 的 Render Hook 自动添加 loading="lazy" 属性:

{{- $src := .Destination | safeURL -}}
{{- $alt := .Text -}}
{{- $title := .Title -}}

<img src="{{ $src }}" 
     alt="{{ $alt }}"
     {{- with $title }} title="{{ . }}"{{ end }} 
     loading="lazy">

位置layouts/_default/_markup/render-image.html

优势

  • 自动为所有 Markdown 图片添加原生懒加载
  • 无需手动修改每篇文章
  • 浏览器原生支持

2. CSS 层:纯 CSS 实现加载动画

使用CSS 的 :has() 选择器和伪元素实现旋转圆环:

// 定义可复用的圆环样式
@mixin loading-spinner($light: rgba(0,0,0,.1), $dark: rgba(0,0,0,.6)) {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 40px;
  height: 40px;
  margin: -20px 0 0 -20px;
  border: 3px solid $light;
  border-top-color: $dark;
  border-radius: 50%;
  animation: image-loading-spin .8s linear infinite;
  z-index: 1;
  transition: opacity .3s;
}

// 文章和足迹地图卡片图片
article :is(a, p, figure):has(> img[loading="lazy"]),
.footprint-popup__slide:has(> img[loading="lazy"]) {
  position: relative;
  display: block;
  
  &::before { @include loading-spinner; }
}

// 图片加载完成后隐藏圆环
article :is(a, p, figure):has(> img.loaded)::before,
.footprint-popup__slide:has(> img.loaded)::before {
  opacity: 0;
  pointer-events: none;
}

// 足迹地图放大显示(白色圆环)
.footprint-photo-viewer__dialog:has(> img) {
  position: relative;
  
  &::before { 
    @include loading-spinner(rgba(255,255,255,.2), rgba(255,255,255,.8)); 
  }
}

.footprint-photo-viewer__dialog:has(> img.loaded)::before {
  opacity: 0;
  pointer-events: none;
}

// 暗色模式适配
.dark {
  article :is(a, p, figure):has(> img[loading="lazy"])::before,
  .footprint-popup__slide:has(> img[loading="lazy"])::before {
    border-color: rgba(255, 255, 255, .1);
    border-top-color: rgba(255, 255, 255, .6);
  }
}

@keyframes image-loading-spin {
  to { transform: rotate(360deg); }
}

核心原理

  1. :has() 选择器:检测父元素是否包含特定子元素

    • :has(> img[loading="lazy"]) - 检测是否有懒加载图片
    • :has(> img.loaded) - 检测图片是否已加载
  2. ::before 伪元素:在图片容器上显示加载动画

    • 不能直接在 <img> 上使用伪元素(规范限制)
    • 通过父容器 ::before 实现覆盖层效果
  3. SCSS Mixin:复用圆环样式,支持参数化配置

    • 普通场景:黑色圆环
    • 放大显示:白色圆环(深色背景)

3. JavaScript 层:监听加载状态

监听图片加载完成,添加 .loaded 类触发 CSS 隐藏动画:

// 图片加载动画
(function() {
  const SELECTORS = 'article img[loading="lazy"], .footprint-popup__slide img[loading="lazy"], .footprint-photo-viewer img';
  
  function handle(img) {
    if (img.complete && img.naturalHeight > 0) {
      img.classList.add('loaded');
    } else {
      const mark = () => img.classList.add('loaded');
      img.addEventListener('load', mark, { once: true });
      img.addEventListener('error', mark, { once: true });
    }
  }

  function init() {
    document.querySelectorAll(SELECTORS).forEach(handle);
    
    new MutationObserver(mutations => {
      mutations.forEach(m => m.addedNodes.forEach(node => {
        if (node.nodeType === 1) {
          if (node.tagName === 'IMG') handle(node);
          node.querySelectorAll?.(SELECTORS).forEach(handle);
        }
      }));
    }).observe(document.body, { childList: true, subtree: true });
  }

  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();
})();

兼容性说明

:has() 选择器支持

浏览器最低版本
Chrome105+
Firefox121+
Safari15.4+
Edge105+

降级方案:不支持 :has() 的浏览器不会显示加载动画,但不影响图片正常加载。

loading="lazy" 支持

浏览器最低版本
Chrome77+
Firefox75+
Safari15.4+
Edge79+

最终效果

文件结构

themes/xiaoten/
├── assets/sass/
│   └── _image-loading.scss          (54 行)
├── static/js/
│   └── image-loading.js              (27 行)
└── layouts/
    ├── _default/
    │   └── _markup/
    │       └── render-image.html     (8 行)
    └── partials/
        └── footer.html               (引入 JS)

总计:约 90 行代码实现全站图片加载动画。

评论