普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月27日首页

案例分析:一个复杂表单的响应式性能优化

作者 wuhen_n
2026年3月27日 09:25

前言

想象我们是一个银行柜员,每天要处理大量客户开户申请。表单有上百个字段:基本信息、家庭信息、资产信息、工作信息、财务信息...

每次输入一个字段,电脑都要卡顿一下。客户不耐烦地说:"你这电脑也太慢了。"

我们只能无奈地说:"不是电脑慢,是这个系统太卡了。"

场景描述 :一个真实的性能噩梦

业务背景

某金融后台系统的客户信息录入表单,用于银行开户、贷款申请、企业信息登记:

// 这个表单有 100+ 个字段
const formData = {
  // 基本信息 - 20+ 字段
  personalInfo: {
    name: '',           // 姓名
    idCard: '',         // 身份证号
    phone: '',          // 手机号
    email: '',          // 邮箱
    birthday: '',       // 生日
    nationality: '',    // 国籍
    occupation: '',     // 职业
    education: '',      // 学历
    maritalStatus: '',  // 婚姻状况
    // ... 还有10多个字段
  },
  
  // 家庭信息 - 15+ 字段
  familyInfo: {
    spouseName: '',
    spousePhone: '',
    children: [],       // 动态增减的孩子列表
    // ...
  },
  
  // 资产信息 - 30+ 字段
  assetInfo: {
    house: [],          // 房产列表(可动态增减)
    vehicle: [],        // 车辆列表(可动态增减)
    deposit: [],        // 存款列表(可动态增减)
    investment: [],     // 投资列表(可动态增减)
    // ...
  },
  
  // 工作信息 - 20+ 字段
  workInfo: {
    companyName: '',
    position: '',
    income: 0,
    workYears: 0,
    // ...
  },
  
  // 财务信息 - 15+ 字段
  financialInfo: {
    monthlyIncome: 0,
    monthlyExpense: 0,
    creditScore: 0,
    // ...
  }
}

性能问题表现

操作 正常预期 实际情况 用户感受
页面加载 < 1秒 3.5秒 "怎么这么慢?"
输入单个字段 立即响应 延迟 200-500ms "打字跟不上"
添加动态字段 瞬间 1-2秒卡顿 "以为点不动"
字段联动 实时 延迟明显 "体验很差"
表单提交 1秒内 5秒+ "是不是卡死了?"

初始代码结构(问题代码)

<!-- ❌ 问题代码:一个组件包含所有字段 -->
<template>
  <form @submit="handleSubmit">
    <!-- 基本信息 -->
    <div class="section">
      <h3>基本信息</h3>
      <input v-model="formData.personalInfo.name" placeholder="姓名" />
      <input v-model="formData.personalInfo.idCard" placeholder="身份证号" />
      <!-- ... 100+ 个字段 -->
    </div>
    
    <!-- 动态资产列表 -->
    <div v-for="(house, index) in formData.assetInfo.house" :key="index">
      <input v-model="house.address" placeholder="地址" />
      <input v-model="house.area" placeholder="面积" />
      <input v-model="house.value" placeholder="价值" />
      <button @click="removeHouse(index)">删除</button>
    </div>
    <button @click="addHouse">添加房产</button>
    
    <!-- 其他字段... -->
  </form>
</template>

<script setup>
import { ref, watch } from 'vue'

// 整个表单数据都是响应式的
const formData = ref(initialData)

// 大量 watch 监听联动
watch(() => formData.value.personalInfo.occupation, (newVal) => {
  // 根据职业动态显示字段
  if (newVal === '医生') {
    formData.value.dynamicFields.hospital = ''
    formData.value.dynamicFields.licenseNumber = ''
  } else if (newVal === '律师') {
    formData.value.dynamicFields.lawFirm = ''
    formData.value.dynamicFields.barNumber = ''
  }
})

watch(() => formData.value.assetInfo.totalValue, (newVal) => {
  // 资产变化影响贷款额度
  formData.value.financialInfo.loanAmount = newVal * 0.7
})
</script>

性能瓶颈分析

1. 响应式系统过载

  • 100+ 个字段全部深度响应式
  • 每次输入触发整个组件的响应式依赖重新计算
  • 递归代理导致内存占用巨大

2. 组件粒度过大

  • 单个组件包含所有逻辑
  • 任何字段变化都导致整个组件重渲染
  • 模板过大,编译和 diff 开销大

3. 联动逻辑低效

  • 过多的 watch 监听
  • 每次输入触发多个 watch
  • watch 中的操作触发更多更新

性能问题诊断

使用 Vue DevTools 分析

  • 步骤1:打开 Vue DevTools 的 Performance 面板
  • 步骤2:开始录制,在表单中输入一个字段
  • 步骤3:停止录制,查看分析结果

分析结果示例

性能时间线分析:

├─ 输入事件处理: 2ms
├─ 响应式依赖收集: 45ms  ← 瓶颈
├─ 组件渲染: 120ms  ← 瓶颈
│  ├─ 模板编译: 35ms
│  ├─ 虚拟 DOM diff: 50ms
│  └─ 真实 DOM 更新: 35ms
└─ watch 回调执行: 35ms  ← 瓶颈

总耗时: 202ms

使用 Chrome DevTools 分析

火焰图分析:

├─ 长任务 (Long Task) > 50ms
│  ├─ reactive setter 调用栈过深
│  ├─ 多个 watch 递归触发
│  └─ 组件渲染重复执行
│
├─ 强制重排 (Forced Reflow)
│  └─ 动态字段添加导致布局抖动
│
└─ 内存分配频繁
   └─ 每次输入都创建大量临时对象

自定义性能监控

// 添加性能监控代码
const perfMonitor = {
  logs: [],
  
  start(operation) {
    return performance.now()
  },
  
  end(operation, startTime) {
    const duration = performance.now() - startTime
    this.logs.push({ operation, duration })
    
    if (duration > 16) {
      console.warn(`⚠️ 慢操作: ${operation} 耗时 ${duration.toFixed(2)}ms`)
    }
    
    return duration
  },
  
  report() {
    console.table(this.logs)
    this.logs = []
  }
}

// 在组件中使用
const handleInput = (field, value) => {
  const start = perfMonitor.start(`update-${field}`)
  
  // 更新数据
  formData.value[field] = value
  
  perfMonitor.end(`update-${field}`, start)
}

优化方案一 :数据结构优化

使用 shallowRef 替代 ref

// ❌ 优化前:深度响应式
const formData = ref(largeFormData)
// 每次修改深层属性都会触发更新

// ✅ 优化后:浅层响应式
const formData = shallowRef(largeFormData)
// 只有整体替换才会触发更新

// 修改数据的模式
function updateField(path, value) {
  // 创建新对象,只修改需要变更的部分
  const newData = { ...formData.value }
  
  // 根据路径找到并修改值
  let current = newData
  for (let i = 0; i < path.length - 1; i++) {
    current = current[path[i]]
  }
  current[path[path.length - 1]] = value
  
  // 整体替换,触发一次更新
  formData.value = newData
}

拆分表单为多个子组件

<!-- ✅ 优化后:拆分为多个子组件 -->
<template>
  <form @submit="handleSubmit">
    <!-- 每个子组件独立渲染 -->
    <PersonalInfoForm 
      v-model="formData.personalInfo"
      @update="handleSectionUpdate"
    />
    
    <FamilyInfoForm 
      v-model="formData.familyInfo"
      @update="handleSectionUpdate"
    />
    
    <AssetInfoForm 
      v-model="formData.assetInfo"
      @update="handleSectionUpdate"
    />
    
    <WorkInfoForm 
      v-model="formData.workInfo"
      @update="handleSectionUpdate"
    />
    
    <FinancialInfoForm 
      v-model="formData.financialInfo"
      @update="handleSectionUpdate"
    />
  </form>
</template>

<script setup>
import { shallowRef } from 'vue'
import PersonalInfoForm from './PersonalInfoForm.vue'
import FamilyInfoForm from './FamilyInfoForm.vue'
import AssetInfoForm from './AssetInfoForm.vue'

// 使用 shallowRef 存储整个表单
const formData = shallowRef(initialData)

// 子组件更新时只更新对应部分
function handleSectionUpdate(section, data) {
  formData.value = {
    ...formData.value,
    [section]: data
  }
}
</script>

使用 Map 管理动态字段

// ❌ 优化前:使用数组存储动态字段
const houses = ref([{ address: '', area: 0, value: 0 }])

function addHouse() {
  houses.value.push({ address: '', area: 0, value: 0 })
  // 每次添加都触发整个数组的响应式更新
}

// ✅ 优化后:使用 Map 存储,减少响应式开销
const houses = shallowRef(new Map())
let nextId = 1

function addHouse() {
  const newMap = new Map(houses.value)
  newMap.set(nextId++, { address: '', area: 0, value: 0 })
  houses.value = newMap  // 整体替换
}

function updateHouse(id, field, value) {
  const newMap = new Map(houses.value)
  const house = newMap.get(id)
  if (house) {
    newMap.set(id, { ...house, [field]: value })
    houses.value = newMap
  }
}

function removeHouse(id) {
  const newMap = new Map(houses.value)
  newMap.delete(id)
  houses.value = newMap
}

优化方案二:渲染优化

虚拟滚动处理长列表

<template>
  <div class="dynamic-list">
    <h3>家庭成员</h3>
    
    <!-- 使用虚拟滚动组件 -->
    <VirtualScroller
      :items="familyMembers"
      :item-height="80"
      class="member-list"
    >
      <template #default="{ item, index }">
        <div class="member-item">
          <input 
            :value="item.name" 
            @input="updateMember(index, 'name', $event.target.value)"
            placeholder="姓名"
          />
          <input 
            :value="item.age" 
            type="number"
            @input="updateMember(index, 'age', $event.target.value)"
            placeholder="年龄"
          />
          <button @click="removeMember(index)">删除</button>
        </div>
      </template>
    </VirtualScroller>
    
    <button @click="addMember">添加家庭成员</button>
  </div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'
import VirtualScroller from 'vue-virtual-scroller'

// 使用 shallowRef 存储列表
const familyMembers = shallowRef([])

function addMember() {
  familyMembers.value = [
    ...familyMembers.value,
    { name: '', age: 0 }
  ]
}

function updateMember(index, field, value) {
  const newMembers = [...familyMembers.value]
  newMembers[index] = { ...newMembers[index], [field]: value }
  familyMembers.value = newMembers
}
</script>

使用 v-memo 缓存静态部分

<template>
  <div class="form-section">
    <h3>基本信息</h3>
    
    <!-- 静态部分使用 v-once -->
    <div v-once class="form-description">
      请填写您的真实信息
    </div>
    
    <!-- 使用 v-memo 缓存不常变化的部分 -->
    <div 
      v-for="field in staticFields" 
      :key="field.key"
      v-memo="[field.key]"
      class="form-row"
    >
      <label>{{ field.label }}</label>
      <input
        :value="formData[field.key]"
        @input="updateField(field.key, $event.target.value)"
      />
    </div>
    
    <!-- 联动字段动态渲染 -->
    <div 
      v-for="field in dynamicFields" 
      :key="field.key"
      class="form-row"
    >
      <label>{{ field.label }}</label>
      <component 
        :is="field.component" 
        v-model="formData[field.key]"
        :options="field.options"
      />
    </div>
  </div>
</template>

异步渲染 - 先渲染首屏

// 分阶段渲染表单
const renderStages = {
  critical: ['personalInfo', 'contactInfo'],      // 首屏必显
  important: ['familyInfo', 'workInfo'],          // 滚动到才渲染
  normal: ['assetInfo', 'financialInfo'],         // 折叠面板内
  lazy: ['attachments', 'remarks']                // 按需加载
}

const visibleSections = ref(new Set(['personalInfo']))

// 使用 Intersection Observer 检测可见性
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const section = entry.target.dataset.section
      if (section && !visibleSections.value.has(section)) {
        visibleSections.value.add(section)
      }
    }
  })
}, { rootMargin: '200px' })

优化方案三:联动逻辑优化

使用计算属性代替 watch

// ❌ 优化前:使用 watch 监听联动
watch(() => formData.value.assetInfo.totalValue, (newVal) => {
  formData.value.financialInfo.loanAmount = newVal * 0.7
  
  if (newVal > 1000000) {
    formData.value.financialInfo.riskLevel = 'high'
  } else if (newVal > 500000) {
    formData.value.financialInfo.riskLevel = 'medium'
  } else {
    formData.value.financialInfo.riskLevel = 'low'
  }
})

// ✅ 优化后:使用计算属性
const loanAmount = computed(() => {
  return formData.value.assetInfo.totalValue * 0.7
})

const riskLevel = computed(() => {
  const total = formData.value.assetInfo.totalValue
  if (total > 1000000) return 'high'
  if (total > 500000) return 'medium'
  return 'low'
})

// 在模板中使用计算属性
<div>贷款额度: {{ loanAmount }}</div>
<div>风险等级: {{ riskLevel }}</div>

防抖处理实时计算

import { debounce } from 'lodash-es'

// ❌ 优化前:每次输入都实时计算
const handleAmountChange = (value) => {
  const loanAmount = calculateLoan(value)
  const interest = calculateInterest(loanAmount)
  const monthlyPayment = calculateMonthlyPayment(loanAmount, interest)
  
  formData.value.financialInfo.loanAmount = loanAmount
  formData.value.financialInfo.interest = interest
  formData.value.financialInfo.monthlyPayment = monthlyPayment
}

// ✅ 优化后:使用防抖,用户停止输入后才计算
const debouncedCalculate = debounce((value) => {
  const loanAmount = calculateLoan(value)
  const interest = calculateInterest(loanAmount)
  const monthlyPayment = calculateMonthlyPayment(loanAmount, interest)
  
  // 批量更新
  formData.value = {
    ...formData.value,
    financialInfo: {
      ...formData.value.financialInfo,
      loanAmount,
      interest,
      monthlyPayment
    }
  }
}, 300)

const handleAmountChange = (value) => {
  debouncedCalculate(value)
}

批量更新优化

// ❌ 优化前:多次更新触发多次渲染
function applyCreditScore(score) {
  formData.value.financialInfo.creditScore = score
  
  if (score >= 800) {
    formData.value.financialInfo.loanRate = 0.035
    formData.value.financialInfo.loanLimit = 5000000
  } else if (score >= 700) {
    formData.value.financialInfo.loanRate = 0.045
    formData.value.financialInfo.loanLimit = 3000000
  }
  
  formData.value.financialInfo.creditLevel = getCreditLevel(score)
}
// 每次属性修改都触发一次更新,共 3 次

// ✅ 优化后:使用批量更新
function applyCreditScore(score) {
  const updates = { creditScore: score }
  
  if (score >= 800) {
    updates.loanRate = 0.035
    updates.loanLimit = 5000000
  } else if (score >= 700) {
    updates.loanRate = 0.045
    updates.loanLimit = 3000000
  }
  
  updates.creditLevel = getCreditLevel(score)
  
  // 批量更新,只触发一次渲染
  formData.value = {
    ...formData.value,
    financialInfo: {
      ...formData.value.financialInfo,
      ...updates
    }
  }
}

优化检查清单

数据结构优化

  • 使用 shallowRef 替代 ref 存储大对象
  • 拆分表单为多个子组件
  • 动态字段使用 Map 管理
  • 按业务模块组织数据结构
  • 避免深层嵌套的响应式数据

渲染优化

  • 长列表使用虚拟滚动
  • 静态内容使用 v-once
  • 不常变化的部分使用 v-memo
  • 非首屏内容异步渲染
  • 条件判断使用计算属性缓存

联动逻辑优化

  • 使用计算属性代替 watch
  • 复杂计算使用防抖/节流
  • 只在必要时触发更新
  • 批量更新使用对象合并
  • 避免在 watch 中修改其他字段

监控与调试

  • 使用 Vue DevTools 分析渲染性能
  • 使用 Chrome DevTools 分析内存占用
  • 添加性能监控埋点
  • 定期检查响应式依赖数量

结语

大表单优化的核心:让不需要响应式的数据不响应,让不需要渲染的部分不渲染。当我们输入一个字段时,只有这个字段对应的子组件重新渲染,而不是整个表单;当我们添加一个动态项时,只更新 Map 中的那一条,而不是整个数组。这样,无论表单有多大,用户都会感觉"很流畅",这就是优化的意义。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

昨天 — 2026年3月26日首页

组件测试策略:测试 Props、事件和插槽

作者 wuhen_n
2026年3月26日 07:19

前言:为什么组件测试要关注 Props、事件和插槽?

组件的本质:输入与输出

<template>
  <!-- Props 是输入 -->
  <ChildComponent 
    :user="userData"
    :showDetails="true"
    @update="handleUpdate"
    @delete="handleDelete"
  >
    <!-- 插槽也是输入 -->
    <template #header>
      <h1>标题</h1>
    </template>
  </ChildComponent>
</template>

组件的测试的关注点

  1. Props 输入是否正确渲染
  2. 事件输出是否正确触发
  3. 插槽内容是否正确分发

为什么这三个要素最重要?

要素 作用 测试重点
Props 父组件向子组件传递数据 组件是否能正确接收并渲染数据
事件 子组件向父组件通信 交互是否能正确触发事件
插槽 父组件控制子组件内容 内容是否能被正确分发和渲染

测试 Props - 验证输入

Props 测试的核心

给子组件输入数据,看它能不能正确显示。

最简单的 Props 测试

我们先来看一个简单的 Props 组件:

<!-- Greeting.vue -->
<template>
  <h1>Hello, {{ name }}!</h1>
</template>

<script setup>
defineProps(['name'])
</script>

其对应的测试:

// Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'

describe('Greeting', () => {
  it('显示传入的名字', () => {
    // 传入 name="张三"
    const wrapper = mount(Greeting, {
      props: { name: '张三' }
    })
    
    // 验证是否显示了"Hello, 张三!"
    expect(wrapper.text()).toBe('Hello, 张三!')
  })
})

测试多种 Props 值

我们再来看一个有多种 Props 值的组件:

<!-- Button.vue -->
<template>
  <button 
    :class="['btn', `btn-${type}`]"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
defineProps({
  text: String,
  type: { type: String, default: 'primary' },
  disabled: { type: Boolean, default: false }
})
</script>

其对应的测试:

// Button.spec.js
import { mount } from '@vue/test-utils'
import Button from './Button.vue'

describe('Button', () => {
  it('显示正确的文字', () => {
    const wrapper = mount(Button, {
      props: { text: '点击我' }
    })
    expect(wrapper.text()).toBe('点击我')
  })
  
  it('默认类型是 primary', () => {
    const wrapper = mount(Button, {
      props: { text: '按钮' }
    })
    expect(wrapper.classes()).toContain('btn-primary')
  })
  
  it('可以设置 type 为 danger', () => {
    const wrapper = mount(Button, {
      props: { text: '删除', type: 'danger' }
    })
    expect(wrapper.classes()).toContain('btn-danger')
  })
  
  it('disabled 属性可以禁用按钮', () => {
    const wrapper = mount(Button, {
      props: { text: '按钮', disabled: true }
    })
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
})

测试复杂 Props 对象

我们再来看一个复杂 Props 对象的组件:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <span v-if="showBadge" class="badge">{{ user.status }}</span>
  </div>
</template>

<script setup>
defineProps({
  user: {
    type: Object,
    required: true
  },
  showBadge: Boolean
})
</script>

其对应的测试:

// UserCard.spec.js
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

const mockUser = {
  name: '张三',
  email: 'zhangsan@example.com',
  status: 'active'
}

describe('UserCard', () => {
  it('显示用户信息', () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('zhangsan@example.com')
  })
  
  it('showBadge 为 true 时显示状态', () => {
    const wrapper = mount(UserCard, {
      props: { 
        user: mockUser,
        showBadge: true 
      }
    })
    
    expect(wrapper.find('.badge').exists()).toBe(true)
    expect(wrapper.find('.badge').text()).toBe('active')
  })
  
  it('showBadge 为 false 时不显示状态', () => {
    const wrapper = mount(UserCard, {
      props: { 
        user: mockUser,
        showBadge: false 
      }
    })
    
    expect(wrapper.find('.badge').exists()).toBe(false)
  })
})

测试 Props 变化后的响应

当 Props 发生变化时,如何测试呢?

<!-- ProgressBar.vue -->
<template>
  <div class="progress-bar">
    <div class="progress-fill" :style="{ width: percent + '%' }"></div>
  </div>
</template>

<script setup>
defineProps(['percent'])
</script>

其对应的测试:

// ProgressBar.spec.js
import { mount } from '@vue/test-utils'
import ProgressBar from './ProgressBar.vue'

describe('ProgressBar', () => {
  it('根据 percent 设置宽度', () => {
    const wrapper = mount(ProgressBar, {
      props: { percent: 50 }
    })
    
    const fill = wrapper.find('.progress-fill')
    expect(fill.attributes('style')).toContain('width: 50%')
  })
  
  it('percent 变化时宽度也跟着变', async () => {
    const wrapper = mount(ProgressBar, {
      props: { percent: 50 }
    })
    
    // 修改 props
    await wrapper.setProps({ percent: 80 })
    
    const fill = wrapper.find('.progress-fill')
    expect(fill.attributes('style')).toContain('width: 80%')
  })
})

测试事件 - 验证输出

事件测试的核心

触发组件的事件交互,看它能不能正确发出事件。

基础事件测试

我们来看一个基础事件组件:

<!-- SubmitButton.vue -->
<template>
  <button @click="handleClick" :disabled="disabled">
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: String,
  disabled: Boolean
})

