Vue生态对比

32 分钟

为什么要做 Vue 生态的横向对比

Vue 生态在过去几年经历了一次整体升级:响应式系统从 Object.defineProperty 迁移到 Proxy,状态管理从 Vuex 过渡到 Pinia,构建工具从 Webpack 转向 Vite,组件编写方式从 Options API 扩展出 Composition API。面试中,这些对比几乎是必考题,但很多回答停留在"Vue3 更快、Pinia 更好用"的层面,缺乏对底层差异和选型依据的理解。

这篇文章按四组对比展开,每组讲清楚差异的根源、实际影响和选型建议。

Vue2 vs Vue3

响应式系统:defineProperty → Proxy

Vue2 的响应式基于 Object.defineProperty,在 data 初始化时递归遍历所有属性,为每个属性添加 getter/setter。这个方案有几个硬伤:

  • 无法检测属性的新增和删除this.obj.newKey = 1 不会触发更新,必须用 Vue.set()
  • 数组变异方法需要特殊处理。Vue2 重写了 pushpopsplice 等 7 个方法,通过下标直接赋值(arr[0] = 'x')不会触发响应。
  • 初始化性能开销大。深层嵌套对象在初始化时就要递归遍历,即使有些属性在整个生命周期内从未被访问。

Vue3 换用 Proxy,在访问时才递归创建深层代理(惰性代理),从根本上解决了这些问题:

// Vue2 — 新增属性不响应
this.user.age = 25 // ❌ 不触发更新
Vue.set(this.user, 'age', 25) // ✅

// Vue3 — Proxy 天然支持
state.user.age = 25 // ✅ 直接触发更新

性能差异:Vue3 的惰性代理意味着一个包含 1000 个属性的对象,如果只用到了其中 10 个,只有这 10 个会被代理。Vue2 会在初始化时遍历全部 1000 个。

兼容性代价:Proxy 不支持 IE11,这也是 Vue3 放弃 IE11 支持的根本原因。

Composition API 与生命周期变化

Vue3 引入了 setup() 函数和 <script setup> 语法糖,生命周期钩子也做了调整:

Vue2Vue3(Options)Vue3(Composition)
beforeCreatebeforeCreatesetup() 本身
createdcreatedsetup() 本身
beforeMountbeforeMountonBeforeMount
mountedmountedonMounted
beforeUpdatebeforeUpdateonBeforeUpdate
updatedupdatedonUpdated
beforeDestroybeforeUnmountonBeforeUnmount
destroyedunmountedonUnmounted

两个关键变化:destroyed 改名为 unmounted(语义更准确),setup() 替代了 beforeCreate + created

Fragment、Teleport、Suspense

Fragment:Vue2 的模板必须有一个根节点,Vue3 支持多根节点。底层原因是 Vue3 的虚拟 DOM 支持 Fragment 类型的 VNode。

<!-- Vue2 必须包一层 -->
<template>
  <div>
    <header />
    <main />
  </div>
</template>

<!-- Vue3 直接多根 -->
<template>
  <header />
  <main />
</template>

Teleport:将组件的 DOM 渲染到指定的目标节点下,典型场景是 Modal、Toast 这类需要挂载到 body 下的组件。Vue2 需要依赖第三方库 portal-vue 实现类似功能。

<Teleport to="body">
  <div class="modal">弹窗内容</div>
</Teleport>

Suspense(实验性):为异步组件提供 loading 状态管理。配合 async setup() 使用,在异步数据加载完成前展示 fallback 内容。

性能提升

Vue3 的性能优化不只是换了 Proxy,编译器层面做了大量工作:

  • 静态提升(Static Hoisting):模板中不会变化的静态节点,在编译阶段提升到 render 函数外部,避免每次渲染都重新创建 VNode。
  • Patch Flags:编译器在生成 VNode 时标记动态绑定的类型(文本、class、style、props 等),diff 时只对比标记的部分,跳过静态内容。
  • Block Tree:将动态节点收集到 Block 中,diff 时直接遍历 Block 内的动态节点,不再需要全量遍历整棵 VNode 树。
  • 事件缓存:内联事件处理函数会被缓存,避免因函数引用变化导致子组件不必要的更新。
