Vue 3 Performance 2026 - Tối ưu rendering từ component đến bundle

Posted on: 4/21/2026 12:15:55 AM

Table of contents

  1. 1. Hiểu Vue 3 Rendering Pipeline — Bottleneck nằm ở đâu?
  2. 2. Vue 3.6 Vapor Mode — Loại bỏ Virtual DOM
    1. 2.1 Cách Vapor Mode hoạt động
    2. 2.2 Bật Vapor Mode
      1. Chiến lược áp dụng Vapor Mode
    3. 2.3 Giới hạn hiện tại
  3. 3. Reactivity Fine-tuning — Giảm chi phí tracking
    1. 3.1 shallowRef và shallowReactive
      1. Khi nào dùng shallowRef?
    2. 3.2 computed vs methods — Cache hay không cache?
    3. 3.3 Tránh inline object trong template
  4. 4. Template-level Optimization — v-once, v-memo và KeepAlive
    1. 4.1 v-once — Render một lần duy nhất
    2. 4.2 v-memo — Memoize có điều kiện
    3. 4.3 KeepAlive — Cache component instance
      1. KeepAlive và Memory
  5. 5. Virtual Scrolling — Render hàng nghìn item mượt mà
    1. 5.1 Triển khai với @tanstack/vue-virtual
    2. 5.2 Kết hợp virtual scrolling với shallowRef
  6. 6. Lazy Loading và Code Splitting
    1. 6.1 Route-level code splitting
    2. 6.2 Component-level lazy loading với defineAsyncComponent
    3. 6.3 Prefetch và Preload strategies
      1. Prefetch với Speculation Rules API
  7. 7. Bundle Optimization — Giảm kích thước gửi đến trình duyệt
    1. 7.1 Phân tích bundle với rollup-plugin-visualizer
    2. 7.2 Import đúng cách — Tree Shaking hiệu quả
    3. 7.3 Cấu hình chunk strategy
  8. 8. Memory Management — Ngăn memory leak
    1. 8.1 Dọn dẹp side effects trong onUnmounted
    2. 8.2 AbortController cho async operations
    3. 8.3 Watch cleanup
      1. Phát hiện memory leak
  9. 9. SSR và SSG — Tối ưu cho First Contentful Paint
    1. 9.1 Nuxt 4 Hybrid Rendering
    2. 9.2 Selective Hydration
  10. 10. Performance Profiling — Đo trước khi tối ưu
    1. 10.1 Vue DevTools Performance Tab
    2. 10.2 Lighthouse và Core Web Vitals
    3. 10.3 Custom performance markers
  11. 11. Checklist tối ưu Vue 3 Production
  12. 12. Kết luận
    1. Theo dõi Vue 3.6 Stable
    2. Nguồn tham khảo

Bạn đã bao giờ deploy một ứng dụng Vue 3 lên production rồi nhận ra trang chủ mất hơn 3 giây để interactive? Danh sách 10.000 item lag khi scroll? Bundle size phình lên 2MB dù chỉ dùng vài component? Đây là những vấn đề phổ biến mà hầu hết Vue developer đều gặp — và Vue 3.6 với Vapor Mode cùng hàng loạt kỹ thuật tối ưu sẽ giúp bạn giải quyết triệt để.

Bài viết này không dừng ở lý thuyết. Chúng ta sẽ đi sâu từ cách Vue render component, tại sao Virtual DOM có thể trở thành bottleneck, cho đến từng kỹ thuật cụ thể với code thực tế — giúp bạn biến ứng dụng Vue 3 từ "chạy được" thành "chạy nhanh".

~97% Cải thiện rendering với Vapor Mode
100ms Mount 100K components (Vapor)
60-80% Giảm bundle size với tree-shaking
10x Tốc độ scroll với virtual list

1. Hiểu Vue 3 Rendering Pipeline — Bottleneck nằm ở đâu?

Trước khi tối ưu, cần hiểu Vue 3 render component như thế nào. Mỗi khi state thay đổi, Vue đi qua một pipeline gồm nhiều bước — và mỗi bước đều có chi phí.

graph LR
    A[State Change] --> B[Trigger Reactivity]
    B --> C[Re-run render function]
    C --> D[Generate new VNode tree]
    D --> E[Diff old vs new VNode]
    E --> F[Patch DOM]

    style A fill:#e94560,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style F fill:#e94560,stroke:#fff,color:#fff

