Vue生态对比
为什么要做 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 重写了
push、pop、splice等 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> 语法糖,生命周期钩子也做了调整:
| Vue2 | Vue3(Options) | Vue3(Composition) |
|---|---|---|
| beforeCreate | beforeCreate | setup() 本身 |
| created | created | setup() 本身 |
| beforeMount | beforeMount | onBeforeMount |
| mounted | mounted | onMounted |
| beforeUpdate | beforeUpdate | onBeforeUpdate |
| updated | updated | onUpdated |
| beforeDestroy | beforeUnmount | onBeforeUnmount |
| destroyed | unmounted | onUnmounted |
两个关键变化: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.nextTick、Vue.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(() => {})
这对库体积影响显著。一个只用了 ref 和 computed 的项目,打包后不会包含 Teleport、Suspense 等未使用的特性代码。
TypeScript 支持
Vue2 的 Options API 对 TS 的支持依赖 vue-class-component 和 vue-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 对比总结
| 维度 | Vue2 | Vue3 |
|---|---|---|
| 响应式 | Object.defineProperty,递归遍历 | Proxy,惰性代理 |
| 新增属性 | 需要 Vue.set() | 直接赋值即可 |
| 模板根节点 | 必须单根 | 支持 Fragment |
| API 导出 | 全局挂载 | 具名导出,支持 tree-shaking |
| TS 支持 | 需要装饰器,推断不完整 | 原生支持,推断完整 |
| IE11 | 支持 | 不支持 |
| 包体积 | 较大(全量引入) | 更小(按需引入) |
| Diff 算法 | 全量遍历 VNode 树 | Patch Flags + Block Tree |
Options API vs Composition API
代码组织方式的根本差异
Options API 按选项类型组织代码:data、computed、methods、watch 各归各位。组件简单时结构清晰,但当一个组件同时处理多个功能(比如搜索、分页、筛选)时,同一个功能的逻辑被打散到不同选项中。
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 有三个难以回避的问题:
- 命名冲突:多个 mixin 定义了同名属性时,合并策略不透明。
- 来源不清:模板中用到的某个变量来自哪个 mixin,只能靠全局搜索。
- 隐式依赖: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 内部通过 ThisType 和 ComponentPublicInstance 实现的。大多数情况下能工作,但遇到 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 拆分命名空间。这种设计在大型项目中导致了几个问题:模块注册繁琐、命名空间字符串容易写错、跨模块访问需要 rootState 和 rootGetters。
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 和各模块的类型,dispatch 和 commit 的参数类型几乎无法自动推断:
// 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) // 参数类型由函数签名决定
模块化对比
| 维度 | Vuex | Pinia |
|---|---|---|
| 模块定义 | 嵌套在根 Store 的 modules 中 | 独立的 defineStore,文件级隔离 |
| 命名空间 | namespaced: true + 字符串路径 | 无需命名空间,Store ID 即标识 |
| 跨 Store 访问 | rootState、rootGetters | 直接 import 另一个 Store |
| 动态注册 | registerModule / unregisterModule | 首次 use 时自动注册 |
| 代码分割 | 需要手动配合动态注册 | 天然支持,按需导入 |
DevTools 支持
两者都支持 Vue DevTools 的状态检查和时间旅行调试。Pinia 的 DevTools 集成体验更好:每个 Store 独立展示,支持直接编辑状态,Action 的调用记录更清晰。Vuex 的 DevTools 在模块嵌套较深时,状态树的展示会变得复杂。
Vuex vs Pinia 对比总结
| 维度 | Vuex | Pinia |
|---|---|---|
| 架构 | 单一状态树 + modules | 多 Store,扁平化 |
| Mutations | 必须,状态修改唯一入口 | 移除,直接在 Action 中修改 |
| TypeScript | 手动声明类型,推断差 | 自动推断,开箱即用 |
| 样板代码 | 多(Mutation + Action + Constant) | 少(直接定义响应式状态和函数) |
| 体积 | ~6KB (gzipped) | ~1.5KB (gzipped) |
| Vue 版本 | Vue2 + Vue3 | Vue2(需插件)+ 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 的冷启动分两步:
- 预构建(Pre-bundling):使用 esbuild 将
node_modules中的依赖预先打包为 ESM 格式。esbuild 用 Go 编写,速度比基于 JavaScript 的打包器快 10-100 倍。这一步只在依赖变化时执行,结果会被缓存。 - 启动 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-loader、babel-loader、css-loader、MiniCssExtractPlugin、HtmlWebpackPlugin 等一系列 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 对比总结
| 维度 | Webpack | Vite |
|---|---|---|
| 开发模式原理 | 全量打包后启动 | 原生 ESM,按需编译 |
| 冷启动速度 | 慢(秒到分钟级) | 快(秒级) |
| HMR 速度 | 与项目规模相关 | 与项目规模无关,毫秒级 |
| 生产打包工具 | Webpack 自身 | Rollup |
| 配置复杂度 | 高 | 低 |
| 插件生态 | 最丰富 | 快速增长,已覆盖主流场景 |
| 浏览器兼容 | 可配置到 IE11 | 默认 modern browsers |
| 成熟度 | 10 年+,大量生产验证 | 较新,但已广泛采用 |
选型建议:Vue3 新项目用 Vite,这是 Vue 官方推荐的构建工具(create-vue 默认使用 Vite)。存量 Webpack 项目不需要急于迁移,但如果开发体验已经成为瓶颈(冷启动慢、HMR 慢),可以考虑迁移到 Vite。迁移的主要成本在于 Webpack 特有的 loader/plugin 需要找到 Vite 的等价方案。
全局选型速查表
| 决策点 | 推荐方案 | 适用场景 |
|---|---|---|
| Vue2 还是 Vue3 | Vue3 | 新项目一律 Vue3;存量项目按迁移收益评估 |
| Options 还是 Composition | Composition API + <script setup> | 新项目统一用 Composition;简单组件两者皆可 |
| Vuex 还是 Pinia | Pinia | 新项目直接 Pinia;Vuex 存量项目按节奏迁移 |
| Webpack 还是 Vite | Vite | 新项目用 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 的拦截机制(包括 has、deleteProperty、ownKeys 等 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(nextTick、ref、computed、watch 等)改为 ES Module 具名导出。打包工具(Webpack/Rollup)在构建时通过静态分析 import 语句,标记未被使用的导出为 dead code,在压缩阶段(Terser)将其移除。Vue2 把 API 挂在 Vue 构造函数上,打包工具无法判断 Vue.xxx 是否被使用,所以无法 tree-shake。
Options API vs Composition API 方向
Q:Composition API 的 ref 和 reactive 有什么区别,什么时候用哪个?
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()。不需要 rootState 或 rootGetters,因为每个 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.context、module.hot)需要替换为 Vite 的等价方案(import.meta.glob、import.meta.hot);三是部分 Webpack loader/plugin 没有 Vite 对应方案,需要自行适配或寻找替代。
总结
四组对比的核心结论:
- Vue2 → Vue3:响应式从
defineProperty到Proxy(惰性代理 + 属性新增支持),编译器优化(Patch Flags + Static Hoisting + Block Tree)大幅减少运行时开销,API 改为具名导出支持 tree-shaking。代价是放弃 IE11。 - Options → Composition:从按选项类型组织到按功能组织,Composable 替代 Mixins 实现无命名冲突的逻辑复用,对 TypeScript 的支持从"能用"到"完整"。两者可共存,新项目推荐 Composition。
- Vuex → Pinia:从单一状态树到多 Store 扁平化,去掉 Mutations 减少样板代码,TypeScript 类型推断从手动声明到自动推断。包体积更小,DevTools 体验更好。
- Webpack → Vite:开发模式从全量打包到原生 ESM 按需编译,冷启动和 HMR 速度质变。生产构建使用 Rollup,tree-shaking 更彻底。配置复杂度大幅降低。
这些变化的共同趋势是:更小的 API 面积、更好的 TypeScript 支持、更少的样板代码、更快的开发体验。理解差异背后的原因比记住结论更重要——面试官追问的往往不是"有什么区别",而是"为什么会有这个区别"。