<template>
  <div>
    <p>静态文本,永远不变</p>
    <p>{{ dynamicText }}</p>
  </div>
</template>

编译后,<p>静态文本,永远不变</p> 对应的 VNode 会被提升为常量,diff 时直接跳过。{{ dynamicText }} 所在节点会带上 PatchFlag.TEXT,diff 时只检查文本内容是否变化。

Tree-shaking 支持

Vue2 的全局 API 挂在 Vue 构造函数上(Vue.nextTickVue.observable 等),打包时无法被 tree-shake 掉。

Vue3 将所有 API 改为具名导出:

// Vue2 — 全局挂载,无法 tree-shake
import Vue from 'vue'
Vue.nextTick(() => {})

// Vue3 — 按需导入,未使用的 API 会被 tree-shake
import { nextTick, ref, computed } from 'vue'
nextTick(() => {})

这对库体积影响显著。一个只用了 refcomputed 的项目,打包后不会包含 TeleportSuspense 等未使用的特性代码。

TypeScript 支持

Vue2 的 Options API 对 TS 的支持依赖 vue-class-componentvue-property-decorator,类型推断不完整,this 的类型在 mixins 场景下经常丢失。

Vue3 从设计之初就用 TypeScript 重写,Composition API 天然适配 TS:

// Vue3 + Composition API,类型推断完整
const count = ref(0) // Ref<number>
const doubled = computed(() => count.value * 2) // ComputedRef<number>

Vue2 vs Vue3 对比总结

维度Vue2Vue3
响应式Object.defineProperty,递归遍历Proxy,惰性代理
新增属性需要 Vue.set()直接赋值即可
模板根节点必须单根支持 Fragment
API 导出全局挂载具名导出,支持 tree-shaking
TS 支持需要装饰器,推断不完整原生支持,推断完整
IE11支持不支持
包体积较大(全量引入)更小(按需引入)
Diff 算法全量遍历 VNode 树Patch Flags + Block Tree

Options API vs Composition API

代码组织方式的根本差异

Options API 按选项类型组织代码:datacomputedmethodswatch 各归各位。组件简单时结构清晰,但当一个组件同时处理多个功能(比如搜索、分页、筛选)时,同一个功能的逻辑被打散到不同选项中。

Composition API 按功能组织代码:一个功能的响应式状态、计算属性、方法和侦听器写在一起,可以抽取为独立的 composable 函数。

<!-- Options API:搜索和分页逻辑交错分布 -->
<script>
export default {
  data() {
    return {
      searchQuery: '',
      searchResults: [],
      currentPage: 1,
      pageSize: 10,
      totalCount: 0,
    }
  },
  computed: {
    totalPages() {
      return Math.ceil(this.totalCount / this.pageSize)
    },
    filteredResults() {
      return this.searchResults.filter(/* ... */)
    },
  },
  methods: {
    async search() { /* 搜索逻辑 */ },
    goToPage(page) { /* 分页逻辑 */ },
  },
  watch: {
    searchQuery: 'search',
    currentPage: 'search',
  },
}
</script>
// Composition API:搜索和分页各自封装
function useSearch() {
  const query = ref('')
  const results = ref([])

  async function search() { /* 搜索逻辑 */ }
  watch(query, search)

  return { query, results, search }
}

function usePagination() {
  const currentPage = ref(1)
  const pageSize = ref(10)
  const totalCount = ref(0)
  const totalPages = computed(() =>
    Math.ceil(totalCount.value / pageSize.value)
  )

  function goToPage(page: number) { currentPage.value = page }

  return { currentPage, pageSize, totalCount, totalPages, goToPage }
}

逻辑复用:Mixins vs Composables

