返回笔记首页

6.2 移动端性能优化 - 完整技术实现方案

主题配置

简历描述模板

版本一:注重性能指标

plain
主导移动端H5性能优化工作,首屏加载时间从3.2s优化至0.8s,FCP提升75%。采用路由懒
加载+组件异步加载策略,首屏JS体积从1.2MB降至280KB。实现虚拟滚动技术处理万级长
列表,内存占用减少80%,滑动帧率稳定在58fps以上。设计渐进式图片加载方案,结合
CDN图片处理能力实现按需加载,图片加载流量节省60%。开发骨架屏生成工具,自动提取
页面结构生成占位UI,用户感知等待时间缩短50%。接入Service Worker实现离线缓存,
二次访问速度提升400%。

版本二:强调技术方案

plain
负责移动端性能优化体系建设,建立从构建、网络、渲染到运行时的全链路优化方案。在
构建层面,通过Tree-shaking、代码分割、资源压缩等手段将打包体积优化40%。网络层
实现了DNS预解析、CDN加速、HTTP/2推送等策略,网络耗时降低35%。渲染层采用SSR预
渲染关键路径,配合骨架屏和渐进式渲染,白屏时间从2.1s降至0.6s。运行时通过虚拟列
表、节流防抖、内存管理等优化,交互响应速度提升50%,内存峰值降低60%。

版本三:突出业务价值

plain
作为性能优化负责人,推动移动端体验升级,核心指标全面提升。通过首屏优化使页面秒
开率从45%提升至82%,用户留存率增长12个百分点。实施智能预加载策略,根据用户行为
预测下一步操作并提前加载资源,页面跳转体验接近原生App。针对弱网环境优化请求重
试和降级方案,2G/3G网络下的可用率从60%提升至85%。建立性能监控平台,实时追踪核
心指标,异常自动告警,性能回归问题发现率100%。优化后用户投诉率下降40%。

SOP 标准回答

Q1: 首屏加载优化做了哪些工作?

标准回答

plain
首屏优化我们从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 路由懒加载配置

javascript
// 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 组件异步加载

vue
<!-- 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 资源预加载策略

javascript
// 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中使用

javascript
// 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 接口合并与缓存

javascript
// 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()

使用示例

vue
<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 固定高度虚拟列表

vue
<!-- 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 动态高度虚拟列表

vue
<!-- 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 使用示例

vue
<!-- 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 智能图片加载组件

vue
<!-- 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 图片预加载管理器

javascript
// 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 使用示例

vue
<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 骨架屏组件

vue
<!-- 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 完整页面骨架屏

vue
<!-- 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 自动骨架屏生成器

javascript
// 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 注册

javascript
// 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 核心代码

javascript
// 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 在应用中使用

javascript
// 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 缓存管理组件

vue
<!-- 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 性能指标采集

javascript
// 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 错误监控

javascript
// 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 在应用中使用

javascript
// 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')

真实项目经验

经验一:首屏优化实战

plain
我们项目最开始首屏时间是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秒,用户反馈明显感觉快了。

经验二:虚拟列表踩坑

plain
我们的商品列表有几千条数据,一开始全部渲染,页面特别卡,滑动帧率只有20fps。我决
定用虚拟滚动优化。

第一版我用的固定高度,每项120px。这个很好实现,只渲染可视区域的15项,滑动时动态
替换。但有个问题,产品经理说有些商品描述很长,高度不固定,固定高度会截断内容。

我改成动态高度,这个实现起来复杂很多。要先渲染一遍测量真实高度,然后缓存起来,
再根据高度计算每项的位置。还要处理高度变化的情况,比如图片加载完高度会变。

还有个坑是滚动太快会白屏。因为DOM渲染需要时间,快速滚动时来不及渲染。我加了个
缓冲区,上下各多渲染5项,这样快速滚动也有个过渡,不会突然白屏。

另外用requestAnimationFrame做节流很重要。scroll事件触发特别频繁,如果每次都处理
会导致掉帧。RAF能保证16ms只执行一次,帧率就稳定了。

最后的效果很好,不管多少数据,DOM节点都是固定的15项,内存占用从200MB降到40MB,
滑动帧率稳定在58fps。用户说滑动很流畅,不卡了。

经验三:Service Worker 灰度发布

plain
我们上了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累积布局偏移。这些指标能全面反映页面的性能状况,我们会实时上报到监控平台,设置阈值告警。