const emit = defineEmits(['click'])

const handleClick = () => {
  emit('click', 'button clicked')
}
</script>

其对应的测试:

// SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from './SubmitButton.vue'

describe('SubmitButton', () => {
  it('点击时触发 click 事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交' }
    })
    
    // 模拟点击
    await wrapper.trigger('click')
    
    // 验证事件被触发
    expect(wrapper.emitted('click')).toBeTruthy()
    
    // 验证事件参数
    expect(wrapper.emitted('click')[0]).toEqual(['button clicked'])
  })
  
  it('禁用时点击不触发事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交', disabled: true }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeFalsy()
  })
})

测试多个事件

我们再来看一个有多个事件的组件:

<!-- SearchInput.vue -->
<template>
  <div>
    <input 
      v-model="value"
      @input="handleInput"
      @keyup.enter="handleEnter"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    <button @click="handleClear" v-if="value">清除</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const value = ref('')
const emit = defineEmits(['input', 'search', 'focus', 'blur', 'clear'])

const handleInput = (e) => {
  value.value = e.target.value
  emit('input', value.value)
}

const handleEnter = () => {
  emit('search', value.value)
}

const handleFocus = () => emit('focus')
const handleBlur = () => emit('blur')
const handleClear = () => {
  value.value = ''
  emit('clear')
}
</script>

其对应的测试:

// SearchInput.spec.js
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'

describe('SearchInput', () => {
  it('输入时触发 input 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('vue')
    
    expect(wrapper.emitted('input')).toBeTruthy()
    expect(wrapper.emitted('input')[0]).toEqual(['vue'])
  })
  
  it('按回车时触发 search 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('vue')
    await input.trigger('keyup.enter')
    
    expect(wrapper.emitted('search')).toBeTruthy()
    expect(wrapper.emitted('search')[0]).toEqual(['vue'])
  })
  
  it('获得焦点时触发 focus 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.trigger('focus')
    
    expect(wrapper.emitted('focus')).toBeTruthy()
  })
  
  it('失去焦点时触发 blur 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.trigger('blur')
    
    expect(wrapper.emitted('blur')).toBeTruthy()
  })
  
  it('点击清除按钮触发 clear 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('test')
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('clear')).toBeTruthy()
  })
})

测试事件顺序

当值发生变化时,如果确定事件的测试顺序呢?

<!-- Counter.vue -->
<template>
  <div>
    <span>{{ count }}</span>
    <button @click="increment">+1</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)
const emit = defineEmits(['change'])

const increment = () => {
  const oldValue = count.value
  count.value++
  emit('change', oldValue, count.value)
}
</script>

其对应的测试:

// Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('点击时触发 change 事件,参数是旧值和新值', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('change')).toBeTruthy()
    expect(wrapper.emitted('change')[0]).toEqual([0, 1])
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('change')[1]).toEqual([1, 2])
  })
})

测试插槽 - 验证内容分发

插槽测试的核心

给组件填充内容,看它能不能正确显示。

测试默认插槽

我们先来看一个默认插槽的组件:

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="content">
      <slot></slot>  <!-- 默认插槽 -->
    </div>
  </div>
</template>

其对应的测试:

// Card.spec.js
import { mount } from '@vue/test-utils'
import Card from './Card.vue'

describe('Card', () => {
  it('显示默认插槽的内容', () => {
    const wrapper = mount(Card, {
      slots: {
        default: '<p class="custom">自定义内容</p>'
      }
    })
    
    expect(wrapper.find('.custom').exists()).toBe(true)
    expect(wrapper.find('.custom').text()).toBe('自定义内容')
  })
})

测试具名插槽

我们再来看一个具名插槽的组件:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

其对应的测试:

// Layout.spec.js
import { mount } from '@vue/test-utils'
import Layout from './Layout.vue'

describe('Layout', () => {
  it('渲染所有插槽', () => {
    const wrapper = mount(Layout, {
      slots: {
        header: '<h1>页面标题</h1>',
        default: '<p>主要内容</p>',
        footer: '<p>版权信息</p>'
      }
    })
    
    expect(wrapper.find('header h1').text()).toBe('页面标题')
    expect(wrapper.find('main p').text()).toBe('主要内容')
    expect(wrapper.find('footer p').text()).toBe('版权信息')
  })
  
  it('没有插槽时不显示对应的区域', () => {
    const wrapper = mount(Layout, {
      slots: {
        default: '<p>内容</p>'
      }
    })
    
    expect(wrapper.find('header').exists()).toBe(true)
    expect(wrapper.find('header').text()).toBe('')
    expect(wrapper.find('footer').exists()).toBe(true)
    expect(wrapper.find('footer').text()).toBe('')
  })
})

测试作用域插槽

我们再来看一个作用域插槽的组件:

<!-- DataTable.vue -->
<template>
  <table>
    <tbody>
      <tr v-for="item in data" :key="item.id">
        <td>
          <!-- 作用域插槽,把数据传给父组件 -->
          <slot name="cell" :item="item">
            {{ item.name }}  <!-- 默认内容 -->
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
defineProps({
  data: Array
})
</script>

其对应的测试:

// DataTable.spec.js
import { mount } from '@vue/test-utils'
import DataTable from './DataTable.vue'

const mockData = [
  { id: 1, name: '张三', age: 25 },
  { id: 2, name: '李四', age: 30 }
]

describe('DataTable', () => {
  it('默认插槽显示 name', () => {
    const wrapper = mount(DataTable, {
      props: { data: mockData }
    })
    
    const cells = wrapper.findAll('td')
    expect(cells[0].text()).toBe('张三')
    expect(cells[1].text()).toBe('李四')
  })
  
  it('自定义插槽显示 age', () => {
    const wrapper = mount(DataTable, {
      props: { data: mockData },
      slots: {
        cell: `
          <template #cell="{ item }">
            <span class="age">{{ item.age }}</span>
          </template>
        `
      }
    })
    
    const ages = wrapper.findAll('.age')
    expect(ages[0].text()).toBe('25')
    expect(ages[1].text()).toBe('30')
  })
})

完整示例 - 表单组件测试

我们来看一个复杂的表单组件:

<!-- LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="username"
        placeholder="用户名"
        @blur="validateUsername"
      />
      <span v-if="usernameError" class="error">{{ usernameError }}</span>
    </div>
    
    <div>
      <input 
        v-model="password"
        type="password"
        placeholder="密码"
        @blur="validatePassword"
      />
      <span v-if="passwordError" class="error">{{ passwordError }}</span>
    </div>
    
    <div>
      <label>
        <input type="checkbox" v-model="remember" />
        记住我
      </label>
    </div>
    
    <button type="submit" :disabled="!isValid">
      {{ loading ? '登录中...' : '登录' }}
    </button>
    
    <div class="footer">
      <slot name="footer"></slot>
    </div>
  </form>
</template>

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

const username = ref('')
const password = ref('')
const remember = ref(false)
const loading = ref(false)

const usernameError = ref('')
const passwordError = ref('')

const emit = defineEmits(['submit', 'loading-change'])

const validateUsername = () => {
  if (!username.value) {
    usernameError.value = '用户名不能为空'
  } else if (username.value.length < 3) {
    usernameError.value = '用户名至少3个字符'
  } else {
    usernameError.value = ''
  }
}

const validatePassword = () => {
  if (!password.value) {
    passwordError.value = '密码不能为空'
  } else if (password.value.length < 6) {
    passwordError.value = '密码至少6个字符'
  } else {
    passwordError.value = ''
  }
}

const isValid = computed(() => {
  return !usernameError.value && !passwordError.value && 
         username.value && password.value
})

const handleSubmit = async () => {
  if (!isValid.value) return
  
  loading.value = true
  emit('loading-change', true)
  
  await new Promise(resolve => setTimeout(resolve, 1000))
  
  emit('submit', {
    username: username.value,
    password: password.value,
    remember: remember.value
  })
  
  loading.value = false
  emit('loading-change', false)
}
</script>

其对应的测试:

// LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'

describe('LoginForm', () => {
  // 1. Props 测试(这里没有 Props,跳过)
  
  // 2. 事件测试
  describe('Events', () => {
    it('提交时触发 submit 事件', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('submit')).toBeTruthy()
      expect(wrapper.emitted('submit')[0]).toEqual([{
        username: 'testuser',
        password: 'password123',
        remember: false
      }])
    })
    
    it('表单无效时不触发 submit', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('submit')).toBeFalsy()
    })
    
    it('提交时触发 loading-change 事件', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('loading-change')).toBeTruthy()
      expect(wrapper.emitted('loading-change')[0]).toEqual([true])
      
      // 等待异步完成
      await new Promise(resolve => setTimeout(resolve, 1100))
      
      expect(wrapper.emitted('loading-change')[1]).toEqual([false])
    })
  })
  
  // 3. 插槽测试
  describe('Slots', () => {
    it('显示 footer 插槽内容', () => {
      const wrapper = mount(LoginForm, {
        slots: {
          footer: '<a href="/register">注册新账号</a>'
        }
      })
      
      expect(wrapper.find('.footer a').text()).toBe('注册新账号')
    })
  })
  
  // 4. 验证逻辑测试
  describe('Validation', () => {
    it('用户名太短时显示错误', async () => {
      const wrapper = mount(LoginForm)
      const usernameInput = wrapper.find('input[placeholder="用户名"]')
      
      await usernameInput.setValue('a')
      await usernameInput.trigger('blur')
      
      expect(wrapper.text()).toContain('用户名至少3个字符')
    })
    
    it('用户名正确时清除错误', async () => {
      const wrapper = mount(LoginForm)
      const usernameInput = wrapper.find('input[placeholder="用户名"]')
      
      await usernameInput.setValue('a')
      await usernameInput.trigger('blur')
      expect(wrapper.text()).toContain('用户名至少3个字符')
      
      await usernameInput.setValue('abc')
      await usernameInput.trigger('blur')
      expect(wrapper.text()).not.toContain('用户名至少3个字符')
    })
    
    it('密码太短时显示错误', async () => {
      const wrapper = mount(LoginForm)
      const passwordInput = wrapper.find('input[placeholder="密码"]')
      
      await passwordInput.setValue('123')
      await passwordInput.trigger('blur')
      
      expect(wrapper.text()).toContain('密码至少6个字符')
    })
  })
  
  // 5. 按钮状态测试
  describe('Submit Button', () => {
    it('表单无效时按钮禁用', async () => {
      const wrapper = mount(LoginForm)
      const button = wrapper.find('button[type="submit"]')
      
      expect(button.attributes('disabled')).toBeDefined()
    })
    
    it('表单有效时按钮启用', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      
      // 触发验证
      await wrapper.find('input[placeholder="用户名"]').trigger('blur')
      await wrapper.find('input[placeholder="密码"]').trigger('blur')
      
      const button = wrapper.find('button[type="submit"]')
      expect(button.attributes('disabled')).toBeUndefined()
    })
    
    it('提交时按钮文字变成"登录中..."', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('input[placeholder="用户名"]').trigger('blur')
      await wrapper.find('input[placeholder="密码"]').trigger('blur')
      
      const button = wrapper.find('button[type="submit"]')
      expect(button.text()).toBe('登录')
      
      await button.trigger('click')
      
      expect(button.text()).toBe('登录中...')
    })
  })
})

常见问题与解决方案

问题一:异步更新导致断言失败

// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
  wrapper.vm.count++
  expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})

// ✅ 正确:使用 await
it('updates count', async () => {
  wrapper.vm.count++
  await wrapper.vm.$nextTick()
  expect(wrapper.find('.count').text()).toBe('1')
})

问题二:插槽内容没有正确渲染

// ❌ 错误:没有正确传递插槽内容
const wrapper = mount(DataTable, {
  slots: {
    'cell-status': '<span class="status">{{ props.value }}</span>'
  }
})

// ✅ 正确:使用模板字符串
const wrapper = mount(DataTable, {
  slots: {
    'cell-status': `
      <template #cell-status="{ value }">
        <span class="status">{{ value }}</span>
      </template>
    `
  }
})

问题三:事件发射次数断言错误

// ❌ 错误:只检查存在性
expect(wrapper.emitted('click')).toBeTruthy()

// ✅ 正确:检查具体次数和参数
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')?.[0]).toEqual([expectedData])

问题四:过度依赖实现细节

// ❌ 错误:测试内部方法
it('calls validateForm method', () => {
  const validateSpy = vi.spyOn(wrapper.vm, 'validateForm')
  // ...
  expect(validateSpy).toHaveBeenCalled()
})

// ✅ 正确:测试用户可见的行为
it('shows validation error when form is invalid', async () => {
  // 操作表单
  // 断言错误消息出现
})

组件测试的最佳实践

Props 测试

  • 基础渲染
  • 默认值
  • 不同值
  • Props 变化后的响应
  • 边界情况(空值、长文本)

事件测试

  • 事件是否触发
  • 事件参数是否正确
  • 事件触发次数
  • 事件顺序
  • 条件触发(禁用时)

插槽测试

  • 默认插槽内容
  • 具名插槽内容
  • 作用域插槽的 props
  • 没有插槽时的行为

结语

组件测试不是测试每一行代码,而是测试组件的行为是否符合预期。 Props 是输入,事件是输出,插槽是扩展点。把握这三个核心,就能写出高效、可靠的组件测试。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

Vue3 单元测试实战:从组合式函数到组件

作者 wuhen_n
2026年3月26日 07:13

前言

在软件开发中,测试常常被视为“有时间再做”的奢侈品。然而,当项目规模扩大、团队人员变动、需求频繁变更时,没有测试的代码库会逐渐变成难以维护的"遗留系统"。

为什么需要测试?

一个没有测试的项目会怎样?

场景1:重构时的不安全感

当我们改完某个模块的代码后,怎么知道有没有破坏原有功能呢?此时我们只能手动点击测试,但只要漏掉一个边界情况就出 Bug。

场景2:新人接手代码

当团队来了新人后,第一件事是需要熟悉项目的代码。但如果没有测试,就没有相关的文档;想要理解一个函数的边界情况就会很困难。

场景3:上线前的焦虑

每次发布都要花好几个小时手工测试一遍。

测试的投资回报率

阶段 没有测试 有测试 收益
开发阶段 手动测试每个功能 保存时自动运行 节省 30% 时间
重构阶段 不敢改代码 改完运行测试 重构效率提升 200%
Code Review reviewer 手动验证 看测试用例理解 时间缩短 50%
上线阶段 每次提心吊胆 测试通过就上线 信心 100%

测试策略金字塔

        /\
       /  \      E2E 测试 (少量)
      /    \     模拟真实用户操作,成本高
     /------\
    /        \   组件测试 (适量)
   /          \  测试组件交互和渲染
  /------------\
 /              \ 单元测试 (大量)
/                \ 测试函数和组合式函数,速度快

原则:底层测试越多,上层测试越少
单元测试:60-70%
组件测试:20-30%
E2E 测试:5-10%

Vitest 快速上手

为什么选择 Vitest?

Vitest 可以与 Vite 的无缝集成,同一套配置、同一套插件、同一套别名。

Jest 的痛点

  • 需要配置 babel-jest、vue-jest、jest-serializer-vue
  • 与 Vite 的别名、插件不共享
  • 速度慢,尤其是冷启动

Vitest 的优势

  • 与 Vite 共享配置,零配置迁移
  • 多线程并发执行,速度快
  • 支持 ES Module 开箱即用
  • 与 Jest 几乎相同的 API

安装 Vitest

# 安装 Vitest
npm install --save-dev vitest

# 安装 Vue 测试工具
npm install --save-dev @vue/test-utils

# 安装 jsdom(浏览器环境模拟)
npm install --save-dev jsdom

Vite 配置集成

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

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,  // 启用全局 API(describe, it, expect)
    environment: 'jsdom',  // 模拟浏览器环境
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],  // 测试文件匹配模式
    coverage: {  // 测试覆盖率配置
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts']
    },
    testTimeout: 5000,  // 测试超时时间
    setupFiles: ['./test/setup.ts']  // 全局 setup 文件
  }
})

添加测试脚本

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

第一个测试

假设我们有这样一个函数:

// src/utils/math.js
export function add(a, b) {
  return a + b
}

其对应的测试:

// src/utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add } from './math'

describe('math.js', () => {
  it('1 + 1 应该等于 2', () => {
    expect(add(1, 1)).toBe(2)
  })
  
  it('负数相加', () => {
    expect(add(-1, -2)).toBe(-3)
  })
})

测试组合式函数

最简单的组合式函数

我们先看一个简单的组合式函数:

// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return { count, increment, decrement, reset }
}

其对应的测试:

// composables/__tests__/useCounter.spec.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('初始值应该是0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })
  
  it('可以设置初始值', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
  
  it('增加1', () => {
    const { count, increment } = useCounter(5)
    increment()
    expect(count.value).toBe(6)
  })
  
  it('减少1', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
  
  it('重置', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    increment()
    expect(count.value).toBe(7)
    reset()
    expect(count.value).toBe(5)
  })
})

带 computed 的组合式函数

我们再来看一个带 computed 的组合式函数:

// composables/useDouble.js
import { ref, computed } from 'vue'

export function useDouble(initialValue = 0) {
  const value = ref(initialValue)
  const double = computed(() => value.value * 2)
  
  const setValue = (newValue) => value.value = newValue
  
  return { value, double, setValue }
}

其对应的测试:

// composables/__tests__/useDouble.spec.js
import { describe, it, expect } from 'vitest'
import { useDouble } from '../useDouble'

describe('useDouble', () => {
  it('double 应该是 value 的两倍', () => {
    const { value, double } = useDouble(3)
    expect(double.value).toBe(6)
  })
  
  it('value 变化时 double 也跟着变', () => {
    const { value, double, setValue } = useDouble(3)
    setValue(5)
    expect(double.value).toBe(10)
    
    value.value = 7
    expect(double.value).toBe(14)
  })
})

带生命周期的组合式函数

我们再来看一个带生命周期的组合式函数:

// composables/useWindowWidth.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowWidth() {
  const width = ref(window.innerWidth)
  
  const updateWidth = () => {
    width.value = window.innerWidth
  }
  
  onMounted(() => {
    window.addEventListener('resize', updateWidth)
  })
  
  onUnmounted(() => {
    window.removeEventListener('resize', updateWidth)
  })
  
  return { width }
}

其对应的测试:

// composables/__tests__/useWindowWidth.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useWindowWidth } from '../useWindowWidth'

// 辅助函数:让生命周期钩子执行
function withSetup(composable) {
  let result
  
  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    }
  })
  
  app.mount(document.createElement('div'))
  
  return [result, app]
}

describe('useWindowWidth', () => {
  it('初始宽度是当前窗口宽度', () => {
    window.innerWidth = 1024
    const [result, app] = withSetup(() => useWindowWidth())
    expect(result.width.value).toBe(1024)
    app.unmount()
  })
  
  it('窗口大小变化时更新宽度', () => {
    window.innerWidth = 1024
    const [result, app] = withSetup(() => useWindowWidth())
    
    // 模拟窗口变化
    window.innerWidth = 800
    window.dispatchEvent(new Event('resize'))
    
    expect(result.width.value).toBe(800)
    app.unmount()
  })
  
  it('组件卸载时移除监听器', () => {
    const removeSpy = vi.spyOn(window, 'removeEventListener')
    
    const [result, app] = withSetup(() => useWindowWidth())
    app.unmount()
    
    expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
  })
})

测试组件

安装 Vue Test Utils

npm install --save-dev @vue/test-utils

最简单的组件

我们来看一个最简单的组件:

<!-- Counter.vue -->
<template>
  <div>
    <span class="count">{{ count }}</span>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => count.value++
const decrement = () => count.value--
</script>

其对应的测试:

// __tests__/Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('初始显示 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('.count').text()).toBe('0')
  })
  
  it('点击增加按钮后变成 1', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button:first-child').trigger('click')
    expect(wrapper.find('.count').text()).toBe('1')
  })
  
  it('点击减少按钮后变成 -1', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button:last-child').trigger('click')
    expect(wrapper.find('.count').text()).toBe('-1')
  })
})

带 Props 的组件

我们再来看一个带 Props 的组件:

<!-- Greeting.vue -->
<template>
  <div>
    <h1>Hello, {{ name }}!</h1>
    <p v-if="showMessage">欢迎使用我们的应用</p>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  showMessage: Boolean
})
</script>

其对应的测试:

// __tests__/Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from '../Greeting.vue'

describe('Greeting', () => {
  it('显示名字', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三' }
    })
    expect(wrapper.text()).toContain('Hello, 张三!')
  })
  
  it('showMessage 为 true 时显示欢迎语', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三', showMessage: true }
    })
    expect(wrapper.text()).toContain('欢迎使用我们的应用')
  })
  
  it('showMessage 为 false 时不显示欢迎语', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三', showMessage: false }
    })
    expect(wrapper.text()).not.toContain('欢迎使用我们的应用')
  })
})

带事件的组件

我们再来看一个带事件的组件:

<!-- SubmitButton.vue -->
<template>
  <button @click="handleClick" :disabled="disabled">
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: String,
  disabled: Boolean
})

const emit = defineEmits(['submit'])

const handleClick = () => {
  emit('submit', 'button clicked')
}
</script>

其对应的测试:

// __tests__/SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from '../SubmitButton.vue'

describe('SubmitButton', () => {
  it('点击时触发 submit 事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交' }
    })
    
    await wrapper.trigger('click')
    
    // 检查事件是否触发
    expect(wrapper.emitted()).toHaveProperty('submit')
    
    // 检查事件参数
    expect(wrapper.emitted('submit')[0]).toEqual(['button clicked'])
  })
  
  it('禁用时点击不触发事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交', disabled: true }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('submit')).toBeUndefined()
  })
})