Pipeline rendering của Vue 3 — từ state change đến DOM update

Trong pipeline này, bước Generate VNodeDiff VNode là tốn kém nhất. Với component tree lớn (hàng nghìn node), việc tạo object VNode, so sánh từng property, rồi quyết định patch — tất cả đều tiêu tốn CPU và bộ nhớ.

Vue 3 đã tối ưu hơn Vue 2 rất nhiều nhờ các compiler hint như patch flags, static hoisting, và block tree. Nhưng bản chất Virtual DOM vẫn là trung gian — và Vapor Mode trong Vue 3.6 sẽ loại bỏ hoàn toàn lớp trung gian này.

2. Vue 3.6 Vapor Mode — Loại bỏ Virtual DOM

Vapor Mode là tính năng quan trọng nhất trong Vue 3.6 (hiện đang ở beta). Thay vì compile template thành render function trả về VNode, Vapor Mode compile trực tiếp thành các lệnh DOM imperative — tương tự cách Svelte và Solid.js hoạt động.

2.1 Cách Vapor Mode hoạt động

graph TB
    subgraph Traditional["Traditional Mode"]
        T1[Template] --> T2[Render Function]
        T2 --> T3[VNode Tree]
        T3 --> T4[Diff Algorithm]
        T4 --> T5[DOM Patches]
    end

    subgraph Vapor["Vapor Mode"]
        V1[Template] --> V2[Compiled DOM Operations]
        V2 --> V3[Direct DOM Updates]
    end

    style T1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style T5 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style V1 fill:#e94560,stroke:#fff,color:#fff
    style V2 fill:#e94560,stroke:#fff,color:#fff
    style V3 fill:#e94560,stroke:#fff,color:#fff

So sánh Traditional Mode và Vapor Mode — Vapor bỏ qua hoàn toàn VNode và Diff

Với Traditional Mode, một component đơn giản hiển thị biến count sẽ tạo VNode object, so sánh với VNode cũ, rồi mới cập nhật text node. Với Vapor Mode, compiler phân tích template tại build time, biết chính xác node nào phụ thuộc vào biến nào, và sinh ra code cập nhật trực tiếp — không qua trung gian.

2.2 Bật Vapor Mode

Vapor Mode hoạt động ở cấp component — bạn có thể mix component truyền thống và Vapor trong cùng một app:

<!-- CounterVapor.vue -->
<!-- Thêm attribute vapor để bật Vapor Mode cho component này -->
<script setup vapor>
import { ref } from 'vue/vapor'

const count = ref(0)
const increment = () => count.value++
</script>

<template>
  <button @click="increment">Count: {{ count }}</button>
</template>

Cấu hình Vite plugin:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      features: {
        vaporMode: true // Cho phép component có attribute "vapor"
      }
    })
  ]
})

Chiến lược áp dụng Vapor Mode

Không cần chuyển toàn bộ app sang Vapor Mode ngay. Bắt đầu với các leaf component render thường xuyên (list item, card, badge) — đây là nơi Vapor Mode mang lại hiệu quả cao nhất vì giảm overhead cho mỗi lần re-render. Component gốc (layout, page) ít re-render nên lợi ích không đáng kể.

2.3 Giới hạn hiện tại

Tính đến tháng 4/2026, Vapor Mode vẫn ở trạng thái beta (v3.6.0-beta). Một số lưu ý:

  • Chưa hỗ trợ đầy đủ <Transition><TransitionGroup>
  • Plugin của một số UI library (Vuetify, PrimeVue) có thể chưa tương thích
  • DevTools profiling cho Vapor component đang được hoàn thiện
  • Không nên dùng trong production cho đến khi stable release

3. Reactivity Fine-tuning — Giảm chi phí tracking

Hệ thống reactivity của Vue 3 dùng Proxy để track dependency. Mặc định, ref()reactive() sẽ deep-track toàn bộ nested object. Với dataset lớn, chi phí tracking này trở nên đáng kể.

3.1 shallowRef và shallowReactive

import { shallowRef, triggerRef } from 'vue'

// ❌ Deep ref — track mọi property trong mỗi item
const items = ref(largeDataset) // 10.000 items, mỗi item 20 properties
// Vue tạo Proxy cho: items.value, items.value[0], items.value[0].name, ...