Options API 时代,逻辑复用靠 Mixins。Mixins 有三个难以回避的问题:

  1. 命名冲突:多个 mixin 定义了同名属性时,合并策略不透明。
  2. 来源不清:模板中用到的某个变量来自哪个 mixin,只能靠全局搜索。
  3. 隐式依赖:mixin 可能依赖组件上的特定 data 或 method,但没有类型约束。

Composable 函数解决了这三个问题:

// composable 的返回值是显式的
const { query, results, search } = useSearch()
const { currentPage, totalPages, goToPage } = usePagination()

// 命名冲突?重命名解构即可
const { query: searchQuery } = useSearch()
const { query: filterQuery } = useFilter()

TypeScript 支持对比

Options API 中 this 的类型推断是 Vue 内部通过 ThisTypeComponentPublicInstance 实现的。大多数情况下能工作,但遇到 Mixins、$refs 和复杂泛型组件时经常断裂。

Composition API 中不存在 this,所有状态都是普通的变量和函数,TypeScript 的标准类型推断就能覆盖全部场景。

何时用哪个

场景推荐原因
简单展示型组件(< 100 行)Options API 或 <script setup> 均可差异不大,团队统一即可
包含多个独立功能的复杂组件Composition API逻辑拆分和复用更清晰
需要跨组件复用逻辑Composition API + Composables替代 Mixins,无命名冲突
团队 TypeScript 深度使用Composition API类型推断完整
维护 Vue2 存量项目Options API保持一致性,避免混用增加认知负担
Vue3 新项目<script setup> + Composition API官方推荐,社区生态主流

核心结论:Composition API 不是 Options API 的替代品,而是复杂场景下的更优解。Vue3 同时支持两种写法,选型取决于项目复杂度和团队习惯。但新项目建议直接使用 <script setup> + Composition API,这是 Vue 社区的主流方向。

Vuex vs Pinia

架构差异:从单一 Store 到多 Store

Vuex 采用单一状态树(Single State Tree),所有模块挂在一个根 Store 下,通过 modules 拆分命名空间。这种设计在大型项目中导致了几个问题:模块注册繁琐、命名空间字符串容易写错、跨模块访问需要 rootStaterootGetters

Pinia 抛弃了单一状态树,每个 Store 都是独立定义、独立注册的。不需要手动注册到根 Store,导入即用:

// Vuex — 模块注册
const store = createStore({
  modules: {
    user: {
      namespaced: true,
      state: () => ({ name: '', token: '' }),
      mutations: { SET_NAME(state, name) { state.name = name } },
      actions: {
        async login({ commit }, credentials) {
          const { name } = await api.login(credentials)
          commit('SET_NAME', name)
        },
      },
    },
    cart: { /* ... */ },
  },
})

// 组件中使用
this.$store.dispatch('user/login', credentials)
this.$store.state.user.name
// Pinia — 独立 Store
export const useUserStore = defineStore('user', () => {
  const name = ref('')
  const token = ref('')

  async function login(credentials: Credentials) {
    const result = await api.login(credentials)
    name.value = result.name
  }

  return { name, token, login }
})

// 组件中使用
const userStore = useUserStore()
userStore.login(credentials)
userStore.name

直观差异:Pinia 的 Store 就是一个 composable 函数,没有嵌套层级,没有命名空间字符串。

Mutations 的去除

Vuex 要求所有状态修改必须通过 Mutation 完成,Action 中不能直接修改 State,只能 commit Mutation。这个设计的初衷是让 DevTools 能追踪每次状态变更,但实际开发中带来了大量样板代码:一个简单的赋值操作需要定义 Mutation 常量、Mutation 函数、Action 函数三层。

Pinia 移除了 Mutations 这一层。状态修改直接在 Action 中完成,DevTools 同样能追踪变更:

// Vuex:修改一个字段需要三层
// 1. mutation-types.ts
export const SET_LOADING = 'SET_LOADING'

// 2. mutations.ts
[SET_LOADING](state, loading) { state.loading = loading }