Mock 外部依赖

为什么要 Mock?

真实开发中,组件往往依赖:

  • API 请求
  • 组合式函数
  • 第三方库

测试时我们也许不能发送真实请求给后端,因此需要使用 Mock 模拟真实数据。

Mock 组合式函数

我们先来看一个 Mock 组合式函数:

<!-- UserProfile.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">错误: {{ error.message }}</div>
    <div v-else>
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>

<script setup>
import { useUser } from '@/composables/useUser'

const props = defineProps({ userId: Number })
const { user, loading, error } = useUser(props.userId)
</script>

其对应的测试:

// __tests__/UserProfile.spec.js
import { mount } from '@vue/test-utils'
import { vi } from 'vitest'

// 先 Mock 模块
vi.mock('@/composables/useUser')

// 再导入(Mock 后的版本)
import { useUser } from '@/composables/useUser'
import UserProfile from '../UserProfile.vue'

describe('UserProfile', () => {
  it('加载中显示 loading', () => {
    // 设置 Mock 返回值
    useUser.mockReturnValue({
      user: ref(null),
      loading: ref(true),
      error: ref(null)
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    })
    
    expect(wrapper.text()).toContain('加载中...')
  })
  
  it('加载成功显示用户信息', () => {
    useUser.mockReturnValue({
      user: ref({ name: '张三', email: 'zhangsan@example.com' }),
      loading: ref(false),
      error: ref(null)
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('zhangsan@example.com')
  })
  
  it('加载失败显示错误', () => {
    useUser.mockReturnValue({
      user: ref(null),
      loading: ref(false),
      error: ref({ message: '用户不存在' })
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 999 }
    })
    
    expect(wrapper.text()).toContain('错误: 用户不存在')
  })
})

Mock API 请求

我们再来看一个 Mock API 请求:

// composables/useApi.js
import { ref } from 'vue'

export function useApi() {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  const fetchData = async (url) => {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch(url)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  return { data, loading, error, fetchData }
}

其对应的测试:

// __tests__/useApi.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useApi } from '../useApi'

describe('useApi', () => {
  it('请求成功', async () => {
    // Mock fetch
    const mockData = { id: 1, name: '测试' }
    global.fetch = vi.fn().mockResolvedValue({
      json: vi.fn().mockResolvedValue(mockData)
    })
    
    const { fetchData, data, loading, error } = useApi()
    
    // 初始状态
    expect(loading.value).toBe(false)
    expect(data.value).toBe(null)
    
    // 请求中
    const promise = fetchData('/api/test')
    expect(loading.value).toBe(true)
    
    // 等待请求完成
    await promise
    
    expect(loading.value).toBe(false)
    expect(data.value).toEqual(mockData)
    expect(error.value).toBe(null)
  })
  
  it('请求失败', async () => {
    global.fetch = vi.fn().mockRejectedValue(new Error('网络错误'))
    
    const { fetchData, data, loading, error } = useApi()
    
    await fetchData('/api/test')
    
    expect(loading.value).toBe(false)
    expect(data.value).toBe(null)
    expect(error.value).toBeInstanceOf(Error)
  })
})

测试最佳实践

测试行为,而非实现细节

不好的测试:关注内部实现细节

it('calls validateEmail function', () => {
  const validateSpy = vi.spyOn(LoginForm.methods, 'validateEmail')
  // ... 测试
  expect(validateSpy).toHaveBeenCalled()
})

好的测试:关注用户可见的行为

it('shows error message when email is invalid', async () => {
  const wrapper = mount(LoginForm)
  
  await wrapper.find('input[type="email"]').setValue('invalid-email')
  await wrapper.find('button[type="submit"]').trigger('click')
  
  expect(wrapper.text()).toContain('请输入有效的邮箱地址')
})

测试公共 API,而非私有状态

// composables/useCounter.ts
export function useCounter() {
  const count = ref(0)  // 内部状态
  const increment = () => count.value++
  
  // ✅ 测试公共 API
  return {
    count,      // 只读状态(通过 ref 暴露)
    increment   // 方法
  }
}

// ✅ 好的测试
it('increments count when increment is called', () => {
  const { count, increment } = useCounter()
  increment()
  expect(count.value).toBe(1)
})

测试边界情况和错误场景

it('handles empty list', () => {
  const wrapper = mount(ProductList, {
    props: { products: [] }
  })
  expect(wrapper.text()).toContain('暂无商品')
})

it('handles extremely long text', () => {
  const longText = 'a'.repeat(1000)
  const wrapper = mount(ProductCard, {
    props: { title: longText }
  })
  // 测试是否被截断或换行
})

it('handles API timeout', async () => {
  vi.mocked(fetch).mockImplementationOnce(
    () => new Promise(resolve => setTimeout(resolve, 10000))
  )
  
  const { fetchData, loading } = useApi()
  const promise = fetchData('/api/test')
  
  expect(loading.value).toBe(true)
  
  // 模拟超时
  await expect(promise).rejects.toThrow('Timeout')
})

测试描述要清晰

不好的测试描述

it('works correctly')
it('handles state')
it('test button')

好的测试描述

it('提交按钮在表单提交时禁用')
it('密码少于6位时显示错误')
it('登录成功后跳转到首页')

保持测试独立

describe('UserStore', () => {
  beforeEach(() => {
    // 每个测试前重置状态
    vi.clearAllMocks()
    localStorage.clear()
  })
  
  it('test 1', () => {})
  it('test 2', () => {})  // 不受 test 1 影响
})

测试覆盖率

配置测试覆盖率

// vite.config.js
export default {
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: [
        'src/**/*.test.ts',
        'src/**/*.spec.ts',
        'src/main.ts',
        'src/router/**'
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80
      }
    }
  }
}

查看覆盖率

npm run test:coverage

# 输出:
File              | % Stmts | % Branch | % Funcs | % Lines
------------------|---------|----------|---------|--------
src/composables/  |   85.71 |    75.00 |   90.00 |   85.71
src/components/   |   72.50 |    66.67 |   80.00 |   72.50
src/utils/        |  100.00 |   100.00 |  100.00 |  100.00

常见问题与解决方案

问题一:组合式函数中的生命周期不执行

// ❌ 错误:直接调用
const result = useWindowWidth()

// ✅ 正确:使用 withSetup
const [result, app] = withSetup(() => useWindowWidth())

问题二:异步测试超时

// ❌ 错误:没有等待异步操作
it('fetches data', () => {
  const { fetchData } = useApi()
  fetchData('/api/test')
  expect(data.value).toBeDefined() // 可能还没返回
})

// ✅ 正确:等待异步操作
it('fetches data', async () => {
  const { fetchData, data } = useApi()
  await fetchData('/api/test')
  expect(data.value).toBeDefined()
})

问题三:测试 DOM 更新

// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
  wrapper.vm.count++
  expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})

// ✅ 正确:使用 nextTick 或 async
it('updates count', async () => {
  wrapper.vm.count++
  await nextTick()
  expect(wrapper.find('.count').text()).toBe('1')
})

问题四:Mock 没有被正确应用

// ❌ 错误:import 在 mock 之前
import { useUser } from './useUser'
vi.mock('./useUser') // 太晚了,模块已经加载

// ✅ 正确:mock 在 import 之前
vi.mock('./useUser')
import { useUser } from './useUser'

测试的最佳实践

测试优先级

  1. 核心业务逻辑(组合式函数)→ 必须测试
  2. 关键用户路径(组件)→ 必须测试
  3. 错误边界 → 必须测试
  4. UI 细节 → 可选

检查清单

  • 每个组合式函数都有单元测试
  • 每个关键组件都有组件测试
  • 测试覆盖了成功和失败两种情况
  • 测试描述清晰,说明预期行为
  • Mock 了外部依赖
  • 测试可以独立运行
  • CI 中自动运行测试

结语

测试不是为了 100% 覆盖率,而是为了重构时的信心。 一个没有测试的项目,重构就是重写;一个有测试的项目,重构是优化。代码是写给人看的,测试是写给未来的自己看的!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

昨天以前首页

CDN图片服务与动态参数优化

作者 wuhen_n
2026年3月25日 09:24

前言

在现代Web应用中,图片已经不再是简单的静态资源,而是需要根据设备、网络、浏览器能力动态优化的核心内容。CDN图片服务提供了强大的动态处理能力,结合前端的智能参数拼接,可以实现图片加载的极致优化。

一个典型的电商场景

  • 商品详情页有10张SKU图片
  • 每张图片需要支持不同尺寸(缩略图、详情图、放大镜图)
  • 需要兼容不支持WebP的老旧浏览器
  • 要求在秒级完成切换,不卡顿

本文将深入探讨如何利用 CDN 图片服务,配合前端策略,打造一个高性能、自适应、可扩展的图片系统。

CDN 图片服务是什么?

CDN 图片服务如何工作

CDN 服务:同一个图片地址,可以动态调整,加参数就能变:

https://cdn.example.com/product.jpg?width=400&quality=80&format=webp

上述地址会一个返回 400px宽、质量80、WebP格式的图片。

主流云服务商的参数格式

  • 阿里云OSS:?x-oss-process=image/resize,w_400/quality,q_80/format,webp
  • 七牛云:?imageView2/2/w/400/q/80/format/webp
  • 腾讯云COS:?imageMogr2/thumbnail/400x/quality/80/format/webp

核心处理操作

操作类型 参数 说明 示例
缩放 resize,w_400 按宽度等比缩放 /resize,w_400
裁剪 crop,w_400,h_400 从中心裁剪固定尺寸 /crop,w_400,h_400
格式转换 format,webp 转换为WebP/AVIF /format,webp
质量调整 quality,q_80 设置压缩质量(1-100) /quality,q_80
锐化 sharpen,s_100 图片锐化处理 /sharpen,s_100
水印 watermark,text_xxx 添加文字/图片水印 /watermark,text_SAMPLE

动态参数拼接 - 让每张图都量身定制

检测设备信息

// utils/device.js
export function getDeviceInfo() {
  // 设备像素比(Retina屏需要更高清的图)
  const dpr = window.devicePixelRatio || 1
  
  // 屏幕宽度
  const screenWidth = window.screen.width
  
  // 网络类型
  const connection = navigator.connection
  const networkType = connection?.effectiveType || '4g'
  const isSlowNetwork = ['slow-2g', '2g'].includes(networkType)
  
  // 是否移动设备
  const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent)
  
  return { dpr, screenWidth, networkType, isSlowNetwork, isMobile }
}

// 使用
const device = getDeviceInfo()
console.log(device)
// { dpr: 3, screenWidth: 390, isSlowNetwork: false, isMobile: true }

计算最佳图片尺寸

// utils/imageCalculator.js
export function calculateImageSize(targetWidth, deviceInfo) {
  const { dpr, isSlowNetwork, isMobile } = deviceInfo
  
  // 基础尺寸 = 目标宽度 × 像素比
  let width = Math.ceil(targetWidth * dpr)
  
  // 慢速网络下降级
  if (isSlowNetwork) {
    width = Math.floor(width * 0.7)
  }
  
  // 计算质量
  let quality = 80
  if (isSlowNetwork) {
    quality = 60
  } else if (isMobile) {
    quality = 75
  }
  
  // 确定格式
  const format = supportsWebP() ? 'webp' : 'jpg'
  
  return { width, quality, format }
}

检测 WebP 支持

// utils/webpDetect.js
let webpSupported = null

export function supportsWebP() {
  if (webpSupported !== null) return webpSupported
  
  // 创建一个1x1的WebP图片测试
  const canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1
  const dataURL = canvas.toDataURL('image/webp')
  
  webpSupported = dataURL.indexOf('image/webp') === 5
  return webpSupported
}

CDN URL构建器

// utils/cdnUrl.js
export function buildCDNUrl(baseUrl, imageKey, options) {
  const { width, quality, format } = options
  
  // 阿里云OSS格式
  const params = [
    `resize,w_${width}`,
    `quality,q_${quality}`,
    format !== 'jpg' ? `format,${format}` : null
  ].filter(Boolean).join('/')
  
  return `${baseUrl}/${imageKey}?x-oss-process=image/${params}`
}

// 使用示例
const device = getDeviceInfo()
const size = calculateImageSize(400, device)
const url = buildCDNUrl('https://cdn.example.com', 'product.jpg', size)

// 结果:https://cdn.example.com/product.jpg?x-oss-process=image/resize,w_800/quality,q_80/format,webp

WebP兼容检测 - 让浏览器自己选

为什么需要检测?

不是所有浏览器都支持 WebP,比如 iOS Safari 14 之前不支持,因此直接使用 WebP 会显示不出来,我们需要让浏览器自己告诉服务器它支持什么格式。

服务端检测(推荐)

// Node.js 后端中间件
app.use((req, res, next) => {
  const accept = req.headers['accept'] || ''
  const supportsWebP = accept.includes('image/webp')
  const supportsAVIF = accept.includes('image/avif')
  
  // 把结果存起来,方便后面用
  res.locals.supportsWebP = supportsWebP
  res.locals.supportsAVIF = supportsAVIF
  
  next()
})

// 在返回HTML时注入
app.get('/', (req, res) => {
  res.render('index', {
    supportsWebP: res.locals.supportsWebP,
    supportsAVIF: res.locals.supportsAVIF
  })
})

前端检测(备选)

// 如果后端拿不到,前端也能检测
export function checkWebPSupport() {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => resolve(true)
    img.onerror = () => resolve(false)
    // 一个1x1的WebP图片的Base64编码
    img.src = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
  })
}

动态选择格式

// composables/useImageFormat.js
import { ref } from 'vue'

export function useImageFormat() {
  const format = ref('jpg')
  
  async function detect() {
    // 优先检测AVIF(最新,压缩率最高)
    const avifSupported = await checkAVIFSupport()
    if (avifSupported) {
      format.value = 'avif'
      return
    }
    
    // 其次WebP
    const webpSupported = await checkWebPSupport()
    if (webpSupported) {
      format.value = 'webp'
      return
    }
    
    // 最后JPEG
    format.value = 'jpg'
  }
  
  detect()
  
  return { format }
}

域名分片 - 突破浏览器并发限制

为什么需要域名分片?

浏览器对同一域名的并发请求数有限制(通常为6-8个)。当页面需要同时加载大量图片时,这些请求会排队等待,导致加载缓慢。

问题示例

// 20张图片使用同一个域名
const urls = images.map(img => `https://cdn.example.com/${img}.jpg`)
// 浏览器最多同时下载6张,剩下14张等待

域名分片实现

// utils/cdnSharding.js
export class CDNSharding {
  constructor(baseDomain, shardCount = 4) {
    // 生成多个子域名
    // 0.cdn.example.com, 1.cdn.example.com, ...
    this.domains = []
    for (let i = 0; i < shardCount; i++) {
      this.domains.push(`https://${i}${baseDomain}`)
    }
    this.current = 0
  }
  
  // 轮询分配
  getUrl(imagePath) {
    const domain = this.domains[this.current % this.domains.length]
    this.current++
    return `${domain}${imagePath}`
  }
  
  // 基于图片ID的一致性分配(同一个图片始终用同一个域名,利于缓存)
  getUrlConsistent(imagePath, imageId) {
    const index = imageId % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
  
  // 基于路径哈希分配
  getUrlByHash(imagePath) {
    let hash = 0
    for (let i = 0; i < imagePath.length; i++) {
      hash = ((hash << 5) - hash) + imagePath.charCodeAt(i)
      hash = hash & hash
    }
    const index = Math.abs(hash) % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
}

// 使用
const sharding = new CDNSharding('.cdn.example.com', 4)

// 原来:一个域名
const oldUrls = images.map(img => `https://cdn.example.com/${img}`)

// 现在:4个域名
const newUrls = images.map(img => sharding.getUrlByHash(img))

DNS预解析优化

<!-- 在HTML头部添加DNS预解析 -->
<head>
  <link rel="dns-prefetch" href="//0.cdn.example.com">
  <link rel="dns-prefetch" href="//1.cdn.example.com">
  <link rel="dns-prefetch" href="//2.cdn.example.com">
  <link rel="dns-prefetch" href="//3.cdn.example.com">
  
  <!-- 预连接(包含TCP握手) -->
  <link rel="preconnect" href="//0.cdn.example.com">
  <link rel="preconnect" href="//1.cdn.example.com">
</head>

性能对比

图片数量 单域名 3个分片 4个分片
10张 2.8秒 1.5秒 1.2秒
20张 5.2秒 2.8秒 2.1秒
50张 12秒 6秒 4.5秒

图片上传组件 - 前端压缩再上传

为什么要前端压缩?

如果我们直接将原始图片(5MB)上传到服务器和 CDN,会非常慢!

但如果我们将图片在前端压缩后(500KB),再上传到服务器和 CDN,就会非常快了!

使用浏览器压缩库

安装

npm install browser-image-compression

使用

<!-- ImageUploader.vue -->
<template>
  <div class="uploader">
    <div class="dropzone" @drop="handleDrop" @dragover.prevent>
      <input type="file" @change="handleFileSelect" accept="image/*">
      <p>点击或拖拽图片上传</p>
    </div>
    
    <div v-if="compressing" class="progress">
      压缩中... {{ progress }}%
    </div>
    
    <div v-if="preview" class="preview">
      <img :src="preview" alt="preview">
      <button @click="upload">上传</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import imageCompression from 'browser-image-compression'

const file = ref(null)
const preview = ref('')
const compressing = ref(false)
const progress = ref(0)

// 压缩配置
const options = {
  maxSizeMB: 1,           // 最大1MB
  maxWidthOrHeight: 1920, // 最大1920px
  useWebWorker: true,     // 使用Web Worker,不卡主线程
  fileType: 'image/webp', // 转成WebP
  initialQuality: 0.8     // 质量80%
}

async function handleFileSelect(event) {
  const rawFile = event.target.files[0]
  if (!rawFile) return
  
  compressing.value = true
  
  try {
    // 压缩图片
    const compressedFile = await imageCompression(rawFile, options)
    file.value = compressedFile
    
    // 预览
    preview.value = URL.createObjectURL(compressedFile)
    
    console.log(`压缩前: ${rawFile.size} bytes`)
    console.log(`压缩后: ${compressedFile.size} bytes`)
    console.log(`节省: ${(1 - compressedFile.size/rawFile.size)*100}%`)
    
  } catch (error) {
    console.error('压缩失败', error)
  } finally {
    compressing.value = false
  }
}

async function upload() {
  if (!file.value) return
  
  const formData = new FormData()
  formData.append('image', file.value)
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  })
  
  const result = await response.json()
  console.log('上传成功:', result.url)
}
</script>

实战:电商SKU图片切换的秒级加载优化

问题分析

电商商品详情页的 SKU 图片切换是一个典型性能挑战:

  • 用户点击不同规格(颜色、尺寸)时,需要切换对应图片
  • 要求切换无延迟,体验流畅
  • 图片需要同时满足缩略图、主图、放大镜等多种尺寸需求

预加载策略

// composables/useSKUImages.js
import { ref } from 'vue'

export function useSKUImages() {
  const images = ref([])
  const currentIndex = ref(0)
  
  // 预加载队列
  const preloadQueue = []
  
  // 加载所有SKU图片
  async function loadSKUs(productId) {
    const response = await fetch(`/api/products/${productId}/skus`)
    const skus = await response.json()
    
    images.value = skus.map(sku => ({
      id: sku.id,
      thumbnail: buildCDNUrl(sku.key, { width: 200, quality: 70 }),
      main: buildCDNUrl(sku.key, { width: 800, quality: 80 }),
      zoom: buildCDNUrl(sku.key, { width: 1600, quality: 90 })
    }))
    
    // 预加载第一张图片
    preloadImages(0, 3)
  }
  
  // 预加载指定范围的图片
  function preloadImages(start, count) {
    for (let i = start; i < start + count && i < images.value.length; i++) {
      const img = images.value[i]
      
      // 用 link 标签预加载
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = 'image'
      link.href = img.main
      document.head.appendChild(link)
    }
  }
  
  // 切换SKU
  function switchSKU(index) {
    if (index === currentIndex.value) return
    
    currentIndex.value = index
    
    // 预加载后面几张
    if (index + 2 < images.value.length) {
      preloadImages(index + 1, 2)
    }
  }
  
  return {
    images,
    currentIndex,
    currentImage: computed(() => images.value[currentIndex.value]),
    loadSKUs,
    switchSKU
  }
}

完整的SKU图片组件

