文章

组件懒加载策略深度解析

从路由懒加载到可见性加载,详解组件懒加载的三层策略、IntersectionObserver原理和骨架屏最佳实践

组件懒加载策略深度解析

一句话概括

懒加载的本质是把「用户不需要立刻看到」的代码推迟到「用户需要时」再下载——路由懒加载是骨架(按页面拆分),组件可见性加载是血肉(滚到才加载),骨架屏 + prefetch 是灵魂(加载不闪屏、点击不等待)。

核心知识点

1. 路由懒加载:最高 ROI 的一刀

一笔投入,效果最猛。每个页面独立 chunk,不访问就不下载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// React Router v6:路由懒加载标准写法
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'

const Home    = lazy(() => import('./pages/Home'))
const Admin   = lazy(() => import('./pages/Admin'))
const Profile = lazy(() => import('./pages/Profile'))

function App() {
  return (
    <Suspense fallback={<PageSpinner />}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/admin" element={<Admin />} />
        <Route path="/profile" element={<Profile />} />
      </Routes>
    </Suspense>
  )
}
1
2
3
4
5
// Vue Router 同理,甚至更简洁
const routes = [
  { path: '/admin', component: () => import('./views/Admin.vue') },
  { path: '/profile', component: () => import('./views/Profile.vue') },
]

一个 1.5MB 的 SPA 做完路由懒加载后,首屏通常降到 300KB 以内——这是优化投入产出比最高的一步。

2. 可见性加载:IntersectionObserver 精准触达

页面底部的评论区、推荐模块——用户可能永远不会滚到那。用 IntersectionObserver 在这些模块「即将进入视口」时才加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { useRef, useState, useEffect } from 'react'

function LazySection({ children, fallback, rootMargin = '200px' }) {
  const ref = useRef(null)
  const [shouldLoad, setShouldLoad] = useState(false)

  useEffect(() => {
    const el = ref.current
    if (!el) return
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setShouldLoad(true)
          observer.disconnect()  // 只触发一次,加载后停止观察
        }
      },
      { rootMargin }  // 提前 200px 触发,下载和渲染无缝衔接
    )
    observer.observe(el)
    return () => observer.disconnect()
  }, [rootMargin])

  return (
    <div ref={ref}>
      {shouldLoad ? children : fallback}
    </div>
  )
}

// 使用
<LazySection rootMargin="300px" fallback={<CommentSkeleton />}>
  <CommentSection />
</LazySection>

为什么不用 scroll 事件? scroll 回调需要 getBoundingClientRect() 强制回流,高频触发时性能灾难;IntersectionObserver 是浏览器异步 API,在 GPU 合成线程做计算,不阻塞主线程。

3. 图片懒加载:原生 loading="lazy" + 渐进式

现代浏览器已经原生支持图片懒加载,一行 HTML 即可:

1
2
3
4
5
<!-- 原生懒加载:Chrome 77+、Safari 15.4+ 已支持 -->
<img src="banner.jpg" loading="lazy" />

<!-- iframe 也支持 -->
<iframe src="embed.html" loading="lazy" />

但生产环境常用增强方案——占位图 + 渐进式加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function LazyImage({ src, alt }) {
  const [loaded, setLoaded] = useState(false)
  return (
    <div style={{ position: 'relative', background: '#f0f0f0' }}>
      {!loaded && <div className="blur-placeholder" />} {/* 模糊占位 */}
      <img
        src={src}
        alt={alt}
        loading="lazy"
        onLoad={() => setLoaded(true)}
        style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
      />
    </div>
  )
}

4. 骨架屏:加载不是 bug,加载时的体验差距才是

懒加载最怕的是:用户滚到一个区域 → 白屏闪烁 → 内容突然蹦出来(布局抖动)。骨架屏解决的就是这段等待期的体感:

1
2
3
4
5
6
7
8
9
10
/* 骨架屏动画 */
.skeleton {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

核心原则:骨架屏的尺寸必须和真实内容一致(或设置 min-height),否则内容出现时页面会跳动——比白屏几秒还让用户难受。

其实你每天都在用

  1. 路由分割:你打开淘宝首页不会下载「订单详情页」的代码——这就是路由懒加载。
  2. loading="lazy":你用 <img loading="lazy"> 时,浏览器自动判断图片离视口距离决定何时下载。
  3. webpackChunkName 魔法注释:你在 import() 里写的 /* webpackChunkName: "admin" */ 控制着产物文件名,方便定位和缓存策略。
  4. IntersectionObserver 不仅是懒加载:它也被用于埋点曝光统计(「用户是否看到了这个广告位」)和无限滚动。
  5. 骨架屏 vs Spinner:YouTube、Twitter 都在用骨架屏而非转圈 spinner——因为它给用户「内容马上就来」的心理暗示,而不是「还在加载」。

常见误解(FAQ)

❌ 误区:「懒加载就是性能优化,做得越细越好」

过度拆分会适得其反——每个 chunk 都是一个 HTTP 请求。30 个 5KB 的 chunk 比 1 个 150KB 的 chunk 在 HTTP/1.1 下慢得多(并发限制 + RTT 开销)。合理的拆分:每个 chunk 30-150KB,vendor 单独抽出。

❌ 误区:「React.lazy 加载失败,fallback 会一直显示」

不会。chunk 加载失败时 React 会 throw error,Suspense 被设计成只处理加载中状态,失败必须用 ErrorBoundary 接住并展示重试按钮——生产环境 lazy + Suspense + ErrorBoundary 是铁三角。

❌ 误区:「只要做好懒加载就不需要关心 bundle 大小了」

懒加载解决的是加载时机问题,不是代码体积问题。一个 500KB 的 chunk 懒加载了还是 500KB——该砍的依赖还是要砍(tree shaking、按需引入),该压缩的还是要压缩。

❌ 误区:「IntersectionObserver 的 rootMargin 设越大越好」

rootMargin 设太大会导致页面一加载就触发大量观察回调,虽然「提前了」但可能影响首屏渲染。一般建议:关键内容 200-300px,尾部内容 0-100px,平衡预加载和性能。

一句话总结

懒加载的目标不是「能不加载就不加载」,而是让用户永远感觉不到「正在加载」——骨架屏、rootMargin、prefetch 的组合拳,比单纯的「晚下载」更重要。

本文由作者按照 CC BY 4.0 进行授权