Skip to content
⚠️ This article was written in 2020. Some content may be outdated.

Vue 組件懶加載策略

首屏加載性能是前端優化的核心指標之一。Vue 項目的路由級懶加載已經很常見,但組件級懶加載、圖片懶加載、數據懶加載這些更細粒度的策略同樣值得深入掌握。本文從工程實踐出發,整理一套完整的懶加載方案。

路由懶加載

最基礎也最有效的優化手段,將路由組件拆分到獨立 chunk。

javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    // 直接導入,首屏組件不懶加載
    component: () => import('../views/Home.vue')
  },
  {
    path: '/dashboard',
    // 異步組件,按需加載
    component: () => import(
      /* webpackChunkName: "dashboard" */
      '../views/Dashboard.vue'
    )
  },
  {
    path: '/settings',
    component: () => import(
      /* webpackChunkName: "settings" */
      '../views/Settings.vue'
    )
  },
  {
    path: '/profile/:id',
    component: () => import(
      /* webpackChunkName: "profile" */
      '../views/Profile.vue'
    )
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

webpackChunkName 註釋讓打包產物有可讀的文件名,方便排查和緩存管理。

組件級懶加載

不是所有組件都需要首屏加載。對於模態框、抽屜、編輯器這類"按需出現"的組件,可以用 defineAsyncComponent 實現懶加載。

vue
<template>
  <div>
    <button @click="showEditor = true">打開富文本編輯器</button>

    <AsyncEditor
      v-if="showEditor"
      v-model="content"
      @close="showEditor = false"
    />
  </div>
</template>

<script>
import { defineAsyncComponent, ref } from 'vue'

// 僅在 v-if 為 true 時才加載組件代碼
const AsyncEditor = defineAsyncComponent({
  loader: () => import('./components/HeavyEditor.vue'),
  loadingComponent: {
    template: '<div class="editor-loading">編輯器加載中...</div>'
  },
  errorComponent: {
    template: '<div class="editor-error">加載失敗,請刷新重試</div>'
  },
  delay: 100,       // 延遲顯示 loading 組件的時間
  timeout: 15000,    // 超時時間
  suspensible: false // 不交給 Suspense 管理
})

export default {
  components: { AsyncEditor },
  setup() {
    const showEditor = ref(false)
    const content = ref('')
    return { showEditor, content }
  }
}
</script>

Intersection Observer 實現圖片懶加載

圖片是最常見的帶寬殺手。使用 IntersectionObserver API 實現可視區域內才加載真實圖片。

javascript
// directives/v-lazy.js
export default {
  mounted(el, binding) {
    const placeholder = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

    el.src = placeholder
    el.dataset.src = binding.value

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            const img = entry.target
            img.src = img.dataset.src
            img.onload = () => {
              img.classList.add('loaded')
            }
            img.onerror = () => {
              img.src = binding.value.fallback || placeholder
            }
            observer.unobserve(img)
          }
        })
      },
      {
        rootMargin: '100px' // 提前 100px 開始加載
      }
    )

    observer.observe(el)
    el._lazyObserver = observer
  },

  unmounted(el) {
    if (el._lazyObserver) {
      el._lazyObserver.disconnect()
    }
  }
}

// 註冊與使用
// app.directive('lazy', vLazy)
// <img v-lazy="imageUrl" alt="產品圖" />

數據懶加載:虛擬滾動

列表數據量大時(超過 1000 條),即使只渲染可見區域的 DOM 也能大幅提升性能。

vue
{% raw %}
<template>
  <div
    ref="container"
    class="virtual-list"
    @scroll="onScroll"
  >
    <div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div
      class="content"
      :style="{ transform: `translateY(${offset}px)` }"
    >
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.name }}
      </div>
    </div>
  </div>
</template>

<script>
import { ref, computed, onMounted } from 'vue'

export default {
  props: {
    items: { type: Array, default: () => [] },
    itemHeight: { type: Number, default: 50 }
  },
  setup(props) {
    const container = ref(null)
    const scrollTop = ref(0)
    const visibleCount = ref(10)

    const totalHeight = computed(() => props.items.length * props.itemHeight)
    const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight))
    const endIndex = computed(() => Math.min(startIndex.value + visibleCount.value, props.items.length))

    const visibleItems = computed(() =>
      props.items.slice(startIndex.value, endIndex.value)
    )

    const offset = computed(() => startIndex.value * props.itemHeight)

    const onScroll = (e) => {
      scrollTop.value = e.target.scrollTop
    }

    onMounted(() => {
      visibleCount.value = Math.ceil(
        container.value.clientHeight / props.itemHeight
      ) + 2
    })

    return { container, totalHeight, visibleItems, offset, onScroll }
  }
}
</script>
{% endraw %}

小結

  • 路由懶加載用 () => import() 配合 webpackChunkName,效果立竿見影
  • defineAsyncComponent 支持自定義 loading/error 組件和超時配置
  • 圖片懶加載使用 IntersectionObserver,設置 rootMargin 提前加載
  • 大列表用虛擬滾動,只渲染可視區域內的 DOM,性能提升可達數十倍
  • 懶加載的核心原則:不在首屏的代碼和資源,一律按需加載

MIT Licensed