<template>
  <div class="sku-images">
    <!-- 缩略图列表 -->
    <div class="thumbnails">
      <div
        v-for="(img, idx) in images"
        :key="img.id"
        class="thumbnail"
        :class="{ active: currentIndex === idx }"
        @click="switchSKU(idx)"
      >
        <img :src="img.thumbnail" :alt="'SKU ' + idx">
      </div>
    </div>
    
    <!-- 主图区域 -->
    <div class="main-image">
      <img
        :src="currentImage?.main"
        :srcset="`
          ${currentImage?.thumbnail} 200w,
          ${currentImage?.main} 800w,
          ${currentImage?.zoom} 1600w
        `"
        sizes="(max-width: 768px) 100vw, 50vw"
        @mouseenter="showZoom = true"
        @mouseleave="showZoom = false"
        @mousemove="handleMouseMove"
      >
    </div>
    
    <!-- 放大镜 -->
    <div v-if="showZoom" class="zoom-lens" :style="lensStyle">
      <img :src="currentImage?.zoom" :style="zoomImageStyle">
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useSKUImages } from './useSKUImages'

const props = defineProps({
  productId: String
})

const { images, currentIndex, currentImage, loadSKUs, switchSKU } = useSKUImages()
const showZoom = ref(false)
const mousePos = ref({ x: 0, y: 0 })

onMounted(() => {
  loadSKUs(props.productId)
})

const lensStyle = computed(() => ({
  left: `${mousePos.value.x}px`,
  top: `${mousePos.value.y}px`
}))

const zoomImageStyle = computed(() => ({
  transform: `translate(${-mousePos.value.x * 2}px, ${-mousePos.value.y * 2}px)`
}))

function handleMouseMove(e) {
  const rect = e.target.getBoundingClientRect()
  mousePos.value = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
}
</script>

最佳实践清单

实施步骤

  1. 接入CDN服务

    • 阿里云OSS / 七牛云 / 腾讯云COS
    • 配置图片处理参数
  2. 动态参数优化检测设备DPR、屏幕宽度、网络类型

    • 计算最佳图片尺寸
    • 动态生成CDN URL
  3. 格式兼容处理

    • 检测浏览器支持的格式
    • 优先AVIF → WebP → JPEG
    • 服务端通过Accept头判断
  4. 域名分片

    • 生成3-4个子域名
    • 轮询或哈希分配图片
    • 添加DNS预解析
  5. 上传优化

    • 前端压缩图片
    • 使用Web Worker不卡UI

优化策略矩阵

策略 适用场景 收益 实现成本
动态尺寸参数 所有图片 减少50-70%体积
WebP/AVIF转换 现代浏览器 额外减少30-50%
域名分片 批量图片加载 提升30-50%并发
客户端压缩 用户上传图片 减少90%上传时间
智能预加载 SKU/轮播图 切换无延迟

结语

CDN图片优化的核心是**"按需供给"**——不给任何设备加载它不需要的像素,不给任何网络传输它不需要的字节。通过动态参数、格式转换、智能预加载的组合,让图片资源真正做到"恰如其分"。

记住:用户不会因为图片加载快而赞美你,但一定会因为加载慢而离开你

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

响应式图片的工程化实践:srcset与picture

作者 wuhen_n
2026年3月25日 09:21

前言

在移动优先和多设备并存的今天,一张图片要在不同尺寸、不同分辨率的屏幕上都能完美展示,是一项极具挑战性的任务。一个简单的<img src="photo.jpg">会导致:

  • Retina屏上图片模糊:1x图在2x屏上被拉伸
  • 移动端加载超大图片:下载了PC端的大图,浪费流量
  • 横竖屏切换时构图不当:竖屏显示的图片被强行裁剪

响应式图片技术正是为解决这些问题而生。本文将深入探讨srcsetpicture的核心原理,并通过Vue组件封装和Vite插件实现,建立一套工程化的响应式图片解决方案。

为什么需要响应式图片?

传统方式:一张图片走天下

<img src="photo.jpg" alt="风景">

传统方式的问题

  • iPhone SE (小屏) → 下载 5MB 的大图 → 浪费
  • iPad (中屏) → 下载 5MB 的大图 → 还行
  • MacBook (大屏) → 下载 5MB 的大图 → 刚好
  • Retina 屏幕 → 下载 5MB 的普通图 → 模糊

设备像素比(DPR)

什么是设备像素比

**设备像素比(Device Pixel Ratio)**是物理像素与逻辑像素的比值:

// 获取当前设备的像素比
const dpr = window.devicePixelRatio || 1;
console.log(dpr); // 普通屏: 1, Retina屏: 2, 高端屏: 3或更高

设备像素比的典型值范围

  • 普通屏幕:DPR = 1
  • Retina屏幕:DPR = 2 / 3
  • 4K屏幕:DPR = 3+

为什么需要关注DPR?

当我们在CSS中设置width: 100px时,在 DPR=2 的屏幕上,实际需要 200 个物理像素来渲染。如果只提供 100px 的图片,就会被拉伸模糊。

三个核心问题

问题1:屏幕大小不同

  • 手机小屏:不需要大图
  • 平板中屏:需要中等图
  • 电脑大屏:需要高清图

问题2:像素密度不同

  • 普通屏:1x 图就够了
  • Retina 屏:需要 2x 图
  • 高端屏:需要 3x 图

问题3:屏幕方向不同

  • 横屏:适合宽幅风景
  • 竖屏:适合高耸人像

srcset - 让浏览器自己选

x描述符(根据像素密度)

告诉浏览器:我有 1x、2x、3x 三个版本:

<img 
  src="photo-1x.jpg"
  srcset="
    photo-1x.jpg 1x,
    photo-2x.jpg 2x,
    photo-3x.jpg 3x
  "
  alt="风景"
>

浏览器在解析时,就会自动选择:

  • iPhone 14 Pro (DPR=3) → 加载 photo-3x.jpg
  • iPhone SE (DPR=2) → 加载 photo-2x.jpg
  • 普通电脑 (DPR=1) → 加载 photo-1x.jpg

w描述符(根据屏幕宽度)

<img 
  src="photo-400w.jpg"
  srcset="
    photo-400w.jpg 400w,
    photo-800w.jpg 800w,
    photo-1200w.jpg 1200w
  "
  sizes="
    (max-width: 600px) 100vw,
    (max-width: 1200px) 50vw,
    800px
  "
  alt="风景"
>

sizes是怎么计算的?

sizes属性告诉浏览器在不同视口宽度下,图像的实际显示宽度,如:

sizes="
  (max-width: 600px) 100vw,   /* 小屏幕:图片占满视口宽度 */
  (max-width: 1200px) 50vw,   /* 中屏幕:图片占视口一半 */
  800px                        /* 大屏幕:图片固定800px */
"

其计算逻辑如下:

  1. 浏览器检查 sizes:sizes: "(max-width: 600px) 100vw, ..."
  2. 匹配条件 (max-width: 600px) 满足:图片宽度 = 100vw = 375px
  3. 考虑 DPR (iPhone SE DPR=2):实际需要 = 375px × 2 = 750px 的图片
  4. 从 srcset 中选择最接近的:400w 太小,1200w 太大 → 选择 800w

picture - 让开发者控制

什么时候需要 picture?

srcset 可以解决图片大小问题,但不能解决构图问题。比如:横屏时,我们需要展示完整的风景;竖屏时,我们需要展示裁剪后的人像,此时 picture 就派上用场了!

picture 的元素的结构

<picture>
  <!-- 针对宽屏的横图 -->
  <source 
    media="(min-width: 1200px)" 
    srcset="hero-wide.jpg"
  >
  <!-- 针对平板的方图 -->
  <source 
    media="(min-width: 768px)" 
    srcset="hero-square.jpg"
  >
  <!-- 针对手机的竖图 -->
  <source 
    media="(max-width: 767px)" 
    srcset="hero-tall.jpg"
  >
  <!-- 降级方案 -->
  <img src="hero-fallback.jpg" alt="Hero image">
</picture>

浏览器会按顺序检查 <source> 元素,选择第一个匹配的媒体条件。

不同格式降级

picture还可以根据浏览器支持的格式提供不同的降级方案:

<picture>
  <!-- 优先使用AVIF(压缩率最高) -->
  <source srcset="image.avif" type="image/avif">
  <!-- 其次使用WebP(广泛支持) -->
  <source srcset="image.webp" type="image/webp">
  <!-- 降级到JPEG(兜底) -->
  <img src="image.jpg" alt="Fallback">
</picture>

srcset vs picture 选择策略

场景 推荐方案 原因
不同分辨率(2x/3x屏) srcset + x描述符 简单直接,浏览器自动选择
不同视口宽度 srcset + w描述符 + sizes 精确控制加载尺寸
不同构图/裁剪 picture + media 艺术指导需求
不同格式降级 picture + type 渐进增强,兼容老旧浏览器

Vue 组件封装:<ResponsiveImage>的设计与实现

组件设计

<!-- ResponsiveImage.vue -->
<template>
  <picture v-if="usePicture">
    <!-- 为每种格式生成 source -->
    <source
      v-for="source in pictureSources"
      :key="source.type"
      :type="source.type"
      :srcset="source.srcset"
      :media="source.media"
    >
    <!-- 兜底图 -->
    <img :src="fallbackSrc" :alt="alt" loading="lazy">
  </picture>
  
  <img
    v-else
    :src="src"
    :srcset="srcsetString"
    :sizes="sizes"
    :alt="alt"
    loading="lazy"
  >
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  // 基础配置
  src: String,           // 原图地址
  alt: String,           // 替代文本
  
  // 响应式配置
  widths: {
    type: Array,
    default: () => [400, 800, 1200]
  },
  formats: {
    type: Array,
    default: () => ['webp', 'avif']
  },
  sizes: {
    type: String,
    default: '100vw'
  },
  
  // 艺术指导
  mobile: String,        // 手机版图片
  tablet: String,        // 平板版图片
  desktop: String        // 桌面版图片
})

// 判断是否使用 picture 模式
const usePicture = computed(() => {
  return props.mobile || props.tablet || props.desktop
})

// 生成 srcset 字符串
const generateSrcset = (basePath, widths, format) => {
  return widths
    .map(w => `${basePath}-${w}w.${format} ${w}w`)
    .join(', ')
}

// picture 模式的 sources
const pictureSources = computed(() => {
  const sources = []
  
  // 为每种格式生成 source
  props.formats.forEach(format => {
    // 桌面版
    if (props.desktop) {
      sources.push({
        media: '(min-width: 1200px)',
        srcset: generateSrcset(props.desktop, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 平板版
    if (props.tablet) {
      sources.push({
        media: '(min-width: 768px) and (max-width: 1199px)',
        srcset: generateSrcset(props.tablet, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 手机版
    if (props.mobile) {
      sources.push({
        media: '(max-width: 767px)',
        srcset: generateSrcset(props.mobile, props.widths, format),
        type: `image/${format}`
      })
    }
  })
  
  return sources
})

// 兜底图片
const fallbackSrc = computed(() => {
  return props.desktop || props.tablet || props.mobile || props.src
})

// 非 picture 模式的 srcset
const srcsetString = computed(() => {
  if (usePicture.value) return ''
  return generateSrcset(props.src, props.widths, 'jpg')
})
</script>

组件使用示例

<template>
  <!-- 方案1:普通响应式图片 -->
  <ResponsiveImage
    src="/images/photo.jpg"
    :widths="[400, 800, 1200]"
    sizes="(max-width: 600px) 100vw, 50vw"
    alt="风景"
  />
  
  <!-- 方案2:艺术指导(不同屏幕不同构图) -->
  <ResponsiveImage
    mobile="/images/hero-mobile.jpg"
    tablet="/images/hero-tablet.jpg"
    desktop="/images/hero-desktop.jpg"
    :widths="[400, 800, 1200]"
    alt="英雄图"
  />
</template>

自动生成多尺寸图片 - Vite 插件

为什么需要插件生成?

假如我们需要手动为每张图片生成:

  • photo-400w.jpg
  • photo-800w.jpg
  • photo-1200w.jpg
  • photo-400w.webp
  • photo-800w.webp
  • photo-1200w.webp
  • photo-400w.avif
  • photo-800w.avif
  • photo-1200w.avif

相当于一张图片就要配置 9 个文件;随着图片数量的增加,这将是一场噩梦!

插件原理与设计

  1. 识别项目中的图片导入
  2. 根据配置生成多种尺寸和格式
  3. 注入对应的 srcset 信息

Vite插件完整实现

/// vite-plugin-responsive-images.js
import sharp from 'sharp'
import { glob } from 'fast-glob'

export default function responsiveImagesPlugin(options) {
  const {
    widths = [400, 800, 1200],
    formats = ['webp', 'avif'],
    quality = 80
  } = options
  
  return {
    name: 'vite-plugin-responsive-images',
    
    async buildStart() {
      // 找到所有图片
      const files = await glob('src/assets/images/**/*.{jpg,jpeg,png}')
      
      console.log(`📸 找到 ${files.length} 张图片`)
      
      for (const file of files) {
        // 为每个尺寸和格式生成图片
        for (const width of widths) {
          for (const format of formats) {
            const outputPath = file
              .replace('src/assets', 'dist/assets')
              .replace(/\.(jpg|jpeg|png)$/, `-${width}w.${format}`)
            
            await sharp(file)
              .resize(width, null, { withoutEnlargement: true })
              .toFormat(format, { quality })
              .toFile(outputPath)
          }
        }
      }
      
      console.log('✅ 图片生成完成')
    }
  }
}

配置插件

// vite.config.js
import responsiveImages from './vite-plugin-responsive-images'

export default {
  plugins: [
    responsiveImages({
      widths: [400, 800, 1200, 1600],
      formats: ['webp', 'avif'],
      quality: 75
    })
  ]
}

性能对比:不同方案下的图片加载体积

测试数据对比

基于典型电商商品详情页的测试结果:

图片类型 原始大小 WebP AVIF 节省空间
商品主图 (1200×1200) 850KB 320KB 210KB 62%-75%
商品缩略图 (400×400) 120KB 45KB 28KB 62%-77%
轮播大图 (1920×1080) 1.2MB 480KB 320KB 60%-73%

响应式方案加载体积对比

设备 传统单图 仅WebP 响应式srcset 响应式+WebP+AVIF
iPhone SE (375pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载400w图 (120KB) 下载400w WebP (45KB)
iPad (768pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载800w图 (280KB) 下载800w WebP (98KB)
MacBook Pro 下载1200w图 (850KB) 下载1200w图 (320KB) 下载1200w图 (850KB) 下载1200w WebP (320KB)
平均节省 基准 62% 51% 80%

加载性能指标提升

指标 优化前 优化后 提升
LCP (最大内容绘制) 3.2s 1.4s 56%
图片请求数 12 8 33%
总图片体积 4.2MB 1.1MB 74%
移动端数据消耗 4.2MB/次访问 0.6MB/次访问 86%

最佳实践清单

配置建议

图片尺寸断点:
├─ 400w:手机小屏
├─ 800w:手机大屏/平板
├─ 1200w:笔记本电脑
├─ 1600w:台式机
└─ 2000w:4K 屏幕

图片格式优先级:
├─ AVIF(最新,压缩率最高)
├─ WebP(广泛支持)
└─ JPEG/PNG(兜底)

sizes 设置:
├─ 手机:(max-width: 600px) 100vw
├─ 平板:(max-width: 1200px) 50vw
└─ 电脑:800px

实施策略选择矩阵

场景 技术方案 关键配置
普通内容图片 srcset + sizes 提供3-5种宽度,设置合理sizes
图标/Logo srcset + x描述符 提供1x/2x/3x版本
不同构图需求 picture + media 针对断点设计不同裁剪
现代格式降级 picture + type AVIF → WebP → JPEG
用户上传内容 动态生成 + CDN处理 根据设备实时转换

实施清单

  • 所有图片提供 3-5 种尺寸
  • 生成 WebP 和 AVIF 格式
  • 使用 <picture> 实现格式降级
  • 设置正确的 sizes 属性
  • 关键图片设置 loading="eager"
  • 非关键图片设置 loading="lazy"
  • 使用 Vite 插件自动生成多尺寸

结业

用户可能不会注意到图片加载很快,但一定会注意到图片加载很慢。响应式图片优化,是对用户体验最深情的告白。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

异步组件与 Suspense:如何优雅地处理加载状态并优化首屏加载?

作者 wuhen_n
2026年3月24日 07:46

前言

如果我们正在打开一个后台管理系统:

  • 我们点击了"数据分析"菜单,但是页面白屏,什么都没发生
  • 我们可以怀疑一下自己:"我点了吗?" 于是又点了一下
  • 5秒后,页面突然跳出来,吐槽一句:"这什么垃圾系统?"

这就是没有处理好加载状态的结果。用户不知道页面在加载,以为系统坏了。而我们要解决的问题,就是让加载过程变得可见、可预期、可恢复

为什么需要异步组件?

传统路由懒加载的问题

// 传统路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 2.5MB
  },
  {
    path: '/analysis',
    component: () => import('./views/DataAnalysis.vue')  // 3.2MB
  }
]

上述代码乍一看没什么问题,但如果遇上网络延迟,加载缓慢等情况,再点击菜单后,就会出现页面白屏的问题,用户也不知道页面正在加载...

异步组件的解决方案

import { defineAsyncComponent } from 'vue'

const AnalysisPage = defineAsyncComponent({
  loader: () => import('./views/DataAnalysis.vue'),
  loadingComponent: LoadingSpinner,  // 加载时显示
  errorComponent: ErrorDisplay,       // 出错时显示
  delay: 200,                         // 延迟200ms显示loading,避免闪烁
  timeout: 5000,                      // 5秒超时
  onError(error, retry, fail, attempts) {
    if (attempts <= 3) {
      retry()  // 重试
    } else {
      fail()
    }
  }
})

异步组件完全指南

基础用法

最简单的异步组件

import { defineAsyncComponent } from 'vue'

const SimpleAsync = defineAsyncComponent(() => 
  import('./components/HeavyComponent.vue')
)

完整配置的异步组件

import { defineAsyncComponent } from 'vue'

const FullAsync = defineAsyncComponent({
  loader: () => import('./components/HeavyComponent.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorDisplay,
  delay: 200,
  timeout: 5000,
  suspensible: true,
  
  onError(error, retry, fail, attempts) {
    if (attempts <= 3 && error.message.includes('network')) {
      console.log(`重试第 ${attempts} 次...`)
      retry()
    } else {
      fail()
    }
  }
})

加载组件的设计

<!-- LoadingSpinner.vue -->
<template>
  <div class="loading-container">
    <div class="spinner"></div>
    <p class="loading-text">加载中...</p>
  </div>
</template>

<style scoped>
.loading-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}

.spinner {
  width: 40px;
  height: 40px;
  border: 3px solid #f3f3f3;
  border-top: 3px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}
</style>

错误组件的设计

<!-- ErrorDisplay.vue -->
<template>
  <div class="error-container">
    <div class="error-icon">⚠️</div>
    <h3>加载失败</h3>
    <p>{{ error?.message || '未知错误' }}</p>
    <button @click="retry" class="retry-btn">重试</button>
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  error: Object
})

const emit = defineEmits(['retry'])

const retry = () => {
  emit('retry')
}
</script>

Suspense - 管理多个异步依赖

什么是 Suspense?

<template>
  <Suspense>
    <!-- 默认插槽:所有异步依赖加载完成后显示 -->
    <template #default>
      <AsyncComponent />
      <AnotherAsyncComponent />
    </template>
    
    <!-- fallback插槽:加载过程中显示 -->
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
</template>

工作原理

页面渲染
    ↓
遇到 <Suspense>
    ↓
检查内部组件是否都准备就绪
    ↓
有未完成的异步依赖?
    ├─ 是 → 显示 fallback
    │        ↓
    │     等待所有依赖完成
    │        ↓
    │     切换到 default
    │
    └─ 否 → 直接显示 default

在 setup 中使用 async/await

<!-- AsyncUserProfile.vue -->
<script setup>
import { ref } from 'vue'

// 直接在 setup 中使用 await
// 这个组件会自动触发 Suspense
const user = await fetch('/api/user').then(r => r.json())
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json())

// 所有数据都加载完成后才渲染
</script>

<template>
  <div>
    <h2>{{ user.name }}</h2>
    <div v-for="post in posts" :key="post.id">
      {{ post.title }}
    </div>
  </div>
</template>

并行数据加载

<!-- Dashboard.vue -->
<script setup>
// 并行加载,提高效率
const [userStats, salesData, recentOrders] = await Promise.all([
  fetch('/api/stats').then(r => r.json()),
  fetch('/api/sales').then(r => r.json()),
  fetch('/api/orders').then(r => r.json())
])
</script>

<template>
  <div class="dashboard">
    <StatsCard :data="userStats" />
    <SalesChart :data="salesData" />
    <OrderList :orders="recentOrders" />
  </div>
</template>

实战案例

案例一:路由级 Suspense

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <Suspense>
      <template #default>
        <component :is="Component" />
      </template>
      
      <template #fallback>
        <div class="page-loading">
          <div class="spinner"></div>
          <p>加载页面中...</p>
        </div>
      </template>
    </Suspense>
  </router-view>
</template>

案例二:骨架屏

<template>
  <Suspense>
    <template #default>
      <UserProfile :user-id="userId" />
    </template>
    
    <template #fallback>
      <!-- 骨架屏:形状匹配实际内容 -->
      <div class="profile-skeleton">
        <div class="skeleton-avatar"></div>
        <div class="skeleton-info">
          <div class="skeleton-line w-32"></div>
          <div class="skeleton-line w-48"></div>
          <div class="skeleton-line w-40"></div>
        </div>
      </div>
    </template>
  </Suspense>
</template>

<style scoped>
.profile-skeleton {
  display: flex;
  gap: 20px;
  padding: 20px;
}

.skeleton-avatar {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

.skeleton-line {
  height: 16px;
  margin-bottom: 12px;
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
  border-radius: 4px;
}

.w-32 { width: 128px; }
.w-40 { width: 160px; }
.w-48 { width: 192px; }

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
</style>

案例三:嵌套 Suspense

<template>
  <!-- 外层:整个页面的加载状态 -->
  <Suspense>
    <template #fallback>
      <PageSkeleton />
    </template>
    
    <template #default>
      <div class="page">
        <Header />
        
        <!-- 内层:局部区域的加载状态 -->
        <Suspense>
          <template #fallback>
            <ContentSkeleton />
          </template>
          
          <template #default>
            <AsyncContent />
          </template>
        </Suspense>
        
        <Footer />
      </div>
    </template>
  </Suspense>
</template>

案例四:预加载

<script setup>
import { defineAsyncComponent, shallowRef } from 'vue'

const showModal = ref(false)
const ModalComponent = shallowRef()

// 鼠标悬停时预加载
const preloadModal = () => {
  ModalComponent.value = defineAsyncComponent(() => 
    import('./components/Modal.vue')
  )
}

// 点击时显示
const openModal = () => {
  showModal.value = true
}
</script>

<template>
  <button 
    @mouseenter="preloadModal"
    @click="openModal"
  >
    打开弹窗
  </button>
  
  <ModalComponent v-if="showModal" />
</template>

性能优化策略

优先级加载

// 定义加载优先级
const loadQueue = {
  critical: [],   // 首屏必需,立即加载
  normal: [],     // 普通优先级
  idle: []        // 空闲时加载
}

function loadWithPriority(loader, priority = 'normal') {
  if (priority === 'critical') {
    // 立即加载
    return defineAsyncComponent(loader)
  }
  
  // 存入队列
  loadQueue[priority].push(loader)
  
  // 返回占位组件
  return defineAsyncComponent({
    loader: () => new Promise(resolve => {
      // 稍后加载
      setTimeout(() => loader().then(resolve), 0)
    })
  })
}

预连接优化

<!-- index.html -->
<head>
  <!-- 预连接到可能用到的域名 -->
  <link rel="preconnect" href="https://api.example.com">
  <link rel="preconnect" href="https://cdn.example.com">
  
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="https://analytics.example.com">
  
  <!-- 预加载关键资源 -->
  <link rel="preload" href="/critical.js" as="script">
</head>

组件缓存

// 缓存已加载的组件
const componentCache = new Map()

function cachedAsyncComponent(path) {
  if (componentCache.has(path)) {
    return componentCache.get(path)
  }
  
  const component = defineAsyncComponent(() => 
    import(path).then(comp => {
      componentCache.set(path, comp)
      return comp
    })
  )
  
  componentCache.set(path, component)
  return component
}

错误处理与降级

完整的错误处理

<template>
  <Suspense @fallback="handleFallback">
    <template #default>
      <AsyncComponent />
    </template>
    
    <template #fallback>
      <LoadingSpinner />
    </template>
  </Suspense>
  
  <div v-if="error" class="error-boundary">
    <ErrorIcon />
    <h3>加载失败</h3>
    <p>{{ error.message }}</p>
    <button @click="retry">重试</button>
    <button @click="useFallback">使用基础版本</button>
  </div>
</template>

<script setup>
import { ref, onErrorCaptured } from 'vue'
import BaseVersion from './BaseVersion.vue'

const error = ref(null)
const useBase = ref(false)

onErrorCaptured((err) => {
  error.value = err
  return false  // 阻止继续传播
})

function retry() {
  error.value = null
  window.location.reload()
}

function useFallback() {
  useBase.value = true
  error.value = null
}
</script>

自动重试机制

function withRetry(loader, maxRetries = 3) {
  return defineAsyncComponent({
    loader: () => {
      return new Promise((resolve, reject) => {
        let attempts = 0
        
        function attempt() {
          loader()
            .then(resolve)
            .catch(error => {
              attempts++
              if (attempts < maxRetries) {
                // 指数退避
                const delay = 1000 * Math.pow(2, attempts - 1)
                console.log(`重试 ${attempts}/${maxRetries},等待 ${delay}ms`)
                setTimeout(attempt, delay)
              } else {
                reject(error)
              }
            })
        }
        
        attempt()
      })
    },
    timeout: 10000
  })
}

性能监控

加载时间监控

// composables/useLoadMonitor.js
export function useLoadMonitor(componentName) {
  const startTime = performance.now()
  
  onMounted(() => {
    const loadTime = performance.now() - startTime
    
    // 上报性能数据
    console.log(`[性能] ${componentName} 加载时间: ${loadTime.toFixed(2)}ms`)
    
    if (loadTime > 3000) {
      console.warn(`⚠️ ${componentName} 加载时间过长: ${loadTime.toFixed(2)}ms`)
    }
  })
}

用户体验指标

指标 目标值 含义
FCP < 1.5s 第一个内容出现的时间
LCP < 2.5s 主要内容出现的时间
TTI < 3.5s 页面可交互的时间
加载反馈 < 100ms 点击后显示加载状态的时间

最佳实践清单

实施检查清单

  • 大组件使用异步加载
  • 配置 loadingComponent 和 errorComponent
  • 设置合理的 delay(200ms)避免闪烁
  • 设置 timeout(5-10秒)避免无限等待
  • 关键路径考虑预加载
  • 设计匹配布局的骨架屏
  • 实现错误重试机制
  • 监控加载性能指标

决策树

组件是否需要异步加载?
├─ 否 → 普通组件
└─ 是 → 是否在首屏?
    ├─ 是 → 考虑预加载
    └─ 否 → 是否需要加载状态?
        ├─ 是 → 使用 Suspense
        └─ 否 → 使用 defineAsyncComponent

结语

好的用户体验 = 立即反馈 + 预期符合 + 可恢复!

当我们优化完加载体验,用户不再抱怨"页面卡",而是觉得"很流畅",那就说明我们成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

ESLint + Prettier + Husky + lint-staged:建立自动化的高效前端工作流

作者 wuhen_n
2026年3月21日 05:36

前言

在团队协作中,代码规范往往是一个容易引发争议却又不得不解决的问题。每个人都有自己的编码习惯,有人喜欢加分号,有人不喜欢;有人用两个空格缩进,有人用四个;有人变量命名用 camelCase,有人用 snake_case。这些差异在 Code Review 时往往演变成无休止的争论,消耗着团队的宝贵时间。

这就是为什么要建立自动化代码规范工作流——让工具做工具擅长的事,让人做人擅长的事。

为什么需要自动化代码规范?

没有规范带来的问题

// 团队成员A写的代码
function fetchData(){
let result = getData()
return result
}

// 团队成员B写的代码
function fetchData() {
  const result = getData();
  return result;
}

上述两段代码看起来差不多,但:

  1. 格式不一致(缩进、空格、分号)
  2. 变量命名风格不同
  3. Code Review 时会争论这些细节

有了自动化工具之后

// 不管我们怎么写,保存时自动变成统一格式
function fetchData() {
  const result = getData()
  return result
}
// 提交时自动检查,有问题就拦截
// 再也不用手动改格式了

自动化工作流的价值

传统流程:
写代码 → 手动检查 → 提交 → Code Review → 发现问题 → 修改 → 再次提交

自动化流程:
写代码 → 保存时自动格式化 → 提交时自动检查 → 提交成功
                        ↓
                    发现问题自动拦截

收益:
- 减少 90% 的代码风格争论
- 提前发现 70% 的潜在错误
- Code Review 时间缩短 50%
- 新人融入时间减少 60%

工具链全景图

四大工具的分工

ESLint:代码质量检查

  • 发现潜在错误(未定义变量、未使用变量)
  • 强制最佳实践(使用 === 代替 ==)
  • 统一代码风格(但能力有限)

Prettier:代码美容师

  • 统一代码风格(空格、换行、引号)
  • 专注格式化,不做质量检查

Husky:看门人

  • 在 Git 操作时触发脚本
  • 确保提交前代码符合规范

lint-staged:高效助手

  • 只检查即将提交的文件
  • 避免检查整个项目,提高效率

工作流程示意图

开发阶段
┌─────────────────┐
│  VS Code 编辑器 │
│  - 保存时格式化 │
│  - 实时错误提示 │
└────────┬────────┘
         ↓
Git 提交阶段
┌─────────────────┐
│   git commit    │
└────────┬────────┘
         ↓
Husky 触发 pre-commit
┌─────────────────┐
│  执行 lint-staged│
└────────┬────────┘
         ↓
lint-staged 检查暂存区
┌─────────────────┐
│ 1. 运行 ESLint  │
│ 2. 运行 Prettier│
└────────┬────────┘
    有问题?→ 拦截提交
         ↓
    没问题 → 提交成功

ESLint - 代码质量守门员

什么是 ESLint?

ESLint 就像考试时的阅卷老师,专门帮我们找出代码中的"错误"和"不规范":

// 1. 未使用的变量
let unusedVar = '没人用我'  // ESLint: 'unusedVar' is defined but never used

// 2. 未定义的变量
console.log(notDefined)  // ESLint: 'notDefined' is not defined

// 3. 不安全的比较
if (count == 1) {  // ESLint: Expected '===' and instead saw '=='
  // ...
}

// 4. 重复定义
let name = '张三'
let name = '李四'  // ESLint: 'name' is already defined

安装和初始化

# 安装 ESLint
npm install eslint --save-dev

# 初始化配置
npx eslint --init

# 交互式选择:
# - How would you like to use ESLint? → To check syntax and find problems
# - What type of modules does your project use? → JavaScript modules (import/export)
# - Which framework does your project use? → Vue.js
# - Does your project use TypeScript? → Yes
# - Where does your code run? → Browser
# - What format do you want your config file to be in? → JavaScript

Vue 3 + TypeScript 项目的最佳配置

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    es2021: true,
    node: true,
    'vue/setup-compiler-macros': true
  },
  extends: [
    'eslint:recommended',
    'plugin:vue/vue3-recommended',  // 使用推荐规则
    'plugin:@typescript-eslint/recommended',
    'plugin:prettier/recommended'    // 整合 Prettier
  ],
  parser: 'vue-eslint-parser',
  parserOptions: {
    ecmaVersion: 'latest',
    parser: '@typescript-eslint/parser',
    sourceType: 'module',
    extraFileExtensions: ['.vue']
  },
  plugins: ['vue', '@typescript-eslint'],
  rules: {
    // 关闭与 Prettier 冲突的规则
    'vue/max-attributes-per-line': 'off',
    'vue/singleline-html-element-content-newline': 'off',
    'vue/html-self-closing': 'off',
    
    // 自定义规则
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    
    // Vue 3 推荐
    'vue/multi-word-component-names': 'off',
    'vue/no-v-model-argument': 'off',
    
    // TypeScript
    '@typescript-eslint/no-explicit-any': 'warn',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-unused-vars': ['error', { 
      argsIgnorePattern: '^_' 
    }]
  },
  globals: {
    defineProps: 'readonly',
    defineEmits: 'readonly',
    defineExpose: 'readonly',
    withDefaults: 'readonly'
  }
}

自定义规则详解

// .eslintrc.js
module.exports = {
  rules: {
    // 规则级别:off(0) 关闭 / warn(1) 警告 / error(2) 错误
    
    // ========== 最佳实践 ==========
    'eqeqeq': ['error', 'always'],  // 必须用 === 和 !==
    'no-eval': 'error',              // 禁止 eval
    'no-implied-eval': 'error',      // 禁止隐式 eval
    'no-with': 'error',              // 禁止 with 语句
    
    // ========== 变量相关 ==========
    'no-unused-vars': ['error', { 
      vars: 'all',                   // 检查所有变量
      args: 'after-used',           // 检查使用后的参数
      ignoreRestSiblings: true      // 忽略剩余参数
    }],
    'no-use-before-define': ['error', { 
      functions: false,              // 函数可以在定义前使用
      classes: true,                // 类不行
      variables: true               // 变量也不行
    }],
    
    // ========== 代码风格 ==========
    // 这些规则会被 Prettier 覆盖,但保留作为参考
    'array-bracket-spacing': ['error', 'never'],  // [1, 2, 3] 而不是 [ 1, 2, 3 ]
    'object-curly-spacing': ['error', 'always'],  // { foo: bar } 而不是 {foo: bar}
    'comma-dangle': ['error', 'never'],           // 不加尾逗号
    'quotes': ['error', 'single'],                // 用单引号
    'semi': ['error', 'never'],                   // 不加分号
    
    // ========== 复杂度控制 ==========
    'max-depth': ['warn', 4],        // 最大嵌套深度不超过4
    'max-params': ['warn', 5],        // 函数参数不超过5个
    'max-statements': ['warn', 30],    // 函数语句不超过30行
    'complexity': ['warn', 10]         // 圈复杂度不超过10
  }
}

在 package.json 中添加脚本

{
  "scripts": {
    "lint": "eslint . --ext .js,.ts,.vue",
    "lint:fix": "eslint . --ext .js,.ts,.vue --fix",
    "lint:src": "eslint src --ext .js,.ts,.vue"
  }
}

Prettier - 代码美容师

什么是 Prettier?

Prettier 是格式化工具,它只有一个任务:把代码变得好看:

// 这是我们写的(乱七八糟)
function   hello(   name   ){
console.log(   `Hello ${   name   }`   )   }

// Prettier 帮我们变成这样
function hello(name) {
  console.log(`Hello ${name}`)
}

安装与基础配置

# 安装 Prettier
npm install --save-dev prettier

# 安装 ESLint 整合插件
npm install --save-dev eslint-config-prettier eslint-plugin-prettier

Prettier 配置文件

// .prettierrc.js
module.exports = {
  // 基础配置
  printWidth: 100,              // 每行最大宽度
  tabWidth: 2,                  // 缩进空格数
  useTabs: false,               // 用空格代替 tab
  semi: false,                  // 不加分号
  singleQuote: true,            // 用单引号
  quoteProps: 'as-needed',      // 对象属性只在必要时加引号
  trailingComma: 'none',        // 不加尾逗号
  bracketSpacing: true,         // 对象括号内加空格 { foo: bar }
  arrowParens: 'always',        // 箭头函数参数总是加括号 (x) => x
  endOfLine: 'auto',            // 自动处理换行符
  
  // Vue 相关
  vueIndentScriptAndStyle: true, // 缩进 <script> 和 <style>
  htmlWhitespaceSensitivity: 'strict',
  
  // 针对不同文件的特殊配置
  overrides: [
    {
      files: '*.vue',
      options: {
        printWidth: 120
      }
    },
    {
      files: '*.md',
      options: {
        proseWrap: 'always'
      }
    }
  ]
}

添加格式化脚本

{
  "scripts": {
    "format": "prettier --write \"src/**/*.{js,ts,vue,json,md}\"",
    "format:check": "prettier --check \"src/**/*.{js,ts,vue,json,md}\""
  }
}

编辑器集成(VS Code)

// .vscode/settings.json
{
  // 保存时自动格式化
  "editor.formatOnSave": true,
  
  // 使用 Prettier 作为默认格式化工具
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  
  // 对特定文件使用不同的格式化工具
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  
  // 保存时自动修复 ESLint 问题
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  
  // 禁用内置的 CSS/HTML 格式化器
  "css.validate": false,
  "scss.validate": false,
  "html.validate.scripts": false,
  "html.validate.styles": false
}

Husky + lint-staged:Git 钩子自动化

什么是 Git 钩子?

Git 钩子就像"守门员":我们在做某个 Git 操作之前,可以先执行一些检查:

你想提交代码 → 守门员检查 → 没问题 → 提交成功
                 ↓
               有问题 → 不让提交,让你改

Husky 安装与配置

# 安装 Husky
npm install --save-dev husky

# 初始化 Husky(创建 .husky 目录)
npx husky install

# 添加 prepare 脚本,确保其他人安装后自动启用
npm pkg set scripts.prepare="husky install"

# 创建 pre-commit 钩子
npx husky add .husky/pre-commit "npx lint-staged"

lint-staged 配置

// .lintstagedrc.js
module.exports = {
  // 对 JS/TS/Vue 文件运行 ESLint(并自动修复)
  '*.{js,jsx,ts,tsx,vue}': ['eslint --fix', 'prettier --write'],
  
  // 对其他文件只运行 Prettier
  '*.{json,md,yml,yaml,html,css,scss}': ['prettier --write']
}

添加提交信息规范

# 安装 commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional

# 创建配置
cat > commitlint.config.js << 'EOF'
module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      [
        'feat',      // 新功能
        'fix',       // 修复
        'docs',      // 文档
        'style',     // 代码风格
        'refactor',  // 重构
        'perf',      // 性能优化
        'test',      // 测试
        'chore',     // 构建/工具
        'revert'     // 回滚
      ]
    ],
    'subject-full-stop': [2, 'never', '.'],  // 结尾不能有句号
    'header-max-length': [2, 'always', 100]  // 最大长度100
  }
}
EOF

# 添加 commit-msg 钩子
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

提交信息格式

# 格式
<type>(<scope>): <subject>

# 示例
feat(user): 添加用户登录功能
fix(api): 修复请求超时问题
docs(readme): 更新安装说明
style(component): 格式化代码
refactor(utils): 重构日期处理函数
perf(list): 优化列表渲染性能

完整工作流演示

日常开发流程

1. 写代码
   ↓
2. 保存文件(VS Code 自动格式化)
   ↓
3. 提交代码
   ↓
4. pre-commit 钩子触发
   ↓
5. lint-staged 检查要提交的文件
   ├─ 通过 → 提交成功
   └─ 不通过 → 显示错误,拒绝提交

常见问题

场景1:提交被拦截,因为代码有问题

$ git commit -m "feat: 添加功能"
→ lint-staged 检查发现错误
→ 提交失败
解决方案:修复错误后重新提交
$ npm run lint:fix
$ git add .
$ git commit -m "feat: 添加功能"

场景2:紧急情况,跳过检查

$ git commit --no-verify -m "hotfix: 紧急修复"

CI/CD 集成

GitHub Actions 配置

# .github/workflows/lint.yml
name: Code Quality

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run ESLint
        run: npm run lint
      
      - name: Check formatting
        run: npm run format:check
      
      - name: Run TypeScript check
        run: npm run type-check

package.json 脚本

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    
    "lint": "eslint . --ext .js,.ts,.vue",
    "lint:fix": "eslint . --ext .js,.ts,.vue --fix",
    "format": "prettier --write \"src/**/*.{js,ts,vue,json,md}\"",
    "format:check": "prettier --check \"src/**/*.{js,ts,vue,json,md}\"",
    "type-check": "vue-tsc --noEmit",
    
    "prepare": "husky install"
  }
}

常见问题与解决方案

ESLint 和 Prettier 冲突

让 Prettier 说了算:

// .eslintrc.js
module.exports = {
  extends: [
    'plugin:vue/vue3-recommended',
    'plugin:prettier/recommended'  // 放在最后,覆盖冲突规则
  ]
}

Husky 钩子不执行

检查钩子权限:

# 1. 检查钩子文件
ls -la .husky/pre-commit

# 2. 添加执行权限
chmod +x .husky/pre-commit

# 3. 重新安装
npm run prepare

lint-staged 运行太慢

// .lintstagedrc.js
module.exports = {
  // 方法1:限制每次检查的文件数
  '*.{js,ts,vue}': files => {
    const chunks = chunk(files, 10)
    return chunks.map(chunk => `eslint --fix ${chunk.join(' ')}`)
  },
  
  // 方法2:先跑 Prettier 再跑 ESLint
  '*.{js,ts,vue}': ['prettier --write', 'eslint --fix']
}

function chunk(arr, size) {
  return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
    arr.slice(i * size, i * size + size)
  )
}

新成员加入时配置不一致

# 解决方案1:提交配置文件到仓库
git add .eslintrc.js .prettierrc.js .vscode/

# 解决方案2:在 README 中说明
## 开发环境配置

1. 安装 Node.js 18+
2. 运行 `npm install`
3. 安装 VS Code 推荐插件
4. 运行 `npm run dev`

# 解决方案3:添加快速启动脚本
npm run setup

完整配置清单

项目文件结构

my-project/
├── .husky/
│   ├── pre-commit          # 提交前检查
│   └── commit-msg          # 提交信息检查
├── .vscode/
│   ├── settings.json       # VS Code 设置
│   └── extensions.json     # 推荐插件
├── .eslintrc.js            # ESLint 配置
├── .prettierrc.js          # Prettier 配置
├── .lintstagedrc.js        # lint-staged 配置
├── commitlint.config.js    # 提交信息规范
├── package.json
└── README.md

快速初始化脚本

#!/bin/bash
# setup.sh - 一键配置代码规范

echo "开始配置代码规范工具链..."

# 1. 安装依赖
npm install --save-dev \
  eslint \
  prettier \
  eslint-config-prettier \
  eslint-plugin-prettier \
  eslint-plugin-vue \
  @typescript-eslint/parser \
  @typescript-eslint/eslint-plugin \
  husky \
  lint-staged \
  @commitlint/cli \
  @commitlint/config-conventional

# 2. 初始化配置文件
# (这里可以复制配置文件内容)

# 3. 初始化 Husky
npx husky install
npm pkg set scripts.prepare="husky install"
npx husky add .husky/pre-commit "npx lint-staged"
npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"'

echo "配置完成!"

规则选择原则

  1. 不要过度约束

    • 能自动修复的用 error
    • 不能自动修复的用 warn
  2. 优先使用社区推荐

    • eslint:recommended
    • vue/vue3-recommended
  3. 团队共识优先

    • 有争议的规则,团队讨论决定
    • 少数服从多数

工具使用原则

  1. 让工具做工具的事

    • 格式化的交给 Prettier
    • 质量检查交给 ESLint
    • 提交检查交给 Git 钩子
  2. 减少手动操作

    • 保存时自动格式化
    • 提交前自动检查
    • CI 自动验证
  3. 允许特殊情况

    • 可以用 --no-verify 跳过
    • 可以用 eslint-disable 忽略
    • 可以用 prettier-ignore 忽略

团队协作建议

  1. 配置纳入版本控制

    • 所有配置文件提交到仓库
    • 新人 clone 后直接可用
  2. 做好文档

    • README 说明如何配置
    • 记录特殊规则的原因
  3. 定期回顾

    • 收集反馈
    • 调整规则
    • 持续优化

结语

工具是辅助,不是目的。代码规范的核心是提升团队效率和代码质量,而不是制造障碍。自动化的代码规范工具链,不是为了限制开发者,而是为了解放开发者。让工具处理那些可以自动化的琐事,让人专注于真正需要思考的业务逻辑。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

网络请求在Vite层的代理与Mock:告别跨域和后端依赖

作者 wuhen_n
2026年3月21日 05:34

前言

在前端开发中,网络请求是连接前后端的桥梁,但也常常成为开发效率的瓶颈。跨域问题、后端接口未就绪、环境不稳定,这些问题每天都在消耗着我们的时间和精力。我们可以先看几个场景:

场景1

我们正在开发一个新功能,需要调用 /api/user/login 接口;启动项目,点击登录,浏览器报错:

Access to fetch at 'http://localhost:3000/api/user/login' 
from origin 'http://localhost:5173' has been blocked by CORS policy

然后我们去找后端同事:"帮我配一下CORS"。后端说:"好的,等我5分钟"。那我们就只能干等着。

场景2

我们要开发一个复杂报表页面,需要调用 /api/report/complex-data。但后端说这个接口要下周才能好;我们只能先写静态数据,等接口好了再改代码联调。

场景3

我们要测试页面在接口返回 500 错误时的表现,但后端服务表现得一直很稳定,怎么也触发不了错误。

这些问题每天都在消耗着我们的时间和精力。而Vite提供的代理和Mock能力,正是解决这些痛点的利器。

为什么要在Vite层解决网络请求问题?

开发环境的三大网络困境

困境1:跨域问题

  • 前端在 localhost:5173
  • 后端在 localhost:3000
  • 浏览器:不同端口 → 跨域 → 拦截

困境2:接口未就绪

  • 后端说要下周才能好
  • 前端这周只能干等?

困境3:环境不稳定

  • 测试服务器时而500,时而超时
  • 开发效率直线下降

传统方案的问题

  • 跨域:让后端配CORS → 依赖后端,每次新增接口都要配
  • Mock:单独启动一个Mock服务 → 多维护一个服务,端口冲突
  • 环境问题:手动改代码 → 容易忘记改回来,导致生产事故

Vite方案的优势

  • 代理:开发服务器自动转发 → 零后端依赖,前端完全可控
  • Mock:插件注入拦截 → 无额外服务,随项目启动
  • 配置中心化 → 一键切换,不会污染业务代码

代理 - 优雅解决跨域问题

代理是什么?

我们可以用一个快递的例子,来理解什么是代理: 我们(浏览器)想通过公司内部快递,寄一份快递给后端,但快递公司说"不同地址不能寄"(跨域):

  • 于是我们找了个中间人:公司综合员(Vite开发服务器)
  • 把快递给综合员(请求发给Vite)
  • 综合员帮我们转寄给后端(Vite转发请求)
  • 后端把回执给综合员,综合员再转交给我们

这个例子的关键是:综合员和你是同一个部门(地址),所以快递公司不拦截。

Vite 代理的工作原理

请求流程:

浏览器 → http://localhost:5173/api/users
             ↓
Vite 开发服务器 (localhost:5173)
             ↓
代理配置匹配 /api
             ↓
转发请求 → http://localhost:3000/api/users
             ↓
后端服务器 (localhost:3000)
             ↓
响应返回 → Vite 服务器
             ↓
转发给浏览器

关键:浏览器只和同源的 Vite 服务器通信,完美绕过跨域

最简单的代理配置

// vite.config.js
export default {
  server: {
    proxy: {
      // 把所有 /api 开头的请求,转发到 http://localhost:3000
      '/api': 'http://localhost:3000'
    }
  }
}

// 现在可以这样请求了
fetch('/api/users')  
// 实际请求:http://localhost:3000/api/users
// 完美绕过跨域!

完整的代理配置详解

// vite.config.js
export default {
  server: {
    proxy: {
      // 详细的代理配置
      '/api': {
        target: 'http://localhost:3000',  // 目标服务器地址
        changeOrigin: true,                // 改变请求源头(重要!)
        
        // 重写路径:去掉 /api 前缀
        rewrite: (path) => path.replace(/^\/api/, ''),
        // 请求 /api/users → 实际转发 /users
        
        secure: false,     // 如果目标是https但证书无效,设为false
        ws: true,          // 支持 WebSocket 代理
        
        // 添加自定义请求头
        headers: {
          'X-Dev-Proxy': 'vite'
        },
        
        // 调试:查看代理过程
        configure: (proxy) => {
          proxy.on('proxyReq', (proxyReq, req) => {
            console.log('→ 代理请求:', req.url)
          })
          proxy.on('proxyRes', (proxyRes, req) => {
            console.log('← 代理响应:', proxyRes.statusCode)
          })
          proxy.on('error', (err) => {
            console.log('✗ 代理错误:', err)
          })
        }
      }
    }
  }
}

多环境代理配置策略

为什么需要多环境?

实际开发中,我们通常需要配置多个环境:

  • 开发环境:连接本地后端 localhost:3000
  • 测试环境:连接测试服务器 test-api.example.com
  • 预发环境:连接预发服务器 staging-api.example.com
  • 生产环境:连接正式服务器 api.example.com

每次切换环境都要改代码,这太麻烦了!

使用环境变量配置

// vite.config.js
import { defineConfig, loadEnv } from 'vite'

export default defineConfig(({ mode }) => {
  // 根据当前模式加载对应的环境变量
  // mode 可能是 development / staging / production
  const env = loadEnv(mode, process.cwd())
  
  return {
    server: {
      proxy: {
        '/api': {
          target: env.VITE_API_URL,  // 从环境变量读取
          changeOrigin: true,
          rewrite: path => path.replace(/^\/api/, '')
        },
        
        // 如果有多个后端服务
        '/upload': {
          target: env.VITE_UPLOAD_URL,
          changeOrigin: true
        }
      }
    }
  }
})

环境变量文件配置

# .env.development - 开发环境
VITE_API_URL=http://localhost:3000
VITE_UPLOAD_URL=http://localhost:3001

# .env.staging - 测试环境
VITE_API_URL=http://test-api.example.com
VITE_UPLOAD_URL=http://test-upload.example.com

# .env.production - 生产环境
VITE_API_URL=https://api.example.com
VITE_UPLOAD_URL=https://upload.example.com

启动不同环境

// package.json
{
  "scripts": {
    "dev": "vite --mode development",
    "dev:staging": "vite --mode staging",
    "build:prod": "vite build --mode production"
  }
}

Mock - 摆脱后端依赖

什么时候需要Mock?

场景1:后端接口还没开发完成

真实接口后端需要开发 2 周后才能完成;此时前端不能等,需要继续开发,我们就可以 Mock 数据继续开发:

// 解决方案:Mock 数据
fetch('/api/complex-report')
  .then(res => res.json())
  .then(data => renderReport(data))  // 用 Mock 数据继续开发

场景2:测试边界情况

const testCases = [
  { status: 200, data: [...] },        // 正常情况
  { status: 500, message: '服务器错误' }, // 错误情况
  { status: 401, message: '未登录' },     // 权限问题
  { status: 200, data: [] }              // 空数据情况
]

场景3:演示或测试环境

不需要真实后端,通过 Mock 数据,前端也能正常跑起来!

安装 vite-plugin-mock

npm install vite-plugin-mock -D

基础配置

// vite.config.js
import { viteMockServe } from 'vite-plugin-mock'

export default {
  plugins: [
    viteMockServe({
      mockPath: 'mock',        // mock文件存放目录
      supportTs: true,          // 支持TypeScript
      watchFiles: true,         // 监听文件变化(修改mock自动重启)
      localEnabled: true,       // 开发环境启用
      prodEnabled: false,       // 生产环境禁用
      
      // 生产环境注入的代码(如果需要)
      injectCode: `
        import { setupProdMockServer } from './mockProdServer';
        setupProdMockServer();
      `
    })
  ]
}

第一个Mock接口

// mock/user.js
export default [
  // GET请求示例
  {
    url: '/api/users',
    method: 'get',
    response: () => {
      return {
        code: 200,
        message: 'success',
        data: [
          { id: 1, name: '张三', age: 25 },
          { id: 2, name: '李四', age: 30 },
          { id: 3, name: '王五', age: 28 }
        ]
      }
    }
  },
  
  // POST请求示例
  {
    url: '/api/login',
    method: 'post',
    response: ({ body }) => {
      const { username, password } = body
      
      // 模拟登录验证
      if (username === 'admin' && password === '123456') {
        return {
          code: 200,
          data: {
            token: 'mock-token-' + Date.now(),
            username
          }
        }
      }
      
      return {
        code: 401,
        message: '用户名或密码错误'
      }
    }
  }
]

带参数的Mock

// mock/user.js
export default [
  // 动态路径参数
  {
    url: '/api/user/:id',
    method: 'get',
    response: ({ params }) => {
      const { id } = params
      
      return {
        code: 200,
        data: {
          id: Number(id),
          name: `用户${id}`,
          age: 20 + Number(id),
          avatar: `https://randomuser.me/api/portraits/${id % 2 ? 'men' : 'women'}/${id}.jpg`
        }
      }
    }
  },
  
  // 查询参数
  {
    url: '/api/users',
    method: 'get',
    response: ({ query }) => {
      const { page = 1, pageSize = 10 } = query
      
      // 生成分页数据
      const start = (page - 1) * pageSize
      const total = 100
      
      const data = Array.from({ length: pageSize }, (_, i) => ({
        id: start + i + 1,
        name: `用户${start + i + 1}`,
        age: 20 + Math.floor(Math.random() * 30)
      }))
      
      return {
        code: 200,
        data: {
          list: data,
          total,
          page: Number(page),
          pageSize: Number(pageSize)
        }
      }
    }
  }
]

