Timeline时间轴组件开发实战与性能优化技巧

书生シ景荣 交互 阅读 1,376
赞 17 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个带 Timeline 时间轴的项目,结果 QA 一测就炸了:页面加载慢得离谱,滚动还卡成 PPT。我本地跑还好,一到低端机上直接卡死,连点击都响应不了。用户反馈说“点一下要等两秒才动”,这哪能忍?

Timeline时间轴组件开发实战与性能优化技巧

这个 Timeline 是动态加载的,初始有 50 条数据,每条包含图片、标题、时间、描述,还有动画入场效果。我一开始图省事,直接用 v-for(Vue 项目)全量渲染,没做任何优化。结果性能问题直接暴露——首屏加载时间 5s+,滚动 FPS 掉到 10 以下。

找到瓶颈了!

先别瞎改,得知道问题在哪。我打开 Chrome DevTools,Performance 面板录了一次滚动操作,一看吓一跳:

  • 主线程被大量 DOM 操作占满,每次滚动都触发重排重绘
  • JS 执行时间占比超高,主要是组件初始化和事件绑定
  • 内存占用一路飙升,GC(垃圾回收)频繁触发

再切到 Memory 面板,拍个快照,发现 Timeline 里的每个 item 都持有了大量闭包引用,根本没释放。难怪越滚越卡。

结论很明确:DOM 太多 + 初始化开销太大 + 没有按需渲染。

方案一:虚拟滚动搞起来

第一反应就是上虚拟滚动(Virtual Scroll)。但 Timeline 不是列表,它有左右交替的布局,传统虚拟滚动库不支持。折腾了半天,发现得自己写。

核心思路很简单:只渲染可视区域内的节点,其余用 padding 占位。关键是怎么算“可视区域”。

我试了两种方式:

  1. 监听 scroll 事件,配合 getBoundingClientRect() 计算
  2. 用 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 特定布局的优化技巧?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论