渐进式渲染实战:提升首屏加载性能的关键技术

艳珂🍀 优化 阅读 674
赞 17 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个内容密集型的详情页,用户一进来就疯狂抱怨“转圈转到怀疑人生”。我自己用手机点开也是一脸黑线——首屏白屏将近5秒,滚动还卡成PPT。这哪是用户体验,简直是用户劝退。

渐进式渲染实战:提升首屏加载性能的关键技术

页面结构其实不复杂:顶部是固定信息,中间一大块是动态加载的图文混排内容,底部还有评论区。问题就出在中间那块——数据量大(30+段落,每段带图),而且全部塞进一个 React 组件里一次性渲染。浏览器直接干懵了,主线程被 JS 占满,连 CSS 动画都卡住。

找到病根了!

先打开 Chrome DevTools 的 Performance 面板录了一次加载过程。果不其然,Main Thread 上一条超长的黄色任务(Scripting)占了4秒多,里面全是 createElement、appendChild 这种 DOM 操作。再看 Memory 曲线,瞬间飙升,说明一次性创建了太多节点。

用 Lighthouse 跑分更惨:FCP(首次内容绘制)5.2s,TTI(可交互时间)6.8s,Performance 分数 28。老板看到这个数据差点没把我叫去喝茶。

结论很明确:不能一股脑全塞进去,得拆!这时候就想到了渐进式渲染(Progressive Rendering)——不是等所有数据都准备好才显示,而是先展示骨架,再逐步填充内容。

试了几种方案,最后这个效果最好

一开始想用 React.lazy + Suspense,但发现它只适合按路由或组件级懒加载,不适合同一页面内大量内容的分批渲染。后来试了 Intersection Observer 做虚拟滚动,但内容高度不固定,计算 offset 太容易出错,维护成本高。

折腾半天,决定用最朴实的办法:**分块渲染 + requestIdleCallback**。核心思路是把 30 段内容切成 5 块,每块 6 段,利用浏览器空闲时间逐步插入 DOM。这样主线程不会被长时间阻塞,用户能快速看到首屏内容,还能马上滚动。

这里注意我踩过好几次坑:千万别用 setTimeout(fn, 0) 来分片!它虽然能切任务,但优先级太高,还是会卡住 UI。requestIdleCallback 才是正解,它只在浏览器空闲时执行,对用户操作几乎无影响。

下面是关键代码对比。优化前是这种“暴力全量渲染”:

// 优化前:一次性渲染所有内容
function ContentList({ items }) {
  return (
    <div>
      {items.map(item => (
        <ContentItem key={item.id} data={item} />
      ))}
    </div>
  );
}

优化后,我封装了一个 ProgressiveRenderer 组件:

import { useState, useEffect, useRef } from 'react';

function ProgressiveRenderer({ items, chunkSize = 6 }) {
  const [renderedItems, setRenderedItems] = useState([]);
  const renderedCountRef = useRef(0);

  useEffect(() => {
    if (renderedCountRef.current >= items.length) return;

    const renderNextChunk = () => {
      const start = renderedCountRef.current;
      const end = Math.min(start + chunkSize, items.length);
      const nextChunk = items.slice(start, end);

      setRenderedItems(prev => [...prev, ...nextChunk]);
      renderedCountRef.current = end;

      // 如果还有剩余,继续安排下一块
      if (end < items.length) {
        if (typeof window.requestIdleCallback !== 'undefined') {
          window.requestIdleCallback(renderNextChunk);
        } else {
          // fallback to setTimeout for older browsers
          setTimeout(renderNextChunk, 0);
        }
      }
    };

    // 首屏立即渲染第一块
    renderNextChunk();
  }, [items, chunkSize]);

  return (
    <div>
      {renderedItems.map(item => (
        <ContentItem key={item.id} data={item} />
      ))}
    </div>
  );
}

用法很简单,把原来的 ContentList 换成 ProgressiveRenderer 就行:

// 使用渐进式渲染
<ProgressiveRenderer items={contentData} chunkSize={6} />

另外,为了提升感知性能,我还加了骨架屏。首屏内容区域先用灰色块占位,等第一块数据渲染完再替换真实内容。用户看到的不再是白屏,而是“有东西在加载”的反馈。

.skeleton {
  background: #f0f0f0;
  border-radius: 4px;
  margin-bottom: 16px;
  height: 20px;
  animation: pulse 1.5s infinite ease-in-out;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

优化后:流畅多了

改完一测,效果立竿见影。首屏内容(前6段)在 800ms 内就出来了,用户能立刻看到信息,还能顺畅滚动。后面的段落随着滚动慢慢追加上来,完全无感。

最爽的是,即使网络慢,用户也不会觉得“卡死”,因为交互早就恢复了。之前卡住的时候,连返回按钮都点不动,现在点哪都跟手。

不过也有一点小瑕疵:如果用户滚得太快,可能会看到还没加载的空白段落。但加个简单的 loading 提示就解决了,体验上反而显得更真实——“哦,还有内容在加载”,而不是“这页面是不是坏了”。

性能数据对比

跑了几轮 Lighthouse,数据变化很直观:

  • FCP(首次内容绘制):从 5.2s 降到 0.8s
  • TTI(可交互时间):从 6.8s 降到 1.1s
  • Performance 分数:从 28 提升到 89
  • 主线程最大任务时长:从 4200ms 降到 320ms

实机测试(iPhone 12)更明显:原来要等 5 秒才能滑动,现在 1 秒内就能上下滚动,后面的内容自动追加,毫无卡顿。用户反馈也从“打不开”变成了“加载挺快”。

结尾

以上是我对渐进式渲染的一次实战优化。核心就两点:一是用 requestIdleCallback 分块渲染避免主线程阻塞,二是配合骨架屏提升感知速度。这个方案不是最优的(比如比不上流式 SSR),但胜在简单、兼容性好,改起来成本低,亲测有效。

如果你的页面也有大量内容一次性渲染的问题,不妨试试这个套路。当然,如果有更优的实现方式,比如结合 Web Worker 或者更智能的分块策略,欢迎评论区交流!

这个技巧的拓展用法还有很多,比如列表无限滚动、大数据表格渲染,后续会继续分享这类博客。以上是我踩坑后的总结,希望对你有帮助。

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

暂无评论