组件懒加载与异步组件

首屏不需要的组件不应该阻塞页面加载——视频播放器、富文本编辑器、图表组件这些"重量级"内容,应该在需要时才加载。Nuxt4 提供了从 Lazy 前缀到 defineAsyncComponent、再到延迟水合(Lazy Hydration)的完整懒加载方案。本章讲清每种方式的原理、适用场景和性能收益。

1. LazyXxx 前缀懒加载原理

1.1 Lazy 前缀的工作方式

Nuxt4 为每个自动导入的组件自动生成一个 Lazy 前缀的版本:

<!-- 普通加载:组件代码包含在页面 JS bundle 中 -->
<VideoPlayer :url="video.url" />
 
<!-- 懒加载:组件代码单独打包,使用时才下载 -->
<LazyVideoPlayer :url="video.url" />

底层机制:

// Nuxt 自动为每个组件生成两个版本:
// 1. VideoPlayer     → import VideoPlayer from '~/components/VideoPlayer.vue'
// 2. LazyVideoPlayer → defineAsyncComponent(() => import('~/components/VideoPlayer.vue'))

1.2 打包差异

不使用 Lazy:
  page-bundle.js (500KB)
    ├── 页面逻辑 (50KB)
    ├── VideoPlayer (200KB)    ← 首屏不需要也要下载
    └── CommentList (250KB)    ← 首屏不需要也要下载

使用 Lazy:
  page-bundle.js (50KB)         ← 首屏 JS 大幅减小
  VideoPlayer.chunk.js (200KB)  ← 用到时才下载
  CommentList.chunk.js (250KB)  ← 用到时才下载

1.3 条件渲染 + Lazy

Lazy 组件与 v-if 配合使用效果最佳——只有条件为 true 时才下载组件代码

<template>
  <div>
    <!-- 点击按钮才加载播放器 -->
    <button @click="showPlayer = true">播放视频</button>
    <LazyVideoPlayer v-if="showPlayer" :url="video.url" />
 
    <!-- 滚动到底部才加载评论 -->
    <LazyCommentList v-if="isBottomVisible" :video-id="video.id" />
 
    <!-- Tab 切换才加载对应内容 -->
    <LazyVideoAnalytics v-if="activeTab === 'analytics'" />
  </div>
</template>

1.4 Lazy 的加载时机

场景行为
&lt;LazyXxx /&gt; 始终渲染页面加载后立即异步下载(不阻塞首屏)
&lt;LazyXxx v-if="false" /&gt;不下载,直到条件变为 true
&lt;LazyXxx v-show="false" /&gt;立即下载(v-show 不影响挂载)

2. defineAsyncComponent 与 Suspense

2.1 defineAsyncComponent 基础

当需要更精细的控制时,使用 Vue 原生的 defineAsyncComponent

const HeavyEditor = defineAsyncComponent({
  // 加载函数
  loader: () => import('~/components/HeavyEditor.vue'),
 
  // 加载中显示
  loadingComponent: EditorSkeleton,
  delay: 200,  // 200ms 后才显示 loading(避免闪烁)
 
  // 加载失败显示
  errorComponent: EditorError,
  timeout: 10000,  // 10 秒超时
 
  // 加载失败时的钩子
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      retry()  // 自动重试 3 次
    } else {
      fail()
    }
  },
})

2.2 与 Suspense 配合

<template>
  <Suspense>
    <!-- 异步组件(可以有 async setup) -->
    <HeavyDashboard />
 
    <!-- 加载中的 fallback -->
    <template #fallback>
      <DashboardSkeleton />
    </template>
  </Suspense>
</template>
 
<script setup lang="ts">
const HeavyDashboard = defineAsyncComponent(
  () => import('~/components/studio/Dashboard.vue')
)
</script>

2.3 NuxtPage 内置 Suspense

Nuxt 的 &lt;NuxtPage&gt; 已经内置了 Suspense 支持:

<!-- app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage />
    <!-- NuxtPage 自动处理页面组件的 async setup -->
    <!-- 页面切换时显示加载状态 -->
  </NuxtLayout>
</template>

3. 延迟水合策略

3.1 什么是延迟水合

默认情况下,SSR 渲染的组件在客户端会立即全部水合。延迟水合允许某些组件推迟 Hydration 时机

默认行为:
  HTML 渲染 → JS 下载 → 所有组件同时 Hydrate → 可交互

延迟水合:
  HTML 渲染 → JS 下载 → 关键组件先 Hydrate → 可交互
                       → 非关键组件稍后 Hydrate(空闲/可见/事件触发)

3.2 Nuxt4 延迟水合指令

