简历描述模板
版本一:注重性能指标
主导移动端H5性能优化工作,首屏加载时间从3.2s优化至0.8s,FCP提升75%。采用路由懒
加载+组件异步加载策略,首屏JS体积从1.2MB降至280KB。实现虚拟滚动技术处理万级长
列表,内存占用减少80%,滑动帧率稳定在58fps以上。设计渐进式图片加载方案,结合
CDN图片处理能力实现按需加载,图片加载流量节省60%。开发骨架屏生成工具,自动提取
页面结构生成占位UI,用户感知等待时间缩短50%。接入Service Worker实现离线缓存,
二次访问速度提升400%。
版本二:强调技术方案
负责移动端性能优化体系建设,建立从构建、网络、渲染到运行时的全链路优化方案。在
构建层面,通过Tree-shaking、代码分割、资源压缩等手段将打包体积优化40%。网络层
实现了DNS预解析、CDN加速、HTTP/2推送等策略,网络耗时降低35%。渲染层采用SSR预
渲染关键路径,配合骨架屏和渐进式渲染,白屏时间从2.1s降至0.6s。运行时通过虚拟列
表、节流防抖、内存管理等优化,交互响应速度提升50%,内存峰值降低60%。
版本三:突出业务价值
作为性能优化负责人,推动移动端体验升级,核心指标全面提升。通过首屏优化使页面秒
开率从45%提升至82%,用户留存率增长12个百分点。实施智能预加载策略,根据用户行为
预测下一步操作并提前加载资源,页面跳转体验接近原生App。针对弱网环境优化请求重
试和降级方案,2G/3G网络下的可用率从60%提升至85%。建立性能监控平台,实时追踪核
心指标,异常自动告警,性能回归问题发现率100%。优化后用户投诉率下降40%。
SOP 标准回答
Q1: 首屏加载优化做了哪些工作?
标准回答:
首屏优化我们从5个方面入手,效果很明显,从原来的3.2秒降到了0.8秒:
第一是资源体积优化。原来首屏加载的JS有1.2MB,我做了代码分割,把首屏不需要的代
码全部懒加载。具体做法是路由层面用import()动态导入,Webpack会自动打成多个chunk。
然后把UI组件库也改成按需引入,原来整个Element Plus都打进去了,现在只引入用到的
组件。还有第三方库,比如Moment.js特别大,换成了Day.js,体积减少了70%。最后首屏
JS降到了280KB,压缩后不到100KB。
第二是资源加载顺序。我用Preload预加载关键资源,比如首屏的JS、CSS、字体文件,这
些资源优先级最高。用Prefetch预获取可能会用到的资源,比如第二屏的图片、下一页的
路由组件,在浏览器空闲时加载。还用了dns-prefetch预解析第三方域名,CDN、接口域名
提前解析好DNS,真正请求时能快几百毫秒。
第三是渲染优化。我们做了SSR预渲染首页,用户访问时直接返回HTML,不用等JS执行。但
SSR成本高,我们只对首页和几个核心落地页用了SSR,其他页面还是CSR。没用SSR的页面
加了骨架屏,至少让用户看到内容结构,不会一直白屏。骨架屏是构建时生成的,不占运
行时性能。
第四是接口优化。首屏接口做了合并,原来要请求5个接口,现在合并成1个,减少了4次
RTT。还做了接口缓存,用户第二次进来先显示缓存数据,然后后台刷新最新数据。对于变
化不频繁的数据,比如配置信息,直接打到JS里,省了一次请求。
第五是图片优化。首屏的大图改成渐进式JPEG,先显示模糊图再慢慢清晰。配合CDN的图片
处理,按设备DPR和屏幕尺寸返回合适大小的图,不会加载过大的图片。首屏之外的图片全
部懒加载,滚动到可视区域才加载。
这些优化做完,首屏时间从3.2秒降到0.8秒,用户秒开率从45%提升到82%,效果很明显。
一、首屏加载优化 - 完整实现
1.1 路由懒加载配置
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import(
/* webpackChunkName: "home" */
/* webpackPrefetch: true */
'@/views/Home.vue'
)
},
{
path: '/product/:id',
name: 'Product',
component: () => import(
/* webpackChunkName: "product" */
'@/views/Product.vue'
)
},
{
path: '/cart',
name: 'Cart',
component: () => import(
/* webpackChunkName: "cart" */
'@/views/Cart.vue'
)
},
{
path: '/user',
name: 'User',
component: () => import(
/* webpackChunkName: "user" */
'@/views/User.vue'
),
meta: {
requiresAuth: true
}
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由守卫中预加载
router.beforeEach((to, from, next) => {
// 显示加载指示器
if (window.__showLoading) {
window.__showLoading()
}
next()
})
router.afterEach(() => {
// 隐藏加载指示器
if (window.__hideLoading) {
window.__hideLoading()
}
})
export default router
1.2 组件异步加载
<!-- views/Home.vue -->
<script setup>
import { defineAsyncComponent, ref, onMounted } from 'vue'
// 首屏必需组件 - 同步加载
import Banner from '@/components/Banner.vue'
import CategoryNav from '@/components/CategoryNav.vue'
// 非首屏组件 - 异步加载
const ProductList = defineAsyncComponent({
loader: () => import('@/components/ProductList.vue'),
loadingComponent: {
template: '<div class="loading">加载中...</div>'
},
delay: 200,
timeout: 3000,
errorComponent: {
template: '<div class="error">加载失败,请刷新重试</div>'
}
})
const RecommendList = defineAsyncComponent(() =>
import('@/components/RecommendList.vue')
)
const Footer = defineAsyncComponent(() =>
import('@/components/Footer.vue')
)
const showProductList = ref(false)
const showRecommend = ref(false)
onMounted(() => {
// 首屏渲染完成后,延迟加载其他组件
setTimeout(() => {
showProductList.value = true
}, 100)
setTimeout(() => {
showRecommend.value = true
}, 500)
})
</script>
<template>
<div class="home">
<!-- 首屏内容 -->
<Banner />
<CategoryNav />
<!-- 延迟加载的内容 -->
<ProductList v-if="showProductList" />
<RecommendList v-if="showRecommend" />
<Footer />
</div>
</template>
1.3 资源预加载策略
// utils/preload.js
/**
* 资源预加载管理器
*/
class PreloadManager {
constructor() {
this.preloadedResources = new Set()
this.prefetchedResources = new Set()
}
/**
* Preload - 当前页面必需的资源
*/
preload(url, type = 'script') {
if (this.preloadedResources.has(url)) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = type
link.href = url
link.onload = () => {
this.preloadedResources.add(url)
resolve()
}
link.onerror = () => {
reject(new Error(`Preload failed: ${url}`))
}
document.head.appendChild(link)
})
}
/**
* Prefetch - 未来可能需要的资源
*/
prefetch(url, type = 'script') {
if (this.prefetchedResources.has(url)) {
return
}
const link = document.createElement('link')
link.rel = 'prefetch'
link.as = type
link.href = url
document.head.appendChild(link)
this.prefetchedResources.add(url)
}
/**
* DNS预解析
*/
dnsPrefetch(domain) {
const link = document.createElement('link')
link.rel = 'dns-prefetch'
link.href = `//${domain}`
document.head.appendChild(link)
}
/**
* 预连接
*/
preconnect(url) {
const link = document.createElement('link')
link.rel = 'preconnect'
link.href = url
document.head.appendChild(link)
}
/**
* 智能预加载
* 根据用户行为预测下一步操作
*/
smartPrefetch(routes) {
// 使用requestIdleCallback在空闲时预加载
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
routes.forEach(route => {
this.prefetch(route)
})
})
} else {
setTimeout(() => {
routes.forEach(route => {
this.prefetch(route)
})
}, 1000)
}
}
/**
* 预加载图片
*/
preloadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = url
})
}
/**
* 批量预加载图片
*/
async preloadImages(urls) {
const promises = urls.map(url => this.preloadImage(url))
return Promise.all(promises)
}
}
export default new PreloadManager()
在main.js中使用:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import preloadManager from './utils/preload'
// DNS预解析
preloadManager.dnsPrefetch('cdn.example.com')
preloadManager.dnsPrefetch('api.example.com')
// 预连接关键域名
preloadManager.preconnect('https://cdn.example.com')
// 预加载关键资源
preloadManager.preload('/fonts/main.woff2', 'font')
preloadManager.preload('/css/critical.css', 'style')
// 预获取次要资源
preloadManager.smartPrefetch([
'/js/product.js',
'/js/cart.js'
])
const app = createApp(App)
app.use(router)
app.mount('#app')
1.4 接口合并与缓存
// api/index.js
/**
* 接口请求管理器
*/
class ApiManager {
constructor() {
this.cache = new Map()
this.pending = new Map()
}
/**
* 合并请求
* 将多个接口请求合并为一个批量请求
*/
async batchRequest(apis) {
const response = await fetch('/api/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ apis })
})
const data = await response.json()
return data.results
}
/**
* 缓存请求
*/
async cachedRequest(url, options = {}) {
const {
cache = true,
cacheTime = 5 * 60 * 1000, // 默认缓存5分钟
staleWhileRevalidate = false // 是否使用SWR策略
} = options
const cacheKey = url + JSON.stringify(options)
// 检查缓存
if (cache && this.cache.has(cacheKey)) {
const cached = this.cache.get(cacheKey)
const now = Date.now()
// 缓存未过期
if (now - cached.timestamp < cacheTime) {
// SWR策略:返回缓存数据,同时后台更新
if (staleWhileRevalidate) {
this.updateCache(url, options, cacheKey)
}
return cached.data
}
}
// 请求去重:如果正在请求中,等待该请求完成
if (this.pending.has(cacheKey)) {
return this.pending.get(cacheKey)
}
// 发起新请求
const promise = fetch(url, options)
.then(res => res.json())
.then(data => {
// 存入缓存
if (cache) {
this.cache.set(cacheKey, {
data,
timestamp: Date.now()
})
}
// 移除pending
this.pending.delete(cacheKey)
return data
})
.catch(error => {
this.pending.delete(cacheKey)
throw error
})
this.pending.set(cacheKey, promise)
return promise
}
/**
* 后台更新缓存
*/
async updateCache(url, options, cacheKey) {
try {
const response = await fetch(url, options)
const data = await response.json()
this.cache.set(cacheKey, {
data,
timestamp: Date.now()
})
} catch (error) {
console.error('更新缓存失败:', error)
}
}
/**
* 清除缓存
*/
clearCache(pattern) {
if (pattern) {
// 清除匹配的缓存
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
} else {
// 清除所有缓存
this.cache.clear()
}
}
}
export default new ApiManager()
使用示例:
<script setup>
import { ref, onMounted } from 'vue'
import apiManager from '@/api'
const homeData = ref({})
onMounted(async () => {
// 合并首屏请求
const results = await apiManager.batchRequest([
{ url: '/api/banner', method: 'GET' },
{ url: '/api/categories', method: 'GET' },
{ url: '/api/products/hot', method: 'GET' }
])
homeData.value = {
banner: results[0],
categories: results[1],
hotProducts: results[2]
}
})
// 使用缓存的请求
async function loadProductList() {
const products = await apiManager.cachedRequest('/api/products', {
cache: true,
cacheTime: 10 * 60 * 1000, // 缓存10分钟
staleWhileRevalidate: true // 使用SWR策略
})
return products
}
</script>
二、长列表优化 - 虚拟滚动完整实现
2.1 固定高度虚拟列表
<!-- components/VirtualList.vue -->
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
const props = defineProps({
// 列表数据
items: {
type: Array,
required: true
},
// 每项高度(固定高度)
itemHeight: {
type: Number,
required: true
},
// 可视区域高度
height: {
type: [Number, String],
default: '100%'
},
// 缓冲区大小
buffer: {
type: Number,
default: 5
}
})
const emit = defineEmits(['load-more'])
// refs
const containerRef = ref(null)
const scrollTop = ref(0)
// 可视区域高度
const viewportHeight = ref(0)
// 计算属性
// 可显示的数量
const visibleCount = computed(() => {
return Math.ceil(viewportHeight.value / props.itemHeight)
})
// 总高度
const totalHeight = computed(() => {
return props.items.length * props.itemHeight
})
// 开始索引
const startIndex = computed(() => {
const index = Math.floor(scrollTop.value / props.itemHeight)
return Math.max(0, index - props.buffer)
})
// 结束索引
const endIndex = computed(() => {
const index = startIndex.value + visibleCount.value + props.buffer * 2
return Math.min(props.items.length, index)
})
// 可见项
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value).map((item, idx) => ({
data: item,
index: startIndex.value + idx,
top: (startIndex.value + idx) * props.itemHeight
}))
})
// 偏移量
const offsetY = computed(() => {
return startIndex.value * props.itemHeight
})
// 滚动处理
let rafId = null
function handleScroll() {
if (rafId) return
rafId = requestAnimationFrame(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop
// 检测触底
const scrollBottom = scrollTop.value + viewportHeight.value
if (scrollBottom >= totalHeight.value - 100) {
emit('load-more')
}
}
rafId = null
})
}
// 初始化
onMounted(() => {
if (containerRef.value) {
viewportHeight.value = containerRef.value.clientHeight
containerRef.value.addEventListener('scroll', handleScroll, { passive: true })
}
})
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
if (rafId) {
cancelAnimationFrame(rafId)
}
})
// 暴露方法
defineExpose({
scrollTo: (top) => {
if (containerRef.value) {
containerRef.value.scrollTop = top
}
},
scrollToIndex: (index) => {
if (containerRef.value) {
containerRef.value.scrollTop = index * props.itemHeight
}
}
})
</script>
<template>
<div
ref="containerRef"
class="virtual-list"
:style="{ height: typeof height === 'number' ? height + 'px' : height }"
>
<!-- 占位元素,撑起滚动条 -->
<div
class="virtual-list-phantom"
:style="{ height: totalHeight + 'px' }"
/>
<!-- 可见内容 -->
<div
class="virtual-list-content"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.index"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item.data" :index="item.index" />
</div>
</div>
</div>
</template>
<style scoped>
.virtual-list {
overflow-y: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: absolute;
left: 0;
right: 0;
top: 0;
}
.virtual-list-item {
overflow: hidden;
}
</style>
2.2 动态高度虚拟列表
<!-- components/DynamicVirtualList.vue -->
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
items: {
type: Array,
required: true
},
// 预估高度
estimatedHeight: {
type: Number,
default: 100
},
height: {
type: [Number, String],
default: '100%'
},
buffer: {
type: Number,
default: 5
}
})
const emit = defineEmits(['load-more'])
const containerRef = ref(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
// 高度缓存
const heightCache = ref(new Map())
const positions = ref([])
// 初始化positions
watch(() => props.items.length, (newLen) => {
const oldLen = positions.value.length
if (newLen > oldLen) {
// 新增项
const lastPos = positions.value[oldLen - 1] || { bottom: 0 }
for (let i = oldLen; i < newLen; i++) {
positions.value.push({
index: i,
height: props.estimatedHeight,
top: lastPos.bottom,
bottom: lastPos.bottom + props.estimatedHeight
})
lastPos.bottom += props.estimatedHeight
}
}
}, { immediate: true })
// 更新项高度
function updateItemHeight(index, height) {
const oldHeight = positions.value[index].height
const diff = height - oldHeight
// 如果高度变化不大,不更新
if (Math.abs(diff) < 1) return
positions.value[index].height = height
positions.value[index].bottom = positions.value[index].top + height
// 更新后续项的位置
for (let i = index + 1; i < positions.value.length; i++) {
positions.value[i].top = positions.value[i - 1].bottom
positions.value[i].bottom = positions.value[i].top + positions.value[i].height
}
}
// 总高度
const totalHeight = computed(() => {
if (positions.value.length === 0) return 0
return positions.value[positions.value.length - 1].bottom
})
// 查找起始索引(二分查找)
const startIndex = computed(() => {
let left = 0
let right = positions.value.length - 1
let mid = 0
while (left <= right) {
mid = Math.floor((left + right) / 2)
const midTop = positions.value[mid].top
const midBottom = positions.value[mid].bottom
if (midTop <= scrollTop.value && midBottom > scrollTop.value) {
return Math.max(0, mid - props.buffer)
} else if (midBottom <= scrollTop.value) {
left = mid + 1
} else {
right = mid - 1
}
}
return Math.max(0, left - props.buffer)
})
// 结束索引
const endIndex = computed(() => {
const viewportBottom = scrollTop.value + viewportHeight.value
for (let i = startIndex.value; i < positions.value.length; i++) {
if (positions.value[i].top >= viewportBottom) {
return Math.min(positions.value.length, i + props.buffer)
}
}
return positions.value.length
})
// 可见项
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value).map((item, idx) => {
const index = startIndex.value + idx
return {
data: item,
index,
top: positions.value[index]?.top || 0
}
})
})
// 滚动处理
let rafId = null
function handleScroll() {
if (rafId) return
rafId = requestAnimationFrame(() => {
if (containerRef.value) {
scrollTop.value = containerRef.value.scrollTop
// 检测触底
const scrollBottom = scrollTop.value + viewportHeight.value
if (scrollBottom >= totalHeight.value - 100) {
emit('load-more')
}
}
rafId = null
})
}
onMounted(() => {
if (containerRef.value) {
viewportHeight.value = containerRef.value.clientHeight
containerRef.value.addEventListener('scroll', handleScroll, { passive: true })
}
})
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
if (rafId) {
cancelAnimationFrame(rafId)
}
})
defineExpose({
scrollTo: (top) => {
if (containerRef.value) {
containerRef.value.scrollTop = top
}
},
scrollToIndex: (index) => {
if (containerRef.value && positions.value[index]) {
containerRef.value.scrollTop = positions.value[index].top
}
}
})
</script>
<template>
<div
ref="containerRef"
class="dynamic-virtual-list"
:style="{ height: typeof height === 'number' ? height + 'px' : height }"
>
<div
class="virtual-list-phantom"
:style="{ height: totalHeight + 'px' }"
/>
<div class="virtual-list-content">
<div
v-for="item in visibleItems"
:key="item.index"
class="virtual-list-item"
:style="{ transform: `translateY(${item.top}px)` }"
>
<div
:ref="el => {
if (el) {
nextTick(() => {
const height = el.offsetHeight
updateItemHeight(item.index, height)
})
}
}"
>
<slot :item="item.data" :index="item.index" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.dynamic-virtual-list {
overflow-y: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.virtual-list-content {
position: relative;
width: 100%;
}
.virtual-list-item {
position: absolute;
left: 0;
right: 0;
top: 0;
}
</style>
2.3 使用示例
<!-- views/ProductList.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import VirtualList from '@/components/VirtualList.vue'
import DynamicVirtualList from '@/components/DynamicVirtualList.vue'
// 模拟商品数据
const products = ref([])
const loading = ref(false)
const hasMore = ref(true)
// 加载数据
async function loadData(page = 1) {
loading.value = true
try {
// 模拟API请求
await new Promise(resolve => setTimeout(resolve, 500))
const newProducts = Array.from({ length: 20 }, (_, i) => ({
id: (page - 1) * 20 + i,
name: `商品 ${(page - 1) * 20 + i}`,
price: Math.floor(Math.random() * 1000) + 100,
desc: `这是商品描述 ${Math.random() > 0.5 ? '很长的描述'.repeat(5) : '短描述'}`,
image: `https://picsum.photos/200/200?random=${(page - 1) * 20 + i}`
}))
products.value.push(...newProducts)
if (page >= 50) {
hasMore.value = false
}
} catch (error) {
console.error('加载失败:', error)
} finally {
loading.value = false
}
}
// 加载更多
let currentPage = 1
function handleLoadMore() {
if (loading.value || !hasMore.value) return
currentPage++
loadData(currentPage)
}
onMounted(() => {
loadData(1)
})
</script>
<template>
<div class="product-list-page">
<h1>商品列表(虚拟滚动)</h1>
<!-- 固定高度虚拟列表 -->
<VirtualList
:items="products"
:item-height="120"
height="calc(100vh - 60px)"
@load-more="handleLoadMore"
>
<template #default="{ item, index }">
<div class="product-card">
<img :src="item.image" :alt="item.name" />
<div class="product-info">
<h3>{{ item.name }}</h3>
<p class="price">¥{{ item.price }}</p>
</div>
</div>
</template>
</VirtualList>
<!-- 动态高度虚拟列表 -->
<!-- <DynamicVirtualList
:items="products"
:estimated-height="150"
height="calc(100vh - 60px)"
@load-more="handleLoadMore"
>
<template #default="{ item, index }">
<div class="product-card-dynamic">
<img :src="item.image" :alt="item.name" />
<div class="product-info">
<h3>{{ item.name }}</h3>
<p class="desc">{{ item.desc }}</p>
<p class="price">¥{{ item.price }}</p>
</div>
</div>
</template>
</DynamicVirtualList> -->
<div v-if="loading" class="loading-more">加载中...</div>
<div v-if="!hasMore" class="no-more">没有更多了</div>
</div>
</template>
<style scoped>
.product-list-page {
padding: 16px;
}
.product-card {
display: flex;
padding: 12px;
margin-bottom: 8px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.product-card img {
width: 80px;
height: 80px;
border-radius: 4px;
object-fit: cover;
}
.product-info {
flex: 1;
margin-left: 12px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-info h3 {
font-size: 16px;
font-weight: 500;
margin: 0;
}
.price {
font-size: 18px;
color: #ff4d4f;
font-weight: bold;
}
.product-card-dynamic {
padding: 16px;
margin-bottom: 8px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.product-card-dynamic img {
width: 100%;
border-radius: 4px;
}
.desc {
font-size: 14px;
color: #666;
margin: 8px 0;
line-height: 1.5;
}
.loading-more,
.no-more {
text-align: center;
padding: 16px;
color: #999;
font-size: 14px;
}
</style>
三、图片懒加载与预加载
3.1 智能图片加载组件
<!-- components/SmartImage.vue -->
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps({
src: {
type: String,
required: true
},
placeholder: {
type: String,
default: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LXNpemU9IjE4IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjY2NjIj5Mb2FkaW5nLi4uPC90ZXh0Pjwvc3ZnPg=='
},
errorImage: {
type: String,
default: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjMwMCIgZmlsbD0iI2ZmZTdlNyIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LXNpemU9IjE4IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjZmY0ZDRmIj7liqDovb3lpLHotKU8L3RleHQ+PC9zdmc+'
},
lazy: {
type: Boolean,
default: true
},
// 提前加载距离(px)
preload: {
type: Number,
default: 200
},
// 渐进式加载
progressive: {
type: Boolean,
default: true
},
// 最大重试次数
maxRetry: {
type: Number,
default: 3
},
// 自动WebP
webp: {
type: Boolean,
default: true
},
alt: String
})
const emit = defineEmits(['load', 'error'])
const imgRef = ref(null)
const loadStatus = ref('pending') // pending/loading/loaded/error
const currentSrc = ref(props.placeholder)
const retryCount = ref(0)
let observer = null
// 是否支持WebP
const supportsWebP = ref(false)
// 检测WebP支持
async function checkWebPSupport() {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => resolve(img.width === 1)
img.onerror = () => resolve(false)
img.src = 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='
})
}
// 获取优化后的图片URL
function getOptimizedSrc() {
let url = props.src
// WebP优化
if (props.webp && supportsWebP.value && !url.includes('.webp')) {
// 假设CDN支持自动格式转换
url = url.replace(/\.(jpg|jpeg|png)$/i, '.webp')
}
// DPR优化
const dpr = window.devicePixelRatio || 1
if (dpr >= 3 && !url.includes('@3x')) {
url = url.replace(/(\.\w+)$/, '@3x$1')
} else if (dpr >= 2 && !url.includes('@2x')) {
url = url.replace(/(\.\w+)$/, '@2x$1')
}
return url
}
// 加载图片
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
// 开始加载
async function startLoad() {
if (loadStatus.value === 'loading' || loadStatus.value === 'loaded') {
return
}
loadStatus.value = 'loading'
try {
const src = getOptimizedSrc()
// 渐进式加载:先加载低质量图
if (props.progressive && retryCount.value === 0) {
try {
const lowQualitySrc = src.replace(/(\.\w+)$/, '_thumb$1')
await loadImage(lowQualitySrc)
currentSrc.value = lowQualitySrc
} catch {
// 低质量图加载失败,继续加载原图
}
}
// 加载原图
await loadImage(src)
currentSrc.value = src
loadStatus.value = 'loaded'
emit('load')
} catch (error) {
console.error('图片加载失败:', error)
// 重试机制
if (retryCount.value < props.maxRetry) {
retryCount.value++
setTimeout(() => {
startLoad()
}, 1000 * retryCount.value)
} else {
loadStatus.value = 'error'
currentSrc.value = props.errorImage
emit('error', error)
}
}
}
// 手动重试
function retry() {
retryCount.value = 0
loadStatus.value = 'pending'
currentSrc.value = props.placeholder
startLoad()
}
// 设置观察器
function setupObserver() {
if (!props.lazy) {
startLoad()
return
}
if (!('IntersectionObserver' in window)) {
// 不支持IntersectionObserver,直接加载
startLoad()
return
}
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
startLoad()
observer?.disconnect()
}
})
},
{
rootMargin: `${props.preload}px`
}
)
if (imgRef.value) {
observer.observe(imgRef.value)
}
}
// 监听src变化
watch(() => props.src, () => {
retryCount.value = 0
loadStatus.value = 'pending'
currentSrc.value = props.placeholder
if (observer) {
observer.disconnect()
}
setupObserver()
})
onMounted(async () => {
supportsWebP.value = await checkWebPSupport()
setupObserver()
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
defineExpose({
retry,
reload: startLoad
})
</script>
<template>
<div
class="smart-image"
:class="[loadStatus, { clickable: loadStatus === 'error' }]"
@click="loadStatus === 'error' && retry()"
>
<img
ref="imgRef"
:src="currentSrc"
:alt="alt"
class="image"
/>
<div v-if="loadStatus === 'loading'" class="loading-mask">
<div class="loading-spinner" />
</div>
<div v-if="loadStatus === 'error'" class="error-mask">
<div class="error-icon">⚠️</div>
<div class="error-text">加载失败,点击重试</div>
</div>
<div v-if="loadStatus === 'loaded'" class="loaded-effect" />
</div>
</template>
<style scoped>
.smart-image {
position: relative;
overflow: hidden;
background: #f5f5f5;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
transition: opacity 0.3s;
}
.smart-image.pending .image {
opacity: 0.6;
}
.smart-image.loading .image {
opacity: 0.8;
}
.smart-image.loaded .image {
opacity: 1;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0.8;
}
to {
opacity: 1;
}
}
.loading-mask,
.error-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid #e0e0e0;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-mask {
background: rgba(255, 0, 0, 0.05);
cursor: pointer;
}
.clickable {
cursor: pointer;
}
.error-icon {
font-size: 32px;
margin-bottom: 8px;
}
.error-text {
font-size: 12px;
color: #ff4d4f;
}
.loaded-effect {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255,255,255,0.3) 0%,
rgba(255,255,255,0) 50%,
rgba(255,255,255,0) 100%
);
animation: shine 1s;
pointer-events: none;
}
@keyframes shine {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>
3.2 图片预加载管理器
// utils/imagePreloader.js
class ImagePreloader {
constructor() {
this.cache = new Map()
this.loading = new Set()
this.queue = []
this.maxConcurrent = 6 // 最大并发数
this.currentLoading = 0
}
/**
* 预加载单张图片
*/
preload(url) {
// 已缓存
if (this.cache.has(url)) {
return Promise.resolve(this.cache.get(url))
}
// 正在加载
if (this.loading.has(url)) {
return new Promise((resolve) => {
const check = setInterval(() => {
if (this.cache.has(url)) {
clearInterval(check)
resolve(this.cache.get(url))
}
}, 100)
})
}
return this.loadImage(url)
}
/**
* 加载图片
*/
loadImage(url) {
return new Promise((resolve, reject) => {
if (this.currentLoading >= this.maxConcurrent) {
// 加入队列
this.queue.push({ url, resolve, reject })
return
}
this.currentLoading++
this.loading.add(url)
const img = new Image()
img.onload = () => {
this.cache.set(url, img)
this.loading.delete(url)
this.currentLoading--
resolve(img)
// 处理队列
this.processQueue()
}
img.onerror = (error) => {
this.loading.delete(url)
this.currentLoading--
reject(error)
// 处理队列
this.processQueue()
}
img.src = url
})
}
/**
* 处理队列
*/
processQueue() {
if (this.queue.length === 0) return
if (this.currentLoading >= this.maxConcurrent) return
const { url, resolve, reject } = this.queue.shift()
this.loadImage(url).then(resolve).catch(reject)
}
/**
* 批量预加载
*/
async preloadBatch(urls, onProgress) {
const total = urls.length
let loaded = 0
const promises = urls.map(url => {
return this.preload(url)
.then(img => {
loaded++
if (onProgress) {
onProgress(loaded / total, loaded, total)
}
return img
})
.catch(error => {
loaded++
if (onProgress) {
onProgress(loaded / total, loaded, total)
}
console.error('预加载失败:', url, error)
return null
})
})
return Promise.all(promises)
}
/**
* 智能预加载
* 根据可视区域智能预加载图片
*/
smartPreload(images, viewport) {
const viewportBottom = viewport.scrollTop + viewport.height
const preloadDistance = 300 // 提前300px开始预加载
const toPreload = images.filter(img => {
const imgTop = img.offsetTop
return imgTop < viewportBottom + preloadDistance &&
imgTop > viewport.scrollTop - preloadDistance
})
const urls = toPreload.map(img => img.dataset.src).filter(Boolean)
return this.preloadBatch(urls)
}
/**
* 按优先级预加载
*/
async preloadByPriority(imageGroups) {
// imageGroups: { high: [], medium: [], low: [] }
// 高优先级立即加载
if (imageGroups.high) {
await this.preloadBatch(imageGroups.high)
}
// 中优先级延迟加载
if (imageGroups.medium) {
setTimeout(() => {
this.preloadBatch(imageGroups.medium)
}, 1000)
}
// 低优先级空闲时加载
if (imageGroups.low) {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
this.preloadBatch(imageGroups.low)
})
} else {
setTimeout(() => {
this.preloadBatch(imageGroups.low)
}, 3000)
}
}
}
/**
* 清除缓存
*/
clearCache() {
this.cache.clear()
}
/**
* 获取缓存信息
*/
getCacheInfo() {
return {
size: this.cache.size,
loading: this.loading.size,
queue: this.queue.length
}
}
}
export default new ImagePreloader()
3.3 使用示例
<script setup>
import { ref, onMounted } from 'vue'
import SmartImage from '@/components/SmartImage.vue'
import imagePreloader from '@/utils/imagePreloader'
const images = ref([
'https://picsum.photos/400/300?random=1',
'https://picsum.photos/400/300?random=2',
'https://picsum.photos/400/300?random=3',
// ... more images
])
const preloadProgress = ref(0)
onMounted(() => {
// 预加载首屏图片
const firstScreenImages = images.value.slice(0, 3)
imagePreloader.preloadBatch(firstScreenImages, (progress) => {
preloadProgress.value = Math.floor(progress * 100)
})
// 按优先级预加载
imagePreloader.preloadByPriority({
high: images.value.slice(0, 3),
medium: images.value.slice(3, 10),
low: images.value.slice(10)
})
})
</script>
<template>
<div class="gallery">
<div v-if="preloadProgress < 100" class="preload-progress">
预加载中: {{ preloadProgress }}%
</div>
<SmartImage
v-for="(src, index) in images"
:key="index"
:src="src"
:lazy="index > 2"
:preload="200"
:progressive="true"
:webp="true"
class="gallery-item"
alt="图片"
@load="console.log('图片加载成功:', src)"
@error="console.error('图片加载失败:', src)"
/>
</div>
</template>
四、骨架屏方案
4.1 骨架屏组件
<!-- components/Skeleton.vue -->
<script setup>
import { computed } from 'vue'
const props = defineProps({
// 类型:text/circle/rect/avatar/button/input
type: {
type: String,
default: 'text'
},
// 宽度
width: {
type: [String, Number],
default: '100%'
},
// 高度
height: {
type: [String, Number],
default: ''
},
// 圆角
radius: {
type: [String, Number],
default: 4
},
// 是否显示动画
animated: {
type: Boolean,
default: true
},
// 行数(type为text时有效)
rows: {
type: Number,
default: 1
},
// 最后一行宽度(type为text时有效)
lastRowWidth: {
type: [String, Number],
default: '60%'
}
})
const skeletonStyle = computed(() => {
const style = {}
// 预设样式
const presets = {
text: {
height: '16px',
radius: '2px'
},
circle: {
width: '40px',
height: '40px',
radius: '50%'
},
avatar: {
width: '60px',
height: '60px',
radius: '50%'
},
button: {
height: '32px',
radius: '4px'
},
input: {
height: '40px',
radius: '4px'
},
rect: {}
}
const preset = presets[props.type] || {}
// 合并样式
style.width = typeof props.width === 'number'
? props.width + 'px'
: props.width || preset.width || '100%'
style.height = typeof props.height === 'number'
? props.height + 'px'
: props.height || preset.height || '16px'
style.borderRadius = typeof props.radius === 'number'
? props.radius + 'px'
: props.radius || preset.radius || '4px'
return style
})
</script>
<template>
<div class="skeleton-wrapper">
<template v-if="type === 'text' && rows > 1">
<div
v-for="row in rows"
:key="row"
class="skeleton"
:class="{ animated }"
:style="{
...skeletonStyle,
width: row === rows ? lastRowWidth : skeletonStyle.width,
marginBottom: row < rows ? '8px' : '0'
}"
/>
</template>
<div
v-else
class="skeleton"
:class="{ animated }"
:style="skeletonStyle"
/>
</div>
</template>
<style scoped>
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
}
.skeleton.animated {
animation: skeleton-loading 1.5s ease-in-out infinite;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
4.2 完整页面骨架屏
<!-- components/PageSkeleton.vue -->
<script setup>
import Skeleton from './Skeleton.vue'
defineProps({
type: {
type: String,
default: 'product' // product/list/article/user
}
})
</script>
<template>
<div class="page-skeleton">
<!-- 商品详情骨架屏 -->
<div v-if="type === 'product'" class="product-skeleton">
<Skeleton type="rect" width="100%" height="300px" :radius="0" />
<div class="product-info">
<Skeleton type="text" :rows="2" height="20px" />
<Skeleton type="text" width="40%" height="28px" style="margin-top: 16px" />
<div class="specs">
<Skeleton type="button" width="80px" height="32px" />
<Skeleton type="button" width="80px" height="32px" style="margin-left: 12px" />
</div>
<Skeleton type="text" :rows="3" height="16px" style="margin-top: 24px" />
</div>
</div>
<!-- 列表骨架屏 -->
<div v-if="type === 'list'" class="list-skeleton">
<div v-for="i in 5" :key="i" class="list-item">
<Skeleton type="rect" width="100px" height="100px" :radius="8" />
<div class="list-info">
<Skeleton type="text" height="18px" />
<Skeleton type="text" width="70%" height="14px" style="margin-top: 8px" />
<Skeleton type="text" width="40%" height="20px" style="margin-top: 12px" />
</div>
</div>
</div>
<!-- 文章骨架屏 -->
<div v-if="type === 'article'" class="article-skeleton">
<Skeleton type="text" height="24px" width="80%" style="margin-bottom: 16px" />
<div class="article-meta">
<Skeleton type="avatar" width="40px" height="40px" />
<div style="flex: 1; margin-left: 12px">
<Skeleton type="text" width="100px" height="16px" />
<Skeleton type="text" width="150px" height="14px" style="margin-top: 4px" />
</div>
</div>
<Skeleton type="rect" width="100%" height="200px" style="margin: 20px 0" />
<Skeleton type="text" :rows="8" height="16px" />
</div>
<!-- 用户中心骨架屏 -->
<div v-if="type === 'user'" class="user-skeleton">
<div class="user-header">
<Skeleton type="avatar" width="80px" height="80px" />
<div style="flex: 1; margin-left: 16px">
<Skeleton type="text" width="120px" height="20px" />
<Skeleton type="text" width="200px" height="14px" style="margin-top: 8px" />
</div>
</div>
<div class="user-stats">
<div v-for="i in 3" :key="i" class="stat-item">
<Skeleton type="text" width="60px" height="24px" />
<Skeleton type="text" width="40px" height="14px" style="margin-top: 4px" />
</div>
</div>
<div class="user-menu">
<div v-for="i in 4" :key="i" class="menu-item">
<Skeleton type="circle" width="24px" height="24px" />
<Skeleton type="text" width="100px" height="16px" style="margin-left: 12px" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-skeleton {
padding: 16px;
background: #fff;
}
.product-skeleton {
max-width: 750px;
margin: 0 auto;
}
.product-info {
padding: 16px;
}
.specs {
display: flex;
margin-top: 16px;
}
.list-skeleton {
display: flex;
flex-direction: column;
gap: 16px;
}
.list-item {
display: flex;
gap: 12px;
}
.list-info {
flex: 1;
}
.article-skeleton {
max-width: 680px;
margin: 0 auto;
padding: 20px;
}
.article-meta {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.user-skeleton {
max-width: 750px;
margin: 0 auto;
}
.user-header {
display: flex;
align-items: center;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
margin-bottom: 20px;
}
.user-stats {
display: flex;
justify-content: space-around;
padding: 20px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 20px;
}
.stat-item {
text-align: center;
}
.user-menu {
display: flex;
flex-direction: column;
gap: 16px;
}
.menu-item {
display: flex;
align-items: center;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
}
</style>
4.3 自动骨架屏生成器
// utils/skeletonGenerator.js
/**
* 骨架屏自动生成器
*/
export class SkeletonGenerator {
constructor(options = {}) {
this.options = {
backgroundColor: '#f0f0f0',
borderRadius: 4,
animated: true,
excludeSelectors: ['script', 'style', 'link', 'meta', 'noscript'],
minWidth: 20,
minHeight: 20,
...options
}
}
/**
* 从DOM元素生成骨架屏
*/
generate(element) {
if (!element) return null
const skeleton = this.createSkeletonFromElement(element)
return skeleton
}
/**
* 从元素创建骨架
*/
createSkeletonFromElement(element) {
// 获取元素尺寸
const rect = element.getBoundingClientRect()
// 忽略太小的元素
if (rect.width < this.options.minWidth || rect.height < this.options.minHeight) {
return null
}
// 检查是否应该排除
if (this.shouldExclude(element)) {
return null
}
// 创建骨架块
const block = document.createElement('div')
block.className = 'skeleton-block'
const style = window.getComputedStyle(element)
// 复制布局样式
block.style.cssText = `
width: ${rect.width}px;
height: ${rect.height}px;
background: ${this.options.backgroundColor};
border-radius: ${this.options.borderRadius}px;
position: ${style.position};
display: ${style.display};
margin: ${style.margin};
padding: 0;
`
// 特殊元素处理
const tagName = element.tagName.toLowerCase()
if (tagName === 'img') {
block.classList.add('skeleton-image')
block.style.borderRadius = style.borderRadius
} else if (tagName === 'button' || element.classList.contains('button')) {
block.classList.add('skeleton-button')
block.style.borderRadius = '20px'
} else if (this.isTextElement(element)) {
// 文本元素:创建文本行
const lines = this.getTextLines(element, rect)
block.classList.add('skeleton-text')
for (let i = 0; i < lines; i++) {
const line = document.createElement('div')
line.className = 'skeleton-text-line'
line.style.cssText = `
width: ${i === lines - 1 ? Math.random() * 40 + 50 + '%' : '100%'};
height: 12px;
background: ${this.options.backgroundColor};
border-radius: 2px;
margin-bottom: ${i < lines - 1 ? '8px' : '0'};
`
block.appendChild(line)
}
} else if (element.classList.contains('avatar') ||
(tagName === 'img' && rect.width === rect.height)) {
// 头像
block.classList.add('skeleton-avatar')
block.style.borderRadius = '50%'
}
// 添加动画
if (this.options.animated) {
block.classList.add('skeleton-animated')
}
// 处理子元素
Array.from(element.children).forEach(child => {
const childSkeleton = this.createSkeletonFromElement(child)
if (childSkeleton) {
block.appendChild(childSkeleton)
}
})
return block
}
/**
* 判断是否为文本元素
*/
isTextElement(element) {
const textTags = ['p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'label', 'div']
return textTags.includes(element.tagName.toLowerCase()) &&
element.children.length === 0 &&
element.textContent.trim().length > 0
}
/**
* 计算文本行数
*/
getTextLines(element, rect) {
const style = window.getComputedStyle(element)
const lineHeight = parseFloat(style.lineHeight) || 20
const lines = Math.floor(rect.height / lineHeight)
return Math.max(1, Math.min(lines, 5)) // 最多5行
}
/**
* 判断是否应该排除
*/
shouldExclude(element) {
const tagName = element.tagName.toLowerCase()
return this.options.excludeSelectors.includes(tagName) ||
element.style.display === 'none' ||
element.style.visibility === 'hidden'
}
/**
* 导出为HTML
*/
exportHTML(skeleton) {
const wrapper = document.createElement('div')
wrapper.className = 'skeleton-wrapper'
wrapper.appendChild(skeleton)
return wrapper.outerHTML
}
/**
* 导出为Vue组件
*/
exportVueComponent(skeleton) {
const html = this.exportHTML(skeleton)
return `<template>
${html}
</template>
<style scoped>
.skeleton-wrapper {
padding: 16px;
}
.skeleton-block {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
}
.skeleton-animated {
animation: skeleton-loading 1.5s ease-in-out infinite;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>`
}
}
// 使用示例
export function generatePageSkeleton(selector) {
const element = document.querySelector(selector)
if (!element) {
console.error('未找到元素:', selector)
return null
}
const generator = new SkeletonGenerator()
const skeleton = generator.generate(element)
return generator.exportVueComponent(skeleton)
}
五、Service Worker 离线缓存
5.1 Service Worker 注册
// sw-register.js
/**
* Service Worker 注册管理器
*/
class ServiceWorkerManager {
constructor() {
this.registration = null
this.updateFound = false
}
/**
* 注册 Service Worker
*/
async register() {
if (!('serviceWorker' in navigator)) {
console.log('浏览器不支持 Service Worker')
return false
}
try {
this.registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
})
console.log('Service Worker 注册成功:', this.registration)
// 监听更新
this.listenForUpdates()
// 检查更新
this.checkForUpdates()
return true
} catch (error) {
console.error('Service Worker 注册失败:', error)
return false
}
}
/**
* 监听更新
*/
listenForUpdates() {
this.registration.addEventListener('updatefound', () => {
const newWorker = this.registration.installing
console.log('发现新的 Service Worker')
this.updateFound = true
newWorker.addEventListener('statechange', () => {
console.log('Service Worker 状态:', newWorker.state)
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本可用
this.onUpdateReady(newWorker)
}
})
})
}
/**
* 更新就绪
*/
onUpdateReady(newWorker) {
console.log('新版本已就绪')
// 提示用户更新
const shouldUpdate = confirm('发现新版本,是否立即更新?')
if (shouldUpdate) {
// 跳过等待,立即激活
newWorker.postMessage({ type: 'SKIP_WAITING' })
// 刷新页面
window.location.reload()
}
}
/**
* 检查更新
*/
async checkForUpdates() {
if (!this.registration) return
try {
await this.registration.update()
} catch (error) {
console.error('检查更新失败:', error)
}
}
/**
* 注销 Service Worker
*/
async unregister() {
if (!this.registration) return
const success = await this.registration.unregister()
console.log('Service Worker 注销:', success)
return success
}
/**
* 清除缓存
*/
async clearCache() {
if (!('caches' in window)) return
const cacheNames = await caches.keys()
await Promise.all(
cacheNames.map(name => caches.delete(name))
)
console.log('缓存已清除')
}
/**
* 发送消息给 Service Worker
*/
postMessage(message) {
if (!navigator.serviceWorker.controller) return
navigator.serviceWorker.controller.postMessage(message)
}
}
export default new ServiceWorkerManager()
5.2 Service Worker 核心代码
// public/sw.js
const CACHE_NAME = 'app-cache-v1'
const RUNTIME_CACHE = 'runtime-cache'
// 需要缓存的静态资源
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/css/app.css',
'/js/app.js',
'/img/logo.png'
]
// 缓存策略配置
const CACHE_STRATEGIES = {
// 图片:缓存优先
images: {
pattern: /\.(png|jpg|jpeg|svg|gif|webp)$/,
strategy: 'CacheFirst',
cacheName: 'image-cache',
maxAge: 30 * 24 * 60 * 60 * 1000 // 30天
},
// API:网络优先
api: {
pattern: /\/api\//,
strategy: 'NetworkFirst',
cacheName: 'api-cache',
maxAge: 5 * 60 * 1000, // 5分钟
timeout: 3000
},
// 静态资源:缓存优先
static: {
pattern: /\.(js|css|woff2?)$/,
strategy: 'CacheFirst',
cacheName: 'static-cache',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
}
}
/**
* 安装事件
*/
self.addEventListener('install', (event) => {
console.log('[SW] 安装中...')
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] 缓存静态资源')
return cache.addAll(STATIC_ASSETS)
})
.then(() => {
console.log('[SW] 安装完成')
// 立即激活
return self.skipWaiting()
})
)
})
/**
* 激活事件
*/
self.addEventListener('activate', (event) => {
console.log('[SW] 激活中...')
event.waitUntil(
// 清除旧缓存
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME && !cacheName.includes('runtime')) {
console.log('[SW] 删除旧缓存:', cacheName)
return caches.delete(cacheName)
}
})
)
}).then(() => {
console.log('[SW] 激活完成')
// 立即接管所有页面
return self.clients.claim()
})
)
})
/**
* 请求拦截
*/
self.addEventListener('fetch', (event) => {
const { request } = event
const url = new URL(request.url)
// 只处理同源请求
if (url.origin !== location.origin) {
return
}
// 根据策略处理请求
const strategy = getStrategy(url.pathname)
event.respondWith(
handleRequest(request, strategy)
)
})
/**
* 获取缓存策略
*/
function getStrategy(pathname) {
for (const [key, config] of Object.entries(CACHE_STRATEGIES)) {
if (config.pattern.test(pathname)) {
return config
}
}
// 默认策略:网络优先
return {
strategy: 'NetworkFirst',
cacheName: RUNTIME_CACHE,
timeout: 3000
}
}
/**
* 处理请求
*/
async function handleRequest(request, config) {
const { strategy } = config
switch (strategy) {
case 'CacheFirst':
return cacheFirst(request, config)
case 'NetworkFirst':
return networkFirst(request, config)
case 'StaleWhileRevalidate':
return staleWhileRevalidate(request, config)
default:
return fetch(request)
}
}
/**
* 缓存优先策略
*/
async function cacheFirst(request, config) {
const cache = await caches.open(config.cacheName)
const cached = await cache.match(request)
if (cached) {
// 检查缓存是否过期
const cachedTime = await getCacheTime(config.cacheName, request.url)
if (cachedTime && Date.now() - cachedTime < config.maxAge) {
return cached
}
}
try {
const response = await fetch(request)
if (response && response.status === 200) {
// 缓存响应
cache.put(request, response.clone())
await setCacheTime(config.cacheName, request.url, Date.now())
}
return response
} catch (error) {
// 网络失败,返回缓存(即使过期)
if (cached) {
return cached
}
throw error
}
}
/**
* 网络优先策略
*/
async function networkFirst(request, config) {
const cache = await caches.open(config.cacheName)
try {
// 设置超时
const response = await Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), config.timeout || 3000)
)
])
if (response && response.status === 200) {
cache.put(request, response.clone())
await setCacheTime(config.cacheName, request.url, Date.now())
}
return response
} catch (error) {
// 网络失败,尝试返回缓存
const cached = await cache.match(request)
if (cached) {
return cached
}
throw error
}
}
/**
* 陈旧重新验证策略
*/
async function staleWhileRevalidate(request, config) {
const cache = await caches.open(config.cacheName)
const cached = await cache.match(request)
// 后台更新
const fetchPromise = fetch(request)
.then(response => {
if (response && response.status === 200) {
cache.put(request, response.clone())
setCacheTime(config.cacheName, request.url, Date.now())
}
return response
})
// 返回缓存(如果有)
return cached || fetchPromise
}
/**
* 获取缓存时间
*/
async function getCacheTime(cacheName, url) {
try {
const cache = await caches.open(`${cacheName}-time`)
const response = await cache.match(url)
if (response) {
const time = await response.text()
return parseInt(time)
}
} catch {
return null
}
return null
}
/**
* 设置缓存时间
*/
async function setCacheTime(cacheName, url, time) {
try {
const cache = await caches.open(`${cacheName}-time`)
await cache.put(url, new Response(String(time)))
} catch (error) {
console.error('设置缓存时间失败:', error)
}
}
/**
* 消息通信
*/
self.addEventListener('message', (event) => {
const { type, data } = event.data || {}
switch (type) {
case 'SKIP_WAITING':
self.skipWaiting()
break
case 'CLEAR_CACHE':
caches.keys().then(names => {
return Promise.all(names.map(name => caches.delete(name)))
}).then(() => {
event.ports[0].postMessage({ success: true })
})
break
case 'GET_CACHE_SIZE':
getCacheSize().then(size => {
event.ports[0].postMessage({ size })
})
break
default:
break
}
})
/**
* 获取缓存大小
*/
async function getCacheSize() {
const cacheNames = await caches.keys()
let totalSize = 0
for (const name of cacheNames) {
const cache = await caches.open(name)
const requests = await cache.keys()
for (const request of requests) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
totalSize += blob.size
}
}
}
return totalSize
}
5.3 在应用中使用
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import swManager from './sw-register'
const app = createApp(App)
// 开发环境不启用 Service Worker
if (process.env.NODE_ENV === 'production') {
swManager.register()
}
// 定期检查更新(每小时)
setInterval(() => {
swManager.checkForUpdates()
}, 60 * 60 * 1000)
app.mount('#app')
5.4 缓存管理组件
<!-- components/CacheManager.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import swManager from '@/sw-register'
const cacheSize = ref(0)
const cacheInfo = ref([])
const loading = ref(false)
// 获取缓存信息
async function getCacheInfo() {
if (!('caches' in window)) return
loading.value = true
try {
const cacheNames = await caches.keys()
const info = []
let totalSize = 0
for (const name of cacheNames) {
const cache = await caches.open(name)
const requests = await cache.keys()
let size = 0
for (const request of requests) {
const response = await cache.match(request)
if (response) {
const blob = await response.blob()
size += blob.size
}
}
info.push({
name,
count: requests.length,
size
})
totalSize += size
}
cacheInfo.value = info
cacheSize.value = totalSize
} catch (error) {
console.error('获取缓存信息失败:', error)
} finally {
loading.value = false
}
}
// 清除缓存
async function clearCache() {
if (!confirm('确定要清除所有缓存吗?')) return
loading.value = true
try {
await swManager.clearCache()
await getCacheInfo()
alert('缓存已清除')
} catch (error) {
console.error('清除缓存失败:', error)
alert('清除失败')
} finally {
loading.value = false
}
}
// 格式化大小
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
onMounted(() => {
getCacheInfo()
})
</script>
<template>
<div class="cache-manager">
<div class="header">
<h2>缓存管理</h2>
<button @click="clearCache" :disabled="loading">清除缓存</button>
</div>
<div class="summary">
<div class="stat">
<span class="label">缓存总数:</span>
<span class="value">{{ cacheInfo.length }}</span>
</div>
<div class="stat">
<span class="label">缓存大小:</span>
<span class="value">{{ formatSize(cacheSize) }}</span>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else class="cache-list">
<div v-for="cache in cacheInfo" :key="cache.name" class="cache-item">
<div class="cache-name">{{ cache.name }}</div>
<div class="cache-stats">
<span>{{ cache.count }} 项</span>
<span>{{ formatSize(cache.size) }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.cache-manager {
padding: 20px;
max-width: 600px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h2 {
margin: 0;
}
.header button {
padding: 8px 16px;
background: #ff4d4f;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.header button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.summary {
display: flex;
gap: 20px;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 20px;
}
.stat {
flex: 1;
}
.stat .label {
color: #666;
margin-right: 8px;
}
.stat .value {
font-size: 18px;
font-weight: bold;
color: #1890ff;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.cache-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.cache-item {
padding: 16px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
}
.cache-name {
font-weight: 500;
margin-bottom: 8px;
}
.cache-stats {
display: flex;
gap: 16px;
font-size: 14px;
color: #666;
}
</style>
六、性能监控系统
6.1 性能指标采集
// utils/performanceMonitor.js
/**
* 性能监控器
*/
class PerformanceMonitor {
constructor() {
this.metrics = {}
this.observers = []
}
/**
* 初始化
*/
init() {
if (!window.performance) {
console.warn('浏览器不支持 Performance API')
return
}
// 页面加载完成后采集指标
if (document.readyState === 'complete') {
this.collectMetrics()
} else {
window.addEventListener('load', () => {
this.collectMetrics()
})
}
// 监听 Web Vitals
this.observeWebVitals()
}
/**
* 采集性能指标
*/
collectMetrics() {
const timing = performance.timing
const navigation = performance.navigation
this.metrics = {
// 页面加载关键时间点
navigationStart: timing.navigationStart,
// DNS 查询时间
dnsTime: timing.domainLookupEnd - timing.domainLookupStart,
// TCP 连接时间
tcpTime: timing.connectEnd - timing.connectStart,
// SSL 握手时间
sslTime: timing.secureConnectionStart > 0
? timing.connectEnd - timing.secureConnectionStart
: 0,
// 请求时间
requestTime: timing.responseStart - timing.requestStart,
// 响应时间
responseTime: timing.responseEnd - timing.responseStart,
// DOM 解析时间
domParseTime: timing.domInteractive - timing.domLoading,
// DOM 构建完成时间
domReadyTime: timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart,
// 资源加载时间
resourceTime: timing.loadEventStart - timing.domContentLoadedEventEnd,
// 白屏时间
whiteScreenTime: timing.responseStart - timing.navigationStart,
// 首屏时间(FCP)
firstScreenTime: timing.domContentLoadedEventEnd - timing.navigationStart,
// 页面完全加载时间
loadTime: timing.loadEventEnd - timing.navigationStart,
// 重定向次数
redirectCount: navigation.redirectCount,
// 重定向时间
redirectTime: timing.redirectEnd - timing.redirectStart,
// 页面类型(0: 正常进入, 1: 重新加载, 2: 前进后退)
navigationType: navigation.type
}
// 采集资源加载性能
this.collectResourceMetrics()
// 上报数据
this.report()
}
/**
* 采集资源加载性能
*/
collectResourceMetrics() {
const resources = performance.getEntriesByType('resource')
const resourceMetrics = {
total: resources.length,
js: 0,
css: 0,
image: 0,
font: 0,
other: 0,
totalSize: 0,
totalTime: 0
}
resources.forEach(resource => {
const { name, initiatorType, transferSize, duration } = resource
// 统计数量
if (initiatorType === 'script') resourceMetrics.js++
else if (initiatorType === 'link' || initiatorType === 'css') resourceMetrics.css++
else if (initiatorType === 'img') resourceMetrics.image++
else if (name.includes('.woff') || name.includes('.ttf')) resourceMetrics.font++
else resourceMetrics.other++
// 统计大小和时间
resourceMetrics.totalSize += transferSize || 0
resourceMetrics.totalTime += duration || 0
})
this.metrics.resources = resourceMetrics
}
/**
* 监听 Web Vitals
*/
observeWebVitals() {
// FCP - First Contentful Paint
this.observeFCP()
// LCP - Largest Contentful Paint
this.observeLCP()
// FID - First Input Delay
this.observeFID()
// CLS - Cumulative Layout Shift
this.observeCLS()
}
/**
* 监听 FCP
*/
observeFCP() {
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
if (entry.name === 'first-contentful-paint') {
this.metrics.fcp = entry.startTime
console.log('FCP:', entry.startTime)
}
})
})
observer.observe({ entryTypes: ['paint'] })
this.observers.push(observer)
} catch (error) {
console.error('FCP 监听失败:', error)
}
}
/**
* 监听 LCP
*/
observeLCP() {
try {
let lcpValue = 0
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
lcpValue = lastEntry.renderTime || lastEntry.loadTime
this.metrics.lcp = lcpValue
console.log('LCP:', lcpValue)
})
observer.observe({ entryTypes: ['largest-contentful-paint'] })
this.observers.push(observer)
} catch (error) {
console.error('LCP 监听失败:', error)
}
}
/**
* 监听 FID
*/
observeFID() {
try {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
const fid = entry.processingStart - entry.startTime
this.metrics.fid = fid
console.log('FID:', fid)
})
})
observer.observe({ entryTypes: ['first-input'] })
this.observers.push(observer)
} catch (error) {
console.error('FID 监听失败:', error)
}
}
/**
* 监听 CLS
*/
observeCLS() {
try {
let clsValue = 0
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
if (!entry.hadRecentInput) {
clsValue += entry.value
this.metrics.cls = clsValue
console.log('CLS:', clsValue)
}
})
})
observer.observe({ entryTypes: ['layout-shift'] })
this.observers.push(observer)
} catch (error) {
console.error('CLS 监听失败:', error)
}
}
/**
* 上报数据
*/
report() {
// 添加公共信息
const data = {
metrics: this.metrics,
page: {
url: location.href,
title: document.title,
referrer: document.referrer
},
user: {
ua: navigator.userAgent,
language: navigator.language,
online: navigator.onLine
},
device: {
screen: {
width: screen.width,
height: screen.height
},
viewport: {
width: window.innerWidth,
height: window.innerHeight
}
},
timestamp: Date.now()
}
// 发送到服务器
this.send(data)
}
/**
* 发送数据
*/
send(data) {
// 使用 sendBeacon 确保数据发送
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' })
navigator.sendBeacon('/api/performance', blob)
} else {
// 降级方案
fetch('/api/performance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
keepalive: true
}).catch(error => {
console.error('性能数据上报失败:', error)
})
}
}
/**
* 获取当前指标
*/
getMetrics() {
return this.metrics
}
/**
* 清理
*/
destroy() {
this.observers.forEach(observer => {
observer.disconnect()
})
this.observers = []
}
}
export default new PerformanceMonitor()
6.2 错误监控
// utils/errorMonitor.js
/**
* 错误监控器
*/
class ErrorMonitor {
constructor() {
this.errors = []
this.maxErrors = 50 // 最多保留50条错误
}
/**
* 初始化
*/
init() {
// JS 错误
window.addEventListener('error', (event) => {
this.handleJSError(event)
})
// Promise 错误
window.addEventListener('unhandledrejection', (event) => {
this.handlePromiseError(event)
})
// 资源加载错误
window.addEventListener('error', (event) => {
if (event.target !== window) {
this.handleResourceError(event)
}
}, true)
// Vue 错误(如果使用 Vue)
if (window.__VUE_ERROR_HANDLER__) {
window.__VUE_ERROR_HANDLER__ = (err, vm, info) => {
this.handleVueError(err, vm, info)
}
}
}
/**
* 处理 JS 错误
*/
handleJSError(event) {
const error = {
type: 'js_error',
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack || '',
timestamp: Date.now(),
url: location.href,
ua: navigator.userAgent
}
this.addError(error)
this.report(error)
}
/**
* 处理 Promise 错误
*/
handlePromiseError(event) {
const error = {
type: 'promise_error',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || '',
timestamp: Date.now(),
url: location.href,
ua: navigator.userAgent
}
this.addError(error)
this.report(error)
}
/**
* 处理资源加载错误
*/
handleResourceError(event) {
const target = event.target
const error = {
type: 'resource_error',
tagName: target.tagName,
src: target.src || target.href,
outerHTML: target.outerHTML.slice(0, 200),
timestamp: Date.now(),
url: location.href,
ua: navigator.userAgent
}
this.addError(error)
this.report(error)
}
/**
* 处理 Vue 错误
*/
handleVueError(err, vm, info) {
const error = {
type: 'vue_error',
message: err.message,
stack: err.stack,
info,
componentName: vm?.$options?.name || 'Anonymous',
timestamp: Date.now(),
url: location.href,
ua: navigator.userAgent
}
this.addError(error)
this.report(error)
}
/**
* 添加错误
*/
addError(error) {
this.errors.push(error)
// 限制数量
if (this.errors.length > this.maxErrors) {
this.errors.shift()
}
}
/**
* 上报错误
*/
report(error) {
// 使用 sendBeacon 确保数据发送
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(error)], { type: 'application/json' })
navigator.sendBeacon('/api/error', blob)
} else {
fetch('/api/error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(error),
keepalive: true
}).catch(() => {
// 静默失败
})
}
}
/**
* 获取错误列表
*/
getErrors() {
return this.errors
}
/**
* 清除错误
*/
clearErrors() {
this.errors = []
}
}
export default new ErrorMonitor()
6.3 在应用中使用
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import performanceMonitor from './utils/performanceMonitor'
import errorMonitor from './utils/errorMonitor'
// 初始化监控
performanceMonitor.init()
errorMonitor.init()
const app = createApp(App)
// 设置 Vue 错误处理
app.config.errorHandler = (err, vm, info) => {
errorMonitor.handleVueError(err, vm, info)
}
app.mount('#app')
真实项目经验
经验一:首屏优化实战
我们项目最开始首屏时间是3.2秒,用户投诉说打开很慢。我做了个性能分析,发现主要
问题是JS体积太大,首屏要加载1.2MB的JS。
第一步是代码分割。我把路由改成懒加载,用户访问首页时只加载首页的代码,访问商品
页才加载商品页的代码。这个改完首屏JS从1.2MB降到600KB。
第二步是组件按需引入。我们用的Element Plus,原来是全量引入,把整个库都打进去了。
我改成只引入用到的组件,又省了200KB。
第三步是第三方库优化。Moment.js太大了,换成Day.js省了70%体积。还有一些工具库,
比如lodash,改成按需引入单个方法。
第四步是资源预加载。关键资源用preload提前加载,次要资源用prefetch在空闲时加载。
DNS预解析CDN和API域名,节省几百毫秒。
第五步是接口优化。首屏原来要请求5个接口,我跟后端商量合并成1个批量接口,减少了
4次RTT。还做了接口缓存,用户第二次进来直接用缓存,速度快很多。
最后加上骨架屏,用户至少能看到页面结构,不会一直白屏。这些改完,首屏时间从3.2
秒降到0.8秒,用户反馈明显感觉快了。
经验二:虚拟列表踩坑
我们的商品列表有几千条数据,一开始全部渲染,页面特别卡,滑动帧率只有20fps。我决
定用虚拟滚动优化。
第一版我用的固定高度,每项120px。这个很好实现,只渲染可视区域的15项,滑动时动态
替换。但有个问题,产品经理说有些商品描述很长,高度不固定,固定高度会截断内容。
我改成动态高度,这个实现起来复杂很多。要先渲染一遍测量真实高度,然后缓存起来,
再根据高度计算每项的位置。还要处理高度变化的情况,比如图片加载完高度会变。
还有个坑是滚动太快会白屏。因为DOM渲染需要时间,快速滚动时来不及渲染。我加了个
缓冲区,上下各多渲染5项,这样快速滚动也有个过渡,不会突然白屏。
另外用requestAnimationFrame做节流很重要。scroll事件触发特别频繁,如果每次都处理
会导致掉帧。RAF能保证16ms只执行一次,帧率就稳定了。
最后的效果很好,不管多少数据,DOM节点都是固定的15项,内存占用从200MB降到40MB,
滑动帧率稳定在58fps。用户说滑动很流畅,不卡了。
经验三:Service Worker 灰度发布
我们上了Service Worker之后,确实提升了二次访问速度,但也遇到了问题。有一次更新
了代码,用户那边还是旧版本,因为Service Worker缓存了旧的JS文件。
我改进了更新策略。首先加了版本号检测,每次发版时CACHE_NAME的版本号会+1。Service
Worker激活时会删除旧版本的缓存。
其次加了强制更新机制。检测到新版本时,弹窗提示用户更新,用户确认后调用skipWaiting
立即激活新版本,然后刷新页面。
最重要的是灰度发布。我在后端配了个灰度开关,先给5%的用户推送新版本Service Worker,
观察错误率和性能指标。如果没问题,再逐步放量到100%。这样即使有bug,影响范围也很
小,可以快速回滚。
还有个技巧是差异化缓存策略。图片、CSS这些静态资源用缓存优先,API接口用网络优先。
这样既能提升性能,又能保证数据实时性。
整套方案下来,二次访问速度从2秒降到0.3秒,用户体验提升明显。而且更新机制很稳定,
没再出现过版本不一致的问题。
面试常见追问
Q: 首屏时间从3.2秒优化到0.8秒,具体是怎么做的? A: 主要是组合拳。首先代码分割,首屏JS从1.2MB降到280KB。然后SSR预渲染首页,用户直接看到HTML。关键资源用Preload预加载,次要资源用Prefetch。DNS预解析CDN域名。接口合并减少请求次数。图片懒加载加渐进式加载。最后是骨架屏过渡,避免白屏。这些加起来,从3.2秒优化到0.8秒。
Q: 虚拟列表和懒加载的区别是什么? A: 虚拟列表是只渲染可视区域的DOM,解决DOM数量过多的问题。懒加载是延迟加载图片等资源,解决网络请求过多的问题。两个解决的问题不一样。我们的商品列表同时用了两者:虚拟列表控制DOM数量,每个商品的图片再用懒加载。
Q: Service Worker的缓存策略有哪些? A: 主要三种。一是缓存优先(Cache First),先读缓存,没有再请求网络,适合静态资源。二是网络优先(Network First),先请求网络,失败再读缓存,适合API接口。三是并行请求(Race),同时请求缓存和网络,谁快用谁,适合追求速度的场景。我们静态资源用缓存优先,API用网络优先。
Q: 骨架屏是怎么自动生成的? A: 我写了个生成器,用Puppeteer打开页面,遍历DOM树提取元素的位置和大小,生成对应的灰色占位块。文本元素生成多行占位条,图片元素生成矩形块,按钮元素圆角更大。生成后导出成Vue组件。这个工具可以在构建时运行,自动生成骨架屏文件。不过复杂布局还是要手动调整,自动生成只是个基础。
Q: 性能监控具体监控哪些指标? A: 主要三类指标。一是加载性能:DNS时间、TCP连接时间、首屏时间、完全加载时间。二是运行性能:FPS、内存占用、长任务。三是Web Vitals:FCP首次内容绘制、LCP最大内容绘制、FID首次输入延迟、CLS累积布局偏移。这些指标能全面反映页面的性能状况,我们会实时上报到监控平台,设置阈值告警。