// ✅ Shallow ref — chỉ track items.value thay đổi
const items = shallowRef(largeDataset)
// Vue chỉ tạo Proxy cho items.value — KHÔNG đi sâu vào từng item

// Khi cần update:
items.value[0].name = 'Updated' // Không trigger re-render (vì shallow)
items.value = [...items.value]  // Trigger re-render (thay đổi reference)

// Hoặc dùng triggerRef để force update mà không tạo array mới:
items.value[0].name = 'Updated'
triggerRef(items) // Force re-render

Khi nào dùng shallowRef?

Dùng shallowRef khi data có nhiều nested object mà bạn thường thay thế toàn bộ (fetch API mới, pagination). Không dùng khi bạn cần reactive cho từng field trong form binding — v-model sẽ không hoạt động với property con của shallowRef.

3.2 computed vs methods — Cache hay không cache?

// ❌ Method — gọi lại mỗi lần component re-render
const getFilteredItems = () => {
  return items.value.filter(item => item.active)
}

// ✅ Computed — chỉ tính lại khi dependency thay đổi
const filteredItems = computed(() => {
  return items.value.filter(item => item.active)
})
// Nếu items không đổi, computed trả về kết quả cached — KHÔNG filter lại

Với danh sách 10.000 item, sự khác biệt giữa method và computed có thể là hàng chục millisecond mỗi lần re-render — đặc biệt khi component cha re-render nhưng data con không đổi.

3.3 Tránh inline object trong template

<!-- ❌ Tạo object mới mỗi lần render → child component luôn re-render -->
<ChildComponent :style="{ color: 'red', fontSize: '14px' }" />
<ChildComponent :config="{ pageSize: 20, sortBy: 'name' }" />

<!-- ✅ Khai báo ngoài template hoặc dùng computed -->
<script setup>
const cardStyle = { color: 'red', fontSize: '14px' }
const tableConfig = computed(() => ({
  pageSize: pageSize.value,
  sortBy: sortField.value
}))
</script>

<ChildComponent :style="cardStyle" />
<ChildComponent :config="tableConfig" />

4. Template-level Optimization — v-once, v-memo và KeepAlive

4.1 v-once — Render một lần duy nhất

Dùng v-once cho subtree hoàn toàn tĩnh (header, footer, disclaimer text). Vue sẽ skip hoàn toàn subtree này trong mọi lần re-render sau đó.

<template>
  <!-- Header tĩnh — không bao giờ thay đổi -->
  <div v-once class="terms-of-service">
    <h2>Điều khoản sử dụng</h2>
    <p>Rất nhiều nội dung tĩnh ở đây...</p>
    <!-- 50+ paragraphs -->
  </div>

  <!-- Phần dynamic -->
  <UserDashboard :user="currentUser" />
</template>

4.2 v-memo — Memoize có điều kiện

v-memo cho phép bạn chỉ định dependency array — tương tự React.memo nhưng ở template level. Rất hữu ích cho list item trong v-for:

<template>
  <div v-for="item in list" :key="item.id"
       v-memo="[item.id === selectedId, item.name, item.status]">
    <!-- Component phức tạp với nhiều child -->
    <ItemCard :item="item" :selected="item.id === selectedId" />
    <ItemActions :status="item.status" />
    <ItemMetadata :item="item" />
  </div>
</template>

Khi scroll qua danh sách 1.000 item mà chỉ thay đổi selectedId, Vue sẽ chỉ re-render 2 item (item cũ bỏ chọn + item mới được chọn) thay vì toàn bộ 1.000 item.

4.3 KeepAlive — Cache component instance

<template>
  <!-- Cache tối đa 5 tab gần nhất -->
  <KeepAlive :max="5" :include="['Dashboard', 'Settings', 'Profile']">
    <component :is="currentTabComponent" />
  </KeepAlive>
</template>

KeepAlive và Memory

Luôn đặt :max cho KeepAlive. Không giới hạn = giữ toàn bộ component instance trong memory → memory leak. Prop max dùng chiến lược LRU (Least Recently Used) — component ít dùng nhất sẽ bị destroy trước.

5. Virtual Scrolling — Render hàng nghìn item mượt mà