// 3. actions.ts
setLoading({ commit }, loading) { commit(SET_LOADING, loading) }

// Pinia:直接修改
const store = useAppStore()
store.loading = true
// 或在 action 中
function setLoading(value: boolean) { loading.value = value }

对于批量修改多个状态字段,Pinia 提供了 $patch

userStore.$patch({
  name: 'Alice',
  token: 'xxx',
})

// 也支持函数式写法
userStore.$patch((state) => {
  state.items.push(newItem)
  state.totalCount++
})

TypeScript 支持

Vuex 的类型支持一直是痛点。store.state.user.name 的类型推断需要手动声明 RootState 和各模块的类型,dispatchcommit 的参数类型几乎无法自动推断:

// Vuex — 需要手动声明大量类型
interface RootState {
  user: UserState
  cart: CartState
}
// dispatch 的 payload 类型?靠开发者自觉
this.$store.dispatch('user/login', credentials) // payload 是 any

Pinia 的类型推断是自动的,因为 Store 就是普通的 TypeScript 函数:

// Pinia — 类型全自动推断
const userStore = useUserStore()
userStore.name // string
userStore.login(credentials) // 参数类型由函数签名决定

模块化对比

维度VuexPinia
模块定义嵌套在根 Store 的 modules 中独立的 defineStore,文件级隔离
命名空间namespaced: true + 字符串路径无需命名空间,Store ID 即标识
跨 Store 访问rootStaterootGetters直接 import 另一个 Store
动态注册registerModule / unregisterModule首次 use 时自动注册
代码分割需要手动配合动态注册天然支持,按需导入

DevTools 支持

两者都支持 Vue DevTools 的状态检查和时间旅行调试。Pinia 的 DevTools 集成体验更好:每个 Store 独立展示,支持直接编辑状态,Action 的调用记录更清晰。Vuex 的 DevTools 在模块嵌套较深时,状态树的展示会变得复杂。

Vuex vs Pinia 对比总结

维度VuexPinia
架构单一状态树 + modules多 Store,扁平化
Mutations必须,状态修改唯一入口移除,直接在 Action 中修改
TypeScript手动声明类型,推断差自动推断,开箱即用
样板代码多(Mutation + Action + Constant)少(直接定义响应式状态和函数)
体积~6KB (gzipped)~1.5KB (gzipped)
Vue 版本Vue2 + Vue3Vue2(需插件)+ Vue3
官方定位Vue2 时代默认方案Vue3 官方推荐

选型建议:Vue3 新项目直接用 Pinia。Vue2 存量项目如果 Vuex 用得稳,没必要为了迁移而迁移;如果计划升级 Vue3,可以逐步将 Vuex 模块迁移到 Pinia。

Webpack vs Vite

原理差异:Bundle-Based vs Native ESM

Webpack 是一个打包器(Bundler)。不管是开发环境还是生产环境,它的工作模式是一样的:从入口文件出发,递归分析所有依赖,构建完整的模块依赖图,然后将所有模块打包成一个或多个 bundle 文件。浏览器加载的是打包后的文件。

Vite 在开发环境和生产环境采用了完全不同的策略:

  • 开发环境:利用浏览器原生的 ES Module 支持。Vite 启动一个 dev server,当浏览器请求某个模块时,Vite 才对该模块进行转换并返回。不做打包,按需编译。
  • 生产环境:使用 Rollup 进行打包。因为原生 ESM 在生产环境存在请求瀑布(嵌套 import 导致大量 HTTP 请求)和浏览器兼容性问题,所以生产构建仍然需要打包。
Webpack 开发模式:
入口 → 分析全部依赖 → 打包成 bundle → 启动 dev server → 浏览器加载 bundle

Vite 开发模式:
启动 dev server → 浏览器请求入口 → 按需转换被请求的模块 → 返回原生 ESM

这个架构差异决定了两者在开发体验上的巨大区别。

冷启动速度

