# 前言和申明
◉ 本方法只是个人对于无限轮盘的一种优化方式,比这种方法跟简单、高效、优雅的方式多了去了,本人仅仅是分享自己对于这种方式的设计思路,请键盘下留情~
◉
本文介绍的无限轮盘的外观样式实际参考的是 Osu! Lazer 的谱面选取界面的轮盘,但并不代表我使用的方法与实现它的方法类似。
◉
如果有更好的设计和方法,或者发现本方法的缺点,请各位评论区点出啦,谢谢。
# 引言 | Intro
本人在美化博客的时候,突发奇想想说复刻一个类似 Osu! Lazer 那样的轮盘系统来选文章一定很酷,刚好网络上没有找到完全契合我的需求的,想说就自己手写一个。我的需求其实很简单,就只要满足:可以滚动挑选、有预览展示、支持无上限的文章(其实本质上一个博客的文章基本上不会超过三位数,其实这一步可以不用考虑,但是我想说既然做了,那么就尽量做一个一劳永逸的功能)。

最开始我的想法很简单也实现了几乎所有的功能,那就是直接通过 hexo 模板先把文章卡片都渲染出来,然后在前端用 querySelectorAll('.track') 一次性把全部文章信息抓进来(标题、描述、封面、链接、日期这些),再交给轮盘去排位置和滚动。这套做法在文章少的时候其实很好用,开发成本也低,功能很快就齐了。但问题也很快出现:当我开始高速滚轮、快速拖动,DOM 更新会变得很重,掉帧和闪烁也开始冒出来。
# 实战方案拆解 | Workflow
由于上面提到的掉帧等问题,我不得不考虑在实际渲染中,添加一些优化模块,这些优化模块虽说可能不能解决真正的无限滚轮(由于 hexo 是静态页面,文章数量不可能无限制的网上张,本质内存也会受不住),但至少能保证在有限的界面中渲染可以保证高效且不失美观的显示效果。即便我自己知道我的方法一定不是最优甚至可能有很多欠妥当的地方,但我仍然认为我应该把这里的方法给大家分享一下,至少也能当作一个技能记录,给可能会需要类似功能的人提供一个参考想法。
概括的来讲,我的优化其实就只有三件事:节点复用、异步回填、轻量化修改。
# 节点复用 | Reusable Nodes
我最开始注意到其中一个卡顿原因,就是最开始给每一篇文章都分配一个自己的** track 卡片**,这导致但文章的数量上涨的时候,每一帧所需更新的 DOM 数量也会随之增长。经过测试,在文章数量在 100 篇以上之后,滚动就会有明显卡顿和滞后,更不要说再加上 css 实现美化的效果了。
于是我立刻就想到我们可以固定实际渲染的卡片数量,让他们以轮盘的方式轮转,然后只需要在这些卡片不显示的时候替换掉内部的实际内容为所需文章的内容就可以了,这样既能解决同一帧中大量 DOM 更新的问题,也能解决大量卡片带来的内存存储问题。

