普通视图

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

异步组件与 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
  • 监控内存使用
  • 配置合理的缓存策略

结语

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

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

Vite 核心原理:ESM 带来的开发时“瞬移”体验

作者 wuhen_n
2026年3月19日 10:18

前言

还记得用Webpack开发时的日常吗? 控制台输入 npm run dev ,等待 30 秒后项目终于启动了 ;过了一会儿,修改了一个文件,保存,等待 10 秒之后热更新完成;后来项目变大了,每次保存要等 20 秒以上...

这是 Webpack 时代的真实写照,而 Vite 的出现,彻底改变了这一切: 控制台输入 npm run dev ,1 秒后项目就启动了;修改了一个文件,保存,50ms 页面就更新了。

Vite是怎么做到的? 它不是魔法,而是巧妙地利用了现代浏览器的原生能力。本文将从最基础的概念讲起,带领我们一步步理解 Vite 的核心原理。

为什么传统构建工具这么慢?

Webpack的工作方式

Webpack 就像我们去参加宴席,必须要等酒店把所有的菜品都准备好,再一次性全部端上来;如果有一道菜没做好,我们就全部得等着:

Webpack的打包过程:
1. 找到入口文件 (main.js)
2. 解析import语句,找出所有依赖
3. 递归解析所有依赖的依赖
4. 把所有文件打包成一个bundle.js
5. 启动开发服务器
6. 浏览器加载bundle.js

随着项目越大,依赖越多,打包就会越慢。

为什么Webpack会越来越慢?

假如我们有这样一个项目结构:

project
├── vue (100个文件)
├── vue-router (50个文件)
├── pinia (30个文件)
├── element-plus (500个文件)
├── 你自己的组件 (200个文件)
└── 各种第三方库 (300个文件)

Webpack 启动时要处理 1180 个文件,并全部打包成一个文件,才能启动开发服务器。

ESM 基础:现代浏览器的模块系统

什么是ES Module?

在 ES Module 出现之前,我们是这样引入 JavaScript 的:

<!-- 老方式:必须按顺序,否则报错 -->
<script src="jquery.js"></script>
<script src="lodash.js"></script>
<script src="app.js"></script>

有了 ES Module 之后,我们可以这样写:

<script type="module">
  // 浏览器会自动加载这些依赖
  import $ from 'https://unpkg.com/jquery'
  import _ from 'https://unpkg.com/lodash'
  import app from './app.js'
</script>

浏览器如何加载ES Module?

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

当浏览器遇到这个脚本时,会进行以下操作:

第1步:下载 main.js
     ↓
第2步:解析 main.js,发现需要 vue、App.vue、router
     ↓
第3步:同时下载 vue、App.vue、router (并行下载)
     ↓
第4步:解析 router.js,发现新的依赖
     ↓
第5步:继续下载新的依赖
     ↓
直到所有依赖都加载完成

而且,浏览器可以并行下载多个文件,互不影响。

ESM的核心特性

特性1:静态导入(编译时确定依赖)

import { ref } from 'vue'  // 打包工具可以静态分析

特性2:动态导入(运行时加载)

if (user.isAdmin) {
  const adminPanel = await import('./AdminPanel.vue')
  // 只有在需要时才加载
}

特性3:模块作用域

// a.js
const name = 'module-a'
export { name }

// b.js
const name = 'module-b'  // 同名变量,互不干扰
export { name }

Vite 的核心思想 - 让浏览器做它擅长的事

Vite 的开发服务器

Vite 的开发服务器做了什么?

// 简化的Vite服务器
class ViteDevServer {
  constructor() {
    this.app = require('koa')()  // HTTP服务器
    this.watcher = require('chokidar').watch('src')  // 文件监听
  }
  
  async start() {
    // 1. 启动HTTP服务器
    this.app.listen(3000)
    
    // 2. 注册中间件
    this.app.use(this.transformMiddleware())
    
    // 3. 开始监听文件变化
    this.watcher.on('change', this.handleFileChange.bind(this))
  }
  
  // 处理文件请求
  async transformMiddleware(ctx, next) {
    if (ctx.path.endsWith('.vue')) {
      // 当浏览器请求 .vue 文件时,才进行编译
      const code = await compileVueFile(ctx.path)
      ctx.body = code
    }
  }
}

Vite的启动流程

传统方式(Webpack):
启动 → 打包所有文件 → 启动服务器 → 浏览器请求 → 返回打包后的文件

Vite方式:
启动 → 启动服务器 → 浏览器请求 → 按需编译 → 返回单个文件

还是用餐厅来比喻:

  • Webpack:客人来之前做好所有菜;如果菜没做好,所有客人都得等着
  • Vite:客人点一道,做一道;做好一道,上一道

一个完整的请求流程

假设我们的项目结构是这样的:

src/
├── main.js
├── App.vue
└── components/
    └── HelloWorld.vue

