Vue3性能優(yōu)化之首屏優(yōu)化實(shí)戰(zhàn)指南
"這個(gè)頁面怎么這么卡?"產(chǎn)品經(jīng)理在演示時(shí)的尷尬,至今還深深印在我腦海里。當(dāng)時(shí)我負(fù)責(zé)的一個(gè)項(xiàng)目應(yīng)用,首屏加載竟然需要5.8秒!用戶直接投訴:"是不是網(wǎng)站壞了?"從那一刻起,我開始了為期3個(gè)月的性能優(yōu)化地獄之旅。今天,我想分享這段從絕望到驚喜的完整優(yōu)化歷程。
開篇慘狀:那個(gè)讓我社死的性能報(bào)告
用戶投訴引發(fā)的"性能危機(jī)"
故事要從去年的一次客戶匯報(bào)說起。我們團(tuán)隊(duì)花了半年時(shí)間開發(fā)的CRM系統(tǒng)要給客戶演示,結(jié)果:
- 首屏加載:5.8秒
- JS bundle大小:2.3MB
- 首次內(nèi)容渲染(FCP):3.2秒
- 可交互時(shí)間(TTI):8.1秒
客戶當(dāng)場(chǎng)問:“你們這是在用2G網(wǎng)絡(luò)測(cè)試的嗎?”
更尷尬的是,我們的競(jìng)爭(zhēng)對(duì)手產(chǎn)品加載只需要1.2秒!
問題排查:一場(chǎng)"性能偵探"之旅
回去后我立即開始排查,發(fā)現(xiàn)了以下觸目驚心的問題:
問題1:Bundle分析顯示的恐怖真相
# 運(yùn)行bundle分析 npm run build:analyze
分析結(jié)果讓我倒吸一口涼氣:
文件大小分析:
├── vendor.js: 1.2MB (包含了整個(gè)lodash庫!)
├── main.js: 800KB
├── icons.js: 300KB (竟然打包了500+個(gè)圖標(biāo))
└── 各種第三方庫占了60%的空間
最離譜的發(fā)現(xiàn):
- 引入了完整的lodash,但只用了3個(gè)方法
- 圖標(biāo)庫包含了500個(gè)圖標(biāo),實(shí)際只用了20個(gè)
- Moment.js帶了全部語言包,我們只需要中文
- 某個(gè)圖表庫占了200KB,但只用來畫了一個(gè)簡(jiǎn)單的折線圖
問題2:瀑布圖分析的悲劇
打開Chrome DevTools的Network面板:
請(qǐng)求瀑布圖:
1. HTML文檔: 200ms
2. main.css: 300ms (阻塞渲染)
3. vendor.js: 1.2s (阻塞執(zhí)行)
4. main.js: 800ms
5. 20個(gè)圖標(biāo)請(qǐng)求: 并發(fā)執(zhí)行,總計(jì)500ms
6. 字體文件: 400ms
7. 各種API請(qǐng)求: 亂成一團(tuán)
最要命的是: 所有資源都在串行加載,沒有任何優(yōu)化策略!
問題3:運(yùn)行時(shí)性能的噩夢(mèng)
使用Vue DevTools的性能分析功能:
// 某個(gè)列表組件的渲染分析
組件渲染耗時(shí):
├── UserList組件: 1200ms
│ ├── 用戶數(shù)據(jù)獲取: 300ms
│ ├── 數(shù)據(jù)處理: 400ms
│ ├── DOM渲染: 500ms
│ └── 重復(fù)渲染次數(shù): 8次 (!!!)
發(fā)現(xiàn)問題:
- 一個(gè)簡(jiǎn)單的用戶列表渲染了8次
- 每次父組件更新,子組件全部重新渲染
- 沒有任何緩存機(jī)制
- 大量不必要的計(jì)算在每次渲染時(shí)重復(fù)執(zhí)行
性能優(yōu)化的"戰(zhàn)術(shù)規(guī)劃"
面對(duì)這一堆問題,我制定了分層次的優(yōu)化策略:
第一層:緊急止血(目標(biāo):減少50%加載時(shí)間)
- 拆分代碼包,按需加載
- 壓縮靜態(tài)資源
- 開啟Gzip壓縮
- CDN優(yōu)化
第二層:深度優(yōu)化(目標(biāo):再減少30%)
- 組件懶加載
- 圖片優(yōu)化
- 緩存策略
- 預(yù)加載關(guān)鍵資源
第三層:精細(xì)化治理(目標(biāo):極致體驗(yàn))
- 虛擬滾動(dòng)
- 內(nèi)存優(yōu)化
- 微前端改造
- 服務(wù)端渲染
一、第一層止血:立竿見影的打包優(yōu)化
從Bundle分析開始:找到真正的"元兇"
首先,我安裝了webpack-bundle-analyzer來可視化分析:
npm install --save-dev webpack-bundle-analyzer
// vue.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
configureWebpack: config => {
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: 'bundle-report.html'
})
)
}
}
}
分析結(jié)果讓我震驚:
問題1:第三方庫的"黑洞"
// ?? 之前的錯(cuò)誤引入方式
import _ from 'lodash' // 整個(gè)lodash庫 (72KB)
import moment from 'moment' // 整個(gè)moment庫 + 語言包 (67KB)
import * as echarts from 'echarts' // 整個(gè)echarts庫 (400KB)
// 我們實(shí)際只用了
_.debounce, _.throttle, _.cloneDeep
moment().format('YYYY-MM-DD')
echarts的一個(gè)簡(jiǎn)單折線圖
立即優(yōu)化:按需引入
// ? 優(yōu)化后的引入方式
import debounce from 'lodash/debounce' // 只有3KB
import throttle from 'lodash/throttle' // 只有2KB
import cloneDeep from 'lodash/cloneDeep' // 只有5KB
import dayjs from 'dayjs' // 替代moment,只有2KB
import { LineChart } from 'echarts/charts' // 按需引入圖表類型
結(jié)果:vendor.js從1.2MB降到400KB!
問題2:圖標(biāo)庫的"災(zāi)難"
// ?? 錯(cuò)誤的圖標(biāo)引入 import '@/assets/icons/iconfont.css' // 500個(gè)圖標(biāo),300KB // 實(shí)際只用了20個(gè)圖標(biāo)
立即優(yōu)化:圖標(biāo)按需加載
// ? 創(chuàng)建圖標(biāo)組件
// components/Icon.vue
<template>
<i :class="`icon-${name}`" v-if="isLoaded"></i>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
name: String
})
const isLoaded = ref(false)
onMounted(async () => {
try {
// 動(dòng)態(tài)加載圖標(biāo)CSS
await import(`@/assets/icons/${props.name}.css`)
isLoaded.value = true
} catch (error) {
console.warn(`Icon ${props.name} not found`)
}
})
</script>
結(jié)果:圖標(biāo)資源從300KB降到15KB!
代碼分割:讓首屏"輕裝上陣"
路由級(jí)別的代碼分割
// ?? 錯(cuò)誤的路由配置
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import UserList from '@/views/UserList.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/users', component: UserList }
]
這種方式會(huì)把所有頁面組件都打包在main.js中。
// ? 優(yōu)化后的路由懶加載
const routes = [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
component: () => import(
/* webpackChunkName: "about" */ '@/views/About.vue'
)
},
{
path: '/users',
component: () => import(
/* webpackChunkName: "user-management" */ '@/views/UserList.vue'
)
}
]
組件級(jí)別的懶加載
// ? 大型組件的懶加載
<template>
<div>
<Header />
<!-- 只有在需要時(shí)才加載重型組件 -->
<Suspense>
<template #default>
<AsyncDataTable v-if="showTable" />
</template>
<template #fallback>
<div>加載中...</div>
</template>
</Suspense>
</div>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue'
const showTable = ref(false)
// 異步組件定義
const AsyncDataTable = defineAsyncComponent({
loader: () => import('@/components/DataTable.vue'),
loadingComponent: () => import('@/components/Loading.vue'),
errorComponent: () => import('@/components/Error.vue'),
delay: 200,
timeout: 3000
})
</script>
Webpack優(yōu)化配置:榨取每一個(gè)字節(jié)
// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin')
module.exports = {
productionSourceMap: false, // 生產(chǎn)環(huán)境不生成source map
configureWebpack: config => {
if (process.env.NODE_ENV === 'production') {
// Gzip壓縮
config.plugins.push(
new CompressionPlugin({
test: /\.(js|css|html|svg)$/,
algorithm: 'gzip',
threshold: 10240, // 只壓縮大于10KB的文件
minRatio: 0.8
})
)
// 代碼分割優(yōu)化
config.optimization = {
...config.optimization,
splitChunks: {
chunks: 'all',
cacheGroups: {
// 將第三方庫單獨(dú)打包
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10
},
// 將常用的工具函數(shù)單獨(dú)打包
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 5,
reuseExistingChunk: true
}
}
}
}
}
},
chainWebpack: config => {
// 預(yù)加載關(guān)鍵資源
config.plugin('preload').tap(() => [
{
rel: 'preload',
include: 'initial',
fileBlacklist: [/\.map$/, /hot-update\.js$/]
}
])
// 預(yù)獲取非關(guān)鍵資源
config.plugin('prefetch').tap(() => [
{
rel: 'prefetch',
include: 'asyncChunks'
}
])
}
}
CDN優(yōu)化:讓靜態(tài)資源"飛起來"
// vue.config.js
const cdn = {
css: [
'https://cdn.jsdelivr.net/npm/element-plus@2.2.0/dist/index.css'
],
js: [
'https://cdn.jsdelivr.net/npm/vue@3.2.31/dist/vue.global.prod.min.js',
'https://cdn.jsdelivr.net/npm/element-plus@2.2.0/dist/index.full.min.js'
]
}
module.exports = {
configureWebpack: config => {
if (process.env.NODE_ENV === 'production') {
// 外部依賴不打包
config.externals = {
vue: 'Vue',
'element-plus': 'ElementPlus'
}
}
},
chainWebpack: config => {
config.plugin('html').tap(args => {
args[0].cdn = cdn
return args
})
}
}
<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
<!-- 預(yù)連接CDN -->
<link rel="dns-prefetch" rel="external nofollow" rel="external nofollow" >
<link rel="preconnect" rel="external nofollow" rel="external nofollow" crossorigin>
<!-- CDN CSS -->
<% for (var i in htmlWebpackPlugin.options.cdn.css) { %>
<link href="<%= htmlWebpackPlugin.options.cdn.css[i] %>" rel="external nofollow" rel="stylesheet">
<% } %>
</head>
<body>
<div id="app"></div>
<!-- CDN JS -->
<% for (var i in htmlWebpackPlugin.options.cdn.js) { %>
<script src="<%= htmlWebpackPlugin.options.cdn.js[i] %>"></script>
<% } %>
</body>
</html>
第一輪優(yōu)化結(jié)果
經(jīng)過這一輪緊急優(yōu)化,我們?nèi)〉昧孙@著成效:
優(yōu)化前 → 優(yōu)化后:
├── 總bundle大小: 2.3MB → 800KB (-65%)
├── 首屏加載時(shí)間: 5.8s → 2.1s (-64%)
├── 首次內(nèi)容渲染: 3.2s → 1.2s (-63%)
└── 可交互時(shí)間: 8.1s → 3.5s (-57%)
客戶的反饋: “嗯,這樣看起來正常多了。”
但我知道,這只是開始。真正的挑戰(zhàn)在后面…
// utils/performance.js
class PerformanceMonitor {
constructor() {
this.metrics = new Map()
this.observers = new Map()
this.init()
}
init() {
// 監(jiān)聽核心Web Vitals
this.observeLCP()
this.observeFID()
this.observeCLS()
this.observeNavigation()
this.observeResource()
}
// Largest Contentful Paint
observeLCP() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
this.recordMetric('LCP', {
value: lastEntry.startTime,
element: lastEntry.element,
timestamp: Date.now()
})
})
observer.observe({ entryTypes: ['largest-contentful-paint'] })
this.observers.set('lcp', observer)
}
// First Input Delay
observeFID() {
const observer = new PerformanceObserver((list) => {
const firstInput = list.getEntries()[0]
this.recordMetric('FID', {
value: firstInput.processingStart - firstInput.startTime,
eventType: firstInput.name,
timestamp: Date.now()
})
})
observer.observe({ entryTypes: ['first-input'] })
this.observers.set('fid', observer)
}
// Cumulative Layout Shift
observeCLS() {
let clsValue = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
}
this.recordMetric('CLS', {
value: clsValue,
timestamp: Date.now()
})
})
observer.observe({ entryTypes: ['layout-shift'] })
this.observers.set('cls', observer)
}
// 路由性能監(jiān)控
measureRouteChange(from, to) {
const startTime = performance.now()
return {
end: () => {
const duration = performance.now() - startTime
this.recordMetric('RouteChange', {
from: from.path,
to: to.path,
duration,
timestamp: Date.now()
})
}
}
}
// 組件渲染性能
measureComponentRender(componentName) {
const startTime = performance.now()
return {
end: () => {
const duration = performance.now() - startTime
this.recordMetric('ComponentRender', {
component: componentName,
duration,
timestamp: Date.now()
})
}
}
}
// API請(qǐng)求性能
measureApiCall(url, method) {
const startTime = performance.now()
return {
end: (response) => {
const duration = performance.now() - startTime
this.recordMetric('ApiCall', {
url,
method,
duration,
status: response.status,
timestamp: Date.now()
})
}
}
}
recordMetric(name, data) {
if (!this.metrics.has(name)) {
this.metrics.set(name, [])
}
this.metrics.get(name).push(data)
// 上報(bào)到監(jiān)控平臺(tái)
this.reportToAnalytics(name, data)
}
reportToAnalytics(name, data) {
// 這里可以接入你的監(jiān)控平臺(tái)
if (window.gtag) {
window.gtag('event', name, {
custom_parameter_1: data.value,
custom_parameter_2: data.timestamp
})
}
// 或者發(fā)送到自己的監(jiān)控服務(wù)
if (process.env.NODE_ENV === 'production') {
fetch('/api/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: name, data })
}).catch(() => {
// 靜默失敗,不影響用戶體驗(yàn)
})
}
}
getMetrics(name) {
return this.metrics.get(name) || []
}
getAverageMetric(name) {
const metrics = this.getMetrics(name)
if (!metrics.length) return 0
const sum = metrics.reduce((acc, metric) => acc + metric.value, 0)
return sum / metrics.length
}
destroy() {
this.observers.forEach(observer => observer.disconnect())
this.observers.clear()
this.metrics.clear()
}
}
export const performanceMonitor = new PerformanceMonitor()
Vue組件性能分析Hook
// composables/usePerformance.js
import { ref, onMounted, onUpdated, onUnmounted, getCurrentInstance } from 'vue'
import { performanceMonitor } from '@/utils/performance'
export function usePerformance(componentName) {
const instance = getCurrentInstance()
const renderTimes = ref([])
const updateCount = ref(0)
let mountStartTime = 0
let updateStartTime = 0
onMounted(() => {
const mountTime = performance.now() - mountStartTime
renderTimes.value.push({
type: 'mount',
duration: mountTime,
timestamp: Date.now()
})
performanceMonitor.recordMetric('ComponentMount', {
component: componentName || instance?.type.name || 'Unknown',
duration: mountTime,
timestamp: Date.now()
})
})
onUpdated(() => {
updateCount.value++
if (updateStartTime > 0) {
const updateTime = performance.now() - updateStartTime
renderTimes.value.push({
type: 'update',
duration: updateTime,
timestamp: Date.now()
})
performanceMonitor.recordMetric('ComponentUpdate', {
component: componentName || instance?.type.name || 'Unknown',
duration: updateTime,
updateCount: updateCount.value,
timestamp: Date.now()
})
}
})
// 在每次更新前記錄開始時(shí)間
const recordUpdateStart = () => {
updateStartTime = performance.now()
}
// 記錄掛載開始時(shí)間
mountStartTime = performance.now()
onUnmounted(() => {
// 清理性能數(shù)據(jù)
renderTimes.value = []
updateCount.value = 0
})
return {
renderTimes: readonly(renderTimes),
updateCount: readonly(updateCount),
recordUpdateStart
}
}
二、核心性能優(yōu)化策略
2.1 響應(yīng)式數(shù)據(jù)優(yōu)化
// composables/useOptimizedData.js
import { ref, shallowRef, computed, readonly, markRaw } from 'vue'
export function useOptimizedData() {
// 1. 大型數(shù)據(jù)集使用shallowRef
const largeDataset = shallowRef([])
// 2. 不需要響應(yīng)式的數(shù)據(jù)使用markRaw
const staticConfig = markRaw({
apiEndpoints: {
users: '/api/users',
products: '/api/products'
},
constants: {
pageSize: 20,
maxRetries: 3
}
})
// 3. 計(jì)算屬性優(yōu)化 - 避免重復(fù)計(jì)算
const expensiveComputed = computed(() => {
// 使用閉包緩存昂貴的計(jì)算結(jié)果
let cache = null
let lastInput = null
return (input) => {
if (input === lastInput && cache !== null) {
return cache
}
// 模擬昂貴的計(jì)算
cache = input.map(item => ({
...item,
processed: heavyProcessing(item)
}))
lastInput = input
return cache
}
})
// 4. 分頁數(shù)據(jù)優(yōu)化
const paginatedData = computed(() => {
const { page, pageSize } = pagination.value
const start = (page - 1) * pageSize
const end = start + pageSize
// 只對(duì)當(dāng)前頁數(shù)據(jù)進(jìn)行響應(yīng)式處理
return largeDataset.value.slice(start, end)
})
// 5. 樹形數(shù)據(jù)扁平化處理
const flattenTree = (tree) => {
const flatMap = new Map()
const traverse = (node, parent = null) => {
flatMap.set(node.id, { ...node, parent })
if (node.children) {
node.children.forEach(child => traverse(child, node.id))
}
}
tree.forEach(node => traverse(node))
return flatMap
}
return {
largeDataset,
staticConfig: readonly(staticConfig),
expensiveComputed,
paginatedData,
flattenTree
}
}
function heavyProcessing(item) {
// 模擬CPU密集型操作
let result = 0
for (let i = 0; i < 1000000; i++) {
result += item.value * Math.random()
}
return result
}
2.2 虛擬列表實(shí)現(xiàn)
<!-- components/VirtualList.vue -->
<template>
<div
ref="containerRef"
class="virtual-list"
:style="{ height: containerHeight + 'px' }"
@scroll="handleScroll"
>
<div
class="virtual-list__phantom"
:style="{ height: totalHeight + 'px' }"
></div>
<div
class="virtual-list__content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="getItemKey(item)"
class="virtual-list__item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index">
{{ item.data }}
</slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
default: 50
},
containerHeight: {
type: Number,
default: 400
},
overscan: {
type: Number,
default: 5
},
getItemKey: {
type: Function,
default: (item) => item.id || item.index
}
})
const containerRef = ref(null)
const scrollTop = ref(0)
// 計(jì)算屬性
const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleCount = computed(() =>
Math.ceil(props.containerHeight / props.itemHeight)
)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
)
const endIndex = computed(() =>
Math.min(props.items.length - 1, startIndex.value + visibleCount.value + props.overscan * 2)
)
const offsetY = computed(() => startIndex.value * props.itemHeight)
const visibleItems = computed(() => {
const items = []
for (let i = startIndex.value; i <= endIndex.value; i++) {
if (props.items[i]) {
items.push({
...props.items[i],
index: i
})
}
}
return items
})
// 滾動(dòng)處理
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop
}
// 滾動(dòng)到指定索引
const scrollToIndex = (index) => {
if (containerRef.value) {
const targetScrollTop = index * props.itemHeight
containerRef.value.scrollTop = targetScrollTop
}
}
// 滾動(dòng)到頂部
const scrollToTop = () => {
scrollToIndex(0)
}
// 滾動(dòng)到底部
const scrollToBottom = () => {
scrollToIndex(props.items.length - 1)
}
defineExpose({
scrollToIndex,
scrollToTop,
scrollToBottom
})
</script>
<style scoped>
.virtual-list {
position: relative;
overflow-y: auto;
}
.virtual-list__phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
.virtual-list__content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-list__item {
box-sizing: border-box;
}
</style>
2.3 圖片懶加載優(yōu)化
// composables/useLazyLoad.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useLazyLoad(options = {}) {
const {
rootMargin = '50px',
threshold = 0.1,
fallbackSrc = '/placeholder.jpg',
errorSrc = '/error.jpg'
} = options
const observer = ref(null)
const loadedImages = new Set()
onMounted(() => {
if ('IntersectionObserver' in window) {
observer.value = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImage(entry.target)
observer.value.unobserve(entry.target)
}
})
}, {
rootMargin,
threshold
})
}
})
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect()
}
})
const loadImage = (img) => {
if (loadedImages.has(img.src)) return
const imageLoader = new Image()
imageLoader.onload = () => {
img.src = imageLoader.src
img.classList.add('loaded')
loadedImages.add(img.src)
}
imageLoader.onerror = () => {
img.src = errorSrc
img.classList.add('error')
}
imageLoader.src = img.dataset.src
}
const observe = (element) => {
if (observer.value && element) {
// 設(shè)置占位圖
if (!element.src) {
element.src = fallbackSrc
}
observer.value.observe(element)
} else {
// 降級(jí)處理:直接加載
loadImage(element)
}
}
return {
observe
}
}
// 指令形式使用
export const lazyLoadDirective = {
mounted(el, binding) {
const { observe } = useLazyLoad(binding.value)
el.dataset.src = binding.value.src || binding.value
observe(el)
}
}
三、構(gòu)建優(yōu)化策略
3.1 代碼分割和懶加載
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { performanceMonitor } from '@/utils/performance'
// 路由級(jí)別的代碼分割
const routes = [
{
path: '/',
name: 'Home',
component: () => import(
/* webpackChunkName: "home" */
'@/views/Home.vue'
)
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import(
/* webpackChunkName: "dashboard" */
/* webpackPreload: true */
'@/views/Dashboard.vue'
),
meta: { requiresAuth: true }
},
{
path: '/reports',
name: 'Reports',
component: () => import(
/* webpackChunkName: "reports" */
'@/views/Reports.vue'
),
meta: { requiresAuth: true, heavy: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由性能監(jiān)控
router.beforeEach((to, from, next) => {
const measurement = performanceMonitor.measureRouteChange(from, to)
// 對(duì)于重型頁面,顯示加載指示器
if (to.meta?.heavy) {
// 顯示全局加載狀態(tài)
window.$loading?.show()
}
// 保存測(cè)量函數(shù)到路由元信息
to.meta._measurement = measurement
next()
})
router.afterEach((to) => {
// 結(jié)束路由切換測(cè)量
if (to.meta?._measurement) {
to.meta._measurement.end()
delete to.meta._measurement
}
// 隱藏加載指示器
if (to.meta?.heavy) {
window.$loading?.hide()
}
})
export default router
3.2 Webpack/Vite優(yōu)化配置
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// 生產(chǎn)環(huán)境移除注釋和空格
isProduction: process.env.NODE_ENV === 'production',
whitespace: 'condense'
}
}
})
],
build: {
// 代碼分割策略
rollupOptions: {
output: {
manualChunks: {
// 將Vue生態(tài)相關(guān)的包單獨(dú)打包
'vue-vendor': ['vue', 'vue-router', 'pinia'],
// UI庫單獨(dú)打包
'ui-vendor': ['element-plus', '@element-plus/icons-vue'],
// 工具庫單獨(dú)打包
'utils-vendor': ['lodash-es', 'dayjs', 'axios'],
// 圖表庫單獨(dú)打包
'chart-vendor': ['echarts', 'chart.js']
},
// 為每個(gè)chunk生成獨(dú)立的CSS文件
assetFileNames: (assetInfo) => {
if (assetInfo.name.endsWith('.css')) {
return 'css/[name].[hash][extname]'
}
return 'assets/[name].[hash][extname]'
},
chunkFileNames: (chunkInfo) => {
const facadeModuleId = chunkInfo.facadeModuleId
if (facadeModuleId) {
const fileName = facadeModuleId.split('/').pop().replace('.vue', '')
return `js/${fileName}.[hash].js`
}
return 'js/[name].[hash].js'
}
}
},
// 壓縮配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.warn']
}
},
// 啟用gzip壓縮
cssCodeSplit: true,
sourcemap: false,
// 設(shè)置chunk大小警告閾值
chunkSizeWarningLimit: 1000
},
// 優(yōu)化依賴預(yù)構(gòu)建
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'axios',
'lodash-es'
],
exclude: [
'@iconify/json'
]
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'~': resolve(__dirname, 'src'),
'components': resolve(__dirname, 'src/components'),
'utils': resolve(__dirname, 'src/utils'),
'stores': resolve(__dirname, 'src/stores'),
'views': resolve(__dirname, 'src/views')
}
}
})
四、運(yùn)行時(shí)性能優(yōu)化
4.1 內(nèi)存泄漏防護(hù)
// composables/useMemoryManager.js
import { onUnmounted, ref } from 'vue'
export function useMemoryManager() {
const timers = ref(new Set())
const observers = ref(new Set())
const eventListeners = ref(new Set())
const abortControllers = ref(new Set())
// 定時(shí)器管理
const setManagedInterval = (callback, delay) => {
const id = setInterval(callback, delay)
timers.value.add(id)
return id
}
const setManagedTimeout = (callback, delay) => {
const id = setTimeout(() => {
callback()
timers.value.delete(id)
}, delay)
timers.value.add(id)
return id
}
const clearManagedTimer = (id) => {
clearInterval(id)
clearTimeout(id)
timers.value.delete(id)
}
// 觀察者管理
const addObserver = (observer) => {
observers.value.add(observer)
return observer
}
// 事件監(jiān)聽器管理
const addManagedEventListener = (element, event, handler, options) => {
element.addEventListener(event, handler, options)
const listener = { element, event, handler }
eventListeners.value.add(listener)
return listener
}
// AbortController管理
const createManagedAbortController = () => {
const controller = new AbortController()
abortControllers.value.add(controller)
return controller
}
// 清理所有資源
const cleanup = () => {
// 清理定時(shí)器
timers.value.forEach(id => {
clearInterval(id)
clearTimeout(id)
})
timers.value.clear()
// 斷開觀察者
observers.value.forEach(observer => {
if (observer.disconnect) observer.disconnect()
if (observer.unobserve) observer.unobserve()
})
observers.value.clear()
// 移除事件監(jiān)聽器
eventListeners.value.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler)
})
eventListeners.value.clear()
// 取消請(qǐng)求
abortControllers.value.forEach(controller => {
controller.abort()
})
abortControllers.value.clear()
}
// 組件卸載時(shí)自動(dòng)清理
onUnmounted(cleanup)
return {
setManagedInterval,
setManagedTimeout,
clearManagedTimer,
addObserver,
addManagedEventListener,
createManagedAbortController,
cleanup
}
}
4.2 緩存策略優(yōu)化
// utils/cache.js
class SmartCache {
constructor(options = {}) {
this.maxSize = options.maxSize || 100
this.defaultTTL = options.defaultTTL || 5 * 60 * 1000 // 5分鐘
this.cache = new Map()
this.timers = new Map()
this.accessCount = new Map()
this.lastAccess = new Map()
}
set(key, value, ttl = this.defaultTTL) {
// 如果緩存已滿,刪除最少使用的項(xiàng)
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
this.evictLRU()
}
// 清除舊的定時(shí)器
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key))
}
// 設(shè)置新值
this.cache.set(key, value)
this.accessCount.set(key, (this.accessCount.get(key) || 0) + 1)
this.lastAccess.set(key, Date.now())
// 設(shè)置過期定時(shí)器
if (ttl > 0) {
const timer = setTimeout(() => {
this.delete(key)
}, ttl)
this.timers.set(key, timer)
}
return value
}
get(key) {
if (!this.cache.has(key)) {
return undefined
}
// 更新訪問統(tǒng)計(jì)
this.accessCount.set(key, (this.accessCount.get(key) || 0) + 1)
this.lastAccess.set(key, Date.now())
return this.cache.get(key)
}
delete(key) {
if (this.timers.has(key)) {
clearTimeout(this.timers.get(key))
this.timers.delete(key)
}
this.cache.delete(key)
this.accessCount.delete(key)
this.lastAccess.delete(key)
}
// LRU淘汰策略
evictLRU() {
let lruKey = null
let lruTime = Infinity
for (const [key, time] of this.lastAccess) {
if (time < lruTime) {
lruTime = time
lruKey = key
}
}
if (lruKey) {
this.delete(lruKey)
}
}
clear() {
this.timers.forEach(timer => clearTimeout(timer))
this.cache.clear()
this.timers.clear()
this.accessCount.clear()
this.lastAccess.clear()
}
// 獲取緩存統(tǒng)計(jì)信息
getStats() {
return {
size: this.cache.size,
maxSize: this.maxSize,
accessCount: Array.from(this.accessCount.entries()),
lastAccess: Array.from(this.lastAccess.entries())
}
}
}
export const apiCache = new SmartCache({ maxSize: 50, defaultTTL: 5 * 60 * 1000 })
export const componentCache = new SmartCache({ maxSize: 20, defaultTTL: 10 * 60 * 1000 })
五、總結(jié)與監(jiān)控指標(biāo)
5.1 關(guān)鍵性能指標(biāo)(KPI)
基于我的項(xiàng)目經(jīng)驗(yàn),以下是需要重點(diǎn)監(jiān)控的指標(biāo):
1.首屏性能指標(biāo)
- FCP (First Contentful Paint) < 1.5s
- LCP (Largest Contentful Paint) < 2.5s
- FID (First Input Delay) < 100ms
- CLS (Cumulative Layout Shift) < 0.1
2.應(yīng)用性能指標(biāo)
- 路由切換時(shí)間 < 300ms
- API響應(yīng)時(shí)間 < 1s
- 組件渲染時(shí)間 < 16ms (60fps)
- 內(nèi)存使用增長(zhǎng)率 < 10MB/分鐘
3.用戶體驗(yàn)指標(biāo)
- 頁面可交互時(shí)間 < 3s
- 滾動(dòng)性能 60fps
- 點(diǎn)擊響應(yīng)時(shí)間 < 50ms
5.2 性能優(yōu)化ROI分析
在實(shí)際項(xiàng)目中,我建議按照以下優(yōu)先級(jí)進(jìn)行優(yōu)化:
1.高ROI優(yōu)化(立即實(shí)施)
- 圖片壓縮和懶加載
- 代碼分割和懶加載
- 減少包體積
2.中ROI優(yōu)化(短期規(guī)劃)
- 虛擬滾動(dòng)
- 組件緩存
- API緩存
3.低ROI優(yōu)化(長(zhǎng)期規(guī)劃)
- 服務(wù)端渲染
- Web Workers
- 精細(xì)化狀態(tài)管理
通過系統(tǒng)性的性能優(yōu)化方法論,我們能夠顯著提升Vue3應(yīng)用的性能表現(xiàn)。記住,性能優(yōu)化是一個(gè)持續(xù)的過程,需要不斷地測(cè)量、分析和改進(jìn)。
以上就是Vue3性能優(yōu)化之首屏優(yōu)化實(shí)戰(zhàn)指南的詳細(xì)內(nèi)容,更多關(guān)于Vue3首屏優(yōu)化的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
結(jié)合Vue控制字符和字節(jié)的顯示個(gè)數(shù)的示例
這篇文章主要介紹了結(jié)合Vue控制字符和字節(jié)的顯示個(gè)數(shù)的示例,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-05-05
Vue子組件調(diào)用父組件事件的3種方法實(shí)例
大家在做vue開發(fā)過程中經(jīng)常遇到父組件需要調(diào)用子組件方法或者子組件需要調(diào)用父組件的方法的情況,這篇文章主要給大家介紹了關(guān)于Vue子組件調(diào)用父組件事件的3種方法,需要的朋友可以參考下2024-01-01
vue自定義switch開關(guān)組件,實(shí)現(xiàn)樣式可自行更改
今天小編就為大家分享一篇vue自定義switch開關(guān)組件,實(shí)現(xiàn)樣式可自行更改,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2019-11-11
Vue 使用iframe引用html頁面實(shí)現(xiàn)vue和html頁面方法的調(diào)用操作
這篇文章主要介紹了Vue 使用iframe引用html頁面實(shí)現(xiàn)vue和html頁面方法的調(diào)用操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-11-11
解決vue項(xiàng)目中出現(xiàn)Invalid Host header的問題
這篇文章主要介紹了解決vue項(xiàng)目中出現(xiàn)"Invalid Host header"的問題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-11-11
如何在Vue.js中實(shí)現(xiàn)標(biāo)簽頁組件詳解
這篇文章主要給大家介紹了關(guān)于如何在Vue.js中實(shí)現(xiàn)標(biāo)簽頁組件的相關(guān)資料,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-01-01
vue學(xué)習(xí)筆記之Vue中css動(dòng)畫原理簡(jiǎn)單示例
這篇文章主要介紹了vue學(xué)習(xí)筆記之Vue中css動(dòng)畫原理,結(jié)合簡(jiǎn)單實(shí)例形式分析了Vue中css樣式變換動(dòng)畫效果實(shí)現(xiàn)原理與相關(guān)操作技巧,需要的朋友可以參考下2020-02-02