高级Mock技巧

模拟不同场景

// mock/scenarios.ts
export default [
  // 模拟分页数据
  {
    url: '/api/users/paged',
    method: 'get',
    response: ({ query }) => {
      const { page = 1, pageSize = 10 } = query
      const start = (page - 1) * pageSize
      const total = 100
      
      const data = Array.from({ length: pageSize }, (_, i) => ({
        id: start + i + 1,
        name: `用户${start + i + 1}`,
        age: 20 + Math.floor(Math.random() * 30)
      }))
      
      return {
        code: 200,
        data: {
          list: data,
          total,
          page: Number(page),
          pageSize: Number(pageSize)
        }
      }
    }
  },
  
  // 模拟延迟
  {
    url: '/api/slow-request',
    method: 'get',
    timeout: 3000, // 3秒延迟
    response: () => {
      return {
        code: 200,
        data: '终于响应了'
      }
    }
  },
  
  // 模拟错误
  {
    url: '/api/error',
    method: 'get',
    statusCode: 500,
    response: () => {
      return {
        code: 500,
        message: '服务器内部错误'
      }
    }
  },
  
  // 模拟超时
  {
    url: '/api/timeout',
    method: 'get',
    timeout: 10000, // 超时时间
    response: () => {
      // 永远不会执行
    }
  }
]