Webpack 的冷启动需要经历完整的构建流程:解析入口、递归收集依赖、执行 loader 转换、生成 bundle。项目越大,依赖越多,冷启动越慢。一个中大型项目(几百个组件、几十个路由)的冷启动时间可能在 30 秒到几分钟不等。

Vite 的冷启动分两步:

  1. 预构建(Pre-bundling):使用 esbuild 将 node_modules 中的依赖预先打包为 ESM 格式。esbuild 用 Go 编写,速度比基于 JavaScript 的打包器快 10-100 倍。这一步只在依赖变化时执行,结果会被缓存。
  2. 启动 dev server:不需要处理业务代码,直接启动 HTTP 服务。

实际效果:同一个项目,Webpack 冷启动 40 秒,Vite 可能只需 1-2 秒。预构建的缓存命中后,后续启动更快。

# esbuild 预构建的缓存目录
node_modules/.vite/

HMR(热模块替换)性能

Webpack 的 HMR 在模块修改后,需要重新构建受影响的 chunk,然后将更新推送到浏览器。项目规模增大时,即使只改了一个文件,HMR 也可能需要几秒钟,因为 Webpack 需要重新遍历依赖图来确定哪些 chunk 受影响。

Vite 的 HMR 基于原生 ESM 的模块边界。修改一个文件时,Vite 只需要让浏览器重新请求这个文件(和直接依赖它的文件),不需要重新构建任何 bundle。HMR 速度与项目规模无关,始终保持在毫秒级别。

Webpack HMR 流程:
文件变更 → 重新构建受影响的 chunk → 推送更新 → 浏览器应用变更

Vite HMR 流程:
文件变更 → 通知浏览器该模块失效 → 浏览器重新请求该模块 → 应用变更

生产构建

Vite 的生产构建使用 Rollup,而不是 esbuild。原因是 esbuild 在代码分割(Code Splitting)和 CSS 处理方面的能力还不够成熟。Rollup 的 tree-shaking 和 chunk 分割更加可靠。

Webpack 的生产构建更成熟,生态更丰富。大量的 loader 和 plugin 经过多年沉淀,覆盖了各种边界场景。

两者在生产构建速度上的差异没有开发环境那么大,因为都需要做完整的打包、压缩和优化。

维度Webpack 生产构建Vite 生产构建(Rollup)
Tree-shaking支持,但不如 Rollup 彻底Rollup 的 tree-shaking 更优
Code Splitting成熟,策略丰富支持,但策略相对简单
插件生态极其丰富Rollup 插件 + Vite 专属插件
CSS 处理css-loader + MiniCssExtractPlugin内置支持,开箱即用
构建速度较慢稍快(Rollup 更轻量)

配置复杂度

Webpack 的配置灵活但复杂。一个典型的 Vue 项目需要配置 vue-loaderbabel-loadercss-loaderMiniCssExtractPluginHtmlWebpackPlugin 等一系列 loader 和 plugin。虽然 vue-cli 封装了大部分配置,但遇到定制需求时,webpack.config.js 的调试成本很高。

Vite 的配置简洁得多。Vue 支持只需要一个插件:

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

export default defineConfig({
  plugins: [vue()],
})

对比一个等价的 Webpack 配置:

// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = {
  entry: './src/main.js',
  module: {
    rules: [
      { test: /\.vue$/, loader: 'vue-loader' },
      { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ },
      { test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'] },
    ],
  },
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({ template: './public/index.html' }),
    new MiniCssExtractPlugin(),
  ],
}

Webpack vs Vite 对比总结

维度WebpackVite
开发模式原理全量打包后启动原生 ESM,按需编译
冷启动速度慢(秒到分钟级)快(秒级)
HMR 速度与项目规模相关与项目规模无关,毫秒级
生产打包工具Webpack 自身Rollup
配置复杂度
插件生态最丰富快速增长,已覆盖主流场景
浏览器兼容可配置到 IE11默认 modern browsers
成熟度10 年+,大量生产验证较新,但已广泛采用