Khi danh sách có hàng nghìn item, render tất cả vào DOM là cách nhanh nhất để giết performance. Virtual scrolling chỉ render các item đang hiển thị trong viewport — giảm DOM node từ hàng nghìn xuống vài chục.

graph TB
    subgraph Normal["Render thông thường"]
        N1["10.000 DOM nodes"]
        N2["Mỗi node = event listener + style calc"]
        N3["Scroll lag 200-500ms"]
    end

    subgraph Virtual["Virtual Scrolling"]
        V1["~30 DOM nodes visible"]
        V2["Recycle khi scroll"]
        V3["Scroll mượt 16ms/frame"]
    end

    style N1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style N2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style N3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style V1 fill:#e94560,stroke:#fff,color:#fff
    style V2 fill:#e94560,stroke:#fff,color:#fff
    style V3 fill:#e94560,stroke:#fff,color:#fff

So sánh số lượng DOM node giữa render thường và virtual scrolling

5.1 Triển khai với @tanstack/vue-virtual

<script setup lang="ts">
import { ref } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

const parentRef = ref<HTMLElement | null>(null)

// Dataset lớn — 50.000 items
const items = shallowRef(generateItems(50_000))

const virtualizer = useVirtualizer({
  count: items.value.length,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 60, // Chiều cao ước tính mỗi item (px)
  overscan: 5, // Render thêm 5 item trên/dưới viewport để scroll mượt
})
</script>

<template>
  <div ref="parentRef" style="height: 600px; overflow-y: auto;">
    <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in virtualizer.getVirtualItems()"
        :key="row.key"
        :style="{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: `${row.size}px`,
          transform: `translateY(${row.start}px)`
        }"
      >
        <ItemRow :item="items[row.index]" />
      </div>
    </div>
  </div>
</template>
Library Kích thước Dynamic height Grid support Ghi chú
@tanstack/vue-virtual ~5KB gzip Headless, flexible, đang được maintain tích cực
vue-virtual-scroller ~8KB gzip API đơn giản, phù hợp list đơn giản
vue-virtual-scroll-grid ~4KB gzip Chuyên cho grid layout

5.2 Kết hợp virtual scrolling với shallowRef

Đây là combo quan trọng nhất cho danh sách lớn:

// ❌ Deep reactive + no virtual scroll = thảm họa
const items = ref(hugeArray) // Proxy cho 50.000 objects

// ✅ Shallow ref + virtual scroll = tối ưu
const items = shallowRef(hugeArray) // Không deep proxy
// + virtual scroll chỉ render 20-30 item visible

// Khi cập nhật 1 item:
const updateItem = (index: number, newData: Partial<Item>) => {
  const updated = [...items.value]
  updated[index] = { ...updated[index], ...newData }
  items.value = updated // Trigger re-render, virtual scroll chỉ render visible items
}

6. Lazy Loading và Code Splitting

Bundle size ảnh hưởng trực tiếp đến Time to Interactive (TTI). Với Vue 3 + Vite, code splitting gần như miễn phí — chỉ cần biết đặt dynamic import đúng chỗ.

6.1 Route-level code splitting

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

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      // ✅ Lazy load — tạo chunk riêng cho mỗi route
      component: () => import('@/pages/HomePage.vue')
    },
    {
      path: '/dashboard',
      component: () => import('@/pages/DashboardPage.vue'),
      children: [
        {
          path: 'analytics',
          // Chunk riêng cho sub-route
          component: () => import('@/pages/dashboard/AnalyticsPage.vue')
        },
        {
          path: 'settings',
          component: () => import('@/pages/dashboard/SettingsPage.vue')
        }
      ]
    },
    {
      path: '/admin',
      // Chunk chung cho nhóm admin pages
      component: () => import(
        /* webpackChunkName: "admin" */
        '@/pages/AdminLayout.vue'
      )
    }
  ]
})

6.2 Component-level lazy loading với defineAsyncComponent

import { defineAsyncComponent } from 'vue'

// Component nặng — chỉ load khi cần
const HeavyChart = defineAsyncComponent({
  loader: () => import('@/components/HeavyChart.vue'),
  loadingComponent: ChartSkeleton,  // Hiển thị skeleton khi đang load
  delay: 200,                       // Chờ 200ms trước khi show loading
  timeout: 10000,                   // Timeout sau 10s
  errorComponent: ChartError,       // Component hiện khi load lỗi
})