使用 mockjs 生成随机数据

// mock/dashboard.js
import Mock from 'mockjs'

export default [
  {
    url: '/api/dashboard',
    method: 'get',
    response: () => {
      return {
        code: 200,
        data: {
          // 随机数字
          visits: Mock.mock('@integer(1000, 10000)'),
          
          // 随机浮点数
          sales: Mock.mock('@float(1000, 10000, 2, 2)'),
          
          // 随机数组
          trends: Mock.mock({
            'data|7': ['@integer(100, 1000)']
          }).data,
          
          // 随机用户列表
          users: Mock.mock({
            'list|10': [{
              'id|+1': 1,
              name: '@cname',  // 中文名
              avatar: '@image(100x100)',  // 随机图片
              'age|20-40': 1,
              email: '@email',
              address: '@county(true)',
              'gender|1': ['男', '女']
            }]
          }).list
        }
      }
    }
  }
]

动态增删改查

// mock/crud.js
// 模拟数据库
const store = {
  users: new Map([
    [1, { id: 1, name: '张三' }],
    [2, { id: 2, name: '李四' }]
  ])
}

export default [
  // 查询列表
  {
    url: '/api/users',
    method: 'get',
    response: () => ({
      code: 200,
      data: Array.from(store.users.values())
    })
  },
  
  // 新增
  {
    url: '/api/users',
    method: 'post',
    response: ({ body }) => {
      const id = store.users.size + 1
      const newUser = { id, ...body }
      store.users.set(id, newUser)
      return {
        code: 200,
        data: newUser
      }
    }
  },
  
  // 删除
  {
    url: '/api/users/:id',
    method: 'delete',
    response: ({ params }) => {
      const id = Number(params.id)
      const deleted = store.users.get(id)
      store.users.delete(id)
      return {
        code: 200,
        data: deleted
      }
    }
  },
  
  // 更新
  {
    url: '/api/users/:id',
    method: 'put',
    response: ({ params, body }) => {
      const id = Number(params.id)
      const user = store.users.get(id)
      if (user) {
        const updated = { ...user, ...body }
        store.users.set(id, updated)
        return {
          code: 200,
          data: updated
        }
      }
      return {
        code: 404,
        message: '用户不存在'
      }
    }
  }
]

代理与Mock协同工作

按需启用Mock

// vite.config.js
import { defineConfig, loadEnv } from 'vite'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())
  
  // 是否启用Mock(从环境变量读取)
  const useMock = env.VITE_USE_MOCK === 'true'
  
  return {
    server: {
      proxy: {
        '/api': {
          target: env.VITE_API_URL,
          changeOrigin: true
        }
      }
    },
    
    plugins: [
      // 只有启用Mock时才加载插件
      useMock && viteMockServe({
        mockPath: 'mock',
        localEnabled: true
      })
    ].filter(Boolean)
  }
})

环境变量配置

# .env.development - 正常开发(连接真实后端)
VITE_API_URL=http://localhost:3000
VITE_USE_MOCK=false

# .env.development.mock - Mock模式(不依赖后端)
VITE_API_URL=http://localhost:3000  # 这个其实用不到了
VITE_USE_MOCK=true

# .env.staging - 测试环境
VITE_API_URL=http://test-api.example.com
VITE_USE_MOCK=false

启动脚本配置

{
  "scripts": {
    "dev": "vite --mode development",
    "dev:mock": "vite --mode development.mock",
    "dev:user": "VITE_USE_MOCK=true vite",  // 临时启用Mock
    "dev:no-mock": "VITE_USE_MOCK=false vite"  // 临时关闭Mock
  }
}

请求封装配合

// src/utils/request.js
import axios from 'axios'

const request = axios.create({
  baseURL: import.meta.env.VITE_API_URL
})

