前言

首页轮播看起来只是定时换背景,但真正稳定的实现还要处理图片加载失败、淡入时的空白、后台标签页暂停绘制和浏览器缓存。本篇重点解释这些容易踩坑的细节。

一、配置、构建和浏览器三层分工

_config.butterfly.yml
保存开关、间隔和图片列表

scripts/home-banner-config.js
构建时把配置写入页面

source/js/home-banner-carousel.js
浏览器预加载并切换图片

source/css/custom.css
控制层级和淡入淡出

把图片列表放进配置文件后,日常换图不需要编辑 JavaScript。

二、怎样配置轮播

home_banner:
enable: true
interval: 5000
images:
- https://example.com/photo-1.jpg
- https://example.com/photo-2.jpg

interval 使用毫秒,5000 表示 5 秒。脚本还会把过小的值限制在 3 秒以上:

const interval = Math.max(
Number(config.interval) || 8000,
3000
)

太快的切换会让图片还没看清就消失,也会增加网络和绘制压力。

三、为什么仍然保留 index_img

轮播依赖 JavaScript。如果脚本尚未执行或用户禁用了脚本,首页不能出现纯色空白。因此仍为 Butterfly 配置静态兜底图:

index_img: https://example.com/fallback.jpg

轮播初始化成功后再清除原背景:

header.style.backgroundImage = 'none'

这叫“渐进增强”:基础页面先可用,JavaScript 只在其上增加能力。

四、构建时怎样把 YAML 交给浏览器

浏览器不能直接读取服务器上的 _config.butterfly.yml。Hexo 过滤器把必要配置写进 <head>

const config = {
interval: Number(carousel.interval) || 8000,
images: carousel.images.filter(image =>
typeof image === 'string' && image.trim()
)
}

html.replace(
'</head>',
`<script>window.__homeBannerConfig=${serialize(config)}</script></head>`
)

只传给前端真正需要的两个字段,避免把整个主题配置暴露到页面。

五、为什么不用一个元素直接换 background-image

如果只对一个元素执行:

element.style.backgroundImage = nextUrl

浏览器会立即替换图片。CSS 很难同时保留上一张并让下一张淡入,图片没加载完时还可能短暂显示背景色。

这里采用“双层交叉淡入”:

旧图 opacity: 1 → 0
新图 opacity: 0 → 1
过渡结束后删除旧图

每张图都是绝对定位的独立元素:

.home-banner-slide {
position: absolute;
inset: 0;
opacity: 0;
background-size: cover;
background-position: center;
transition: opacity 1.2s ease-in-out;
}

.home-banner-slide.is-active {
opacity: 1;
}

六、预加载怎样避免坏图造成空白

切换前先创建 Image 对象:

const preload = url => new Promise(resolve => {
const image = new Image()
image.onload = () => resolve(true)
image.onerror = () => resolve(false)
image.src = url
})

showNext() 会依次尝试后续图片:

let attempts = 0
let nextIndex = index

while (attempts < images.length) {
nextIndex = (nextIndex + 1) % images.length
if (await preload(images[nextIndex])) break
attempts += 1
}

某张图片失效时自动跳过;所有图片都失败时保留当前画面,等待下一轮,而不是把可用旧图删掉。

七、“先变蓝再显示下一张”是怎样产生的

淡入淡出的关键顺序是:

  1. 保存当前活动层;
  2. 创建并插入下一层;
  3. 确保浏览器先绘制下一层的 opacity: 0
  4. 给下一层添加 is-active
  5. 再让旧层淡出;
  6. 过渡完成后删除旧层。

正确代码:

const previousSlide = activeSlide
const nextSlide = makeSlide(images[nextIndex])
carousel.append(nextSlide)

void nextSlide.offsetWidth
nextSlide.classList.add('is-active')
previousSlide.classList.remove('is-active')

setTimeout(() => previousSlide.remove(), 1250)
activeSlide = nextSlide

早期问题出在定时器中引用了后来已经被修改的 activeSlide。变量更新后,定时器可能删除新图而不是旧图,于是页面只剩头部底色。

把旧层立即保存为 previousSlide,定时器闭包就会一直指向正确元素。

八、void nextSlide.offsetWidth 有什么作用

浏览器会批量合并 DOM 和样式修改。如果刚插入元素就立刻添加 is-active,它可能只绘制最终的 opacity: 1,中间没有可过渡的起点。

读取 offsetWidth 会迫使浏览器先完成一次布局:

void nextSlide.offsetWidth

此时浏览器已经确认元素初始状态是 opacity: 0,下一行改为 1 才会触发过渡。

最初尝试过 requestAnimationFrame(),但后台标签页会降低或暂停动画帧调度,恢复页面时容易出现状态不同步。强制布局在这里更可控。

九、后台标签页怎样处理

浏览器标签页不可见时不必继续切图:

if (document.hidden) return scheduleNext()

这里没有停止整个轮播,也没有一次性补做错过的切换,只是把下一次检查推迟一个间隔。用户回到页面后,从当前图片继续,避免瞬间连续跳过多张。

十、层级为什么要单独设置

轮播层放在最底层,导航和首页文字放在上方:

#page-header.full_page {
isolation: isolate;
}

#home-banner-carousel {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}

#page-header.full_page #nav {
z-index: 2;
}

isolation: isolate 创建独立层叠上下文,避免负层级跑到整个页面背后;pointer-events: none 保证背景不会挡住菜单点击。

十一、为什么代码改了,线上仍像没改

浏览器和 Nginx 可能长期缓存静态 JavaScript。即使服务器文件已经更新,旧标签页仍可能使用缓存版本。

解决方法是在资源地址后增加版本号:

inject:
bottom:
- <script src="/js/home-banner-carousel.js?v=20260715-2" defer></script>

文件内容变化时同步更新 v=,浏览器会把它视为新地址重新请求。版本号不参与服务器文件查找,只用于区分缓存键。

十二、维护入口

  • 添加、删除图片和修改间隔:_config.butterfly.ymlhome_banner
  • 配置注入:scripts/home-banner-config.js
  • 图片预加载和切换:source/js/home-banner-carousel.js
  • 淡入时长与页面层级:source/css/custom.css
  • 更新脚本后刷新缓存:修改注入 URL 的 ?v=

结语

一个稳定轮播依赖的不是定时器,而是“下一张先加载成功、旧层保留到过渡完成、后台页面不补帧、资源更新能绕过缓存”这四个条件。