// Kết hợp với v-if — chỉ load khi user thực sự cần
const showChart = ref(false)
<template>
  <button @click="showChart = true">Xem biểu đồ</button>

  <!-- HeavyChart.vue chỉ được download khi showChart = true -->
  <HeavyChart v-if="showChart" :data="chartData" />
</template>

6.3 Prefetch và Preload strategies

// Prefetch khi user hover — trải nghiệm mượt mà
const prefetchDashboard = () => {
  import('@/pages/DashboardPage.vue')
}

// Trong template
// <router-link to="/dashboard" @mouseenter="prefetchDashboard">
//   Dashboard
// </router-link>

// Hoặc dùng router.beforeResolve để prefetch tự động
router.beforeResolve((to) => {
  const matched = to.matched
  // Vite tự handle prefetch cho dynamic imports
})

Prefetch với Speculation Rules API

Nếu đã dùng Speculation Rules API cho navigation (Chrome 121+), Vue Router sẽ tận dụng prerender của trình duyệt — trang đích được render sẵn trước khi user click. Kết hợp với route-level code splitting, đây là combo mạnh nhất cho perceived performance.

7. Bundle Optimization — Giảm kích thước gửi đến trình duyệt

graph TD
    A[Source Code] --> B[Vite Build]
    B --> C[Tree Shaking]
    C --> D[Code Splitting]
    D --> E[Minification]
    E --> F[Compression]

    C -->|Loại bỏ dead code| G["~30-50% giảm"]
    D -->|Chunk theo route| H["Load on demand"]
    E -->|Terser/esbuild| I["~20-30% giảm"]
    F -->|Brotli/Gzip| J["~70-80% giảm"]

    style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style B fill:#e94560,stroke:#fff,color:#fff
    style C fill:#2c3e50,stroke:#fff,color:#fff
    style D fill:#2c3e50,stroke:#fff,color:#fff
    style E fill:#2c3e50,stroke:#fff,color:#fff
    style F fill:#2c3e50,stroke:#fff,color:#fff
    style G fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style H fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style I fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50
    style J fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50

Pipeline tối ưu bundle — mỗi bước giảm đáng kể kích thước output

7.1 Phân tích bundle với rollup-plugin-visualizer

// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      open: true,
      filename: 'stats.html',
      gzipSize: true,
      brotliSize: true,
    })
  ]
})

Sau khi build, mở file stats.html — đây là bản đồ treemap giúp bạn thấy ngay thư viện nào chiếm nhiều dung lượng nhất. Thường thấy các "tội đồ": lodash (import cả library thay vì từng function), moment.js (thay bằng dayjs), icon library (import toàn bộ thay vì từng icon).

7.2 Import đúng cách — Tree Shaking hiệu quả

// ❌ Import toàn bộ — không tree-shake được
import _ from 'lodash'
import * as Icons from '@heroicons/vue/24/solid'

// ✅ Import từng function/component
import debounce from 'lodash-es/debounce'
import { ArrowLeftIcon, CheckIcon } from '@heroicons/vue/24/solid'

// ❌ UI Library — import toàn bộ
import ElementPlus from 'element-plus'
app.use(ElementPlus) // Bundle thêm ~500KB

// ✅ Import theo component (auto-import plugin)
// unplugin-vue-components + unplugin-auto-import
import { ElButton, ElInput, ElTable } from 'element-plus'

7.3 Cấu hình chunk strategy

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Tách vendor chunks — cached lâu dài
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['element-plus'],
          'chart-vendor': ['echarts'],
        }
      }
    },
    // Target modern browsers — nhỏ hơn, nhanh hơn
    target: 'es2022',
    // Chunk size warning
    chunkSizeWarningLimit: 500,
  }
})
Chiến lược Trước Sau Tác động
Tree shaking lodash → lodash-es 71KB 4KB -94%
moment.js → dayjs 67KB 2.9KB -96%
Auto-import UI components ~500KB ~80KB -84%
Tách vendor chunks 1 file 1.2MB 5 files, cached Faster subsequent loads
Brotli compression 300KB gzip 240KB brotli -20%

8. Memory Management — Ngăn memory leak