// 请求拦截器 - 可以添加统一处理
request.interceptors.request.use(config => {
  // 添加token
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// 响应拦截器 - 统一处理错误
request.interceptors.response.use(
  response => response.data,
  error => {
    // 统一错误处理
    if (error.response?.status === 401) {
      // 跳转登录
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

export default request

最佳实践与项目组织

Mock 文件组织结构

project/
├── mock/
│   ├── index.ts                 # 主入口,导出所有 Mock
│   ├── utils/                   
│   │   ├── response.ts          # 响应工具函数
│   │   ├── database.ts          # 模拟数据库
│   │   └── generator.ts         # 数据生成器
│   ├── modules/
│   │   ├── user/
│   │   │   ├── index.ts         # 用户模块 Mock
│   │   │   ├── data.ts          # 用户数据
│   │   │   └── scenarios.ts     # 用户场景
│   │   ├── order/
│   │   │   ├── index.ts
│   │   │   ├── data.ts
│   │   │   └── scenarios.ts
│   │   └── product/
│   │       ├── index.ts
│   │       ├── data.ts
│   │       └── scenarios.ts
│   └── fixtures/
│       ├── users.json           # 静态数据
│       └── products.json
└── vite.config.ts

统一响应格式

// mock/utils/response.ts
export interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
}

// 成功响应
export const success = <T>(data: T, message = 'success'): ApiResponse<T> => ({
  code: 200,
  message,
  data
})

// 错误响应
export const error = (message: string, code = 500): ApiResponse => ({
  code,
  message,
  data: null
})

// 分页响应
export const paged = <T>(
  list: T[],
  total: number,
  page: number,
  pageSize: number
) => success({
  list,
  total,
  page,
  pageSize,
  totalPages: Math.ceil(total / pageSize)
})

主入口文件

// mock/index.js
import user from './modules/user'
import order from './modules/order'
import product from './modules/product'

// 合并所有mock
export default [
  ...user,
  ...order,
  ...product
]

模块化示例

// mock/modules/user.js
import { success, error } from '../utils/response'

export default [
  // 登录
  {
    url: '/api/login',
    method: 'post',
    response: ({ body }) => {
      const { username, password } = body
      
      if (username === 'admin' && password === '123456') {
        return success({
          token: 'mock-token',
          username
        })
      }
      
      return error('用户名或密码错误', 401)
    }
  },
  
  // 获取用户信息
  {
    url: '/api/user/info',
    method: 'get',
    response: ({ headers }) => {
      const token = headers.authorization
      
      if (!token) {
        return error('未登录', 401)
      }
      
      return success({
        id: 1,
        name: '张三',
        avatar: 'https://randomuser.me/api/portraits/men/1.jpg',
        roles: ['admin']
      })
    }
  }
]

常见问题与解决方案

问题一:代理不生效

检查点1:路径是否正确

fetch('/api/users')  // 正确
fetch('api/users')   // 错误,缺少斜杠

检查点2:代理配置是否正确

proxy: {
  '/api': 'http://localhost:3000'  // 请求会转发到 http://localhost:3000/api
  // 如果需要重写路径:
  '/api': {
    target: 'http://localhost:3000',
    rewrite: path => path.replace(/^\/api/, '')  // 转发到 http://localhost:3000
  }
}

检查点3:后端是否在运行

curl http://localhost:3000/api/test

问题二:Mock 数据不更新

// vite.config.ts
export default {
  plugins: [
    viteMockServe({
      watchFiles: true,  // 确保开启监听
      // 或者手动清除缓存
      logger: true       // 查看日志
    })
  ]
}

// 如果还是不更新,尝试:
// 1. 重启开发服务器
// 2. 删除 node_modules/.vite 缓存
// 3. 检查文件修改时间

问题三:代理和 Mock 冲突

场景:同一个路径既配置了代理,又配置了 Mock,这时可能会引发冲突

解决方案1:优先级控制

plugins: [
  viteMockServe({
    // 确保 Mock 插件在代理之前
    // 插件的顺序决定了优先级
  })
]

解决方案2:使用不同路径

proxy: {
  '/api/real': 'http://localhost:3000',  // 真实 API
}
// Mock 使用相同路径,但通过插件配置

解决方案3:条件启用

const useMock = process.env.USE_MOCK === 'true'

proxy: {
  ...(!useMock && {
    '/api': 'http://localhost:3000'
  })
}

问题四:开发环境正常,生产环境报404

解决方案:确保生产环境用真实接口

// 请求封装中判断
const baseURL = import.meta.env.PROD 
  ? 'https://api.example.com'  // 生产用真实地址
  : '/api'                      // 开发用代理

const request = axios.create({ baseURL })

代理与 Mock 的最佳实践

配置清单

  • 基础代理配置完成
  • 多环境代理配置
  • Mock 插件安装配置
  • Mock 接口编写规范
  • 环境变量控制开关
  • 代理与 Mock 协同策略

开发流程建议

阶段1:后端接口未定义
├─ 前端先定义接口格式
├─ 编写Mock数据
└─ 前端独立开发

阶段2:后端开发中
├─ 已完成的接口用代理
├─ 未完成的用Mock
└─ 逐步替换

阶段3:后端全部完成
├─ 关闭Mock
├─ 全部使用代理
└─ 联调测试

阶段4:特殊场景测试
├─ 临时启用Mock
├─ 模拟各种边界情况
└─ 测试完成后关闭

常用配置模板

// vite.config.js - 完整配置模板
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd())
  const useMock = env.VITE_USE_MOCK === 'true'
  
  return {
    plugins: [
      vue(),
      useMock && viteMockServe({
        mockPath: 'mock',
        supportTs: true,
        watchFiles: true,
        logger: true
      })
    ].filter(Boolean),
    
    server: {
      proxy: {
        '/api': {
          target: env.VITE_API_URL,
          changeOrigin: true,
          rewrite: path => path.replace(/^\/api/, ''),
          configure: (proxy) => {
            proxy.on('proxyReq', (proxyReq, req) => {
              console.log('[代理]', req.method, req.url)
            })
          }
        }
      }
    }
  }
})

三个黄金原则

  1. 需要时才启用,不需要时关闭
  2. 模拟真实场景,不止是成功路径
  3. 与代理无缝切换,对业务代码无侵入

结语

代理和Mock不是用来骗人的,而是用来解放前端的。好的代理和Mock策略应该是:

  • 开发时:前端不依赖后端,想怎么测就怎么测
  • 联调时:一键关闭Mock,无缝切换到真实接口
  • 维护时:配置清晰,不会因为忘记关Mock而出问题

掌握了这些,我们就可以告别跨域报错,告别等待后端,让开发效率真正起飞!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

开发环境优化完全指南:告别等待,让开发如丝般顺滑

作者 wuhen_n
2026年3月20日 09:33

前言

想象一下这个场景:

我们正在写一个复杂的组件,思路如泉涌。保存文件,想看看效果:5 秒... 10 秒... 30 秒...

等页面刷新出来的时候,我们已经忘了刚才在想什么。心流被打断,灵感消失,只能重新理清思路。

这不是技术问题,这是对开发者时间的浪费。

根据 Stack Overflow 2023 年的调查,前端开发者平均每天要等待 30 - 60 分钟用于构建和热更新。

好消息是:这些等待时间,大部分都可以被优化掉。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮你一步步把开发环境的等待时间从“喝杯咖啡”缩短到“眨个眼”。

为什么会慢?先找到问题在哪

# 早上9点,开始工作
$ npm run dev

# 等待... 30 秒后项目终于启动了
# 打开浏览器,还要等 10 秒才能看到页面

# 修改一个文件,保存
# 等待... 10 秒后热更新完成

# 一天下来:
# 启动次数:10次 × 30 秒 = 300秒
# 修改次数:100次 × 10 秒 = 1500秒
# 总等待时间:1500秒 = 25分钟

这还只是保守估计。在大项目中,等待时间可能是这个数字的 3-5 倍。

开发环境的性能瓶颈

开发环境的速度主要受四个因素影响:

  1. 依赖处理:扫描、预构建 node_modules
  2. 文件编译:转换 .vue.ts.scss 等文件
  3. 模块图维护:跟踪文件之间的依赖关系
  4. 网络传输:浏览器加载文件的速度

如何判断瓶颈在哪?

我们可以使用 Vite 的调试模式:

vite --debug

我们会看到类似这样的输出:

vite:deps 扫描依赖中... 245.3ms
vite:deps 找到 156 个依赖 245.3ms
vite:deps 预构建中... 3240.5ms  ← 这里最慢!
vite:server 服务器启动完成 3512.8ms

根据输出结果,我们就可以做出正确的决断:

  • 如果 预构建 时间最长 → 优化依赖预构建
  • 如果 转换文件 时间最长 → 优化文件编译
  • 如果 服务器启动 时间最长 → 优化配置

依赖预构建优化 - 80%的性能提升从这里开始

什么是依赖预构建?

想象我们要整理一个巨大的图书馆(node_modules):

  • 不预构建:每次有人要看书,都要现场整理那一本书
  • 预构建:提前把所有书整理好,有人要就直接拿

Vite 的预构建就是提前把第三方库整理成浏览器可以直接使用的格式。

为什么需要手动配置预构建?

Vite 默认会自动预构建,但它其实没有那么智能,以下场景,Vite 并不会预构件:

场景1:动态导入

if (user.isAdmin) {
  const Chart = await import('echarts')  // 不会被预构建!
}

场景2:Monorepo 本地包

import { Button } from '@company/ui'  // 不会被预构建!

场景3:深层依赖

import 'a'  // a 依赖 b,b 依赖 c  // c 可能不会被预构建! 

include 优化:告诉 Vite 需要预构建什么

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

export default defineConfig({
  plugins: [vue()],
  
  optimizeDeps: {
    // ✅ 需要预构建的依赖
    include: [
      // 1. 体积大的库(减少请求数)
      'echarts',           // 原来可能有几百个文件,合并成一个
      'lodash-es',         // lodash-es 有 600+ 个文件!
      'ant-design-vue',    // UI 库通常都很大
      
      // 2. Monorepo 中的本地包
      '@company/ui',
      '@company/utils',
      '@company/hooks',
      
      // 3. 动态导入的库
      'monaco-editor',     // 只在需要时加载,但预构建后加载更快
      'xlsx',              // 导出功能可能不常用,但需要时希望快
      
      // 4. 有深层依赖的库
      'date-fns',          // 有很多子模块
      'lodash'             // 虽然不推荐,但如果用了就预构建
    ]
  }
})

exclude 优化:告诉 Vite 不需要预构建什么

// vite.config.js
export default defineConfig({
  optimizeDeps: {
    exclude: [
      // 1. 已经提供 ESM 格式的现代库
      'vue',           // Vue 本身已经优化好
      'vue-router',    // 不需要再打包
      'pinia',
      
      // 2. 很少用到的大库(按需加载更好)
      'pdfjs-dist',    // 只在查看 PDF 时用到
      'three',         // 只在 3D 页面用到
      
      // 3. 有特殊构建要求的库
      '@sentry/browser',  // 有自己的构建工具
      'firebase'          // 复杂的构建配置
    ]
  }
})

include 还是 exclude?一个流程看懂

遇到一个依赖 →
    ↓
是本地包(@company/xxx)? → 是 → include
    ↓否
是动态导入的? → 是 → include
    ↓否
体积 > 1MB? → 是 → include(除非很少用)
    ↓否
依赖深度 > 3层? → 是 → include
    ↓否
已提供 ESM 格式? → 是 → 可以 exclude
    ↓否
用默认行为

实战:如何找出需要 include 的依赖

// scripts/analyze-deps.js
import fs from 'fs'
import path from 'path'

// 分析 node_modules 中哪些包体积大
function findHeavyDeps() {
  const nodeModules = path.resolve('node_modules')
  const deps = fs.readdirSync(nodeModules)
    .filter(d => !d.startsWith('.'))
    .map(dep => {
      const pkgPath = path.join(nodeModules, dep)
      try {
        const stats = fs.statSync(pkgPath)
        return { name: dep, size: stats.size }
      } catch {
        return { name: dep, size: 0 }
      }
    })
    .sort((a, b) => b.size - a.size)
    .slice(0, 20)  // 前20个最大的
  
  console.log('体积最大的依赖:')
  deps.forEach(d => {
    console.log(`${d.name}: ${(d.size / 1024 / 1024).toFixed(2)}MB`)
  })
}

findHeavyDeps()

文件监听优化 - 让电脑知道该看哪

为什么需要优化文件监听?

Vite 默认会监听项目中的所有文件。在大型项目中,这可能会导致很多问题:

  • CPU 占用高:要监控几万个文件的变化
  • 内存占用大:要维护所有文件的状态
  • 更新慢:变化时要检查的文件太多

配置监听范围

// vite.config.js
export default defineConfig({
  server: {
    watch: {
      // ❌ 不要监听这些文件夹
      ignored: [
        '**/node_modules/**',  // 依赖包,不需要监听
        '**/dist/**',          // 构建输出,不需要监听
        '**/.git/**',          // git 目录
        '**/.idea/**',         // IDE 配置
        '**/.vscode/**',       // VSCode 配置
        '**/*.log',            // 日志文件
        '**/coverage/**',      // 测试覆盖率报告
        '**/tests/**',         // 测试文件(通常不需要热更新)
        '**/__tests__/**',     // 同上
        '**/__mocks__/**'      // Mock 文件
      ],
      
      // 只在需要的地方监听
      // 默认会监听整个项目,但我们可以更精确
      paths: [
        'src/**',              // 源代码
        'index.html',          // 入口文件
        'vite.config.js'       // 配置文件
      ]
    }
  }
})

热更新优化 - 从“等 5 秒”到“眨眼就好”

热更新为什么慢?

修改文件
    ↓
Vite 发现变化
    ↓
重新编译这个文件
    ↓
找出所有依赖这个文件的模块(可能很多!)
    ↓
重新编译所有受影响的模块
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求新模块
    ↓
执行更新

优化一:减少模块依赖范围

// 不好的做法:一个文件导入太多东西
// UserManagement.vue
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
import { useSettingsStore } from '@/stores/settings'
import UserList from './UserList.vue'
import UserForm from './UserForm.vue'
import UserFilters from './UserFilters.vue'
import UserStats from './UserStats.vue'
// ... 20 个 import

// ✅ 好的做法:按需加载,拆分组件
// UserManagement.vue
import { useUserStore } from '@/stores/user'  // 只导入需要的

// 其他组件通过异步加载
const UserList = defineAsyncComponent(() => import('./UserList.vue'))
const UserForm = defineAsyncComponent(() => import('./UserForm.vue'))
const UserFilters = defineAsyncComponent(() => import('./UserFilters.vue'))

优化二:定义热更新边界

// 在组件中明确告诉 Vite 如何处理更新
if (import.meta.hot) {
  // 1. 接受自身更新(默认行为)
  import.meta.hot.accept()
  
  // 2. 只接受某些依赖的更新
  import.meta.hot.accept(['./api.js', './utils.js'], (modules) => {
    console.log('API 或工具函数更新了')
    // 重新执行某些逻辑
  })
  
  // 3. 拒绝更新(某些模块不适合热更新)
  import.meta.hot.decline('./heavy-chart.js')
  
  // 4. 清理资源(更新前执行)
  import.meta.hot.dispose(() => {
    // 清理定时器、事件监听器等
    clearInterval(timer)
    window.removeEventListener('resize', handler)
  })
}

优化三:CSS 热更新优化

// vite.config.js
export default defineConfig({
  css: {
    // 开发时的 CSS 选项
    devSourcemap: false,  // 关闭 sourcemap,加快速度
    
    preprocessorOptions: {
      scss: {
        // 缓存编译结果
        implementation: 'sass',
        // 避免使用 fiber(会导致热更新慢)
        fiber: false,
        // 全局注入变量(只注入需要的)
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

优化四:使用更快的编译器

// vite.config.js
export default defineConfig({
  // 使用 esbuild 替代 tsc 进行 TypeScript 转译
  esbuild: {
    target: 'es2020',
    // 启用 esbuild 的 JSX 编译
    jsxFactory: 'h',
    jsxFragment: 'Fragment',
    // 排除不需要转译的文件
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules/
  },
  
  // 生产构建时才使用 TypeScript 检查
  plugins: [
    vue(),
    // 开发环境不检查类型,加快速度
    process.env.NODE_ENV === 'production' && tsChecker()
  ]
})

内存优化 - 让浏览器喘口气

为什么内存占用高?

内存占用主要来自:

  • 模块图:记录所有文件的依赖关系
  • 转换缓存:每个文件转换后的结果
  • sourcemap:调试用的映射信息
  • 浏览器缓存:编译后的代码

配置内存限制

// vite.config.js
export default defineConfig({
  server: {
    // 模块缓存限制
    moduleCache: {
      maxSize: 500  // 最多缓存 500 个模块
    },
    
    // 模块图清理间隔
    moduleGraph: {
      pruneInterval: 60000  // 每 60 秒清理一次未使用的模块
    }
  },
  
  // 开发环境关闭 sourcemap
  build: {
    sourcemap: false
  },
  
  // 限制处理的文件大小
  esbuild: {
    exclude: [/\.(png|jpe?g|gif|webp|mp4|webm|ogg|mp3|wav|flac|aac)$/]
  }
})

内存监控和自动清理

// 在 vite.config.js 中添加内存监控
export default defineConfig({
  plugins: [
    {
      name: 'memory-monitor',
      configureServer(server) {
        let timer = setInterval(() => {
          const used = process.memoryUsage().heapUsed / 1024 / 1024 / 1024
          
          if (used > 1.5) {  // 超过 1.5GB
            console.log(`🧹 内存使用 ${used.toFixed(2)}GB,正在清理...`)
            
            // 清理模块缓存
            server.moduleGraph.clear()
            
            // 强制垃圾回收(如果可用)
            if (global.gc) {
              global.gc()
            }
          }
        }, 60000)  // 每分钟检查一次
        
        // 服务器关闭时清理定时器
        server.httpServer?.on('close', () => {
          clearInterval(timer)
        })
      }
    }
  ]
})

一键优化配置模板

完整的优化配置

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { dependencies } from './package.json'

// 需要预构建的重型依赖
const heavyDeps = [
  'echarts',
  'ant-design-vue',
  'lodash-es',
  'xlsx',
  'monaco-editor',
  'd3',
  'three',
  '@company/ui',
  '@company/utils',
  '@company/charts'
]

// 不需要预构建的现代库
const esmDeps = ['vue', 'vue-router', 'pinia', 'vueuse']

export default defineConfig({
  plugins: [vue()],
  
  // 依赖优化
  optimizeDeps: {
    include: heavyDeps,
    exclude: esmDeps,
    // 使用 esbuild 加速
    esbuildOptions: {
      target: 'es2020',
      define: {
        'process.env.NODE_ENV': '"development"'
      }
    }
  },
  
  // 开发服务器配置
  server: {
    // 启用 HTTP/2 加速请求
    https: true,
    http2: true,
    
    // 文件监听优化
    watch: {
      ignored: [
        '**/node_modules/**',
        '**/dist/**',
        '**/.git/**',
        '**/.idea/**',
        '**/.vscode/**',
        '**/*.log',
        '**/coverage/**',
        '**/tests/**',
        '**/__tests__/**',
        '**/__mocks__/**'
      ]
    },
    
    // 内存优化
    moduleCache: {
      maxSize: 500
    },
    
    // 热更新优化
    hmr: {
      timeout: 5000,
      overlay: false  // 关闭错误覆盖,加快速度
    }
  },
  
  // 编译优化
  esbuild: {
    target: 'es2020',
    include: /\.(ts|jsx|tsx)$/,
    exclude: /node_modules|\.(png|jpe?g|gif|webp|mp4)$/,
    jsxFactory: 'h',
    jsxFragment: 'Fragment'
  },
  
  // CSS 优化
  css: {
    devSourcemap: false,
    preprocessorOptions: {
      scss: {
        implementation: 'sass',
        fiber: false,
        additionalData: `@import "@/styles/variables.scss";`
      }
    }
  }
})

NPM 脚本优化

{
  "scripts": {
    "dev": "vite",
    "dev:debug": "vite --debug",
    "dev:fresh": "rm -rf node_modules/.vite && vite",
    "dev:profile": "vite --profile",
    "build": "vite build",
    "preview": "vite preview",
    "analyze": "node scripts/analyze-deps.js"
  }
}

常见问题速查表

启动很慢

可能原因 解决方案
预构建太多 优化 include 配置
文件监听范围太大 配置 watch.ignored
依赖版本冲突 删除 node_modules 重装
磁盘 I/O 瓶颈 迁移到 SSD

热更新慢

可能原因 解决方案
模块图过大 拆分大组件
没有定义热更新边界 使用 import.meta.hot.accept()
CSS 编译慢 优化预处理器配置
浏览器卡顿 关闭不必要的扩展

内存占用高

可能原因 解决方案
缓存太多 限制 moduleCache.maxSize
没有垃圾回收 添加内存监控和清理
sourcemap 太大 关闭 devSourcemap
内存泄漏 检查插件和代码

优化检查清单

  • 使用 vite --debug 分析启动时间
  • 确认 include 包含所有重型依赖
  • 确认 exclude 排除了已优化的依赖
  • 优化文件监听范围
  • 拆分大文件为小组件
  • 使用虚拟列表处理长列表
  • 启用 HTTP/2
  • 监控内存使用
  • 配置合理的缓存策略

结语

记住:开发者的时间比机器的时间更宝贵。花一个小时优化开发环境,可能每天能为团队节省数小时的等待时间。这是性价比最高的投资之一。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南

作者 wuhen_n
2026年3月20日 10:53

前言

当我们的应用从开发环境走向生产环境,真正的挑战才刚刚开始。用户不会关心我们的代码写得多么优雅,他们只关心页面加载快不快、交互流不流畅。一个未经优化的生产构建,可能让我们的用户在第一秒就流失。

为什么要优化生产构建?

一个真实的反面教材

我们先来看一个系统打包后的产物:

dist/
├── index.html                5KB
├── assets/index.abc123.js    2.8MB  ← 一个文件包含了所有代码
├── assets/vendor.def456.js   1.2MB  ← 第三方库
├── assets/style.ghi789.css   180KB
└── images/
    ├── logo.png              120KB  ← 未压缩
    ├── banner.jpg            850KB  ← 巨大
    └── ...

当用户访问这个系统时:

  • 下载 2.8MB + 1.2MB + 180KB + 970KB = 约 5MB
  • 4G 网络下需要 2 秒;3G 网络会更慢
  • 用户早跑了

构建优化的核心目标

优化维度 目标 收益
拆包优化 分离业务代码和第三方库 利用浏览器缓存,二次访问提速
图片压缩 减少图片体积 平均减少 60-80% 体积
Gzip/Brotli 压缩文本资源 减少 70-90% 传输体积
长期缓存 文件名哈希,内容变化才更新 最大化缓存利用率

优化能带来什么?

指标 优化前 优化后 提升
首屏 JS 体积 4.2 MB 2.1 MB 50%
图片总体积 2.8 MB 0.6 MB 78%
传输体积(Gzip后) 3.2 MB 0.8 MB 75%
首次加载时间 3.2 秒 1.1 秒 65%
二次加载时间 2.1 秒 0.3 秒 85%

先诊断,后开药 - 构建分析工具

为什么要先分析?

就像医生看病要先做检查一样,优化构建也要先找到问题在哪。在主观上,我们可能会觉得是不是某个依赖太大了?但实际上可能是另一个我们没想到的库!

使用 rollup-plugin-visualizer 分析

安装

npm install --save-dev rollup-plugin-visualizer

配置

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

export default {
  plugins: [
    visualizer({
      filename: 'dist/stats.html',  // 输出文件
      open: true,                   // 构建后自动打开
      gzipSize: true,                // 显示 gzip 后大小
      brotliSize: true,              // 显示 brotli 后大小
      template: 'treemap'            // 图表类型: treemap, sunburst, network
    })
  ]
}

运行构建

npm run build
// 浏览器会自动打开一个酷炫的图表
// 一眼就能看出哪些文件最大

使用 vite-bundle-visualizer 分析

安装

npm install --save-dev vite-bundle-visualizer

运行分析

npx vite-bundle-visualizer

输出示例

┌───────────────────────┬─────────────┬──────────┬───────┐
│       Module          │    Size     │  Gzip    │ Brotli│
├───────────────────────┼─────────────┼──────────┼───────┤
│ node_modules/         │ 2.3 MB      │ 680 KB   │ 520 KB│
│   vue/                │ 680 KB      │ 210 KB   │ 160 KB│
│   element-plus/       │ 890 KB      │ 280 KB   │ 210 KB│
│   echarts/            │ 520 KB      │ 150 KB   │ 115 KB│
│   lodash-es/          │ 210 KB      │ 62 KB    │ 48 KB │
│ src/                  │ 1.8 MB      │ 480 KB   │ 360 KB│
└───────────────────────┴─────────────┴──────────┴───────┘

自定义分析脚本

// scripts/analyze.js
import fs from 'fs'
import path from 'path'
import { gzipSizeSync } from 'gzip-size'
import { brotliSizeSync } from 'brotli-size'

function analyzeDist() {
  const distDir = path.resolve('./dist/assets')
  const files = fs.readdirSync(distDir)
  
  let totalSize = 0
  let totalGzip = 0
  let totalBrotli = 0
  
  console.log('📦 构建产物分析\n')
  
  files
    .filter(f => f.endsWith('.js') || f.endsWith('.css'))
    .forEach(file => {
      const filePath = path.join(distDir, file)
      const content = fs.readFileSync(filePath)
      const size = content.length
      const gzip = gzipSizeSync(content)
      const brotli = brotliSizeSync(content)
      
      totalSize += size
      totalGzip += gzip
      totalBrotli += brotli
      
      console.log(`${file}:`)
      console.log(`  Raw:    ${(size / 1024).toFixed(2)} KB`)
      console.log(`  Gzip:   ${(gzip / 1024).toFixed(2)} KB (${(gzip/size*100).toFixed(0)}%)`)
      console.log(`  Brotli: ${(brotli / 1024).toFixed(2)} KB (${(brotli/size*100).toFixed(0)}%)\n`)
    })
  
  console.log('📊 总计:')
  console.log(`  Raw:    ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Gzip:   ${(totalGzip / 1024 / 1024).toFixed(2)} MB`)
  console.log(`  Brotli: ${(totalBrotli / 1024 / 1024).toFixed(2)} MB`)
}

analyzeDist()

看懂分析结果

分析结果能告诉我们什么?

1. 找出最大的依赖

  • echarts: 520KB → 考虑按需加载
  • monaco-editor: 2.8MB → 考虑动态导入

2. 找出重复的依赖

  • lodash 和 lodash-es 同时存在? → 统一用 lodash-es
  • moment 和 dayjs 同时存在? → 用 dayjs 替代 moment

3. 找出可以拆分的点

  • node_modules 打包在一起太大了 → 拆成多个 chunk
  • 所有页面代码都在一个文件里 → 按路由拆分

拆包策略 - 把大象放进冰箱

为什么要拆包?

用一个比喻来解释

不拆包:把所有东西都塞进一个行李箱
├─ 想拿牙刷 → 要翻遍整个箱子
├─ 箱子破了 → 所有东西都掉出来
└─ 箱子太大 → 搬不动

拆包:分成多个小包
├─ 洗漱包:牙刷、牙膏、毛巾
├─ 衣物包:衣服、裤子、袜子
├─ 电子包:充电器、数据线
├─ 哪个包破了 → 只损失那部分
└─ 每个包都很轻 → 好搬

技术层面的好处

不拆包:
├─ 修改一行代码 → 整个大文件缓存失效
└─ 用户每次更新都要重新下载所有代码

拆包后:
├─ 第三方库独立 → 几乎不变,长期缓存
├─ 业务代码拆分 → 只下载修改的部分
└─ 多个小文件可以并行下载

基础拆包配置

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 最基本的拆包策略
        manualChunks: {
          // 将 Vue 全家桶打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia', 'vuex'],
          
          // 将 UI 库打包在一起
          'vendor-ui': ['element-plus', '@element-plus/icons-vue', 'ant-design-vue'],
          
          // 将工具库打包在一起
          'vendor-utils': ['lodash-es', 'dayjs', 'axios', 'date-fns'],
          
          // 将图表库打包在一起
          'vendor-charts': ['echarts', 'd3', 'chart.js']
        }
      }
    }
  }
}

智能拆包:根据依赖关系自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          // node_modules 中的依赖
          if (id.includes('node_modules')) {
            // 按包名拆分
            if (id.includes('vue')) {
              return 'vendor-vue'  // 所有 vue 相关
            }
            
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'   // UI 库
            }
            
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts' // 图表库
            }
            
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'  // 工具库
            }
            
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'  // 编辑器单独打包
            }
            
            // 其他依赖打包在一起
            return 'vendor-other'
          }
          
          // 业务代码按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) {
              return `page-${match[1]}` // 按页面拆分
            }
          }
          
          // 公共组件按模块拆分
          if (id.includes('/src/components/')) {
            const match = id.match(/\/src\/components\/([^\/]+)/)
            if (match) {
              return `components-${match[1]}`
            }
          }
        }
      }
    }
  }
}