选型建议:Vue3 新项目用 Vite,这是 Vue 官方推荐的构建工具(create-vue 默认使用 Vite)。存量 Webpack 项目不需要急于迁移,但如果开发体验已经成为瓶颈(冷启动慢、HMR 慢),可以考虑迁移到 Vite。迁移的主要成本在于 Webpack 特有的 loader/plugin 需要找到 Vite 的等价方案。

全局选型速查表

决策点推荐方案适用场景
Vue2 还是 Vue3Vue3新项目一律 Vue3;存量项目按迁移收益评估
Options 还是 CompositionComposition API + <script setup>新项目统一用 Composition;简单组件两者皆可
Vuex 还是 PiniaPinia新项目直接 Pinia;Vuex 存量项目按节奏迁移
Webpack 还是 ViteVite新项目用 Vite;Webpack 项目视开发体验痛点决定是否迁移

面试高频追问

Vue2 → Vue3 方向

Q:Vue3 的 Proxy 响应式和 Vue2 的 defineProperty 响应式,性能差异的根本原因是什么?

核心在于「惰性代理」vs「递归遍历」。Vue2 在组件初始化时就递归遍历 data 中的所有层级,为每个属性设置 getter/setter。Vue3 的 Proxy 只在属性被访问时才创建深层代理。一个有 100 个字段但模板只用了 5 个的对象,Vue2 要初始化 100 个响应式属性,Vue3 只代理被访问的 5 个。

Q:Vue3 为什么放弃 IE11 支持?

Proxy 无法被完整 polyfill。Object.defineProperty 可以逐个属性拦截,但 Proxy 的拦截机制(包括 hasdeletePropertyownKeys 等 trap)没有 ES5 等价实现。Vue 团队曾经考虑过为 IE11 提供降级的响应式方案,但最终认为维护两套响应式系统的成本太高,不如直接放弃。

Q:Vue3 的编译优化具体怎么减少 diff 开销的?

三个关键机制配合:Patch Flags 标记动态绑定类型,diff 时只对比变化的部分;Static Hoisting 将静态节点提升到 render 函数外部,diff 时直接跳过;Block Tree 将动态节点收集到平面数组中,避免递归遍历整棵树。三者叠加后,模板中静态内容越多,Vue3 相对 Vue2 的 diff 性能优势越大。

Q:Vue3 的 tree-shaking 是怎么实现的?

Vue3 将所有全局 API(nextTickrefcomputedwatch 等)改为 ES Module 具名导出。打包工具(Webpack/Rollup)在构建时通过静态分析 import 语句,标记未被使用的导出为 dead code,在压缩阶段(Terser)将其移除。Vue2 把 API 挂在 Vue 构造函数上,打包工具无法判断 Vue.xxx 是否被使用,所以无法 tree-shake。

Options API vs Composition API 方向

Q:Composition API 的 refreactive 有什么区别,什么时候用哪个?

ref 可以包装任意类型(基础类型和对象),通过 .value 访问。reactive 只能包装对象,直接访问属性。使用建议:优先用 ref,因为它的语义更统一(始终通过 .value 访问),且不会因为解构而丢失响应性。reactive 适合用在不需要重新赋值整个对象的场景。

const count = ref(0)
count.value++ // ✅

const state = reactive({ count: 0 })
state.count++ // ✅

// reactive 的陷阱:解构丢失响应性
const { count: destructuredCount } = state // ❌ 不再是响应式的
const { count: refCount } = toRefs(state)  // ✅ 用 toRefs 解决

Q:Composable 和 React 的 Custom Hook 有什么异同?

相同点:都是把有状态的逻辑封装为可复用的函数,都能组合使用。

核心差异:React Hook 每次渲染都会重新执行,依赖 Hooks 的调用顺序(不能放在条件语句中)。Vue Composable 只在 setup() 中执行一次,后续通过响应式系统自动追踪依赖,没有调用顺序限制,也不需要依赖数组(如 useEffect 的 deps)。

Q:为什么 Composition API 对 TypeScript 更友好?