这一步的主要收益是能保证 DOM 数量恒定,浏览器不用频繁创建/销毁,布局树稳定很多。唯一的难点就在于如何保证正在显示的卡片与所有文章的索引 index 之间的同步。给大家提供一个伪代码在下方(实际上因为可能大家的主题之类的不一样,实际代码会有变动,这边只提供伪代码表达逻辑即可)
const visibleCount = 11;
const trackPool = createTrackCards(visibleCount); // Create a fixed-size DOM pool.
function renderWheel(offset) {
const headIndex = Math.floor(offset) - Math.floor(visibleCount / 2);
trackPool.forEach((card, slotIndex) => {
const sourceIndex = headIndex + slotIndex;
const post = posts[sourceIndex];
if (!post) {
card.hidden = true;
return;
}
card.hidden = false;
// Only update content when this slot points to a new post.
if (card.dataset.sourceIndex !== String(sourceIndex)) {
card.dataset.sourceIndex = String(sourceIndex);
updateCardContent(card, post);
}
updateCardPosition(card, slotIndex, offset);
});
}
# 异步回填 | Async Refill
在通过节点复用解决了轮盘无限增长的问题之后,我们又遇到了一个会严重影响卡顿的问题,具体就是在高速滚动的状态下,会有频繁的内容进行更新和渲染,在极端状况下,某些轮转的卡片可能来不及在一次轮转中渲染出它所需的东西,这就会占据主线程导致大量卡顿,这不是我们想要的。一个粗暴的解决办法是通过限制转轮的最高转速来解决,但是这治标不治本,特别是在文章达到几十上百篇的时候,限制转轮速度会带来很不适的体验(具体来说就是需要转很久)。这个问题其实是由于我们解决第一个问题所带来的,节点复用解决了“壳子”的无限增长问题,但带来了频繁的内容切换:如果每次一换位就立刻更新卡片的 DOM,高速滚动时会严重掉帧。
![]() |
![]() |
|---|
对于上面的问题,我参考了图片异步加载的方法,想到了一个解决办法。在我们浏览图集的时候,会发现快速的滚动并不会卡顿,这是因为图片的加载是异步的,它不是等到图片完全加载出现了才释放交互锁,这就带来了无比丝滑的浏览体验,只不过图片的显示会有滞后效果。不过这个滞后效果正是我们需要的,因为对于正在高速转转轮的用户来说,他们并不需要完整地看完路上每一个文章的内容信息,一般高速滚动只会发生在他们知道文章大概位置从而快速切换过去的时刻。这个情况下,中间快速略过的卡片不需要完整展示内容信息,甚至可以优化掉内容渲染。
基于上面的思路,我设计了一个主要的实现方法:在轮盘高速滚动的时候,卡片转圈的时候收到「需要更新」的指令后,不会立刻更新卡片上的内容,而是会做上一个标记,表示「这个卡片是脏的」,同时完全不显示上面的内容(这一块只是视觉上,不需要做到清空真实 DOM 内容从而不会造成卡顿)。之后按照
一个固定的时间间隔 asyncRenderInterval(越短越丝滑,但卡顿也越高)进行异步渲染。
简而言之就是,不论用户如何滚动,卡片之间总是按照「渲染 A 卡片 -> 间隔 -> 渲染 B 卡片 -> 间隔 -> ...」的固定顺序。这样即便滚动很快,也只是卡片上内容展示没显示而已,稍微等待一会就会逐步显示,类似图片的那种异步出现效果。简化版伪代码如下:
const asyncRenderInterval = 30;
let nextRenderTime = 0;
let renderCursor = 0;
function markSlotDirty(slotIndex, sourceIndex) {
const slot = trackPool[slotIndex];
poolAssignments[slotIndex] = sourceIndex;
pendingSourceIndex[slotIndex] = sourceIndex;
// Hide content visually first, but do not rebuild the DOM immediately.
slot.classList.add('is-content-hidden');
}
function renderOneDirtySlot(now) {
if (now < nextRenderTime) return;
for (let i = 0; i < trackPool.length; i++) {
const slotIndex = (renderCursor + i) % trackPool.length;
const sourceIndex = pendingSourceIndex[slotIndex];
if (sourceIndex == null) continue;
// Only apply the latest async render task for this slot.
// Drop stale work if this slot has already moved to another post.
if (sourceIndex !== poolAssignments[slotIndex]) {
pendingSourceIndex[slotIndex] = null;
continue;
}
updateCardContent(trackPool[slotIndex], posts[sourceIndex]);
trackPool[slotIndex].classList.remove('is-content-hidden');
pendingSourceIndex[slotIndex] = null;
renderCursor = slotIndex + 1;
nextRenderTime = now + asyncRenderInterval;
break;
}
}
有个需要注意的事项,每次回填前都必须确认当前槽位仍然对应同一篇文章,必须得要保证每个槽位只接收最新的异步渲染任务。
这块是防“串位”的关键。因为你高速滚动时,旧任务很容易晚到,所以每次回填前都必须确认当前槽位仍然对应同一篇文章。这样可以保证每个槽位只接收最新的异步渲染任务;如果不做这个过期判定,就可能出现 A 卡位突然显示 B 内容的情况。
# 轻量化修改 | Lightweight Updates
在完成节点复用和异步回填之后,轮盘的主要性能问题基本就已经被拆开了:DOM 数量不会随着文章数量无限增长,内容回填也不会在某一帧突然集中爆发。但这里还有最后一个容易被忽略的问题,就是每一帧到底修改了什么。因为滚动动画本身一定需要逐帧更新,如果我们在 render() 里频繁读写布局、创建节点、改 innerHTML、重新计算复杂内容,那么前面做的优化还是会被抵消掉。
所以我这里采用的思路是:把每一帧的更新范围压到最小。渲染循环只负责计算当前卡片应该出现在什么位置、应该有多大、透明度和层级是多少;至于内容更新、详情预览、标题截断这些相对重的事情,都放到状态变化或异步队列里处理。简而言之,每一帧只做“视觉层”的修改,不做“结构层”的修改。也就是说,动画帧里只改 transform、opacity、zIndex、visibility 这类样式属性,不在这里新增/删除 DOM,也不在这里替换大块 HTML。这样浏览器可以更稳定地走合成层更新,减少重新布局和重绘的压力。

这一步的主要收益是让滚动动画本身保持稳定。即使内容还没回填完,轮盘的位置计算和视觉运动也不会被内容渲染拖住。简化版伪代码如下:
function renderFrame(offset) {
syncPoolAssignments(offset);
for (let i = 0; i < trackPool.length; i++) {
const card = trackPool[i];
const sourceIndex = poolAssignments[i];
const sourceDiff = sourceIndex - offset;
const visual = calculateCardVisual(sourceDiff);
// Only update lightweight visual styles in the animation frame.
card.style.transform = `translate3d(${visual.x}px, ${visual.y}px, 0) scale(${visual.scale})`;
card.style.opacity = String(visual.opacity);
card.style.zIndex = String(visual.zIndex);
card.style.visibility = visual.visible ? 'visible' : 'hidden';
// Never rebuild content here.
if (card.dataset.sourceIndex !== String(sourceIndex)) {
markSlotDirty(i, sourceIndex);
}
}
renderOneDirtySlot(performance.now());
requestAnimationFrame(() => renderFrame(nextOffset));
}
值得注意的是:不要在动画帧里读取会强制布局的属性之后,又立刻写样式,比如在循环中反复读取 offsetHeight / getBoundingClientRect() 再写 transform。如果确实需要测量高度,最好在初始化、resize、内容回填完成之后单独缓存下来。这样 renderFrame() 才能保持足够轻,轮盘滚动也会更稳定。
# 小结 | Conclusion
以上三个方法就是我主要的实现至少流畅的思路,就如我最开始说的,这绝对不是最优的方法,我只是提供了一个思路。现在 Vibe Coding 已经那么流行了,前端的部分交给 AI 实现实在是非常容易。我觉得有必要整理一些思路方面的设计方法,这对于我们驾驭 AI,或是防止被 AI 替代都是有好处的。如果你也想做这种“看起来无限”的轮盘,或者是其他有的没的样式,不用一上来就考虑特别复杂,在制作的时候吧问题汇总起来然后逐步击破我觉得更重要。我把我的整体思路整理成下面这张思维导图:

如果你觉得我的思路不错,或是有更好的方法和看法,请麻烦留下您宝贵的留言和意见,或者通过我的 email 都是可以的,再次感谢你的阅读~