高级拆包:基于大小的自动拆分

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string, { getModuleInfo }) {
          // 如果模块大于 500KB,单独拆包
          const moduleInfo = getModuleInfo(id)
          if (moduleInfo && moduleInfo.code) {
            const size = Buffer.byteLength(moduleInfo.code, 'utf8')
            if (size > 500 * 1024) { // 500KB
              const name = id.match(/[^/]+\.(js|ts|vue)$/)?.[0]
              return `large-${name}`  // 大文件单独打包
            }
          }
          
          // 继续其他拆分逻辑
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
          }
        }
      }
    }
  }
}

异步 chunk 的命名优化

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 异步 chunk 命名
        chunkFileNames: 'assets/chunks/[name]-[hash].js',
        
        // 入口文件命名
        entryFileNames: 'assets/[name]-[hash].js',
        
        // 资源文件命名
        assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
        
        manualChunks: {
          // ... 拆包配置
        }
      }
    }
  }
}

// 输出结果:
// assets/index-abc123.js                (入口)
// assets/chunks/vendor-vue-def456.js    (Vue 相关)
// assets/chunks/page-dashboard-ghi789.js (页面)
// assets/images/logo-jkl012.png         (图片)

拆包后的效果

拆包方式 文件数量 缓存利用率 适用场景
不拆包 1个 极低 小项目
按依赖拆分 5-10个 中大型项目
按页面拆分 10-50个 较高 多页面应用
按大小拆分 可变 中等 有大文件的项目

图片压缩 - 看不见的优化

为什么图片是优化重点?

我们先来看一个典型的页面资源分布:

const pageResources = {
  js: '2.8MB (40%)',
  css: '180KB (3%)',
  images: '3.5MB (50%)',  // 图片占了一半!
  fonts: '500KB (7%)'
}

在页面中,图片通常占页面总体积的 50-70%,因此优化图片是最容易见效的!

vite-plugin-image-optimizer 配置

安装

npm install --save-dev vite-plugin-image-optimizer

配置

// vite.config.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'

export default {
  plugins: [
    ViteImageOptimizer({
      // 配置文件类型和压缩参数
      png: {
        quality: 80,  // PNG 质量 0-100
        compressionLevel: 9, // 压缩级别 0-9
      },
      jpeg: {
        quality: 75,  // JPEG 质量
        progressive: true, // 渐进式 JPEG
      },
      jpg: {
        quality: 75,
      },
      webp: {
        quality: 75,  // WebP 质量
        lossless: false, // 是否无损
      },
      avif: {
        quality: 60,  // AVIF 质量
        lossless: false,
      },
      svg: {
        // SVG 优化选项
        plugins: [
          {
            name: 'preset-default',
            params: {
              overrides: {
                removeViewBox: false, // 保留 viewBox
                cleanupIds: false,     // 保留 ID
              },
            },
          },
        ],
      },
      tiff: {
        quality: 70,
      },
      gif: {
        optimizationLevel: 3, // 优化级别 1-3
      },
    })
  ]
}

不同图片类型的优化策略

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 根据不同用途设置不同参数
      
      // 1. 图标类:需要清晰,适当压缩
      'src/assets/icons/**/*': {
        png: { quality: 90 },
        svg: { plugins: ['preset-default'] }
      },
      
      // 2. 背景图:可以牺牲一些质量换取体积
      'src/assets/backgrounds/**/*': {
        jpeg: { quality: 65 },
        webp: { quality: 60 }
      },
      
      // 3. 产品图:平衡质量和体积
      'src/assets/products/**/*': {
        jpeg: { quality: 80 },
        webp: { quality: 75 }
      },
      
      // 4. 用户上传:保持较好质量
      'src/assets/uploads/**/*': {
        jpeg: { quality: 85 },
        png: { quality: 85 }
      }
    })
  ]
}

使用现代图片格式

配置

// vite.config.ts
export default {
  plugins: [
    ViteImageOptimizer({
      // 生成 WebP 版本(浏览器支持更好)
      webp: {
        quality: 75
      },
      
      // 生成 AVIF 版本(压缩率更高)
      avif: {
        quality: 60
      }
    })
  ]
}

在组件中配合使用

<template>
  <!-- picture 元素让浏览器选择最佳格式 -->
  <picture>
    <!-- 现代浏览器优先使用 AVIF -->
    <source srcset="/image.avif" type="image/avif">
    <!-- 其次使用 WebP -->
    <source srcset="/image.webp" type="image/webp">
    <!-- 降级到 JPEG -->
    <img src="/image.jpg" alt="图片" loading="lazy">
  </picture>
</template>

懒加载与图片优化结合

<template>
  <img 
    v-lazy="optimizedImageUrl"
    :data-srcset="`
      ${smallImage} 400w,
      ${mediumImage} 800w,
      ${largeImage} 1200w
    `"
    sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
    loading="lazy"
    :alt="alt"
  >
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps<{ 
  imagePath: string,
  alt?: string 
}>()

// 根据视图宽度选择合适大小的图片
const optimizedImageUrl = computed(() => {
  // 假设构建时生成了不同尺寸的图片
  // logo-small.jpg, logo-medium.jpg, logo-large.jpg
  const width = typeof window !== 'undefined' ? window.innerWidth : 1200
  
  if (width < 600) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-small.$1')
  }
  if (width < 1200) {
    return props.imagePath.replace(/\.(jpg|png)$/, '-medium.$1')
  }
  return props.imagePath.replace(/\.(jpg|png)$/, '-large.$1')
})
</script>

图片优化的效果

图片类型 优化前 优化后 节省
PNG 图标 120KB 35KB 71%
JPG 产品图 850KB 180KB 79%
WebP 背景 650KB 110KB 83%
SVG 矢量 15KB 8KB 47%
总体积 2.8MB 0.6MB 78%

Gzip/Brotli 压缩 - 让传输更轻盈

什么是 Gzip/Brotli?

我们可以用快递来比喻,比如我们有一件很大的“羽绒服”要邮寄给浏览器:

  • 原始文件:一件羽绒服(很大,但很轻)
  • Gzip:真空压缩袋,把羽绒服压扁
  • Brotli:更好的真空压缩袋,压得更扁

当浏览器收到压缩后的文件,它只需要打开压缩袋,羽绒服(文件)就可以恢复原状!

压缩算法的对比

算法 压缩率 压缩速度 解压速度 浏览器支持
Gzip 中等 所有浏览器
Brotli 中等 现代浏览器 (92%)
Deflate 极快 极快 所有浏览器

相同文件对比

  • 原始 JS: 1000 KB
  • Gzip: 280 KB (72% 减少)
  • Brotli: 220 KB (78% 减少)
  • Brotli 比 Gzip 再减少 21% 体积

使用 vite-plugin-compression 配置

安装

npm install --save-dev vite-plugin-compression

配置

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240, // 10KB 以上才压缩
      deleteOriginFile: false, // 保留原文件
      verbose: true, // 输出压缩信息
      filter: /\.(js|css|html|svg)$/ // 只压缩文本文件
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240,
      deleteOriginFile: false,
      verbose: true,
      filter: /\.(js|css|html|svg)$/
    })
  ]
}

// 构建结果:
// index.abc123.js
// index.abc123.js.gz    (Gzip)
// index.abc123.js.br    (Brotli)

智能压缩策略 - 多算法混合策略

// vite.config.ts
import compression from 'vite-plugin-compression'

export default {
  plugins: [
    // 对不同的资源使用不同的策略
    
    // 1. HTML: 使用 Brotli(最高压缩率)
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.html$/,
      threshold: 1024
    }),
    
    // 2. JS/CSS: 同时生成 Gzip 和 Brotli
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 10240
    }),
    
    // 3. 大文件用 Brotli,小文件用 Gzip
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      filter: /\.(js|css)$/,
      threshold: 51200 // 50KB 以上用 Brotli
    }),
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      filter: /\.(js|css)$/,
      threshold: 10240, // 10-50KB 用 Gzip
      deleteOriginFile: true // 小文件可以删除原文件
    })
  ]
}

Nginx 配置示例

# nginx.conf
server {
  listen 80;
  server_name example.com;
  root /usr/share/nginx/html;
  
  # 开启 Gzip
  gzip on;
  gzip_vary on;
  gzip_min_length 10240;
  gzip_types text/plain text/css text/xml text/javascript 
             application/javascript application/x-javascript 
             application/xml application/json;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.1;
  
  # Brotli 支持(需要编译 brotli 模块)
  brotli on;
  brotli_min_length 10240;
  brotli_types text/plain text/css text/xml text/javascript 
               application/javascript application/x-javascript 
               application/xml application/json;
  brotli_comp_level 6;
  
  location / {
    try_files $uri $uri/ /index.html;
    
    # 尝试 Brotli,然后是 Gzip,最后是原始文件
    location ~* \.(js|css)$ {
      try_files $uri.br $uri.gz $uri =404;
      
      # 根据 Accept-Encoding 设置正确的 Content-Encoding
      if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
        add_header Content-Type $content_type;
      }
      if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
        add_header Content-Type $content_type;
      }
      
      # 长期缓存
      expires 1y;
      add_header Cache-Control "public, immutable";
      add_header Vary Accept-Encoding;
    }
    
    # 图片缓存
    location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
      expires 30d;
      add_header Cache-Control "public";
    }
  }
}

验证压缩效果

# 使用 curl 验证压缩

# 查看是否支持压缩
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js

# 响应头应该包含
Content-Encoding: br
Content-Type: application/javascript
Content-Length: 220000

# 下载并解压验证
curl -H "Accept-Encoding: br" https://example.com/app.js | brotli -d

# 或者使用 httpie
http https://example.com/app.js Accept-Encoding:br

长期缓存策略:让缓存最大化

文件名哈希的原理

// 构建后的文件名
// index.[hash].js

// 哈希是基于文件内容生成的
// 内容不变 → 哈希不变 → 缓存有效
// 内容变化 → 哈希变化 → 重新下载

dist/
├── index.abc123.js    // 哈希基于内容生成
├── index.def456.js    // 内容变化,哈希变化
├── vendor-vue.123abc.js // 第三方库几乎不变
└── vendor-ui.456def.js   // UI 库偶尔更新

配置文件名哈希

// vite.config.ts
export default {
  build: {
    rollupOptions: {
      output: {
        // 入口文件
        entryFileNames: 'assets/[name].[hash].js',
        
        // 异步 chunk
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        
        // 资源文件
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks: {
          // 稳定的第三方库单独打包(几乎不变)
          'vendor-stable': [
            'vue',
            'vue-router',
            'pinia',
            'vuex'
          ],
          
          // 可能更新的 UI 库单独打包
          'vendor-ui': [
            'element-plus',
            '@element-plus/icons-vue',
            'ant-design-vue'
          ],
          
          // 可能更新的工具库
          'vendor-utils': [
            'lodash-es',
            'dayjs',
            'axios'
          ]
        }
      }
    },
    
    // 生成 manifest.json
    manifest: true
  }
}

Nginx 缓存配置

# nginx.conf
server {
  # 静态资源缓存配置
  
  # JS/CSS 长期缓存(带 hash 的文件)
  location ~* \.(js|css)$ {
    # 匹配带 hash 的文件
    if ($uri ~* "\.[a-f0-9]{8,20}\.(js|css)$") {
      expires 1y;
      add_header Cache-Control "public, immutable";
    }
    
    # 如果不带 hash,短时间缓存
    expires 1h;
    add_header Cache-Control "public";
    
    # 尝试压缩版本
    try_files $uri.br $uri.gz $uri =404;
    add_header Vary Accept-Encoding;
  }
  
  # 图片等资源
  location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
    expires 30d;
    add_header Cache-Control "public";
  }
  
  # 字体文件
  location ~* \.(woff2?|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Access-Control-Allow-Origin "*";
  }
  
  # HTML 文件不缓存
  location ~* \.html$ {
    expires -1;
    add_header Cache-Control "no-cache, must-revalidate";
  }
}

Service Worker 缓存策略

// sw.js
const CACHE_NAME = 'v1'
const CACHE_URLS = [
  '/',
  '/index.html',
  '/manifest.json'
]

// 安装时缓存核心资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(CACHE_URLS))
  )
})

// 缓存策略:缓存优先,网络回退
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url)
  
  // 静态资源使用 Cache First 策略
  if (url.pathname.match(/\.(js|css|png|jpg|webp)$/)) {
    event.respondWith(
      caches.match(event.request)
        .then(response => {
          // 缓存命中直接返回
          if (response) return response
          
          // 未命中则请求网络并缓存
          return fetch(event.request).then(response => {
            const clone = response.clone()
            caches.open(CACHE_NAME).then(cache => {
              cache.put(event.request, clone)
            })
            return response
          })
        })
    )
  } 
  // HTML 使用 Network First 策略
  else if (url.pathname.endsWith('.html') || url.pathname === '/') {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone()
          caches.open(CACHE_NAME).then(cache => {
            cache.put(event.request, clone)
          })
          return response
        })
        .catch(() => caches.match(event.request))
    )
  }
})

缓存命中率的提升

文件类型 更新频率 缓存策略 命中率
vendor-vue.js 几乎不变 永久缓存 99%
vendor-ui.js 偶尔更新 永久缓存 92%
page-*.js 经常更新 永久缓存 65%
图片 很少更新 30天缓存 95%
字体 从不更新 永久缓存 99%

实战案例:一个中大型项目的构建优化

优化前的状态

// 项目信息
// - 页面数量:45 个
// - 组件数量:850 个
// - 第三方依赖:230 个
// - 图片数量:1200 张

// 构建产物
dist/ 总大小: 45 MB
├── js/      28 MB
├── css/     2.5 MB
├── images/  14 MB
└── others/  0.5 MB

// 性能指标
// - 构建时间:3 分 45 秒
// - 首屏体积:4.2 MB
// - 加载时间:3.2 秒

优化步骤

第一步:分析找出问题

# 运行分析
npx vite-bundle-visualizer

# 发现问题
echarts: 1.2MB        ← 太大
monaco-editor: 2.8MB  ← 巨大!
lodash-es: 210KB      ← 还好
moment: 450KB         ← 可以用 dayjs 替代

第二步:优化拆包

// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // 把 echarts 单独打包
            if (id.includes('echarts')) {
              return 'vendor-echarts'
            }
            
            // 把 monaco-editor 单独打包
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            
            // 其他分组
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus')) return 'vendor-ui'
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            
            return 'vendor-other'
          }
          
          // 按页面拆分
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    }
  }
}

第三步:图片压缩

// vite.config.js
export default {
  plugins: [
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    })
  ]
}

第四步:开启压缩

// vite.config.js
export default {
  plugins: [
    compression({
      algorithm: 'brotliCompress',
      threshold: 10240
    })
  ]
}

第五步:按需加载

// 大组件使用动态导入
const MonacoEditor = defineAsyncComponent(() => 
  import('monaco-editor')
)

// 路由懒加载
const routes = [
  {
    path: '/dashboard',
    component: () => import('./views/Dashboard.vue')  // 按需加载
  }
]

优化后的结果

指标 优化前 优化后 提升
构建时间 3 分 45 秒 2 分 20 秒 38%
总大小 45 MB 18 MB 60%
首屏 JS 体积 4.2 MB 1.8 MB 57%
图片体积 14 MB 3.5 MB 75%
传输体积 3.2 MB 0.8 MB 75%
加载时间 3.2 秒 1.1 秒 65%

常见问题与解决方案

问题一:拆包过多导致请求数爆炸

// ❌ 错误:拆得太细
manualChunks(id) {
  // 每个依赖都单独打包
  return id.match(/node_modules\/([^\/]+)/)?.[1]
}
// 结果:产生 200+ 个文件,HTTP/1.1 下性能差

// ✅ 正确:合理分组
manualChunks(id) {
  if (id.includes('node_modules')) {
    if (id.includes('vue')) return 'vendor-vue'
    if (id.includes('lodash')) return 'vendor-utils'
    if (id.includes('echarts')) return 'vendor-charts'
    if (id.includes('monaco')) return 'vendor-monaco'
    return 'vendor-other' // 其他合并
  }
}

问题二:图片压缩后质量下降

// 解决方案:选择性压缩
ViteImageOptimizer({
  // 图标保留较高品质
  'src/assets/icons/**/*': {
    png: { quality: 90 },
    svg: { plugins: ['preset-default'] }
  },
  
  // 背景图可以接受较低品质
  'src/assets/backgrounds/**/*': {
    jpeg: { quality: 65 },
    webp: { quality: 60 }
  },
  
  // 产品图需要平衡
  'src/assets/products/**/*': {
    jpeg: { quality: 80 },
    webp: { quality: 75 }
  }
})

// 或者使用图片 CDN 动态处理
<img src="https://cdn.example.com/image.jpg?x-oss-process=image/resize,w_400/quality,q_80">

问题三:Brotli 压缩太慢

// ✅ 解决方案:选择性使用 Brotli
compression({
  algorithm: 'brotliCompress',
  threshold: 50000,  // 50KB 以上才用 Brotli
  filter: /\.(js|css)$/
})

// 小文件继续用 Gzip
compression({
  algorithm: 'gzip',
  threshold: 10240,  // 10-50KB 用 Gzip
  filter: /\.(js|css)$/
})

问题四:CDN 不支持 Brotli

# ✅ 解决方案:同时生成 Gzip 和 Brotli
location /assets {
    # 优先尝试 Brotli
    try_files $uri.br $uri.gz $uri =404;
    
    # 根据 Accept-Encoding 返回正确的 Content-Encoding
    if ($http_accept_encoding ~* br) {
        add_header Content-Encoding br;
    }
    if ($http_accept_encoding ~* gzip) {
        add_header Content-Encoding gzip;
    }
}

生产环境优化的最佳实践

优化检查清单

  • 使用 visualizer 分析构建产物
  • 配置 manualChunks 合理拆包
  • 图片资源压缩优化
  • 启用 Gzip/Brotli 压缩
  • 配置长期缓存策略
  • 设置性能预算
  • 在 CI/CD 中集成检查
  • 定期监控 Web Vitals

配置文件模板

// vite.config.ts - 生产环境优化完整配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import compression from 'vite-plugin-compression'

export default defineConfig(({ mode }) => ({
  plugins: [
    vue(),
    
    // 图片压缩
    ViteImageOptimizer({
      png: { quality: 75 },
      jpeg: { quality: 70 },
      webp: { quality: 70 },
      avif: { quality: 60 }
    }),
    
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz',
      threshold: 10240
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br',
      threshold: 10240
    }),
    
    // 构建分析(只在需要时开启)
    process.env.ANALYZE && visualizer({
      open: true,
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true
    })
  ].filter(Boolean),
  
  build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: mode === 'production',
        drop_debugger: true
      }
    },
    
    rollupOptions: {
      output: {
        entryFileNames: 'assets/[name].[hash].js',
        chunkFileNames: 'assets/chunks/[name].[hash].js',
        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
        
        manualChunks(id) {
          if (id.includes('node_modules')) {
            if (id.includes('vue')) return 'vendor-vue'
            if (id.includes('element-plus') || id.includes('antd')) {
              return 'vendor-ui'
            }
            if (id.includes('echarts') || id.includes('d3')) {
              return 'vendor-charts'
            }
            if (id.includes('lodash') || id.includes('dayjs')) {
              return 'vendor-utils'
            }
            if (id.includes('monaco-editor')) {
              return 'vendor-monaco'
            }
            return 'vendor-other'
          }
          
          if (id.includes('/src/views/')) {
            const match = id.match(/\/src\/views\/([^\/]+)/)
            if (match) return `page-${match[1]}`
          }
        }
      }
    },
    
    chunkSizeWarningLimit: 500,
    sourcemap: mode !== 'production',
    manifest: true
  }
}))

性能目标参考

指标 优秀 一般
首屏 JS 体积 < 200KB 200-500KB > 500KB
总构建体积 < 2MB 2-5MB > 5MB
图片体积占比 < 30% 30-50% > 50%
压缩率 > 70% 50-70% < 50%
缓存命中率 > 80% 50-80% < 50%
FCP < 1.5s 1.5-2.5s > 2.5s
LCP < 2.5s 2.5-4s > 4s

三个核心原则

  1. 测量优先:没有数据的优化是盲目的
  2. 渐进改进:每次只优化一个指标
  3. 用户优先:始终以用户体验为导向

结语

优化的终极目标是让用户感受不到加载的存在。当用户打开我们的应用时,内容瞬间呈现,交互立即响应,这就说明我们的优化成功了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

❌
❌