Memory leak trong Vue app thường âm thầm — ứng dụng chạy tốt ban đầu nhưng chậm dần sau vài phút sử dụng. Nguyên nhân phổ biến nhất:

8.1 Dọn dẹp side effects trong onUnmounted

// ❌ Quên cleanup — event listener tồn tại mãi
onMounted(() => {
  window.addEventListener('resize', handleResize)
  const timer = setInterval(fetchData, 5000)
})

// ✅ Cleanup đúng cách
onMounted(() => {
  window.addEventListener('resize', handleResize)
  const timer = setInterval(fetchData, 5000)

  onUnmounted(() => {
    window.removeEventListener('resize', handleResize)
    clearInterval(timer)
  })
})

8.2 AbortController cho async operations

// ✅ Cancel pending requests khi component unmount
const fetchUserData = () => {
  const controller = new AbortController()

  onUnmounted(() => controller.abort())

  return fetch('/api/users', { signal: controller.signal })
    .then(res => res.json())
    .catch(err => {
      if (err.name !== 'AbortError') throw err
    })
}

8.3 Watch cleanup

// ✅ onCleanup trong watch — dọn dẹp effect cũ trước khi chạy effect mới
watch(searchQuery, (query, _, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(data => results.value = data)
})

Phát hiện memory leak

Dùng Chrome DevTools → Memory → Heap Snapshot. Chụp snapshot trước và sau khi navigate đi rồi quay lại một trang. So sánh 2 snapshot — nếu có object tăng lên mà không giảm, đó là memory leak. Đặc biệt chú ý Detached HTMLElement — DOM node đã bị remove nhưng vẫn bị reference giữ lại.

9. SSR và SSG — Tối ưu cho First Contentful Paint

Client-side rendering (CSR) phải đợi JavaScript download + parse + execute mới hiển thị nội dung. SSR và SSG giải quyết điều này bằng cách render HTML sẵn trên server.

graph LR
    subgraph CSR["CSR (Client-Side)"]
        C1[Request] --> C2[Blank HTML]
        C2 --> C3[Download JS ~1-2s]
        C3 --> C4[Parse + Execute]
        C4 --> C5[Render content]
    end

    subgraph SSR["SSR (Server-Side)"]
        S1[Request] --> S2[Full HTML ready]
        S2 --> S3[Display immediately]
        S3 --> S4[Hydrate JS async]
    end

    style C1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C4 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style C5 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style S1 fill:#e94560,stroke:#fff,color:#fff
    style S2 fill:#e94560,stroke:#fff,color:#fff
    style S3 fill:#e94560,stroke:#fff,color:#fff
    style S4 fill:#e94560,stroke:#fff,color:#fff

So sánh timeline CSR vs SSR — SSR hiển thị nội dung ngay lập tức

9.1 Nuxt 4 Hybrid Rendering

Nuxt 4 cho phép cấu hình rendering strategy per-route:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Trang chủ — SSG (prerender tại build time)
    '/': { prerender: true },

    // Blog posts — ISR (regenerate mỗi 3600s)
    '/blog/**': { isr: 3600 },

    // Dashboard — CSR only (cần auth, data realtime)
    '/dashboard/**': { ssr: false },

    // API routes — cache 60s tại CDN edge
    '/api/**': { cache: { maxAge: 60 } },
  }
})

9.2 Selective Hydration

Không phải mọi component đều cần interactive ngay. Dùng LazyHydration để trì hoãn hydration cho component dưới fold:

<template>
  <!-- Hydrate ngay — above the fold -->
  <HeroBanner />

  <!-- Hydrate khi visible trong viewport -->
  <LazyHydrationOnVisible>
    <FeatureShowcase />
  </LazyHydrationOnVisible>

  <!-- Hydrate khi user interact (click, hover) -->
  <LazyHydrationOnInteraction :triggers="['click', 'mouseover']">
    <CommentSection />
  </LazyHydrationOnInteraction>

  <!-- Hydrate khi idle — lowest priority -->
  <LazyHydrationWhenIdle>
    <Footer />
  </LazyHydrationWhenIdle>
</template>

10. Performance Profiling — Đo trước khi tối ưu

Tối ưu mà không đo lường chẳng khác gì chữa bệnh mà không chẩn đoán. Vue DevTools cung cấp profiler chi tiết cho component rendering.