<template>
  <!-- 空闲时水合(requestIdleCallback) -->
  <LazyVideoRecommendations hydrate-on-idle />
 
  <!-- 进入视口时水合(IntersectionObserver) -->
  <LazyCommentList hydrate-on-visible />
 
  <!-- 用户交互时水合(click/focus/hover) -->
  <LazyVideoPlayer hydrate-on-interaction />
 
  <!-- 媒体查询匹配时水合 -->
  <LazySidebar hydrate-on-media-query="(min-width: 768px)" />
 
  <!-- 永不水合(纯静态展示) -->
  <LazyStaticBanner :hydrate="false" />
</template>

3.3 各策略详解

策略触发时机适用场景
hydrate-on-idle浏览器空闲时非关键交互组件
hydrate-on-visible进入视口时折叠内容、底部评论
hydrate-on-interaction用户交互时视频播放器(点击才需要)
hydrate-on-media-query媒体查询匹配时仅桌面端的侧边栏
:hydrate="false"永不水合纯展示的静态内容

3.4 延迟水合的性能收益

首页加载(无延迟水合):
  Hydration 时间:120ms(所有组件)
  TTI:800ms

首页加载(有延迟水合):
  Hydration 时间:40ms(仅关键组件)
  TTI:500ms(-37.5%)
  折叠组件:滚动到时才水合
  侧边栏:桌面端才水合

4. AI 视频播放器按需加载与骨架屏

4.1 完整实战方案

<!-- app/pages/videos/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { data: video } = await useFetch(`/api/videos/${route.params.id}`)
 
const showPlayer = ref(false)
</script>
 
<template>
  <div class="max-w-6xl mx-auto">
    <!-- 视频封面 / 播放器区域 -->
    <div class="aspect-video bg-black rounded-xl overflow-hidden">
      <template v-if="showPlayer">
        <LazyVideoPlayer
          :url="video.url"
          :poster="video.thumbnail"
          @ended="showPlayer = false"
        />
      </template>
      <template v-else>
        <!-- 点击封面开始播放(此时才加载播放器组件) -->
        <div
          class="relative w-full h-full cursor-pointer group"
          @click="showPlayer = true"
        >
          <img :src="video.thumbnail" :alt="video.title" class="w-full h-full object-cover" />
          <div class="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/40 transition">
            <PlayIcon class="w-16 h-16 text-white" />
          </div>
        </div>
      </template>
    </div>
 
    <!-- 视频信息(立即渲染) -->
    <VideoInfo :video="video" />
 
    <!-- 相关推荐(空闲时水合) -->
    <LazyVideoRecommendations
      :video-id="video.id"
      hydrate-on-idle
    />
 
    <!-- 评论区(进入视口时水合 + 懒加载) -->
    <LazyCommentSection
      :video-id="video.id"
      hydrate-on-visible
    />
  </div>
</template>

4.2 骨架屏组件

<!-- app/components/video/PlayerSkeleton.vue -->
<template>
  <div class="aspect-video bg-gray-200 dark:bg-gray-800 rounded-xl animate-pulse flex items-center justify-center">
    <div class="w-16 h-16 rounded-full bg-gray-300 dark:bg-gray-700" />
  </div>
</template>
<!-- app/components/CommentSkeleton.vue -->
<template>
  <div class="space-y-4">
    <div v-for="i in 3" :key="i" class="flex gap-3 animate-pulse">
      <div class="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 shrink-0" />
      <div class="flex-1 space-y-2">
        <div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
        <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
        <div class="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
      </div>
    </div>
  </div>
</template>

4.3 配合 defineAsyncComponent 的高级用法

// app/components/video/Player.client.vue 的高级加载配置
const VideoPlayer = defineAsyncComponent({
  loader: () => import('~/components/video/Player.client.vue'),
  loadingComponent: () => import('~/components/video/PlayerSkeleton.vue'),
  delay: 100,
  timeout: 15000,
  onError(error, retry, fail, attempts) {
    // 网络错误时重试
    if (error.message.includes('fetch') && attempts <= 3) {
      retry()
      return
    }
    fail()
  },
})

本章小结

  • Lazy 前缀&lt;LazyXxx /&gt; 自动将组件代码分割为独立 chunk,按需下载
  • v-if + Lazy:条件为 true 时才开始下载组件代码,首屏 JS 大幅减小
  • defineAsyncComponent:精细控制加载中组件、错误组件、超时、重试等
  • 延迟水合hydrate-on-idle(空闲)、hydrate-on-visible(可见)、hydrate-on-interaction(交互)
  • 骨架屏:在组件加载前展示占位内容,避免布局跳动
  • 实战:视频播放器点击加载 + 推荐列表空闲水合 + 评论区可见水合

下一章讲 ClientOnly 与 .client/.server 组件的运行环境隔离。