Options API 中,this 的类型是 Vue 内部通过泛型和 ThisType 推断出来的,遇到 Mixins 注入的属性、$refs 类型、跨组件注入等场景会失败。Composition API 中没有 this,所有状态都是标准的变量和函数,TypeScript 的标准类型推断和类型窄化机制就能完整覆盖。

Vuex vs Pinia 方向

Q:Pinia 去掉 Mutations 后,怎么保证状态变更的可追踪性?

Pinia 通过 $subscribe API 和 Vue DevTools 插件追踪状态变更。每次状态变化(无论是直接赋值、$patch 还是 Action 内修改)都会被 DevTools 记录。可追踪性并不依赖 Mutations 这个概念,而是依赖响应式系统本身的变更检测。Mutations 本质上是 Vuex 为了配合 DevTools 的时间旅行调试而加的约束,Pinia 用更轻量的方式实现了同样的效果。

Q:Pinia 的 Store 之间如何互相访问?

直接在一个 Store 的 Action 或 Getter 中调用另一个 Store 的 useXxxStore()。不需要 rootStaterootGetters,因为每个 Store 都是独立的 composable:

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()

  const discountedTotal = computed(() => {
    return userStore.isVip ? total.value * 0.9 : total.value
  })

  return { discountedTotal }
})

Webpack vs Vite 方向

Q:Vite 开发时不打包,那 node_modules 里的依赖怎么处理?

Vite 在启动时用 esbuild 对 node_modules 中的依赖做预构建(Pre-bundling),把 CommonJS 模块转为 ESM 格式,同时把散碎的小模块合并为单个文件(比如 lodash-es 的几百个文件合并成一个),避免浏览器发送大量 HTTP 请求。预构建结果缓存在 node_modules/.vite/ 下,只有 package.json 或 lock 文件变化时才重新执行。

Q:Vite 生产构建为什么用 Rollup 而不是 esbuild?

esbuild 在代码分割(Code Splitting)、CSS 代码分割、资产处理(asset handling)和生成符合旧浏览器要求的语法降级方面还不够成熟。Rollup 在这些方面更加完善和可控。Vite 团队在 Rolldown(Rust 实现的 Rollup 兼容打包器)成熟后,计划统一开发和生产环境的打包工具。

Q:从 Webpack 迁移到 Vite,最常遇到的问题是什么?

三类常见问题:一是 CommonJS 模块兼容性,Vite 开发模式基于 ESM,部分只提供 CJS 格式的老包需要通过 optimizeDeps 配置预构建;二是 Webpack 特有的语法(require.contextmodule.hot)需要替换为 Vite 的等价方案(import.meta.globimport.meta.hot);三是部分 Webpack loader/plugin 没有 Vite 对应方案,需要自行适配或寻找替代。

总结

四组对比的核心结论:

  1. Vue2 → Vue3:响应式从 definePropertyProxy(惰性代理 + 属性新增支持),编译器优化(Patch Flags + Static Hoisting + Block Tree)大幅减少运行时开销,API 改为具名导出支持 tree-shaking。代价是放弃 IE11。
  2. Options → Composition:从按选项类型组织到按功能组织,Composable 替代 Mixins 实现无命名冲突的逻辑复用,对 TypeScript 的支持从"能用"到"完整"。两者可共存,新项目推荐 Composition。
  3. Vuex → Pinia:从单一状态树到多 Store 扁平化,去掉 Mutations 减少样板代码,TypeScript 类型推断从手动声明到自动推断。包体积更小,DevTools 体验更好。
  4. Webpack → Vite:开发模式从全量打包到原生 ESM 按需编译,冷启动和 HMR 速度质变。生产构建使用 Rollup,tree-shaking 更彻底。配置复杂度大幅降低。

这些变化的共同趋势是:更小的 API 面积、更好的 TypeScript 支持、更少的样板代码、更快的开发体验。理解差异背后的原因比记住结论更重要——面试官追问的往往不是"有什么区别",而是"为什么会有这个区别"。