← 返回场景决策
1. KeepAlive — 组件缓存决策
KeepAlive = 浏览器标签页
→
切到别的 tab 不会关掉,回来还在
不用 KeepAlive = 每次重新打开网页
→
关掉再打开,一切从头来
类比理解:KeepAlive 就像浏览器标签页——你从标签 A 切到标签 B,A 并没有关闭,只是隐藏了;切回来时,A 的内容原封不动。不用 KeepAlive 就像每次都关闭网页再重新打开——所有状态归零,页面重新加载。
✅ 什么时候用
1. 列表页 → 详情页 → 返回列表页,保留滚动位置和筛选条件
用户在列表页滚到了第 3 屏,点进详情页,返回后不想重新从第 1 屏开始。KeepAlive 可以保留组件实例,回来时滚动位置、分页、搜索条件都在。
2. Tab 切换保留表单状态
多 Tab 页,用户在 Tab A 填了一半表单,切到 Tab B 查看,再切回 A 时不想表单被清空。
3. 频繁切换的组件
如后台管理的多 Tab 页签、仪表盘的不同面板、编辑器的多个文件 Tab。频繁创建/销毁组件开销大,缓存后切换更流畅。
❌ 什么时候不能用 / 边界
1. 组件内部有定时器/轮询,离开时必须停止
KeepAlive 只是"休眠"组件,不会触发 unmounted。如果组件有 setInterval 轮询,切走后定时器仍在执行,浪费资源甚至造成 bug。
2. 同一路由不同参数(如 /user/1 → /user/2)需要重新加载
默认情况下,同一路由组件被缓存后,参数变化不会重新创建组件实例。需要配合 onActivated 或 watch 路由参数来手动刷新。
3. 组件数据依赖 props 变化需要每次刷新
如果组件的渲染完全依赖父组件传入的 props,且 props 每次都不同,缓存会导致显示旧数据。
4. 内存敏感页面(大量组件缓存会 OOM)
每个缓存的组件实例都占用内存。如果页面有很多大组件(如大数据表格、图表),全缓存可能导致内存溢出。需用 max 限制缓存数量。
include / exclude / max 属性详解
// include: 只有匹配的组件会被缓存(支持字符串、正则、数组)
<KeepAlive include="UserList,OrderList">
<component> :is="currentTab" />
</KeepAlive>
// exclude: 匹配的组件不会被缓存
<KeepAlive :exclude="/(Settings|Log)/">
<component :is="currentTab" />
</KeepAlive>
// max: 最多缓存多少组件实例(LRU 策略,最久未访问的先销毁)
<KeepAlive :max="10">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</KeepAlive>
// 动态 include:结合 ref 动态控制缓存列表
<KeepAlive :include="cacheList">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</KeepAlive>
面试金句
Slot(插槽) 是 Vue 组件内容分发的机制:父组件向子组件传递模板内容。三类插槽:默认插槽、具名插槽(v-slot:name / #name)、作用域插槽(子组件向父组件传数据)。Vue 3 语法:v-slot 简写为 #。使用场景:布局组件、列表组件、可定制 UI 组件库。
注意:include/exclude 匹配的是组件的 name 选项。在 <script setup> 中,组件名默认取文件名,建议用 defineOptions({ name: 'UserList' }) 显式指定。
onActivated / onDeactivated 生命周期
import { onActivated, onDeactivated, ref, onMounted, onUnmounted } from 'vue'
// 组件被激活(从缓存中恢复显示)时触发
onActivated(() => {
// 适合:恢复轮询、恢复定时器、刷新数据
resumePolling()
console.log('组件被激活')
})
// 组件被停用(切走但不销毁)时触发
onDeactivated(() => {
// 适合:暂停轮询、暂停定时器、保存临时状态
pausePolling()
console.log('组件被停用')
})
// 生命周期触发顺序对比:
// 首次进入:mounted → activated
// 切走: deactivated(不会触发 unmounted)
// 切回来: activated(不会触发 mounted)
// 销毁缓存:unmounted
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
完整示例:路由级 KeepAlive + meta 标记
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
component: () => import('../views/Home.vue')
},
{
path: '/users',
name: 'UserList',
component: () => import('../views/UserList.vue'),
meta: { keepAlive: true } // ✅ 需要缓存
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('../views/UserDetail.vue'),
meta: { keepAlive: false } // ❌ 不缓存
},
{
path: '/settings',
name: 'Settings',
component: () => import('../views/Settings.vue'),
meta: { keepAlive: false } // ❌ 不缓存(每次都要刷新设置)
},
{
path: '/orders',
name: 'OrderList',
component: () => import('../views/OrderList.vue'),
meta: { keepAlive: true } // ✅ 需要缓存
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
面试金句
KeepAlive 缓存不活动的组件实例,避免重复渲染。核心属性:include/exclude(白名单/黑名单组件名)、max(最大缓存数)。生命周期:onActivated(从缓存恢复)和 onDeactivated(被缓存时)。常见场景:Tab 切换、列表→详情返回时保留滚动位置和表单状态。
<!-- App.vue -->
<template>
<div>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/users">用户列表</router-link>
<router-link to="/orders">订单列表</router-link>
<router-link to="/settings">设置</router-link>
</nav>
<!-- 核心:根据路由 meta.keepAlive 决定是否缓存 -->
<router-view v-slot="{ Component, route }">
<KeepAlive :include="cacheList">
<component
:is="Component"
:key="route.path"
/>
</KeepAlive>
</router-view>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const cacheList = ref(['UserList', 'OrderList'])
// 动态管理缓存列表:根据 meta.keepAlive 添加/移除
watch(
() => route.name,
(name) => {
if (route.meta.keepAlive && name && !cacheList.value.includes(name)) {
cacheList.value.push(name)
}
},
{ immediate: true }
)
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
include 动态控制:移除缓存
// 在组件内部主动将自己从缓存列表移除
// UserList.vue
<script setup>
import { onDeactivated } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCacheStore } from '@/stores/cache'
const route = useRoute()
const router = useRouter()
const cacheStore = useCacheStore()
// 某些条件下主动清除自己的缓存
function invalidateCache() {
cacheStore.removeFromCache('UserList')
}
// 示例:数据发生重大变更后,下次进入需要刷新
async function handleBulkDelete() {
await deleteUsers(selectedIds)
invalidateCache() // 下次进入时重新加载
fetchUsers() // 当前也需要刷新
}
</script>
面试金句
KeepAlive 的 include/exclude 通过组件 name 匹配来精确控制缓存范围。动态修改 include 数组可以主动移除缓存——当组件 name 从数组中删除时,该组件的缓存实例会被立即销毁。适合"数据变更后需要刷新"的场景。
路由参数变化的处理
// 同一组件、不同参数:/user/1 → /user/2
// 方案 1:用 :key 强制重建(最简单,但失去缓存意义)
<component :is="Component" :key="route.fullPath" />
// 方案 2:watch 路由参数,手动刷新数据(保留缓存但更新数据)
<script setup>
import { watch, onActivated } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const userData = ref(null)
async function loadUser(id) {
userData.value = await fetchUser(id)
}
// 首次挂载和参数变化时加载
watch(
() => route.params.id,
(newId) => {
if (newId) loadUser(newId)
},
{ immediate: true }
)
// 激活时也可以刷新(从详情页返回时)
onActivated(() => {
if (route.params.id) {
loadUser(route.params.id)
}
})
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
轮询组件的 KeepAlive 处理
// Dashboard.vue — 有实时轮询的组件
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
const metrics = ref({})
let pollingTimer = null
function startPolling() {
fetchMetrics() // 立即获取一次
pollingTimer = setInterval(fetchMetrics, 5000)
}
function stopPolling() {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
async function fetchMetrics() {
metrics.value = await api.getMetrics()
}
// ✅ 激活时启动轮询
onActivated(() => {
startPolling()
})
// ✅ 停用时停止轮询(关键!否则切走后还在请求)
onDeactivated(() => {
stopPolling()
})
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
决策表
| 场景 | 用不用 | 原因 |
| 列表页 → 详情页 → 返回 | ✅ 用 | 保留滚动位置、筛选条件、分页状态 |
| 多 Tab 切换保留表单 | ✅ 用 | 避免用户填写的内容丢失 |
| 频繁切换的仪表盘面板 | ✅ 用 | 减少重复创建/销毁的开销 |
| 组件有定时器/轮询 | ⚠️ 慎用 | 必须配合 onDeactivated 清理,否则轮询不会停止 |
| 同路由不同参数(/user/1→2) | ⚠️ 需处理 | 缓存导致参数变化不重载,需 watch 路由参数或用 :key |
| 每次进入都要最新数据 | ❌ 不用 | 缓存会导致数据过时,需要在 onActivated 中手动刷新 |
| 大量组件都缓存 | ❌ 不用 | 内存压力,用 max 限制或选择性缓存 |
| 单次展示页(如结果页) | ❌ 不用 | 用完即走,无需缓存 |
警告:内存泄漏风险
KeepAlive 缓存的组件不会被销毁,其中的事件监听器、定时器、WebSocket 连接都不会自动清理。必须在 onDeactivated 中手动清理。否则即使组件不可见,这些资源仍在运行,导致内存泄漏和性能问题。
警告:路由参数问题
同一个路由组件被缓存后,从 /user/1 导航到 /user/2 时,组件不会重新创建。必须通过 watch(route.params) 或 :key="route.fullPath" 来处理。用 :key 会失去缓存效果,watch 更灵活但代码更多。
2. computed vs watch — 响应式选型
computed = Excel 公式
→
A1 = B1 + C1,改了 B1 自动变,永远是"计算结果"
watch = 宏/VBA
→
改了 A1 → 触发一段操作(发请求、改 DOM、写数据库)
类比理解:computed 就像 Excel 中的公式单元格——A1=B1+C1,你改了 B1,A1 自动更新,它永远是"计算结果"本身。watch 就像 Excel 的宏——当某个单元格变化时,触发一段操作(发请求、写文件、改其他单元格),它关注的是"变化后做什么事"。
✅ computed 什么时候用
1. 派生状态 — 从现有数据计算出新数据
如 fullName = firstName + lastName、total = items.reduce(...)、filtered = list.filter(...)。核心:结果完全由依赖决定,无副作用。
2. 过滤 / 排序
搜索过滤、按日期排序、按状态分组。输入变 → 输出自动变。
3. 格式化
日期格式化、金额格式化、百分比计算。原始值不变,展示格式随原始值自动更新。
4. 多个源合并一个结果
从多个 ref/reactive 组合出一个结果,如 isReady = loaded && authenticated && !error。
// computed 典型用法
<script setup>
import { ref, computed } from 'vue'
// 1. 派生状态
const firstName = ref('张')
const lastName = ref('三')
const fullName = computed(() => firstName.value + lastName.value)
// 2. 过滤 + 排序
const searchQuery = ref('')
const users = ref([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 25 },
{ name: 'Charlie', age: 35 }
])
const filteredUsers = computed(() => {
const q = searchQuery.value.toLowerCase()
return users.value
.filter(u => u.name.toLowerCase().includes(q))
.sort((a, b) => a.age - b.age)
})
// 3. 格式化
const price = ref(1299.5)
const formattedPrice = computed(() => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(price.value)
})
// formattedPrice.value → "¥1,299.50"
// 4. 多源合并
const loaded = ref(false)
const authenticated = ref(false)
const hasError = ref(false)
const isReady = computed(() => loaded.value && authenticated.value && !hasError.value)
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
✅ watch 什么时候用
1. 异步操作(API 调用)
数据变化后发请求、查询后端。computed 不能异步。
2. 副作用(DOM 操作 / 路由跳转 / LocalStorage)
修改 DOM、写 localStorage、跳转路由、操作第三方库实例。这些都不是"计算结果"。
3. 开销大的操作(防抖搜索)
搜索输入防抖后发请求,不能用 computed(每次击键都重新计算)。
4. 需要旧值对比
如:状态从 A 变到 B 时做特定处理,需要知道旧值和新值。
// watch 典型用法
<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
// 1. 异步操作:搜索防抖
const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
let debounceTimer = null
watch(searchQuery, (newVal) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
loading.value = true
try {
results.value = await api.search(newVal)
} finally {
loading.value = false
}
}, 300)
})
// 2. 副作用:自动保存到 localStorage
const draft = ref('')
watch(draft, (newVal) => {
localStorage.setItem('draft', newVal)
})
// 3. 需要旧值:状态变化检测
const status = ref('idle')
watch(status, (newVal, oldVal) => {
if (oldVal === 'idle' && newVal === 'running') {
showStartNotification()
}
if (newVal === 'error') {
reportError({ from: oldVal, to: newVal })
}
})
// 4. 路由参数变化时重新请求
const route = useRoute()
const userData = ref(null)
watch(
() => route.params.id,
async (newId) => {
if (newId) {
userData.value = await api.getUser(newId)
}
},
{ immediate: true }
)
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
❌ computed 不能做什么
// ❌ 错误:computed 中做异步操作
const data = computed(async () => {
const res = await fetch('/api/data') // ❌ computed 不支持异步
return res.json() // 返回的是 Promise,不是值
})
// ❌ 错误:computed 中修改外部状态
const count = ref(0)
const doubled = computed(() => {
count.value++ // ❌ 不能在 computed 中修改其他响应式数据
return count.value * 2
})
// ❌ 错误:computed 没有返回值
const logger = computed(() => {
console.log(count.value) // ❌ 只是为了执行副作用,不是计算
// 没有 return
})
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
❌ watch 不该做什么
// ❌ 不该:纯计算(应该用 computed)
const firstName = ref('张')
const lastName = ref('三')
const fullName = ref('')
// ❌ 错误做法:用 watch 赋值
watch([firstName, lastName], ([f, l]) => {
fullName.value = f + l // 应该用 computed
})
// ✅ 正确做法
const fullName = computed(() => firstName.value + lastName.value)
// ❌ 不该:只为了赋值给另一个变量
const price = ref(100)
const discountPrice = ref(0)
// ❌ 错误做法
watch(price, (newVal) => {
discountPrice.value = newVal * 0.8 // 应该用 computed
})
// ✅ 正确做法
const discountPrice = computed(() => price.value * 0.8)
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
watchEffect vs watch 区别
// watchEffect: 自动追踪依赖,立即执行,拿不到旧值
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const data = ref(null)
// 自动追踪内部使用的所有响应式数据
const stop = watchEffect(async () => {
// 自动追踪 userId.value
data.value = await api.getUser(userId.value)
})
stop() // 手动停止
// watch: 需要显式指定监听源,可拿到新值和旧值
watch(
userId, // 显式指定监听什么
async (newId, oldId) => {
// 可以拿到新值和旧值
console.log(`从 ${oldId} 变为 ${newId}`)
data.value = await api.getUser(newId)
}
)
// watchEffect 适用场景:
// - 不需要旧值
// - 想立即执行
// - 依赖关系复杂,懒得列出
// watch 适用场景:
// - 需要新旧值对比
// - 需要精确控制监听哪个数据
// - 需要延迟执行(不设 immediate)
// - 需要深层监听(deep: true)
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
| 特性 | computed | watch | watchEffect |
| 必须有返回值 | ✅ 是 | ❌ 否 | ❌ 否 |
| 支持异步 | ❌ 否 | ✅ 是 | ✅ 是 |
| 可以产生副作用 | ❌ 否 | ✅ 是 | ✅ 是 |
| 缓存结果 | ✅ 是(依赖不变不重算) | ❌ 否 | ❌ 否 |
| 获取旧值 | ❌ 否 | ✅ 是 | ❌ 否 |
| 自动追踪依赖 | ✅ 是 | ❌ 需显式指定 | ✅ 是 |
| 默认立即执行 | ✅ 是 | ❌ 否(需 immediate) | ✅ 是 |
| 典型场景 | 派生/过滤/格式化 | API/副作用/旧值对比 | 立即执行副作用 |
决策表
| 场景 | 用哪个 | 原因 |
| 全名 = 姓 + 名 | computed | 纯计算,必须有返回值 |
| 搜索关键词变化 → 发 API | watch | 异步操作 + 副作用 |
| 过滤列表 | computed | 纯函数,缓存结果 |
| 数据变化 → 写 localStorage | watch | 副作用 |
| 金额格式化 | computed | 纯转换 |
| 路由参数变 → 请求新数据 | watch | 异步请求 |
| 状态从 A 变到 B → 特殊处理 | watch | 需要旧值 |
| 自动保存草稿 | watch / watchEffect | 副作用,不需要旧值可用 watchEffect |
| 多条件判断是否可提交 | computed | 纯逻辑判断 |
警告:watch 的 immediate 陷阱
watch 默认不立即执行(lazy),只在监听源变化时触发。如果加 immediate: true,回调会立即执行一次,此时旧值为 undefined。需要处理 oldVal 为 undefined 的情况,否则可能报错。
警告:watch 的 deep 陷阱
deep: true 会深度遍历对象的所有嵌套属性,开销很大。对大对象使用 deep 监听可能导致性能问题。替代方案:精确监听某个嵌套属性 () => obj.nested.field,或使用 watchEffect 让它自动追踪。
// deep 的正确用法和替代方案
// ❌ 开销大:深度监听整个大对象
watch(
bigObject,
(newVal) => { /* ... */ },
{ deep: true } // 遍历所有嵌套属性,性能差
)
// ✅ 精确监听某个嵌套属性
watch(
() => bigObject.value.config.theme, // 只监听这个路径
(newTheme) => {
applyTheme(newTheme)
}
)
// ✅ 或者用 watchEffect 自动追踪
watchEffect(() => {
// 只追踪实际访问的属性
applyTheme(bigObject.value.config.theme)
})
// immediate 的正确处理
watch(
userId,
async (newId, oldId) => {
// immediate 时 oldId 是 undefined
if (oldId !== undefined && newId === oldId) return
userData.value = await api.getUser(newId)
},
{ immediate: true }
)
面试金句
watchEffect 自动追踪回调函数内部的响应式依赖,无需手动声明。vs watch:watchEffect 立即执行(类似 React useEffect 无依赖数组)、无法获取旧值、适合执行副作用而非响应数据变化。清理副作用:onCleanup 回调或返回的 stop 函数。
3. ref vs reactive — 响应式数据选型
ref = 保鲜盒
→
把东西装进去,拿出来时要打开 .value 盖子
reactive = 整个冰箱
→
直接往里面放东西,但不能把冰箱搬走换一个
类比理解:ref 就像保鲜盒——你把食物(值)装进保鲜盒里,每次取用都得打开盖子(.value)。好处是可以随时换一盒新的东西。reactive 就像整个冰箱——你直接往里面放东西,不需要开盖子,但冰箱不能整个搬走换一台(不能重新赋值整个对象,否则丢失响应式)。
✅ ref 什么时候用
1. 单个值(数字 / 字符串 / 布尔)
reactive 不能用于基本类型,ref 是唯一选择。
2. 从函数返回响应式数据
composable 函数返回 ref,调用方可以随意重新赋值而不丢失响应式。
3. 需要重新赋值整个变量
如从 API 获取数据后整体替换、切换选中项、重置状态。ref.value = newObj 可以整体替换,reactive 做不到。
✅ reactive 什么时候用
1. 对象 / 数组
当数据本身就是对象结构,访问时不需要 .value,代码更简洁。
2. 表单数据
表单有多个字段,用 reactive 组织更直观,访问 form.name 而不是 form.value.name。
3. 不需要重新赋值引用的复杂结构
如果对象的引用不会变(只会修改属性),reactive 更方便。
❌ reactive 的坑
// ❌ 坑1:重新赋值整个对象 → 丢失响应式
const state = reactive({ name: 'Alice', age: 30 })
// ❌ 错误:整体替换丢失响应式
state = { name: 'Bob', age: 25 } // ❌ TypeError: Assignment to constant variable
// 即使不用 const,用 let,新对象也不是响应式的
// ✅ 正确:逐个属性赋值
state.name = 'Bob'
state.age = 25
// ✅ 或者用 Object.assign
Object.assign(state, { name: 'Bob', age: 25 })
// ❌ 坑2:从函数返回后解构 → 丢失响应式
const form = reactive({ name: '', email: '' })
const { name, email } = form // ❌ 解构后 name 和 email 是普通变量,不是响应式的
// ✅ 正确:用 toRefs
import { toRefs } from 'vue'
const { name, email } = toRefs(form) // ✅ name 和 email 是 ref,保持响应式
// ❌ 坑3:用在基本类型
const count = reactive(0) // ❌ reactive 不支持基本类型,会报警告
// ✅ 正确:基本类型只能用 ref
const count = ref(0)
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
toRefs 解决解构丢失响应式
import { reactive, toRefs } from 'vue'
// composable 返回 reactive + toRefs
function useUserForm() {
const form = reactive({
name: '',
email: '',
phone: ''
})
function reset() {
Object.assign(form, { name: '', email: '', phone: '' })
}
function submit() {
return api.saveUser({ ...form })
}
// ✅ 用 toRefs 包装,解构后仍然是响应式的
return { ...toRefs(form), reset, submit }
}
// 使用
const { name, email, phone, reset, submit } = useUserForm()
// name, email, phone 都是 ref,保持响应式
面试金句
Composable(组合式函数) 是 Vue 3 逻辑复用的核心模式:以 use 开头的函数,封装响应式状态和逻辑。vs Mixins:Composable 有明确的数据来源、无命名冲突、TypeScript 类型安全。设计原则:每个 composable 只负责一个功能(如 useMouse、useFetch、useLocalStorage),返回 ref 和方法。
ref vs reactive 全面对比
| 特性 | ref | reactive |
| 支持基本类型 | ✅ 是 | ❌ 否 |
| 支持对象类型 | ✅ 是 | ✅ 是 |
| 访问方式 | .value(模板自动解包) | 直接访问属性 |
| 可以整体替换 | ✅ ref.value = newObj | ❌ 丢失响应式 |
| 解构后保持响应式 | ✅ 是 | ❌ 需 toRefs |
| 从函数返回 | ✅ 安全 | ⚠️ 需注意解构 |
| 模板中使用 | 自动解包,无需 .value | 直接使用 |
| 在 reactive 中使用 | 自动解包 | 直接嵌套 |
| TS 类型推导 | Ref<T> | T(更自然) |
决策表
| 场景 | 用哪个 | 原因 |
| 计数器、开关、ID | ref | 基本类型,reactive 不支持 |
| 从 API 获取的数据(整体替换) | ref | data.value = newData 可以整体替换 |
| composable 函数返回值 | ref | 调用方可以随意重新赋值 |
| 表单数据 | reactive | 多字段对象,访问更自然 |
| 配置对象(不改引用) | reactive | 只改属性不改引用 |
| 数组(push/splice 操作) | reactive | 操作更自然,不需要 .value |
| 不确定用什么 | ref | ref 更安全,通用性更好 |
综合示例
// 场景 1:列表数据 + 筛选(ref 为主)
<script setup>
import { ref, computed } from 'vue'
const users = ref([]) // ✅ ref:API 返回后整体替换
const loading = ref(false) // ✅ ref:基本类型
const searchQuery = ref('') // ✅ ref:基本类型
const currentPage = ref(1) // ✅ ref:基本类型
async function fetchUsers() {
loading.value = true
try {
// ✅ 可以整体替换
users.value = await api.getUsers({
page: currentPage.value,
query: searchQuery.value
})
} finally {
loading.value = false
}
}
</script>
// 场景 2:表单数据(reactive 更自然)
<script setup>
import { reactive, toRefs } from 'vue'
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
agree: false
})
function handleSubmit() {
// 直接访问属性,无需 .value
if (form.password !== form.confirmPassword) {
return showError('密码不一致')
}
api.register(form)
}
function handleReset() {
// ✅ Object.assign 保持响应式
Object.assign(form, {
username: '',
email: '',
password: '',
confirmPassword: '',
agree: false
})
}
</script>
// 场景 3:混用(推荐模式)
<script setup>
import { ref, reactive } from 'vue'
// 基本类型用 ref
const count = ref(0)
const isActive = ref(false)
const selectedId = ref(null)
// 固定结构的对象用 reactive
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 需要整体替换的用 ref
const userList = ref([])
const currentUser = ref(null)
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
警告:解构丢失响应式
reactive 对象解构后,每个属性变成普通变量,不再有响应式。必须用 toRefs() 包装后再解构。这是 reactive 最常见的坑。
警告:重新赋值问题
reactive 不能重新赋值整个对象。如果需要整体替换数据,用 ref 或 Object.assign()。在 composable 中推荐返回 ref,因为调用方可能需要重新赋值。
4. v-if vs v-show — 条件渲染决策
v-if = 临时工
→
需要时招来,不需要时辞退(创建/销毁 DOM)
v-show = 常驻员工
→
一直在,忙时上班闲时休息(display: none/block)
类比理解:v-if 就像临时工——忙的时候招进来(创建 DOM),不忙的时候辞退(销毁 DOM),每次招人都有成本(创建开销)。v-show 就像常驻员工——一直在公司(DOM 一直存在),忙的时候上班(display: block),闲的时候休息(display: none),切换成本低但一直占着工位(内存)。
✅ v-if 什么时候用
1. 条件很少变化
如权限判断、角色判断——登录后基本不变,用 v-if 避免渲染不需要的 DOM。
2. 初始条件为 false(不渲染)
如弹窗、抽屉——默认不显示,用 v-if 避免初始化开销(组件不会 mount,不会发请求)。
3. 需要配合 transition
Vue 的 <transition> 组件的 enter/leave 动画需要 v-if 的真实 DOM 创建/销毁来触发。
✅ v-show 什么时候用
1. 频繁切换(如 Tab)
Tab 切换、折叠面板、工具栏显隐。v-show 只切换 CSS display 属性,开销极低。
2. 初始化就需要 DOM(如获取元素尺寸)
需要获取元素的 offsetWidth/offsetHeight,v-if 为 false 时元素不存在,无法获取。
3. 条件变化极频繁
如鼠标悬停显示 tooltip、拖拽时显示辅助线。v-if 反复创建/销毁开销大。
❌ 不该用的情况
// ❌ v-if 不该用于频繁切换
<template>
<!-- ❌ 错误:Tab 切换用 v-if,每次切换都创建/销毁组件 -->
<div v-if="activeTab === 'home'">首页内容</div>
<div v-if="activeTab === 'profile'">个人资料</div>
<div v-if="activeTab === 'settings'">设置</div>
<!-- ✅ 正确:Tab 切换用 v-show -->
<div v-show="activeTab === 'home'">首页内容</div>
<div v-show="activeTab === 'profile'">个人资料</div>
<div v-show="activeTab === 'settings'">设置</div>
</template>
// ❌ v-show 不该用于条件为 false 时也要初始化的场景
<template>
<!-- ❌ 错误:弹窗用 v-show,即使不显示也会 mount 组件发请求 -->
<DetailModal v-show="showModal" :id="currentId" />
<!-- ✅ 正确:弹窗用 v-if,不显示时不会创建组件 -->
<DetailModal v-if="showModal" :id="currentId" />
</template>
面试金句
v-if vs v-show:v-if 是真正的条件渲染(false 时 DOM 不存在),v-show 只是 CSS display:none(DOM 始终存在)。选择原则:频繁切换用 v-show(避免反复创建/销毁 DOM),条件不常变用 v-if(减少初始渲染开销)。v-if 和 v-for 不要同时用在同一元素。
Tab 切换示例
<template>
<div class="tab-container">
<div class="tab-header">
<button
v-for="tab in tabs"
:key="tab.key"
:class="['tab-btn', { active: activeTab === tab.key }]"
@click="activeTab = tab.key"
>
{{ tab.label }}
</button>
</div>
<!-- ✅ 频繁切换用 v-show -->
<div v-show="activeTab === 'overview'" class="tab-content">
<OverviewPanel />
</div>
<div v-show="activeTab === 'analytics'" class="tab-content">
<AnalyticsPanel />
</div>
<div v-show="activeTab === 'settings'" class="tab-content">
<SettingsPanel />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import OverviewPanel from './OverviewPanel.vue'
import AnalyticsPanel from './AnalyticsPanel.vue'
import SettingsPanel from './SettingsPanel.vue'
const activeTab = ref('overview')
const tabs = [
{ key: 'overview', label: '概览' },
{ key: 'analytics', label: '数据分析' },
{ key: 'settings', label: '设置' }
]
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
权限控制示例
<template>
<!-- ✅ 权限判断用 v-if:条件几乎不变,不需要隐藏的 DOM -->
<AdminPanel v-if="userStore.isAdmin" />
<UserPanel v-else />
<!-- ✅ 角色判断用 v-if -->
<button v-if="hasPermission('article:delete')" @click="handleDelete">
删除
</button>
<!-- ❌ 错误:权限用 v-show,DOM 中仍然可以看到隐藏内容 -->
<AdminPanel v-show="userStore.isAdmin" />
<!-- 用户可以在 DevTools 中改 display 看到 AdminPanel! -->
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
function hasPermission(perm) {
return userStore.permissions.includes(perm)
}
</script>
面试金句
v-if vs v-show:v-if 是真正的条件渲染(false 时 DOM 不存在),v-show 只是 CSS display:none(DOM 始终存在)。选择原则:频繁切换用 v-show(避免反复创建/销毁 DOM),条件不常变用 v-if(减少初始渲染开销)。v-if 和 v-for 不要同时用在同一元素。
动画场景示例
<template>
<!-- ✅ v-if + transition:进入/离开动画 -->
<Transition name="fade">
<Modal v-if="showModal" @close="showModal = false" />
</Transition>
<!-- ✅ v-if + transition + mode:切换动画 -->
<Transition name="slide" mode="out-in">
<component :is="currentView" :key="currentView" />
</Transition>
</template>
<style>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.slide-enter-active, .slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from {
transform: translateX(30px);
}
.slide-leave-to {
transform: translateX(-30px);
}
</style>
面试金句
Transition / TransitionGroup 是 Vue 内置的过渡动画组件。Transition 处理单元素/组件的进入/离开动画(v-if/v-show),TransitionGroup 处理列表动画(v-for)。六个 CSS 类名:v-enter-from / v-enter-active / v-enter-to / v-leave-from / v-leave-active / v-leave-to。JavaScript Hooks:@before-enter、@enter、@leave 等。
v-if + template(不生成多余 DOM)
// 多个元素需要同一种条件判断,但不想加额外 DOM 节点
<template v-if="isLoggedIn">
<h2>欢迎回来</h2>
<p>上次登录:{{ lastLogin }}</p>
<button @click="handleLogout">退出</button>
</template>
// v-show 不支持 template(因为它操作 CSS,template 不生成 DOM 节点)
面试金句
v-if vs v-show:v-if 是真正的条件渲染(false 时 DOM 不存在),v-show 只是 CSS display:none(DOM 始终存在)。选择原则:频繁切换用 v-show(避免反复创建/销毁 DOM),条件不常变用 v-if(减少初始渲染开销)。v-if 和 v-for 不要同时用在同一元素。
决策表
| 场景 | 用哪个 | 原因 |
| Tab 切换 | v-show | 频繁切换,CSS 切换开销小 |
| 权限/角色判断 | v-if | 条件很少变,不需要隐藏 DOM |
| 弹窗/抽屉/对话框 | v-if | 初始为 false,不渲染避免初始化开销 |
| 折叠面板 | v-show | 频繁展开/收起 |
| tooltip / hover 提示 | v-show | 极频繁切换 |
| 需要进入/离开动画 | v-if | 配合 Transition 组件 |
| 获取隐藏元素尺寸 | v-show | v-if 为 false 时元素不存在 |
| 条件为 false 时避免组件初始化 | v-if | 不创建组件,不发请求 |
| 大量条件渲染(性能优先) | v-if | 减少 DOM 节点数量 |
警告:v-if 和 v-for 不能一起用!
Vue 3 中 v-if 优先级高于 v-for,但混用会导致逻辑混乱和性能问题。应该用 computed 先过滤,或者用 <template v-for> 包裹 <div v-if>。
// ❌ 错误:v-if 和 v-for 一起用
<div v-for="item in items" v-if="item.active">
{{ item.name }}
</div>
// ✅ 正确:用 computed 过滤
<div v-for="item in activeItems" :key="item.id">
{{ item.name }}
</div>
// computed
const activeItems = computed(() => items.value.filter(item => item.active))
// ✅ 或者:外层 template v-for,内层 v-if
<template v-for="item in items" :key="item.id">
<div v-if="item.active">{{ item.name }}</div>
</template>
面试金句
computed 创建计算属性,自动追踪依赖并缓存结果——依赖不变时返回缓存值,依赖变化时重新计算。vs methods:methods 每次调用都执行,computed 有缓存。Vue 3 中 computed 返回只读 ref,可通过 computed({ get, set }) 创建可写计算属性。
5. nextTick — 什么时候必须等
nextTick = 等快递到货再签收
→
你下了单,不能马上拿到,等送到了再操作
不用 nextTick = 下单就冲去取
→
还没送到呢,白跑一趟
类比理解:nextTick 就像等快递——你下了单(修改数据),不能马上去取(操作 DOM),因为快递还在路上(Vue 还没更新 DOM)。nextTick 就是"等快递到了通知我",然后你再签收(操作更新后的 DOM)。不用 nextTick 就像下单后立刻冲去快递站,发现还没到,白跑一趟。
✅ 什么时候必须用
1. 修改数据后立即操作 DOM(获取高度/宽度)
Vue 的 DOM 更新是异步的,修改数据后 DOM 还没更新,此时获取的尺寸是旧的。必须等 nextTick 后再操作。
2. 修改数据后 focus 输入框
用 v-if 控制输入框的显示,v-if 变为 true 后输入框还不存在,必须在 nextTick 中 focus。
3. $refs 操作
v-for 动态渲染的元素,渲染后才能通过 $refs 访问。必须在 nextTick 中操作。
4. 动态组件加载后操作
component :is 动态切换组件后,新组件还没 mount,需要在 nextTick 后操作。
❌ 什么时候不需要
1. 在 watch 里操作 DOM
watch 回调执行时,Vue 已经完成了 DOM 更新(watch 在微任务后执行),可以直接操作 DOM。
2. 在 mounted 里
mounted 时 DOM 已经就绪,不需要 nextTick(除非 mounted 中修改了数据又想立即操作更新后的 DOM)。
3. 可以用 CSS 解决的
如果只是控制显隐、过渡动画等,CSS 就够了,不需要用 JS 操作 DOM。
nextTick 原理:微任务队列
// Vue 的更新机制:
// 1. 数据变化 → 2. 标记组件需要更新 → 3. 放入微任务队列
// 4. 当前同步代码执行完 → 5. 执行微任务(更新 DOM)
// 6. nextTick 回调在 DOM 更新之后执行
// 简化的原理:
const pendingUpdates = []
let isFlushing = false
function queueUpdate(component) {
if (!pendingUpdates.includes(component)) {
pendingUpdates.push(component)
}
if (!isFlushing) {
isFlushing = true
queueMicrotask(() => { // 微任务
flushUpdates() // 批量更新 DOM
runNextTickCallbacks() // 执行 nextTick 回调
isFlushing = false
})
}
}
面试金句
nextTick 在下次 DOM 更新循环结束后执行回调。原理:Vue 的响应式更新是异步的(微任务队列),修改响应式数据后 DOM 不会立即更新,nextTick 确保在 DOM 更新后操作。使用场景:修改数据后立即获取更新后的 DOM 尺寸/位置、操作 DOM 后触发动画。
示例 1:获取 DOM 高度
<script setup>
import { ref, nextTick } from 'vue'
const items = ref(['A', 'B', 'C'])
const listRef = ref(null)
const listHeight = ref(0)
async function addItem() {
items.value.push('D')
// ❌ 错误:此时 DOM 还没更新,拿到的是旧高度
// listHeight.value = listRef.value.offsetHeight
// ✅ 正确:等 DOM 更新后再获取
await nextTick()
listHeight.value = listRef.value.offsetHeight
console.log('列表高度:', listHeight.value)
}
</script>
<template>
<div>
<ul ref="listRef">
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
<p>列表高度:{{ listHeight }}px</p>
<button @click="addItem">添加项目</button>
</div>
</template>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
示例 2:动态显示输入框并 focus
<script setup>
import { ref, nextTick } from 'vue'
const showInput = ref(false)
const inputRef = ref(null)
async function enableEdit() {
showInput.value = true
// v-if 变为 true,但 DOM 还没渲染 input
// ❌ inputRef.value.focus() → 报错:input 还不存在
// ✅ 等 DOM 更新后再 focus
await nextTick()
inputRef.value.focus()
}
function handleBlur() {
showInput.value = false
}
</script>
<template>
<div>
<span v-if="!showInput" @click="enableEdit">
点击编辑
</span>
<input
v-else
ref="inputRef"
@blur="handleBlur"
/>
</div>
</template>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
示例 3:聊天窗口滚动到底部
<script setup>
import { ref, nextTick } from 'vue'
const messages = ref([])
const chatRef = ref(null)
const newMessage = ref('')
async function sendMessage() {
if (!newMessage.value.trim()) return
messages.value.push({
id: Date.now(),
text: newMessage.value,
isMine: true
})
newMessage.value = ''
// ✅ 等新消息渲染后再滚动
await nextTick()
chatRef.value.scrollTop = chatRef.value.scrollHeight
}
// 接收他人消息也要滚动
watch(messages, async () => {
await nextTick()
chatRef.value.scrollTop = chatRef.value.scrollHeight
})
</script>
<template>
<div class="chat">
<div ref="chatRef" class="chat-messages">
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg', { mine: msg.isMine }]"
>
{{ msg.text }}
</div>
</div>
<input v-model="newMessage" @keyup.enter="sendMessage" />
</div>
</template>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
决策表
| 场景 | 要不要 nextTick | 原因 |
| 修改数据后获取 DOM 尺寸 | ✅ 必须 | DOM 还没更新,获取的是旧值 |
| v-if 显示后 focus 输入框 | ✅ 必须 | 元素还不存在,无法 focus |
| 渲染后滚动到指定位置 | ✅ 必须 | 元素还没渲染,无法滚动 |
| 动态组件后操作子组件方法 | ✅ 必须 | 子组件还没 mount |
| 在 watch 中操作 DOM | 通常不需要 | watch 回调时 DOM 已更新 |
| 在 mounted 中操作 DOM | 通常不需要 | mounted 时 DOM 已就绪 |
| 可以用 CSS 替代的操作 | ❌ 不要用 | CSS 更高效,优先用 CSS |
提示:不要过度使用 nextTick
nextTick 是"逃生舱",不是常规操作。如果大量使用 nextTick,说明你的数据流设计有问题。优先用 Vue 的声明式渲染(computed、v-bind、watch),只在必须操作 DOM 时才用 nextTick。
6. Pinia vs Vuex — 状态管理选型
不需要状态管理 = 两三人小组口头沟通
Pinia = 企业微信(Vue3 版)
→
轻量、现代、类型安全
Vuex = 老式 OA 系统
→
能用,但笨重、模板多、TS 支持差
类比理解:如果项目只有两三个组件,就像两三人的小组——口头沟通(props/emits)就够了,不需要什么系统。Pinia 就像企业微信——轻量、现代、沟通方便,Vue3 时代的标配。Vuex 就像老式 OA 系统——功能是全的,但流程繁琐(mutations/actions/state/getters 四件套),TypeScript 支持差,新项目不推荐。
✅ Pinia 什么时候用
1. Vue 3 项目
Pinia 是 Vue 3 官方推荐的状态管理库,完美支持 Composition API。
2. 新项目
没有历史包袱,直接用 Pinia,更简洁、类型推导更好。
3. 需要 TypeScript 支持
Pinia 天然支持 TypeScript,类型推导自动,不需要手动声明类型。
4. 模块化更好
每个 Store 独立定义,不需要嵌套模块,天然 tree-shakable。
✅ Vuex 什么时候用
1. Vue 2 项目
Vue 2 + Vuex 4 是经典搭配,迁移成本高的话就别换。
2. 历史遗留项目
已有大量 Vuex 代码的项目,渐进迁移可以,不建议一次性全换。
3. 团队已经熟悉 Vuex
团队全员熟悉 Vuex,学习成本是考虑因素,但新项目仍推荐 Pinia。
❌ 什么时候不需要状态管理
1. 组件自己的状态(props/emits 够用)
只有父子组件通信,不需要全局状态。
2. 临时状态
弹窗开关、loading 状态、表单临时数据——这些放在组件内部就好。
3. 简单的跨组件通信
provide/inject 或 mitt 事件总线就够了,不需要引入整个状态管理库。
Pinia: Setup Store vs Option Store
// Option Store(类似 Vuex 的结构)
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
token: '',
loading: false
}),
getters: {
isLoggedIn: (state) => !!state.token,
fullName: (state) => state.user ? `${state.user.firstName} ${state.user.lastName}` : ''
},
actions: {
async login(credentials) {
this.loading = true
try {
const res = await api.login(credentials)
this.token = res.token
this.user = res.user
localStorage.setItem('token', res.token)
} finally {
this.loading = false
}
},
logout() {
this.token = ''
this.user = null
localStorage.removeItem('token')
}
}
})
// Setup Store(类似 Composition API)
export const useUserStore = defineStore('user', () => {
// state
const user = ref(null)
const token = ref('')
const loading = ref(false)
// getters
const isLoggedIn = computed(() => !!token.value)
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
// actions
async function login(credentials) {
loading.value = true
try {
const res = await api.login(credentials)
token.value = res.token
user.value = res.user
localStorage.setItem('token', res.token)
} finally {
loading.value = false
}
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('token')
}
return { user, token, loading, isLoggedIn, fullName, login, logout }
})
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
跨组件使用 Store
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
function addItem(product) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...product, quantity: 1 })
}
}
function removeItem(productId) {
items.value = items.value.filter(i => i.id !== productId)
}
function clearCart() {
items.value = []
}
return { items, totalItems, totalPrice, addItem, removeItem, clearCart }
})
// ProductCard.vue — 添加商品到购物车
<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
function handleAddToCart(product) {
cartStore.addItem(product)
}
</script>
// Header.vue — 显示购物车数量
<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
// cartStore.totalItems 自动响应式更新
</script>
<template>
<div class="header-cart">
🛒 {{ cartStore.totalItems }}
</div>
</template>
// CartPage.vue — 显示购物车详情
<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
</script>
<template>
<div>
<div v-for="item in cartStore.items" :key="item.id">
{{ item.name }} × {{ item.quantity }} = ¥{{ item.price * item.quantity }}
<button @click="cartStore.removeItem(item.id)">删除</button>
</div>
<div>总计:¥{{ cartStore.totalPrice }}</div>
</div>
</template>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
Pinia vs Vuex 对比
| 特性 | Pinia | Vuex |
| mutations | 不需要 | 必须(同步操作) |
| actions | 直接写函数 | 必须定义在 actions 里 |
| 嵌套模块 | 不需要,每个 store 独立 | 需要 modules + namespaced |
| TypeScript | 天然支持 | 需要大量类型声明 |
| DevTools | 支持 | 支持 |
| SSR | 支持 | 支持 |
| 代码量 | 少 30-50% | 模板代码多 |
| Vue 2 支持 | v2 版本支持 | 原生支持 |
| 学习曲线 | 低 | 中 |
| Composition API | 完美适配 | 适配一般 |
决策表
| 场景 | 选什么 | 原因 |
| Vue 3 新项目 | Pinia | 官方推荐,完美支持 Composition API |
| Vue 2 新项目 | Vuex 4 | Vuex 对 Vue 2 支持更成熟 |
| Vue 2 → Vue 3 迁移 | Pinia | Pinia 同时支持 Vue 2 和 3,可以先迁移到 Pinia 再升级 Vue |
| 需要 TS | Pinia | 类型推导自动,不需要手动声明 |
| 大型历史 Vuex 项目 | 保持 Vuex | 迁移成本高,渐进式迁移 |
| 只有父子通信 | 都不需要 | props/emits 够用 |
| 只有两三个共享状态 | provide/inject | 不需要引入整个状态管理库 |
7. 组件通信 — 6种方式选型
props/emits = 父母和孩子对话
provide/inject = 爷爷直接给孙子零花钱
Pinia = 学校广播(全校都能听到)
EventBus = 邮局寄信(谁都能寄谁都能收)
类比理解:props/emits 就像父母和孩子对话——直接、清晰、有来有往。provide/inject 就像爷爷直接给孙子零花钱——跳过中间代,不用一层层转交。Pinia 就像学校广播——全校都能听到,适合跨区域通知。EventBus 就像邮局寄信——谁都能寄谁都能收,但信可能寄丢、可能寄重复。
方式 1:props / emits — 父子通信
// Parent.vue
<template>
<Child
:message="parentMsg"
:count="count"
@update:count="count = $event"
@submit="handleSubmit"
/>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const parentMsg = ref('来自父组件的消息')
const count = ref(0)
function handleSubmit(payload) {
console.log('子组件提交:', payload)
}
</script>
// Child.vue
<template>
<div>
<p>{{ message }}</p>
<p>计数:{{ count }}</p>
<button @click="increment">+1</button>
<button @click="submitForm">提交</button>
</div>
</template>
<script setup>
const props = defineProps({
message: { type: String, required: true },
count: { type: Number, default: 0 }
})
const emit = defineEmits(['update:count', 'submit'])
function increment() {
emit('update:count', props.count + 1)
}
function submitForm() {
emit('submit', { count: props.count, timestamp: Date.now() })
}
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
方式 2:provide / inject — 祖孙(跨层级)
// Grandparent.vue(爷爷组件)
<script setup>
import { provide, ref, readonly } from 'vue'
const theme = ref('dark')
const user = ref({ name: 'Alice', role: 'admin' })
// ✅ 用 readonly 防止子组件直接修改(单向数据流)
provide('theme', readonly(theme))
provide('user', readonly(user))
// 提供修改方法(而不是让子组件直接修改)
provide('setTheme', (newTheme) => {
theme.value = newTheme
})
</script>
// Grandchild.vue(孙子组件,无论多深都能 inject)
<script setup>
import { inject } from 'vue'
// 第二个参数是默认值(找不到 provider 时使用)
const theme = inject('theme', 'light')
const user = inject('user', { name: '', role: 'guest' })
const setTheme = inject('setTheme', () => {})
</script>
<template>
<div :class="theme">
<p>当前主题:{{ theme }}</p>
<p>用户:{{ user.name }}({{ user.role }})</p>
<button @click="setTheme('light')">切换亮色</button>
</div>
</template>
// 用 Symbol 做 key(推荐,避免命名冲突)
// keys.js
export const ThemeKey = Symbol('theme')
export const UserKey = Symbol('user')
// Provider
provide(ThemeKey, readonly(theme))
// Injector
const theme = inject(ThemeKey, 'light')
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
方式 3:Pinia — 跨组件 / 全局状态
// 已经在上一节详细介绍了 Pinia
// 这里展示典型的跨组件通信场景
// stores/notification.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useNotificationStore = defineStore('notification', () => {
const notifications = ref([])
const unreadCount = computed(() =>
notifications.value.filter(n => !n.read).length
)
function addNotification(notification) {
notifications.value.push({
id: Date.now(),
read: false,
timestamp: new Date(),
...notification
})
}
function markAsRead(id) {
const n = notifications.value.find(n => n.id === id)
if (n) n.read = true
}
return { notifications, unreadCount, addNotification, markAsRead }
})
// 任何组件都可以发通知
// OrderForm.vue
const notificationStore = useNotificationStore()
notificationStore.addNotification({ title: '新订单', message: '订单 #123 已创建' })
// 任何组件都可以读通知
// Header.vue
const notificationStore = useNotificationStore()
// {{ notificationStore.unreadCount }} 自动更新
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
方式 4:EventBus (mitt) — 兄弟 / 无关系组件
// eventBus.js
import mitt from 'mitt'
const emitter = mitt()
// 类型安全(TypeScript)
type Events = {
add-to-cart: { id: number; name: string; price: number }
user-logged-in: { userId: number }
notification: { type: string; message: string }
}
const emitter = mitt<Events>()
export default emitter
// ProductCard.vue(发送事件)
<script setup>
import emitter from '@/utils/eventBus'
function handleAdd(product) {
emitter.emit('add-to-cart', {
id: product.id,
name: product.name,
price: product.price
})
}
</script>
// CartSidebar.vue(接收事件)
<script setup>
import { onMounted, onUnmounted } from 'vue'
import emitter from '@/utils/eventBus'
function handleAddToCart(product) {
console.log('收到加入购物车事件:', product)
}
onMounted(() => {
emitter.on('add-to-cart', handleAddToCart)
})
// ✅ 必须在卸载时移除监听,否则内存泄漏
onUnmounted(() => {
emitter.off('add-to-cart', handleAddToCart)
})
</script>
面试金句
Vue 3 生命周期 Hooks:onMounted(DOM 挂载完成)、onUpdated(响应式数据变更导致 DOM 更新后)、onUnmounted(组件卸载,清理定时器/事件监听)。vs Options API:beforeCreate/created 被setup()替代。注册时机:必须在 setup() 同步注册,不能在异步回调中调用。
方式 5:$refs — 父调子方法
// Parent.vue
<template>
<div>
<button @click="validateForm">验证表单</button>
<button @click="resetForm">重置表单</button>
<!-- 通过 ref 获取子组件实例 -->
<ChildForm ref="formRef" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChildForm from './ChildForm.vue'
const formRef = ref(null)
function validateForm() {
// ✅ 直接调用子组件暴露的方法
const isValid = formRef.value?.validate()
if (isValid) {
submitData()
}
}
function resetForm() {
formRef.value?.reset()
}
</script>
// ChildForm.vue
<script setup>
import { reactive } from 'vue'
const form = reactive({
name: '',
email: ''
})
function validate() {
if (!form.name || !form.email) {
showError('请填写所有字段')
return false
}
return true
}
function reset() {
form.name = ''
form.email = ''
}
// ✅ 必须用 defineExpose 暴露方法,否则父组件访问不到
defineExpose({ validate, reset })
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
方式 6:$attrs — 透传属性
// 父组件传入很多属性,中间组件不需要处理,直接透传给底层组件
// Parent.vue
<template>
<FormWrapper
placeholder="请输入"
maxlength="20"
:disabled="isDisabled"
class="custom-input"
@input="handleInput"
/>
</template>
// FormWrapper.vue(中间层,不需要处理每个 prop)
<template>
<div class="form-wrapper">
<!-- v-bind="$attrs" 把所有未声明的 props 和事件透传给子组件 -->
<BaseInput v-bind="$attrs" />
</div>
</template>
// 如果不希望自动透传到根元素,设置 inheritAttrs: false
defineOptions({ inheritAttrs: false })
// BaseInput.vue(最终接收所有属性的组件)
<template>
<input v-bind="$attrs" />
</template>
面试金句
defineProps 声明组件接收的 props,是编译时宏(无需 import)。TypeScript 支持:defineProps<{ title: string; count?: number }>()。Vue 3.5+ 的 响应式 props 解构允许直接解构 props 且保持响应式(const { title } = defineProps())。props 是只读的,不能在子组件中直接修改。
决策表:关系 → 方式
| 组件关系 | 推荐方式 | 备选 | 原因 |
| 父子 | props/emits | $refs | 最直观,Vue 推荐方式 |
| 祖孙(跨层级) | provide/inject | Pinia | 避免 props 逐层传递 |
| 兄弟组件 | Pinia | EventBus | 共享状态用 Pinia,一次性事件用 EventBus |
| 跨组件/全局 | Pinia | provide/inject | 全局状态管理首选 |
| 无关系组件 | EventBus | Pinia | 一次性事件通知用 EventBus,共享状态用 Pinia |
| 父调子方法 | $refs + defineExpose | emits | 命令式调用,如表单 validate/reset |
| 属性透传 | $attrs | 逐个传 props | 减少中间组件的模板代码 |
警告:provide/inject 的响应式问题
provide/inject 本身不是响应式的。如果 provide 一个普通值,inject 得到的也是普通值。必须 provide 一个 ref 或 reactive 对象才能保持响应式。推荐用 readonly() 包装后 provide,防止子组件直接修改。
警告:EventBus 的内存泄漏
EventBus 监听器不会自动清理。如果在 onMounted 中注册了监听器,必须在 onUnmounted 中移除,否则组件销毁后监听器仍然存在,导致内存泄漏和重复执行。推荐用 Pinia 替代大部分 EventBus 场景。
8. 路由守卫 — 用哪个守卫
beforeEach = 小区大门保安
→
所有进出都查(全局验证)
beforeEnter = 单元门禁
→
只查进这栋楼的(路由级权限)
beforeRouteLeave = 出门检查
→
你确定要出门吗?(离开确认)
类比理解:beforeEach 就像小区大门的保安——所有人进出都要检查(全局登录验证、权限检查)。beforeEnter 就像单元门禁——只检查进入这栋楼的人(某条路由的专属权限)。beforeRouteLeave 就像出门检查——"你确定要出门吗?东西都带了吗?"(表单未保存确认、资源清理)。
1. beforeEach — 全局前置守卫
适用:登录验证、权限控制、全局逻辑
每次路由切换都会执行,适合做全局性的检查。如:未登录用户不能访问需要认证的页面。
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ ... })
// ✅ 全局登录验证
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
const isLoggedIn = !!token
// 白名单路由:不需要登录就能访问
const whiteList = ['/login', '/register', '/forgot-password']
if (whiteList.includes(to.path)) {
// 已登录用户访问登录页,重定向到首页
if (isLoggedIn) {
next({ path: '/' })
} else {
next()
}
} else {
// 需要认证的页面
if (isLoggedIn) {
next()
} else {
// ✅ 保存原目标路由,登录后跳回去
next({
path: '/login',
query: { redirect: to.fullPath }
})
}
}
})
// ✅ 全局权限检查
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 需要权限的路由
if (to.meta.requiresAuth) {
if (!userStore.isLoggedIn) {
next('/login')
return
}
// 需要特定角色
if (to.meta.requiredRole) {
if (userStore.role !== to.meta.requiredRole) {
next('/403')
return
}
}
}
next()
})
面试金句
beforeEach 是全局前置守卫,每次路由切换前执行,适合做登录验证和权限控制。返回 false 或调用 next(false) 会取消导航。Vue Router 4 推荐用 return 代替 next() 参数。
2. beforeEnter — 路由级前置守卫
适用:特定路由的权限控制、数据预加载
只在进入特定路由时执行,适合该路由独有的逻辑。如:管理员页面只允许 admin 角色进入。
const routes = [
{
path: '/admin',
component: () => import('../views/Admin.vue'),
meta: { requiresAuth: true },
beforeEnter: (to, from, next) => {
const userStore = useUserStore()
// ✅ 只有管理员能进入这个路由
if (userStore.role === 'admin') {
next()
} else {
next('/403')
}
}
},
{
path: '/editor/:id',
component: () => import('../views/Editor.vue'),
beforeEnter: async (to, from, next) => {
// ✅ 进入编辑器前预加载数据
try {
const doc = await api.getDocument(to.params.id)
// 把预加载的数据存到路由 meta 中
to.meta.document = doc
next()
} catch (error) {
next({ path: '/404' })
}
}
}
]
面试金句
defineAsyncComponent 定义异步组件,实现组件级代码分割。Vue 3 写法:defineAsyncComponent(() => import("Comp.vue"))。高级选项:loadingComponent、errorComponent、delay、timeout。搭配 Suspense 统一管理异步组件加载状态。
3. beforeRouteEnter — 组件内守卫(进入时)
适用:组件级权限检查、数据预加载
定义在组件内部,此时组件实例还未创建,拿不到 this。需要通过 next(vm => {}) 回调访问组件实例。
// UserDetail.vue
<script setup>
import { ref, onMounted } from 'vue'
import { onBeforeRouteEnter, onBeforeRouteUpdate, onBeforeRouteLeave } from 'vue-router'
const userData = ref(null)
// ✅ 进入组件前(组件实例还没创建)
// ⚠️ 拿不到 this,因为组件还没创建
onBeforeRouteEnter(async (to, from) => {
// 可以在这里做权限检查
if (!hasPermission('user:view')) {
return '/403'
}
// 可以预加载数据
const data = await api.getUser(to.params.id)
to.meta.userData = data
})
// ✅ 路由参数更新时(/user/1 → /user/2)
onBeforeRouteUpdate(async (to, from) => {
// 组件实例已存在,可以直接访问
userData.value = await api.getUser(to.params.id)
})
// ✅ 离开组件前
onBeforeRouteLeave((to, from) => {
// 见下面 beforeRouteLeave 详解
})
</script>
// Options API 写法(拿到组件实例)
export default {
beforeRouteEnter(to, from, next) {
// ⚠️ 拿不到 this
// ✅ 通过 next 的回调拿到组件实例
next(vm => {
vm.loadData(to.params.id)
})
}
}
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
4. beforeRouteUpdate — 同一路由不同参数
适用:/user/1 → /user/2 同一组件不同参数
复用组件时,路由参数变化不会重新创建组件。需要在 beforeRouteUpdate 中手动刷新数据。
// UserDetail.vue
<script setup>
import { ref } from 'vue'
import { onBeforeRouteUpdate } from 'vue-router'
const userData = ref(null)
const loading = ref(false)
async function loadUser(id) {
loading.value = true
try {
userData.value = await api.getUser(id)
} finally {
loading.value = false
}
}
// ✅ /user/1 → /user/2 时触发
// 组件不会重新创建,需要手动刷新数据
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await loadUser(to.params.id)
}
})
</script>
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
5. beforeRouteLeave — 离开确认
适用:表单未保存确认、离开前清理资源
最常见的用法:用户编辑了表单但未保存就要离开,弹出确认对话框。
// EditForm.vue
<script setup>
import { ref } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
const isDirty = ref(false) // 表单是否有未保存的修改
let pollingTimer = null
// ✅ 表单未保存确认
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
const answer = confirm(
'您有未保存的修改,确定要离开吗?离开后修改将丢失。'
)
if (!answer) {
return false // 取消导航
}
}
// ✅ 离开前清理资源
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
// 允许离开
return true
})
</script>
// 更优雅的确认方式(使用自定义对话框而非 confirm)
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
// 返回 Promise,等待用户操作
return new Promise((resolve) => {
showConfirmDialog({
title: '未保存的修改',
message: '您有未保存的修改,确定要离开吗?',
onConfirm: () => resolve(true),
onCancel: () => resolve(false)
})
})
}
})
面试金句
ref 用于创建基本类型的响应式数据(String/Number/Boolean),必须通过 .value 访问(模板中自动解包)。ref vs reactive 的选择:简单值用 ref,对象/数组用 reactive。但 ref 也可存对象——内部会自动调用 reactive 包装。实际开发中推荐统一用 ref,避免 reactive 的解构丢失响应式问题。
完整示例:登录拦截 + 权限控制
// router/guards.js
import { useUserStore } from '@/stores/user'
export function setupRouterGuards(router) {
// 1. 全局前置守卫:登录验证 + 权限检查
router.beforeEach(async (to, from) => {
const userStore = useUserStore()
// 白名单
const whiteList = ['/login', '/register', '/404', '/403']
if (whiteList.includes(to.path)) {
return true
}
// 未登录 → 跳登录页
if (!userStore.isLoggedIn) {
return {
path: '/login',
query: { redirect: to.fullPath }
}
}
// 首次进入,获取用户信息
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
} catch {
userStore.logout()
return { path: '/login' }
}
}
// 权限检查
if (to.meta.requiredRole) {
if (userStore.role !== to.meta.requiredRole) {
return { path: '/403' }
}
}
return true
})
// 2. 全局后置钩子:设置页面标题、上报等
router.afterEach((to, from) => {
// 设置页面标题
document.title = to.meta.title
? `${to.meta.title} - MyApp`
: 'MyApp'
// 页面访问上报
reportPageView(to.fullPath)
})
}
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { setupRouterGuards } from './guards'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' }
},
{
path: '/',
component: () => import('@/views/Home.vue'),
meta: { title: '首页', requiresAuth: true }
},
{
path: '/admin',
component: () => import('@/views/Admin.vue'),
meta: { title: '管理后台', requiresAuth: true, requiredRole: 'admin' }
},
{
path: '/profile',
component: () => import('@/views/Profile.vue'),
meta: { title: '个人资料', requiresAuth: true }
}
]
})
setupRouterGuards(router)
export default router
面试金句
defineAsyncComponent 定义异步组件,实现组件级代码分割。Vue 3 写法:defineAsyncComponent(() => import("Comp.vue"))。高级选项:loadingComponent、errorComponent、delay、timeout。搭配 Suspense 统一管理异步组件加载状态。
决策表:需求 → 守卫
| 需求 | 用哪个守卫 | 原因 |
| 全局登录验证 | beforeEach | 所有路由都需要检查 |
| 全局权限控制 | beforeEach | 统一处理角色权限 |
| 特定路由权限 | beforeEnter | 只对某个路由做检查 |
| 路由级数据预加载 | beforeEnter | 进入该路由前获取数据 |
| 组件级权限检查 | beforeRouteEnter | 在组件内部定义逻辑 |
| 同路由不同参数 | beforeRouteUpdate | 组件复用,参数变化时刷新 |
| 表单未保存确认 | beforeRouteLeave | 离开前拦截,防止数据丢失 |
| 离开前清理资源 | beforeRouteLeave | 清理定时器、WebSocket等 |
| 设置页面标题 | afterEach | 导航完成后更新标题 |
警告:next() 必须调用
在 Vue Router 4 中,推荐用 return 代替 next()。但如果使用 next(),必须确保每条逻辑路径都调用了 next(),否则路由导航会被卡住(页面不动,也不报错)。特别注意 if/else 分支和 try/catch 中的所有路径。
警告:异步守卫
异步守卫(如 await API 调用)期间,用户可能快速点击其他链接,导致多个导航同时进行。建议在 beforeEach 中检查导航是否被中断。Vue Router 4 提供了 NavigationFailure 来处理这种情况。
警告:无限重定向
守卫中重定向时要避免循环。如:beforeEach 中未登录就跳 /login,但 /login 的守卫又检查了什么导致再次重定向。确保白名单路由不会触发认证检查。
// ❌ 无限重定向的典型错误
router.beforeEach((to, from, next) => {
const isLoggedIn = false
if (!isLoggedIn) {
next('/login') // → 触发 /login 的 beforeEach
// → 又不满足条件 → 又 next('/login')
// → 无限循环!
} else {
next()
}
})
// ✅ 正确:白名单排除
router.beforeEach((to, from, next) => {
const isLoggedIn = false
const whiteList = ['/login', '/register']
if (whiteList.includes(to.path) || isLoggedIn) {
next()
} else {
next('/login') // /login 在白名单里,不会再次触发重定向
}
})
// ✅ Vue Router 4 推荐写法(用 return 代替 next)
router.beforeEach((to, from) => {
const isLoggedIn = false
const whiteList = ['/login', '/register']
if (whiteList.includes(to.path) || isLoggedIn) {
return true // 允许导航
}
return { // 重定向
path: '/login',
query: { redirect: to.fullPath }
}
})
面试金句
场景:决策表:需求 → 守卫
| 需求 | 用哪个守卫 | 原因 |
| 全局登录验证 | beforeEach | 所有路由都需要检查 |
| 全局权限控制 | beforeEach | 统一处理角色权限 |
| 特定路由权限 | beforeEnter | 只对某个路由做检查 |
| 路由级数据预加载 | beforeEnter | 进入该路由前获取数据 |
| 组件级权限检查 | beforeRouteEnter | 在组件内部定义逻辑 |
| 同路由不同参数 | beforeRouteUpdate | 组件复用,参数变化时刷新 |
| 表单未保存确认 | beforeRouteLeave | 离开前拦截,防止数据丢失 |
| 离开前清理资源 | beforeRouteLeave | 清理定时器、WebSocket等 |
| 设置页面标题 | afterEach | 导航完成后更新标题 |
警告:next() 必须调用
在 Vue Router 4 中,推荐用 return 代替 next()。但如果使用 next(),必须确保每条逻辑路径都调用了 next(),否则路由导航会被卡住(页面不动,也不报错)。特别注意 if/else 分支和 try/catch 中的所有路径。
警告:异步守卫
异步守卫(如 await API 调用)期间,用户可能快速点击其他链接,导致多个导航同时进行。建议在 beforeEach 中检查导航是否被中断。Vue Router 4 提供了 NavigationFailure 来处理这种情况。
警告:无限重定向
守卫中重定向时要避免循环。如:beforeEach 中未登录就跳 /login,但 /login 的守卫又检查了什么导致再次重定向。确保白名单路由不会触发认证检查。
Vue 3 场景决策 — 什么时候用 / 什么时候不用 / 边界
← 返回场景决策