Timeline时间轴组件开发实战与性能优化技巧
优化前:卡得不行
上周上线一个带 Timeline 时间轴的项目,结果 QA 一测就炸了:页面加载慢得离谱,滚动还卡成 PPT。我本地跑还好,一到低端机上直接卡死,连点击都响应不了。用户反馈说“点一下要等两秒才动”,这哪能忍?
这个 Timeline 是动态加载的,初始有 50 条数据,每条包含图片、标题、时间、描述,还有动画入场效果。我一开始图省事,直接用 v-for(Vue 项目)全量渲染,没做任何优化。结果性能问题直接暴露——首屏加载时间 5s+,滚动 FPS 掉到 10 以下。
找到瓶颈了!
先别瞎改,得知道问题在哪。我打开 Chrome DevTools,Performance 面板录了一次滚动操作,一看吓一跳:
- 主线程被大量 DOM 操作占满,每次滚动都触发重排重绘
- JS 执行时间占比超高,主要是组件初始化和事件绑定
- 内存占用一路飙升,GC(垃圾回收)频繁触发
再切到 Memory 面板,拍个快照,发现 Timeline 里的每个 item 都持有了大量闭包引用,根本没释放。难怪越滚越卡。
结论很明确:DOM 太多 + 初始化开销太大 + 没有按需渲染。
方案一:虚拟滚动搞起来
第一反应就是上虚拟滚动(Virtual Scroll)。但 Timeline 不是列表,它有左右交替的布局,传统虚拟滚动库不支持。折腾了半天,发现得自己写。
核心思路很简单:只渲染可视区域内的节点,其余用 padding 占位。关键是怎么算“可视区域”。
我试了两种方式:
- 监听 scroll 事件,配合
getBoundingClientRect()计算 - 用 Intersection Observer(IO)
第一种在低端机上 scroll 事件太频繁,容易卡;第二种更省性能,但兼容性差点(不过现在基本够用了)。最后选了 IO,亲测有效。
下面是简化后的核心逻辑:
// 虚拟滚动控制器
class VirtualTimeline {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.visibleStart = 0;
this.visibleCount = 0;
this.totalHeight = items.length * itemHeight;
this.observer = new IntersectionObserver(
this.handleIntersect.bind(this),
{ threshold: 0 }
);
this.render();
}
render() {
// 清空旧内容
this.container.innerHTML = '';
// 创建总高度占位
const wrapper = document.createElement('div');
wrapper.style.height = ${this.totalHeight}px;
wrapper.style.position = 'relative';
// 只渲染可视区域
const viewportHeight = window.innerHeight;
const scrollTop = this.container.scrollTop;
this.visibleStart = Math.floor(scrollTop / this.itemHeight);
this.visibleCount = Math.ceil(viewportHeight / this.itemHeight) + 2; // 多渲染两屏防白屏
for (let i = this.visibleStart; i < this.visibleStart + this.visibleCount && i < this.items.length; i++) {
const itemEl = this.createItem(this.items[i]);
itemEl.style.position = 'absolute';
itemEl.style.top = ${i * this.itemHeight}px;
itemEl.dataset.index = i;
wrapper.appendChild(itemEl);
this.observer.observe(itemEl);
}
this.container.appendChild(wrapper);
}
handleIntersect(entries) {
// 实际项目中可做懒加载、动画触发等
entries.forEach(entry => {
if (entry.isIntersecting) {
// 比如:触发图片懒加载
const img = entry.target.querySelector('img[data-src]');
if (img) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
}
});
}
}
这里注意我踩过好几次坑:绝对定位的 top 必须精确计算,否则滚动会跳;visibleCount 一定要多预留几项,不然快速滚动会出现空白;IO 的回调里别做 heavy 操作,否则又卡了。
方案二:图片懒加载 + 骨架屏
Timeline 里每条都有图,之前全量加载,50 张图一起请求,带宽直接打满。改成懒加载后,首屏请求从 50 降到 5 个左右。
配合骨架屏,用户感知体验提升巨大。代码其实很简单:
<!-- 每个 item 的结构 -->
<div class="timeline-item">
<div class="skeleton" v-if="!loaded"></div>
<img :src="imgSrc" :data-src="realImg" @load="onLoad" v-show="loaded" />
</div>
// 在 Intersection Observer 回调里赋值 realImg 到 src
骨架屏用 CSS 动画实现,轻量又快。这个改动几乎没成本,但首屏加载时间直接砍掉 2 秒。
方案三:事件委托 + 避免重复绑定
原来每个 item 都绑了 click 事件,50 个就是 50 个监听器。改成事件委托,整个 Timeline 只绑一个:
container.addEventListener('click', (e) => {
if (e.target.closest('.timeline-item')) {
const index = e.target.closest('.timeline-item').dataset.index;
// 处理点击
}
});
内存占用立马降下来,GC 压力小多了。
优化后:流畅多了
三招组合拳打完,效果立竿见影:
- 首屏加载时间从 5.2s 降到 800ms
- 滚动 FPS 从 10 提升到 55+(低端机也能跑 40+)
- 内存占用稳定在 80MB 左右,不再持续增长
最爽的是,现在加 200 条数据也不卡了。测试机(红米 Note 8)上滚动丝滑,QA 再也没提过卡顿问题。
当然,也不是完美:快速滚动时偶尔会有 item 闪现(因为 IO 回调延迟),但无伤大雅。如果真要解决,可以预加载前后几屏,但我觉得没必要,毕竟用户体验已经达标。
性能数据对比
贴一下 Lighthouse 报告的关键指标(模拟 Moto G4):
| 指标 | 优化前 | 优化后 |
|---|---|---|
| FCP(首次内容绘制) | 3.1s | 0.7s |
| TTI(可交互时间) | 5.8s | 1.2s |
| Speed Index | 6.3s | 1.5s |
| 滚动 FPS(实测) | 8-12 | 45-58 |
数据不会骗人。特别是 TTI,从近 6 秒干到 1 秒出头,用户再也不用盯着白屏干等了。
结尾碎碎念
这次优化最大的体会是:别一上来就堆 fancy 动画和复杂交互,先把基础性能打好。Timeline 这种长列表,虚拟滚动几乎是必选项,哪怕自己手写也值得。
另外,DevTools 真的是神器,别凭感觉瞎猜,数据才是王道。
以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流,比如有没有更轻量的虚拟滚动实现?或者 Timeline 特定布局的优化技巧?

暂无评论