浏览器访问页面的过程如下:

// 第1步:浏览器请求 index.html
GET /index.html

// index.html 内容
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/src/main.js"></script>
  </head>
</html>

// 第2步:浏览器发现需要 main.js
GET /src/main.js

// main.js 内容
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

// 第3步:浏览器发现需要 vue 和 App.vue
GET /@modules/vue  // Vite 特殊处理
GET /src/App.vue

// 第4步:App.vue 中又引用了 HelloWorld.vue
GET /src/components/HelloWorld.vue

// 第5步:全部加载完成,页面显示

依赖预构建 - 解决性能瓶颈

如果没有预构建,会有什么问题?

问题1:CommonJS 模块无法在浏览器直接运行

import _ from 'lodash'  // lodash 是 CommonJS 格式,浏览器不认识

问题2:大量小文件请求

import { debounce } from 'lodash-es'
// lodash-es 有 600 多个文件!
// 浏览器要发 600 多个请求!

问题3:深度嵌套的依赖

import A from 'package-a'
// package-a 依赖 package-b
// package-b 依赖 package-c
// 每个包都要单独请求

预构建做了什么?

  1. 扫描项目中的所有 import
  2. 找出第三方依赖(不是相对路径的)
  3. esbuild 打包成单个文件
  4. 存到 node_modules/.vite/
  5. 下次直接使用打包后的文件

esbuild 为什么这么快?

  1. 用 Go 语言写的(直接编译成机器码)
  2. 充分利用 CPU 多核
  3. 一切从零设计,没有历史包袱
  4. 高度并行化

热更新 - 瞬间响应的秘密

热更新模式

修改代码 → 页面自动更新 → 状态保持不变 → 继续工作

热更新的工作原理

我们修改了一个文件
    ↓
Vite 监听到文件变化
    ↓
重新编译这个文件
    ↓
通过 WebSocket 通知浏览器
    ↓
浏览器请求更新的文件
    ↓
执行热更新回调
    ↓
页面局部更新,状态保留

WebSocket 通信

// 服务器端
class HMRServer {
  constructor(server) {
    // 创建 WebSocket 服务
    this.ws = new WebSocket.Server({ server })
    
    // 所有连接的客户端
    this.clients = new Set()
    
    this.ws.on('connection', (socket) => {
      this.clients.add(socket)
      
      socket.on('close', () => {
        this.clients.delete(socket)
      })
    })
  }
  
  // 文件变化时通知所有客户端
  sendUpdate(file) {
    const message = JSON.stringify({
      type: 'update',
      file: file,
      timestamp: Date.now()
    })
    
    this.clients.forEach(client => {
      client.send(message)
    })
  }
}

// 浏览器端
const socket = new WebSocket(`ws://${location.host}`)

socket.onmessage = async ({ data }) => {
  const { type, file, timestamp } = JSON.parse(data)
  
  if (type === 'update') {
    // 重新加载修改的文件
    const module = await import(`${file}?t=${timestamp}`)
    
    // 执行热更新
    if (import.meta.hot) {
      import.meta.hot.accept(file, module)
    }
  }
}

Vue 组件的热更新

// Vue 组件的热更新实现
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 更新组件
    const { render, data } = newModule
    
    // 保留当前组件的状态
    const oldData = instance.data
    
    // 应用新的渲染函数
    instance.render = render
    
    // 重新渲染
    instance.update()
  })
}

插件系统:Vite 的扩展能力

插件的工作流程

请求进入
    ↓
resolveId(解析模块 ID)
    ↓
load(加载模块内容)
    ↓
transform(转换代码)
    ↓
返回给浏览器

插件的钩子函数

// 一个完整的 Vite 插件
const myPlugin = {
  name: 'vite:my-plugin',
  
  // 构建阶段钩子
  options(options) {
    // 修改或扩展配置
    return options
  },
  
  buildStart() {
    // 构建开始时调用
    console.log('构建开始')
  },
  
  // 解析模块 ID
  resolveId(source, importer) {
    if (source === 'virtual-module') {
      return '\0virtual-module' // \0 标记为虚拟模块
    }
  },
  
  // 加载模块
  load(id) {
    if (id === '\0virtual-module') {
      return 'export default "virtual module content"'
    }
  },
  
  // 转换代码
  async transform(code, id) {
    if (id.endsWith('.special')) {
      // 转换特殊文件格式
      const result = await compileSpecial(code)
      return {
        code: result.js,
        map: result.sourcemap
      }
    }
  },
  
  // 配置解析完成后
  configResolved(config) {
    console.log('配置已解析', config)
  },
  
  // 热更新处理
  handleHotUpdate(ctx) {
    // 自定义热更新逻辑
  },
  
  // 构建结束
  buildEnd() {
    console.log('构建结束')
  },
  
  // 关闭服务
  closeBundle() {
    console.log('服务关闭')
  }
}