10.1 Vue DevTools Performance Tab

Mở Vue DevTools → Performance → bấm Record → tương tác với app → Stop. Bạn sẽ thấy:

  • Component render time — component nào tốn thời gian nhất?
  • Re-render count — component nào re-render không cần thiết?
  • Event timeline — thứ tự các event trigger re-render

10.2 Lighthouse và Core Web Vitals

Metric Mục tiêu Kỹ thuật tối ưu
LCP (Largest Contentful Paint) < 2.5s SSR/SSG, preload critical resources, optimize images
INP (Interaction to Next Paint) < 200ms v-memo, virtual scrolling, debounce handlers, Vapor Mode
CLS (Cumulative Layout Shift) < 0.1 Skeleton loading, fixed dimensions, font preload
TTFB (Time to First Byte) < 800ms Edge caching, CDN, SSR streaming

10.3 Custom performance markers

// Đo thời gian render component cụ thể
import { onMounted, onBeforeMount } from 'vue'

const useRenderPerf = (componentName: string) => {
  onBeforeMount(() => {
    performance.mark(`${componentName}-start`)
  })

  onMounted(() => {
    performance.mark(`${componentName}-end`)
    performance.measure(
      `${componentName} render`,
      `${componentName}-start`,
      `${componentName}-end`
    )
    const measure = performance.getEntriesByName(`${componentName} render`)[0]
    if (measure.duration > 16) {
      console.warn(`⚠️ ${componentName} render took ${measure.duration.toFixed(1)}ms`)
    }
  })
}

11. Checklist tối ưu Vue 3 Production

Tổng hợp toàn bộ kỹ thuật đã trình bày, dưới đây là checklist theo thứ tự ưu tiên — bắt đầu từ những thay đổi có impact lớn nhất và dễ áp dụng nhất:

Bước 1 — Đo lường (Impact: cần thiết)
Chạy Lighthouse, mở Vue DevTools Profiler. Xác định bottleneck thực tế trước khi tối ưu bất cứ thứ gì.
Bước 2 — Code Splitting (Impact: cao)
Route-level lazy loading cho mọi page. Component-level lazy loading cho component nặng (chart, editor, map).
Bước 3 — Bundle audit (Impact: cao)
Chạy rollup-plugin-visualizer. Thay lodash → lodash-es, moment → dayjs. Auto-import UI components.
Bước 4 — List optimization (Impact: cao cho app data-heavy)
Virtual scrolling cho list > 100 items. shallowRef cho dataset lớn. v-memo cho list item phức tạp.
Bước 5 — SSR/SSG (Impact: cao cho SEO và FCP)
Cân nhắc Nuxt 4 hybrid rendering. SSG cho trang tĩnh, SSR cho trang dynamic, CSR cho dashboard.
Bước 6 — Fine-tuning (Impact: trung bình)
v-once cho static content. KeepAlive với max prop. Cleanup side effects. AbortController cho fetch.
Bước 7 — Vapor Mode (Impact: cao, khi stable)
Khi Vue 3.6 stable — migrate leaf components sang Vapor Mode. Bắt đầu với component re-render nhiều nhất.

12. Kết luận

Performance không phải là tính năng thêm vào cuối dự án — nó là mindset xuyên suốt quá trình phát triển. Vue 3 đã cung cấp đầy đủ công cụ từ compiler-level (Vapor Mode, patch flags) đến runtime-level (shallowRef, v-memo, KeepAlive) để xây dựng ứng dụng nhanh.

Quan trọng nhất: đo trước, tối ưu sau. Không phải mọi ứng dụng đều cần virtual scrolling hay Vapor Mode. Dùng DevTools profiler và Lighthouse để tìm bottleneck thực tế, rồi áp dụng kỹ thuật phù hợp. Một shallowRef đặt đúng chỗ có thể hiệu quả hơn việc refactor cả component tree.

Theo dõi Vue 3.6 Stable

Vue 3.6 với Vapor Mode đang trong giai đoạn beta cuối. Khi stable release (dự kiến Q2-Q3 2026), đây sẽ là bước nhảy lớn nhất về performance cho Vue ecosystem — ngang hàng với Svelte 5 và Solid.js mà không cần thay đổi mental model. Theo dõi Vue.js Core GitHub để cập nhật.

Nguồn tham khảo