常用插件示例

// 环境变量注入插件
function injectEnvPlugin(env: Record<string, string>) {
  return {
    name: 'vite:inject-env',
    
    transform(code, id) {
      if (id.includes('node_modules')) return
      
      // 替换环境变量
      return code.replace(
        /import\.meta\.env\.(\w+)/g,
        (_, key) => JSON.stringify(env[key])
      )
    }
  }
}

// 文件大小监控插件
function sizeMonitorPlugin() {
  return {
    name: 'vite:size-monitor',
    
    generateBundle(_, bundle) {
      Object.entries(bundle).forEach(([name, asset]) => {
        if (asset.type === 'chunk') {
          const size = asset.code.length
          const kb = (size / 1024).toFixed(2)
          
          if (size > 100 * 1024) {
            console.warn(`⚠️ 大文件警告: ${name} (${kb}KB)`)
          } else {
            console.log(`✅ ${name}: ${kb}KB`)
          }
        }
      })
    }
  }
}

Vite vs Webpack

启动时间对比

项目规模 Webpack Vite 差距
小项目(50组件) 8.5秒 1.2秒 Vite快7倍
中项目(200组件) 22秒 2.1秒 Vite快10倍
大项目(1000组件) 58秒 3.8秒 Vite快15倍

热更新时间对比

操作 Webpack Vite 差距
修改一个组件 2.8秒 45ms Vite快62倍
修改CSS 1.5秒 8ms Vite快187倍
保存后恢复 3.1秒 60ms Vite快52倍

资源消耗对比

指标 Webpack Vite 差距
CPU占用 45% 18% 降低60%
内存占用 1.8GB 420MB 降低77%
电池消耗 延长2-3倍

常见问题与优化技巧

问题一:依赖预构建失效

修改了 node_modules 里的代码,但是不生效:

解决方案1:强制重新预构建

// vite.config.ts
export default {
  optimizeDeps: {
    // 强制重新预构建
    force: true
  }
}

解决方案2:删除缓存目录

$ rm -rf node_modules/.vite

解决方案3:重启开发服务器

npm run dev

问题二:热更新不生效

修改了文件,但页面不更新,可以按以下步骤排查:

步骤1:检查 WebSocket 连接

打开浏览器控制台,看是否有 WebSocket 连接。

步骤2:检查文件监听配置

export default {
  server: {
    watch: {
      // 确保没有忽略我们的文件
      ignored: ['!**/node_modules/**']
    }
  }
}

步骤3:手动触发更新

if (import.meta.hot) {
  import.meta.hot.accept()
}

问题三:首次加载慢

第一次打开页面要等很久。

解决方案:预加载关键路由

export default {
  optimizeDeps: {
    include: [
      // 预构建这些依赖
      'vue',
      'vue-router',
      'pinia',
      // 你的常用组件
      'src/components/Button.vue',
      'src/components/Modal.vue'
    ]
  }
}

问题四:内存占用过高

// vite.config.ts
export default {
  server: {
    // 限制缓存大小
    moduleCache: {
      maxSize: 100 * 1024 * 1024 // 100MB
    },
    
    // 清理未使用的模块
    moduleGraph: {
      pruneInterval: 60000 // 每 60 秒清理一次
    }
  }
}

Vite 的最佳实践

Vite 配置文件模板

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

export default defineConfig({
  // 插件
  plugins: [vue()],
  
  // 开发服务器配置
  server: {
    port: 3000,
    open: true,  // 自动打开浏览器
    proxy: {
      '/api': 'http://localhost:8080'  // 代理
    }
  },
  
  // 构建配置
  build: {
    target: 'es2020',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: true
  },
  
  // 依赖优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia']
  },
  
  // 别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
})

性能优化清单

  • 依赖预构建:配置 optimizeDeps.include 预构建常用依赖
  • 路由懒加载:使用动态 import() 分割代码
  • 图片优化:使用 vite-plugin-image-optimizer
  • CSS 提取:生产环境提取独立 CSS 文件
  • Gzip 压缩:使用 vite-plugin-compression

学习要点

  1. 理解 ESM 的核心特性:静态导入、模块作用域、浏览器加载机制
  2. 掌握依赖预构建的作用:解决 CommonJS 兼容性、减少请求数
  3. 熟悉热更新的工作流程:WebSocket 通信、模块边界、HMR API
  4. 学会编写 Vite 插件:钩子函数、虚拟模块、代码转换
  5. 能够诊断和优化性能问题:预构建失效、热更新慢、内存占用高

结语

Vite 的出现,标志着前端构建工具从打包时代进入了原生 ESM 时代。理解它的核心原理,不仅能让我们更高效地使用它,更能让我们对现代前端开发有更深的理解。

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

生产环境极致优化:拆包、图片压缩、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. 用户优先:始终以用户体验为导向

结语

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

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

❌
❌