Vue3 高频面试题
1. Vue3 的响应式原理(Proxy)
Vue3 使用 Proxy 替代 Vue2 的 Object.defineProperty,实现了更强大、更高效的响应式系统。
核心:reactive(对象) + ref(基本类型) + effect(副作用追踪)
| 特性 | Vue2(defineProperty) | Vue3(Proxy) |
|---|---|---|
| 检测属性添加/删除 | ❌ 需要 $set/$delete |
✅ 自动检测 |
| 数组下标修改 | ❌ 需要 $set |
✅ 自动检测 |
| Map/Set/WeakMap | ❌ | ✅ |
| 深度监听 | 递归遍历(初始化开销大) | 惰性代理(访问时才代理) |
| 性能 | 初始化慢 | 按需代理,更快 |
javascript
// ========== Vue3 响应式核心原理(简化) ==========
const targetMap = new WeakMap() // 全局依赖存储
let activeEffect = null // 当前正在执行的副作用
// 依赖收集
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Set()))
dep.add(activeEffect)
}
// 派发更新
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) dep.forEach(effect => effect())
}
// reactive 实现
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
const result = Reflect.get(target, key, receiver)
// ✅ 惰性代理:访问嵌套对象时才递归创建 Proxy
if (typeof result === 'object' && result !== null) {
return reactive(result)
}
return result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (oldValue !== value) trigger(target, key)
return result
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key)
trigger(target, key) // ✅ 删除属性也能触发更新
return result
}
})
}
// ref 实现(包装基本类型)
function ref(value) {
const wrapper = {
get value() {
track(wrapper, 'value')
return value
},
set value(newVal) {
if (newVal !== value) {
value = newVal
trigger(wrapper, 'value')
}
}
}
return wrapper
}
💡 面试加分点: Vue3 的"惰性代理"是性能提升的关键------只有访问嵌套对象时才创建 Proxy,而非像 Vue2 一样初始化时递归遍历。
WeakMap存储依赖关系,当对象被 GC 时依赖自动清理,不会内存泄漏。
2. ref 和 reactive 怎么选?
| 特性 | ref |
reactive |
|---|---|---|
| 适用类型 | 任意类型(基本类型 + 对象) | 仅对象/数组 |
| 访问方式 | .value(模板中自动解包) |
直接访问属性 |
| 重新赋值 | ✅ count.value = 10 |
❌ 不能整体替换 |
| 解构 | ✅ 不丢失响应式 | ❌ 解构丢失响应式 |
| TS 推断 | 自动推断泛型 | 需要接口定义 |
javascript
import { ref, reactive, toRefs, toRef, isRef, unref, shallowRef, triggerRef } from 'vue'
// ✅ ref:推荐作为默认选择
const count = ref(0)
const name = ref('张三')
const user = ref({ name: '张三', age: 25 })
user.value = { name: '李四', age: 30 } // ✅ 可以整体替换
// ✅ reactive:复杂对象场景
const state = reactive({ count: 0, list: [], user: null })
state.count++ // 直接修改
state.list.push('item') // 数组方法也是响应式的
// state = { count: 1 } // ❌ 不能整体替换!
// ⚠️ reactive 解构会丢失响应式
const { count } = state // ❌ count 不再是响应式的
const { count } = toRefs(state) // ✅ toRefs 保持响应式
const countRef = toRef(state, 'count') // ✅ 取单个属性的 ref
// ✅ shallowRef:大数据优化(只追踪 .value 变化,不递归代理内部)
const bigData = shallowRef({ list: new Array(10000) })
bigData.value.list[0] = 'new' // ❌ 不会触发更新
bigData.value = { list: [...] } // ✅ 替换 .value 才触发
triggerRef(bigData) // ✅ 手动触发更新
// ✅ 实用工具
isRef(count) // true
unref(count) // 0(ref 返回 .value,否则直接返回)
💡 面试加分点: Vue 官方推荐统一用
ref。ref的优势:可整体替换、解构不丢失响应式、更易追踪(.value访问)。shallowRef+triggerRef是大数据场景的标准优化手段。
3. Composition API vs Options API
Composition API 是 Vue3 最重要的新特性,将逻辑按功能聚合而非按选项分散。
javascript
// ❌ Options API 的问题:同一功能的代码分散在不同选项中
export default {
data() {
return {
// 功能A的数据 和 功能B的数据 混在一起
userList: [], userLoading: false,
searchQuery: '', searchResults: []
}
},
computed: { /* A和B的computed混在一起 */ },
methods: { /* A和B的方法混在一起 */ },
watch: { /* A和B的watch混在一起 */ }
}
// ✅ Composition API:逻辑按功能聚合,可提取为 composable
// composables/useUsers.ts
export function useUsers() {
const list = ref([])
const loading = ref(false)
const fetchUsers = async () => {
loading.value = true
try { list.value = await api.getUsers() }
finally { loading.value = false }
}
onMounted(fetchUsers)
return { list, loading, fetchUsers }
}
// composables/useSearch.ts
export function useSearch(fetchFn) {
const query = ref('')
const results = ref([])
const debouncedSearch = useDebounceFn(async () => {
results.value = await fetchFn(query.value)
}, 300)
watch(query, debouncedSearch)
return { query, results }
}
// 组件中组合使用
// <script setup>
const { list, loading, fetchUsers } = useUsers()
const { query, results } = useSearch(api.searchUsers)
// </script>
Composable 编写规范:
javascript
// ✅ 完整的 composable 示例
// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url))
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
} catch (err) {
error.value = err as Error
} finally {
loading.value = false
}
}
watchEffect(() => { toValue(url); execute() }) // url 变化自动重新请求
return { data, error, loading, refetch: execute }
}
// 使用
const { data: users, loading, error } = useFetch<User[]>('/api/users')
// 响应式 URL(URL 变了自动重新请求)
const userId = ref(1)
const { data: user } = useFetch(() => `/api/users/${userId.value}`)
💡 面试加分点: Composable 命名以
use开头,返回 ref 而非 reactive(方便解构)。Composable 可以接受MaybeRefOrGetter类型参数,用toValue()解包,实现既支持静态值又支持响应式值。
4. <script setup> 语法糖详解
html
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import MyComponent from './MyComponent.vue'
// ✅ 顶层变量/函数自动暴露给模板
const count = ref(0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
// ✅ 导入的组件自动注册
// 直接在模板中使用 <MyComponent />
// ✅ defineProps:声明 props(编译器宏,不需要 import)
interface Props {
title: string
count?: number
user?: { name: string; age: number }
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
user: () => ({ name: '', age: 0 })
})
// ✅ defineEmits:声明事件
const emit = defineEmits<{
(e: 'update', value: string): void
(e: 'delete', id: number): void
}>()
// Vue 3.3+ 简化写法
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
}>()
// ✅ defineModel:双向绑定(Vue 3.4+)
const modelValue = defineModel<string>()
const name = defineModel<string>('name')
// ✅ defineExpose:暴露给父组件的属性/方法
const inputRef = ref<HTMLInputElement>()
const focus = () => inputRef.value?.focus()
defineExpose({ focus, count })
// ✅ defineOptions:声明组件选项(Vue 3.3+)
defineOptions({
name: 'MyComponent',
inheritAttrs: false
})
// ✅ defineSlots:声明插槽类型(Vue 3.3+)
const slots = defineSlots<{
default(props: { msg: string }): any
header(props: { title: string }): any
}>()
// ✅ useAttrs / useSlots
import { useAttrs, useSlots } from 'vue'
const attrs = useAttrs()
const slots2 = useSlots()
</script>
💡 面试加分点:
<script setup>中的defineProps、defineEmits、defineModel、defineExpose、defineOptions、defineSlots都是编译器宏 ,不需要import。Vue 3.3 新增了defineOptions和defineSlots,Vue 3.4 新增了defineModel。
5. Vue3 生命周期
scss
setup() ← 替代 beforeCreate + created
onBeforeMount() ← beforeMount
onMounted() ← mounted
onBeforeUpdate() ← beforeUpdate
onUpdated() ← updated
onBeforeUnmount() ← beforeDestroy(Vue2)
onUnmounted() ← destroyed(Vue2)
onActivated() ← keep-alive 激活
onDeactivated() ← keep-alive 停用
onErrorCaptured() ← 捕获后代错误
onServerPrefetch() ← SSR 专用
javascript
// <script setup>
import {
onBeforeMount, onMounted, onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted, onActivated, onDeactivated,
onErrorCaptured
} from 'vue'
// ✅ setup 本身 = beforeCreate + created
console.log('setup 执行(等同于 created)')
const data = ref(null)
fetchData().then(res => data.value = res) // 在 setup 中发请求
onMounted(() => {
console.log('DOM 挂载完成')
// 初始化第三方库、操作 DOM、添加事件监听
initChart()
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
// ✅ 清理副作用(避免内存泄漏)
destroyChart()
window.removeEventListener('resize', handleResize)
clearInterval(timer)
})
onErrorCaptured((err, instance, info) => {
console.error('子组件错误:', err)
return false // 阻止向上传播
})
// ✅ 同一个钩子可以注册多次(按顺序执行)
onMounted(() => console.log('mounted 回调 1'))
onMounted(() => console.log('mounted 回调 2'))
父子组件生命周期顺序(与 Vue2 相同):
arduino
挂载:父setup → 父onBeforeMount → 子setup → 子onBeforeMount → 子onMounted → 父onMounted
更新:父onBeforeUpdate → 子onBeforeUpdate → 子onUpdated → 父onUpdated
卸载:父onBeforeUnmount → 子onBeforeUnmount → 子onUnmounted → 父onUnmounted
💡 面试加分点:
setup()中没有this,所有组合式 API 的生命周期钩子可以注册多次且按顺序执行。onServerPrefetch是 SSR 专用钩子,在服务端渲染时预取数据。
6. computed 与 watch / watchEffect
| 特性 | computed | watch | watchEffect |
|---|---|---|---|
| 依赖声明 | 自动追踪 | 显式指定 | 自动追踪 |
| 缓存 | ✅ | ❌ | ❌ |
| 旧值 | ❌ | ✅ | ❌ |
| 立即执行 | 访问时执行 | immediate: true |
默认立即 |
| 异步 | ❌ | ✅ | ✅ |
| 用途 | 派生数据 | 监听变化做副作用 | 自动追踪多依赖 |
javascript
import { ref, computed, watch, watchEffect, watchPostEffect } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const searchQuery = ref('')
const page = ref(1)
// ✅ computed:派生数据(有缓存)
const fullName = computed(() => `${firstName.value}${lastName.value}`)
// 可写 computed
const writableFullName = computed({
get: () => `${firstName.value}${lastName.value}`,
set: (val) => { firstName.value = val[0]; lastName.value = val.slice(1) }
})
// ✅ watch:显式监听,可获取旧值
watch(searchQuery, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`)
page.value = 1
})
// 监听多个源
watch([searchQuery, page], ([q, p], [oldQ, oldP]) => {
fetchData(q, p)
})
// 选项
watch(source, callback, {
immediate: true, deep: true,
flush: 'post', // 'pre'(默认) | 'post'(DOM更新后) | 'sync'
once: true, // Vue 3.4+,只触发一次
})
// ✅ watchEffect:自动追踪 + 默认立即执行
const stop = watchEffect((onCleanup) => {
const controller = new AbortController()
fetch(`/api?q=${searchQuery.value}&p=${page.value}`, {
signal: controller.signal
}).then(r => r.json()).then(data => { /* ... */ })
onCleanup(() => controller.abort()) // ✅ 清理上一次请求(处理竞态)
})
stop() // 手动停止
// ✅ watchPostEffect:DOM 更新后执行(= watchEffect + flush: 'post')
watchPostEffect(() => {
// 可以安全访问更新后的 DOM
})
💡 面试加分点:
watchEffect的onCleanup是处理竞态条件 的标准方案------依赖变化重新执行前,先取消上一次未完成的请求。Vue 3.5+ 中onCleanup改名为onWatcherCleanup,可从vue导入。
watch 与 watchEffect 的核心区别(面试重点):
| 区别点 | watch |
watchEffect |
|---|---|---|
| 依赖声明方式 | 显式指定监听源 | 自动收集回调中使用的响应式依赖 |
| 执行时机 | 默认惰性(变化才执行) | 立即执行一次(收集依赖) |
| 旧值访问 | ✅ 回调第二个参数是旧值 | ❌ 无法获取旧值 |
| 监听精度 | 可以精确指定某个 ref / 某个属性 | 回调中所有访问到的响应式数据都是依赖 |
| 深度监听 | 需要 { deep: true } |
默认会追踪回调中所有嵌套访问 |
| 适用场景 | 需要旧值对比 / 精确控制 / 条件触发 | 多依赖自动追踪 / 副作用同步 |
javascript
import { ref, reactive, watch, watchEffect } from 'vue'
const userId = ref(1)
const filters = reactive({ status: 'active', page: 1 })
const data = ref(null)
// ============ watch 适用场景 ============
// 场景1:需要旧值对比
watch(userId, (newId, oldId) => {
console.log(`用户从 ${oldId} 切换到 ${newId}`)
// 可以基于新旧值做不同处理
if (oldId !== newId) {
resetPageState()
}
fetchUser(newId)
})
// 场景2:精确监听对象中的某个属性(用 getter 函数)
watch(
() => filters.status, // ⚠️ 监听 reactive 对象的属性必须用 getter
(newStatus) => {
console.log('状态筛选变了:', newStatus)
filters.page = 1 // 切换状态时重置页码
}
)
// 场景3:监听整个 reactive 对象(自动 deep)
watch(filters, (newVal) => {
// ⚠️ reactive 对象作为监听源时,自动深度监听
// ⚠️ 此时 newVal === oldVal(同一个对象引用)
fetchList(newVal)
})
// 场景4:只在特定条件下才执行操作
watch(userId, (id) => {
if (id > 0) { // 条件判断
fetchUser(id)
}
}, { immediate: true })
// 场景5:使用 once 只触发一次(Vue 3.4+)
watch(userId, (id) => {
analytics.track('first_user_view', { id })
}, { once: true })
// ============ watchEffect 适用场景 ============
// 场景1:多依赖自动追踪(不用手动列出所有依赖)
watchEffect(async () => {
// 自动追踪 userId.value、filters.status、filters.page
const res = await fetch(
`/api/users/${userId.value}?status=${filters.status}&page=${filters.page}`
)
data.value = await res.json()
// 任何一个依赖变化都会重新执行
})
// 场景2:副作用与依赖绑定(类似 React useEffect 但不需要依赖数组)
watchEffect(() => {
document.title = `用户 ${userId.value} - 第 ${filters.page} 页`
// 不需要像 watch 那样手动列出 [userId, () => filters.page]
})
// 场景3:配合 onCleanup 处理竞态
watchEffect((onCleanup) => {
const controller = new AbortController()
fetch(`/api/users/${userId.value}`, { signal: controller.signal })
.then(r => r.json())
.then(d => { data.value = d })
// 依赖变化时,先取消上一次请求
onCleanup(() => controller.abort())
})
// ============ 常见误区 ============
// ❌ 误区1:watchEffect 中使用条件分支导致依赖收集不完整
watchEffect(() => {
if (userId.value > 0) {
// 只有 userId > 0 时才会访问 filters.status
// 所以 userId <= 0 时,filters.status 的变化不会触发重新执行!
fetch(`/api?status=${filters.status}`)
}
})
// ✅ 解决:把所有需要追踪的依赖放在条件外面先"读"一下
watchEffect(() => {
const id = userId.value
const status = filters.status // 确保被追踪
if (id > 0) {
fetch(`/api?status=${status}`)
}
})
// ❌ 误区2:watch 监听 reactive 属性忘记用 getter
watch(filters.status, (val) => { /* ... */ })
// ⚠️ 这里 filters.status 是一个字符串值 'active',不是响应式的!
// ✅ 正确写法
watch(() => filters.status, (val) => { /* ... */ })
// ❌ 误区3:watchEffect 中使用异步操作后的响应式访问不会被追踪
watchEffect(async () => {
const res = await fetch('/api')
// ⚠️ await 之后的代码在微任务中执行
// 此时 watchEffect 的同步追踪阶段已结束
console.log(userId.value) // 这个访问 **不会** 被追踪为依赖!
})
// ✅ 解决:在 await 之前先访问所有需要追踪的依赖
watchEffect(async () => {
const id = userId.value // ✅ 在 await 之前访问
const res = await fetch(`/api/users/${id}`)
data.value = await res.json()
})
选择指南(面试答题模板):
用 watch 当你需要:
├── 获取旧值对比(newVal vs oldVal)
├── 精确监听某个特定数据源
├── 惰性执行(只在变化时才触发)
├── 配合 immediate / deep / once 等选项精细控制
└── 监听 props 的变化
用 watchEffect 当你需要:
├── 依赖多个响应式数据,不想手动列出
├── 默认立即执行一次(初始化逻辑)
├── 副作用与依赖自动绑定(类似 React useEffect 但更智能)
└── 代码更简洁、不关心旧值
7. Pinia 状态管理(全面详解)
Pinia 是 Vue3 官方推荐的状态管理库,替代 Vuex。
Pinia vs Vuex 对比:
| 特性 | Pinia | Vuex |
|---|---|---|
| Vue 版本 | Vue2/Vue3 | Vue2(Vuex3) / Vue3(Vuex4) |
| mutations | ❌ 没有(直接修改 state) | ✅ 必须通过 mutations |
| 模块化 | 天然支持(每个 store 独立) | modules + namespaced |
| TypeScript | ✅ 完美支持 | ❌ 需要额外类型声明 |
| 体积 | ~1KB | ~10KB |
| DevTools | ✅ | ✅ |
| SSR | ✅ 简单 | 需要额外配置 |
| 组合式写法 | ✅ setup() 风格 |
❌ |
javascript
// ========== 安装与配置 ==========
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
const app = createApp(App)
app.use(createPinia())
// ========== 选项式 Store ==========
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
double: (state) => state.count * 2,
// getter 使用其他 getter
doublePlusOne(): number {
return this.double + 1 // ✅ 通过 this 访问其他 getter
}
},
actions: {
increment() { this.count++ },
async fetchCount() {
const { data } = await api.getCount()
this.count = data.count
// ✅ action 中可以直接修改 state(没有 mutations 的概念)
}
}
})
// ========== 组合式 Store(推荐) ==========
// stores/user.ts
export const useUserStore = defineStore('user', () => {
// ref → state
const userInfo = ref<UserInfo | null>(null)
const token = ref(localStorage.getItem('token') || '')
// computed → getters
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.name ?? '游客')
// function → actions
async function login(credentials: LoginParams) {
const { data } = await api.login(credentials)
token.value = data.token
userInfo.value = data.userInfo
localStorage.setItem('token', data.token)
}
async function logout() {
await api.logout()
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
}
function $reset() {
token.value = ''
userInfo.value = null
}
return { userInfo, token, isLoggedIn, userName, login, logout, $reset }
})
// ========== 组件中使用 ==========
// <script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// ⚠️ 直接解构会丢失响应式
const { isLoggedIn } = userStore // ❌ 不是响应式的
const { isLoggedIn } = storeToRefs(userStore) // ✅ storeToRefs 保持响应式
// 注意:actions 不需要 storeToRefs,直接解构
const { login, logout } = userStore // ✅ 方法直接解构
// 直接修改 state
userStore.token = 'new-token' // ✅ 直接修改
userStore.$patch({ token: 'new' }) // ✅ 批量修改
userStore.$patch(state => { // ✅ 函数式批量修改
state.token = 'new'
state.userInfo = null
})
userStore.$reset() // ✅ 重置为初始值
// </script>
javascript
// ========== Store 间互相调用 ==========
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))
async function checkout() {
const userStore = useUserStore() // ✅ 在 action 中调用其他 store
if (!userStore.isLoggedIn) throw new Error('请先登录')
await api.checkout(items.value)
items.value = []
}
return { items, total, checkout }
})
// ========== Pinia 插件 ==========
// plugins/piniaPersistedState.ts(持久化插件)
import { watch } from 'vue'
export function piniaPersistedState({ store }) {
// 从 localStorage 恢复
const saved = localStorage.getItem(`pinia-${store.$id}`)
if (saved) store.$patch(JSON.parse(saved))
// 订阅变化自动保存
store.$subscribe((mutation, state) => {
localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
})
}
// main.ts
const pinia = createPinia()
pinia.use(piniaPersistedState)
// ========== 推荐使用 pinia-plugin-persistedstate ==========
// npm install pinia-plugin-persistedstate
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)
// store 中启用
export const useUserStore = defineStore('user', {
state: () => ({ token: '', userInfo: null }),
persist: {
key: 'user-store',
storage: localStorage, // 或 sessionStorage
pick: ['token'], // 只持久化 token
}
})
💡 面试加分点: Pinia 去掉 mutations 的设计理由------mutations 在 Vuex 中的目的是让 DevTools 追踪状态变化,但 Pinia 通过
$subscribe同样实现了追踪,不需要额外概念。组合式 Store 中$reset()需要手动实现(选项式自动支持)。
8. Vue Router 4(Vue3 版本)全面详解
javascript
// ========== 创建路由 ==========
import { createRouter, createWebHistory, createWebHashHistory, createMemoryHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // History 模式
// history: createWebHashHistory(), // Hash 模式
// history: createMemoryHistory(), // 内存模式(SSR)
routes: [
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: 'about', name: 'About', component: () => import('@/views/About.vue') },
]
},
// 动态路由
{ path: '/user/:id(\\d+)', name: 'User', component: () => import('@/views/User.vue'), props: true },
// 可选参数
{ path: '/search/:keyword?', name: 'Search', component: () => import('@/views/Search.vue') },
// 命名视图(一个页面多个 router-view)
{
path: '/admin',
components: {
default: () => import('@/views/Admin.vue'),
sidebar: () => import('@/components/AdminSidebar.vue')
}
},
// 重定向
{ path: '/home', redirect: '/' },
{ path: '/user-home', redirect: { name: 'User', params: { id: 1 } } },
// 别名
{ path: '/dashboard', component: Dashboard, alias: ['/panel', '/cp'] },
// 404 通配符(必须放最后)
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') }
],
// 滚动行为
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0, behavior: 'smooth' }
}
})
路由模式对比:
| 特性 | createWebHistory |
createWebHashHistory |
createMemoryHistory |
|---|---|---|---|
| URL | /user/1 |
/#/user/1 |
无 URL 变化 |
| 服务器配置 | ✅ 需要 fallback | ❌ 不需要 | - |
| SEO | ✅ 友好 | ❌ | - |
| 使用场景 | 生产环境 | 兼容场景 | SSR / 测试 |
9. Vue Router 4 导航守卫(完整)
javascript
// ========== 完整导航流程 ==========
// 1. 导航触发
// 2. 失活组件:onBeforeRouteLeave
// 3. 全局:beforeEach
// 4. 重用组件:onBeforeRouteUpdate
// 5. 路由配置:beforeEnter
// 6. 解析异步路由组件
// 7. 全局:beforeResolve
// 8. 导航确认
// 9. 全局:afterEach
// 10. DOM 更新
// ========== 全局前置守卫(权限控制) ==========
const whiteList = ['/login', '/register', '/404']
router.beforeEach(async (to, from) => {
// Vue Router 4 不再需要 next(),返回值即可
const userStore = useUserStore()
// 设置页面标题
document.title = (to.meta.title as string) || '默认标题'
// 白名单直接放行
if (whiteList.includes(to.path)) return true
// 未登录 → 跳登录页
if (!userStore.token) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
// 已登录但无用户信息 → 获取用户信息
if (!userStore.userInfo) {
try {
await userStore.getUserInfo()
// 动态路由:获取权限后添加路由
const routes = await generateRoutes(userStore.roles)
routes.forEach(route => router.addRoute(route))
return to.fullPath // 重新导航(确保动态路由已添加)
} catch {
userStore.logout()
return { name: 'Login', query: { redirect: to.fullPath } }
}
}
// 权限检查
if (to.meta.roles && !to.meta.roles.includes(userStore.role)) {
return { name: 'Forbidden' }
}
return true // 放行
})
// ========== 全局后置钩子 ==========
router.afterEach((to, from, failure) => {
if (failure) console.error('导航失败:', failure)
NProgress.done() // 关闭进度条
})
// ========== 路由独享守卫 ==========
{
path: '/admin',
component: Admin,
beforeEnter: (to) => {
if (!isAdmin()) return { name: 'Forbidden' }
}
}
// ========== 组件内守卫(Composition API) ==========
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
// 路由参数变化时(如 /user/1 → /user/2,组件被复用)
onBeforeRouteUpdate(async (to) => {
const userData = await fetchUser(to.params.id)
user.value = userData
})
// 离开路由前
onBeforeRouteLeave((to, from) => {
if (hasUnsavedChanges.value) {
return window.confirm('有未保存的更改,确定离开?')
}
})
💡 面试加分点: Vue Router 4 的守卫不再需要
next()------通过return false(取消)、return { name: 'xxx' }(重定向)、return true/不返回(放行)控制。router.addRoute()实现动态路由是后台管理系统权限控制的核心方案。
10. Vue3 组件通信方式(全面总结)
| 方式 | 方向 | 适用场景 |
|---|---|---|
| props / emit | 父 ↔ 子 | 基本父子通信 |
| v-model | 父 ↔ 子 | 双向绑定(支持多个) |
| defineModel | 父 ↔ 子 | v-model 简化写法(3.4+) |
| provide / inject | 祖先 → 后代 | 跨层级(✅ 响应式) |
| $attrs | 父 → 子 | 属性透传(含事件) |
| defineExpose / ref | 父 → 子 | 访问子组件方法/属性 |
| Pinia | 任意 | 全局状态管理 |
| mitt | 任意 | 事件总线(替代 EventBus) |
javascript
// ========== 1. props + emit ==========
// 子组件
const props = defineProps<{ title: string; count?: number }>()
const emit = defineEmits<{ update: [value: string]; delete: [id: number] }>()
emit('update', 'new value')
// ========== 2. v-model(Vue3 支持多个) ==========
// 父组件:<MyForm v-model:name="name" v-model:age="age" />
// 子组件
const props = defineProps(['name', 'age'])
const emit = defineEmits(['update:name', 'update:age'])
// ========== 3. defineModel(Vue 3.4+,极简) ==========
// 子组件
const name = defineModel<string>('name')
const age = defineModel<number>('age', { default: 18 })
// 直接读写,自动同步到父组件
name.value = '新名字'
// ========== 4. provide / inject(Vue3 中是响应式的) ==========
// 祖先组件
const theme = ref('dark')
const toggleTheme = () => { theme.value = theme.value === 'dark' ? 'light' : 'dark' }
provide('theme', { theme: readonly(theme), toggleTheme }) // ✅ readonly 防止子组件修改
// 后代组件
const { theme, toggleTheme } = inject('theme', { theme: ref('light'), toggleTheme: () => {} })
// 推荐使用 InjectionKey 保证类型安全
import type { InjectionKey, Ref } from 'vue'
const ThemeKey: InjectionKey<{ theme: Readonly<Ref<string>>; toggle: () => void }> = Symbol('theme')
// ========== 5. $attrs(Vue3 合并了 $listeners) ==========
// Vue3 中 $attrs 包含所有未被 props/emits 声明的属性和事件
// <Child class="custom" @click="handler" data-id="1" />
const attrs = useAttrs()
// attrs = { class: 'custom', onClick: handler, 'data-id': '1' }
// ========== 6. defineExpose + ref ==========
// 子组件
const inputRef = ref<HTMLInputElement>()
const focus = () => inputRef.value?.focus()
defineExpose({ focus })
// 父组件
const childRef = ref<InstanceType<typeof ChildComponent>>()
onMounted(() => childRef.value?.focus())
// ========== 7. mitt 事件总线 ==========
// eventBus.ts
import mitt from 'mitt'
type Events = {
'user-login': { name: string }
'theme-change': string
}
export const emitter = mitt<Events>()
// 发送
emitter.emit('user-login', { name: '张三' })
// 监听
emitter.on('user-login', (data) => console.log(data.name))
// 清理(在 onUnmounted 中)
onUnmounted(() => emitter.off('user-login', handler))
💡 面试加分点: Vue3 的
$attrs合并了 Vue2 的$attrs和$listeners,事件以onXxx形式存在。provide/inject配合readonly()和InjectionKey是推荐的跨层级通信方式------比 Pinia 更轻量,比 props 层层传递更优雅。
11. Vue3 的虚拟 DOM 和 Diff 算法优化
Vue3 编译优化三板斧:Patch Flag + Block Tree + 静态提升
javascript
// 模板:
// <div>
// <p>静态文本</p>
// <p>{{ message }}</p>
// <p :class="cls">动态class</p>
// </div>
// Vue3 编译后(伪代码):
const _hoisted = createVNode('p', null, '静态文本') // ✅ 静态提升:只创建一次
function render() {
return createBlock('div', null, [
_hoisted, // 直接复用
createVNode('p', null, msg.value, 1 /* TEXT */), // PatchFlag=1 只比较文本
createVNode('p', { class: cls.value }, '动态', 2 /* CLASS */), // PatchFlag=2 只比较class
])
}
// ========== Patch Flag 枚举 ==========
// 1 = TEXT 动态文本
// 2 = CLASS 动态 class
// 4 = STYLE 动态 style
// 8 = PROPS 动态属性
// 16 = FULL_PROPS 带有动态 key 的属性
// 32 = HYDRATE_EVENTS 需要事件监听器
// 64 = STABLE_FRAGMENT 子节点顺序不变的 fragment
// -1 = HOISTED 静态提升
// -2 = BAIL 需要完整 diff
// ========== Block Tree ==========
// 将模板中的动态节点收集为扁平数组,diff 时只遍历动态节点
// 传统 diff:遍历整棵 VNode 树
// Block Tree:只遍历 dynamicChildren 数组
// ========== 最长递增子序列(LIS)优化节点移动 ==========
// 旧:[A, B, C, D, E]
// 新:[A, D, B, C, E]
// LIS 找到 [A, B, C, E](不需要移动),只移动 D
💡 面试加分点: Vue3 编译优化让运行时性能提升 2-6 倍。Patch Flag 在编译时就确定了需要 diff 的属性类型,运行时可以跳过不变的部分。Block Tree 将 O(模板大小) 降为 O(动态节点数)。
12. Teleport 和 Suspense
html
<!-- ========== Teleport:将内容渲染到指定 DOM 位置 ========== -->
<template>
<button @click="showModal = true">打开弹窗</button>
<Teleport to="body">
<div v-if="showModal" class="modal-overlay">
<div class="modal">
<h2>弹窗标题</h2>
<p>弹窗内容</p>
<button @click="showModal = false">关闭</button>
</div>
</div>
</Teleport>
<!-- 可以传送到任何 CSS 选择器匹配的元素 -->
<Teleport to="#notifications">
<Toast v-if="showToast" :message="toastMsg" />
</Teleport>
<!-- disabled:条件性禁用传送 -->
<Teleport to="body" :disabled="isMobile">
<ContextMenu />
</Teleport>
</template>
<!-- ========== Suspense:处理异步组件加载状态 ========== -->
<template>
<Suspense>
<template #default>
<AsyncDashboard /> <!-- 异步组件(setup 中有 await) -->
</template>
<template #fallback>
<LoadingSkeleton /> <!-- 加载中的占位 -->
</template>
</Suspense>
</template>
<!-- 配合 onErrorCaptured 处理加载失败 -->
<script setup>
import { onErrorCaptured, ref } from 'vue'
const error = ref(null)
onErrorCaptured((err) => {
error.value = err
return false
})
</script>
<template>
<div v-if="error">加载失败:{{ error.message }}</div>
<Suspense v-else>
<template #default><AsyncComponent /></template>
<template #fallback><div>加载中...</div></template>
</Suspense>
</template>
javascript
// 异步 setup 组件(可以在 setup 中使用 await)
// AsyncDashboard.vue
// <script setup>
const data = await fetch('/api/dashboard').then(r => r.json())
// Suspense 会等待这个异步 setup 完成
// </script>
💡 面试加分点:
Teleport解决了模态框/弹窗的 z-index 和样式隔离问题。Suspense目前仍是实验性功能 ,但在 Nuxt 3 中已大量使用。多个Teleport可以传送到同一目标元素,按顺序追加。
13. 自定义指令(Vue3 版本)
javascript
// Vue3 指令钩子与组件生命周期一致
const vDirective = {
created(el, binding, vnode, prevVnode) {}, // 元素属性/事件应用前
beforeMount(el, binding) {}, // 挂载到 DOM 前
mounted(el, binding) {}, // 挂载后(最常用)
beforeUpdate(el, binding) {}, // VNode 更新前
updated(el, binding) {}, // VNode 更新后
beforeUnmount(el, binding) {}, // 卸载前
unmounted(el, binding) {} // 卸载后
}
// ========== <script setup> 中自动注册:以 v 开头的变量 ==========
// <script setup>
// v-focus 指令
const vFocus = { mounted: (el) => el.focus() }
// v-permission 权限指令
const vPermission = {
mounted(el, binding) {
const userStore = useUserStore()
if (!userStore.permissions.includes(binding.value)) {
el.parentNode?.removeChild(el)
}
}
}
// v-loading 加载指令
const vLoading = {
mounted(el, binding) {
el.style.position = 'relative'
if (binding.value) addLoadingMask(el)
},
updated(el, binding) {
if (binding.value !== binding.oldValue) {
binding.value ? addLoadingMask(el) : removeLoadingMask(el)
}
}
}
// v-debounce 防抖指令
const vDebounce = {
mounted(el, binding) {
const { value: fn, arg = '300' } = binding
let timer
el.addEventListener('click', () => {
clearTimeout(timer)
timer = setTimeout(() => fn(), parseInt(arg))
})
}
}
// v-click-outside 点击外部
const vClickOutside = {
mounted(el, binding) {
el._handler = (e) => {
if (!el.contains(e.target)) binding.value(e)
}
document.addEventListener('click', el._handler)
},
unmounted(el) {
document.removeEventListener('click', el._handler)
}
}
// </script>
// <template>
// <input v-focus />
// <button v-permission="'admin:delete'">删除</button>
// <div v-loading="isLoading">内容</div>
// <button v-debounce:500="handleSubmit">提交</button>
// <div v-click-outside="closeMenu">菜单</div>
// </template>
💡 面试加分点:
<script setup>中以v开头的变量自动注册为指令。Vue3 的指令钩子名与组件生命周期一致(mounted/updated/unmounted),Vue2 用的是bind/inserted/update/unbind。
14. keep-alive 缓存组件
html
<!-- Vue3 的 keep-alive 配合 router-view -->
<template>
<router-view v-slot="{ Component, route }">
<transition :name="route.meta.transition || 'fade'" mode="out-in">
<keep-alive :include="cachedViews" :max="10">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
// 动态控制缓存列表
const cachedViews = ref(['Home', 'List', 'Dashboard'])
const addCache = (name: string) => {
if (!cachedViews.value.includes(name)) cachedViews.value.push(name)
}
const removeCache = (name: string) => {
cachedViews.value = cachedViews.value.filter(v => v !== name)
}
provide('cacheControl', { addCache, removeCache })
</script>
javascript
// 被缓存组件内部
// <script setup>
import { onActivated, onDeactivated, ref } from 'vue'
const scrollTop = ref(0)
onActivated(() => {
// 从缓存恢复时触发
fetchLatestData()
window.scrollTo(0, scrollTop.value)
})
onDeactivated(() => {
// 进入缓存时触发
scrollTop.value = document.documentElement.scrollTop
})
// </script>
💡 面试加分点:
keep-alive的max使用 LRU 算法------超出限制时销毁最久没被访问的组件。缓存组件不触发onMounted/onUnmounted,而是onActivated/onDeactivated。配合<Transition>可实现页面切换动画。
15. Vue3 的 v-model 机制
html
<!-- Vue3 的 v-model = modelValue + update:modelValue -->
<!-- <CustomInput v-model="value" /> 等价于 -->
<CustomInput :modelValue="value" @update:modelValue="val => value = val" />
<!-- ✅ Vue3 支持多个 v-model -->
<UserForm v-model:name="userName" v-model:age="userAge" v-model:email="userEmail" />
javascript
// ========== 传统写法 ==========
// CustomInput.vue
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// <template><input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" /></template>
// ========== defineModel 写法(Vue 3.4+,推荐) ==========
const model = defineModel<string>()
// <template><input v-model="model" /></template>
// 多个 v-model
const name = defineModel<string>('name')
const age = defineModel<number>('age', { default: 0 })
// ========== v-model 修饰符 ==========
// <MyInput v-model.capitalize="text" />
const [model, modifiers] = defineModel<string>({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
💡 面试加分点: Vue2 的 v-model 一个组件只能用一个(用
.sync做额外双向绑定),Vue3 废弃.sync,用多个v-model:xxx替代。defineModel(3.4+)将defineProps+defineEmits+ 手动同步简化为一行代码。
16. 插槽(Slots)详解
html
<!-- ========== 默认插槽 + 具名插槽 ========== -->
<!-- Card.vue -->
<template>
<div class="card">
<header v-if="$slots.header"><slot name="header" /></header>
<main><slot>默认内容</slot></main>
<footer v-if="$slots.footer"><slot name="footer" /></footer>
</div>
</template>
<!-- 使用 -->
<Card>
<template #header><h2>标题</h2></template>
<p>主要内容</p>
<template #footer><button>确定</button></template>
</Card>
<!-- ========== 作用域插槽 ========== -->
<!-- DataTable.vue -->
<template>
<table>
<tr v-for="row in data" :key="row.id">
<td v-for="col in columns" :key="col.key">
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
</table>
</template>
<!-- 使用(自定义某列的渲染) -->
<DataTable :data="users" :columns="columns">
<template #status="{ row, value }">
<span :class="value === 'active' ? 'green' : 'red'">
{{ value === 'active' ? '启用' : '禁用' }}
</span>
</template>
<template #actions="{ row }">
<button @click="edit(row)">编辑</button>
<button @click="del(row.id)">删除</button>
</template>
</DataTable>
javascript
// <script setup> 中检测插槽
import { useSlots } from 'vue'
const slots = useSlots()
const hasHeader = computed(() => !!slots.header)
// TypeScript 类型安全的插槽(Vue 3.3+)
defineSlots<{
default(props: { msg: string }): any
header(props: { title: string }): any
item(props: { data: Item; index: number }): any
}>()
💡 面试加分点: 作用域插槽是组件库开发的核心------让父组件决定"怎么渲染",子组件提供"渲染什么数据"。Vue3 的
defineSlots(3.3+)提供了编译时类型检查。
17. Vue3 性能优化全攻略
| 分类 | 优化手段 | 说明 |
|---|---|---|
| 编译层 | Tree-shaking | 未使用的 API 不打包 |
| 静态提升 | 静态节点只创建一次 | |
| Patch Flag | 运行时只 diff 动态部分 | |
| 组件层 | 路由懒加载 | () => import('./xxx.vue') |
| 异步组件 | defineAsyncComponent() |
|
v-show vs v-if |
频繁切换用 v-show | |
v-once |
只渲染一次 | |
v-memo |
缓存列表项(3.2+) | |
| 响应式 | shallowRef/shallowReactive |
减少深层响应式开销 |
markRaw |
标记永远不需要响应式的对象 | |
| computed 缓存 | 替代复杂模板表达式 | |
| 列表 | 唯一 key | 不用 index |
| 虚拟滚动 | 大列表(vue-virtual-scroller) | |
| 网络 | keep-alive 缓存 | 避免重复请求 |
| 图片懒加载 | v-lazy / IntersectionObserver |
|
| 按需导入 | 第三方库按需引入 |
javascript
// ========== v-memo(Vue 3.2+):条件性缓存列表项 ==========
// <div v-for="item in list" :key="item.id" v-memo="[item.selected]">
// 只有 item.selected 变化时才重新渲染这个节点
// {{ item.name }} - {{ item.description }} - {{ item.selected }}
// </div>
// ========== shallowRef + triggerRef 优化大数据 ==========
import { shallowRef, triggerRef, markRaw } from 'vue'
const bigList = shallowRef(new Array(100000).fill(null).map((_, i) => ({ id: i, name: `item${i}` })))
// 修改内部不触发更新(性能好)
bigList.value[0].name = 'updated'
triggerRef(bigList) // 手动触发更新
// ========== markRaw:标记不需要响应式的对象 ==========
import { markRaw } from 'vue'
const chart = markRaw(new ECharts(dom)) // ECharts 实例不需要响应式
const state = reactive({
chart: markRaw(new ECharts(dom)), // 在 reactive 中也不会被代理
map: markRaw(new Map()) // 超大 Map 不需要响应式
})
// ========== defineAsyncComponent 异步组件 ==========
import { defineAsyncComponent } from 'vue'
const HeavyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSkeleton,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 10000,
onError(error, retry, fail, attempts) {
if (attempts <= 3) retry()
else fail()
}
})
💡 面试加分点:
v-memo是 Vue 3.2 的高级优化------对大列表只重新渲染"数据真正变化"的项。markRaw适合第三方库实例(ECharts、地图 SDK 等),避免 Proxy 代理产生兼容问题和性能开销。
18. Vue3 错误处理机制
javascript
// ========== 1. 全局错误处理器 ==========
const app = createApp(App)
app.config.errorHandler = (err, instance, info) => {
console.error('全局错误:', err)
console.error('错误组件:', instance?.$options?.name || instance)
console.error('错误来源:', info) // 如 "setup function" "render function" "watcher"
// 上报到 Sentry / 自建监控
Sentry.captureException(err, { extra: { info, component: instance?.$options?.name } })
}
app.config.warnHandler = (msg, instance, trace) => {
console.warn('Vue 警告:', msg) // 仅开发环境
}
// ========== 2. 组件级错误边界 ==========
// ErrorBoundary.vue
// <script setup>
import { onErrorCaptured, ref, h } from 'vue'
const error = ref<Error | null>(null)
const retry = ref(0)
onErrorCaptured((err, instance, info) => {
error.value = err as Error
return false // 阻止向上传播
})
const reset = () => { error.value = null; retry.value++ }
// </script>
// <template>
// <div v-if="error" class="error-boundary">
// <p>出错了: {{ error.message }}</p>
// <button @click="reset">重试</button>
// </div>
// <slot v-else :key="retry" />
// </template>
// 使用
// <ErrorBoundary>
// <RiskyComponent />
// </ErrorBoundary>
// ========== 3. 路由错误处理 ==========
router.onError((error) => {
// 处理动态导入失败(部署更新后旧 chunk 404)
if (error.message.includes('Failed to fetch dynamically imported module')) {
window.location.reload()
}
})
// ========== 4. API 请求统一错误处理 ==========
// composables/useRequest.ts
export function useRequest<T>(fn: () => Promise<T>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
data.value = await fn() as T
} catch (err) {
error.value = err as Error
} finally {
loading.value = false
}
}
return { data, error, loading, execute }
}
💡 面试加分点: Vue3 自动捕获
setup()、生命周期钩子、watch/watchEffect中的异步错误。路由懒加载失败(chunk 404)是常见的线上问题,通过router.onError检测并自动刷新页面是标准解决方案。
19. Vue3 + TypeScript 最佳实践
typescript
// ========== 组件 Props 类型 ==========
// <script setup lang="ts">
interface Props {
title: string
count?: number
status: 'active' | 'inactive'
user?: { name: string; age: number }
list: Item[]
onChange?: (value: string) => void
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
status: 'active',
user: () => ({ name: '', age: 0 }),
list: () => []
})
// ========== Emits 类型 ==========
const emit = defineEmits<{
update: [value: string]
delete: [id: number]
change: [value: string, oldValue: string]
}>()
// ========== ref 类型 ==========
import { ref, type Ref } from 'vue'
const count = ref<number>(0)
const user = ref<User | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)
// ========== reactive 类型 ==========
interface State {
loading: boolean
data: User[]
error: Error | null
}
const state: State = reactive({
loading: false,
data: [],
error: null
})
// ========== computed 类型 ==========
const double = computed<number>(() => count.value * 2)
// ========== provide / inject 类型安全 ==========
import type { InjectionKey } from 'vue'
interface ThemeContext {
theme: Readonly<Ref<'light' | 'dark'>>
toggle: () => void
}
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
// 提供
provide(ThemeKey, { theme: readonly(theme), toggle: toggleTheme })
// 注入(有类型推断)
const themeCtx = inject(ThemeKey)! // ! 断言非空
// ========== 全局组件类型声明 ==========
// env.d.ts 或 global.d.ts
declare module 'vue' {
interface ComponentCustomProperties {
$filters: { currency: (v: number) => string }
$http: typeof axios
}
}
// </script>
💡 面试加分点: Vue3 + TS 的核心类型工具:
PropType<T>(选项式 props 类型)、InjectionKey<T>(provide/inject 类型)、InstanceType<typeof Comp>(组件实例类型)。withDefaults解决了defineProps<T>()无法直接设置默认值的问题。
20. Vue3 新特性总结(3.0 - 3.5)
| 版本 | 重要特性 |
|---|---|
| 3.0 | Composition API、Teleport、Suspense、Fragment、Proxy 响应式 |
| 3.1 | onServerPrefetch、defineAsyncComponent 改进 |
| 3.2 | <script setup> 正式、v-memo、v-bind in CSS、defineCustomElement |
| 3.3 | defineOptions、defineSlots、defineEmits 简化语法、泛型组件 |
| 3.4 | defineModel、v-bind 同名简写、watch 的 once 选项、Proxy 性能优化 |
| 3.5 | 响应式 Props 解构、useTemplateRef、SSR 改进、onWatcherCleanup |
html
<!-- ========== v-bind in CSS(3.2+) ========== -->
<script setup>
const color = ref('red')
const fontSize = ref(16)
</script>
<style scoped>
.text {
color: v-bind(color);
font-size: v-bind(fontSize + 'px'); /* 支持表达式 */
}
</style>
<!-- ========== 泛型组件(3.3+) ========== -->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{ list: T[]; selected?: T }>()
defineEmits<{ select: [item: T] }>()
</script>
<!-- ========== v-bind 同名简写(3.4+) ========== -->
<img :id="id" :src="src" :alt="alt" />
<!-- 简写为 -->
<img :id :src :alt />
<!-- ========== 响应式 Props 解构(3.5+) ========== -->
<script setup>
const { title, count = 0 } = defineProps<{ title: string; count?: number }>()
// 直接解构且保持响应式!(3.5 之前解构会丢失响应式)
watchEffect(() => console.log(title, count))
</script>
<!-- ========== useTemplateRef(3.5+) ========== -->
<script setup>
import { useTemplateRef, onMounted } from 'vue'
const inputEl = useTemplateRef('my-input') // 通过字符串匹配 ref
onMounted(() => inputEl.value?.focus())
</script>
<template>
<input ref="my-input" />
</template>
💡 面试加分点: 面试中能提到 Vue 3.3-3.5 的新特性说明你持续关注技术发展。
defineModel(3.4)简化双向绑定、响应式 Props 解构(3.5)解决了长期痛点、泛型组件(3.3)让类型安全的组件库开发成为可能。
21. SSR(服务端渲染)和 Nuxt 3
| 渲染方式 | 说明 | 首屏速度 | SEO | 服务器压力 |
|---|---|---|---|---|
| CSR | 客户端渲染(SPA) | 慢 | ❌ | 低 |
| SSR | 服务端渲染 | ✅ 快 | ✅ | 高 |
| SSG | 静态站点生成 | ✅ 最快 | ✅ | ❌ 无 |
| ISR | 增量静态生成 | ✅ 快 | ✅ | 低 |
javascript
// ========== Vue3 原生 SSR ==========
// server.js
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const app = createSSRApp({
data: () => ({ count: 0 }),
template: `<button @click="count++">{{ count }}</button>`
})
const html = await renderToString(app)
// → '<button>0</button>'
// ========== Nuxt 3 核心概念 ==========
// 自动路由(基于文件系统)
// pages/
// ├── index.vue → /
// ├── about.vue → /about
// ├── users/
// │ ├── index.vue → /users
// │ └── [id].vue → /users/:id
// 数据获取
// <script setup>
const { data, pending, error, refresh } = await useFetch('/api/users')
const { data: user } = await useAsyncData('user', () => $fetch(`/api/users/${route.params.id}`))
// </script>
// 自动导入(不需要 import)
// - Vue API(ref、computed、watch...)
// - 组合函数(useFetch、useState、useRoute...)
// - components/ 下的组件
// ========== Nuxt 3 常用功能 ==========
// 状态管理
const counter = useState('counter', () => 0)
// 中间件
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { isLoggedIn } = useAuth()
if (!isLoggedIn.value && to.path !== '/login') {
return navigateTo('/login')
}
})
// SEO
useHead({ title: '页面标题', meta: [{ name: 'description', content: '描述' }] })
useSeoMeta({ title: '标题', ogTitle: '分享标题', description: '描述' })
💡 面试加分点: SSR 的核心挑战是"注水"(Hydration)------服务端返回 HTML,客户端接管时将事件绑定到已有 DOM 上。Nuxt 3 基于 Nitro 引擎,支持部署到 Node.js、Vercel、Cloudflare Workers 等多种平台。
22. Vue3 的 Transition 和动画
html
<!-- ========== 基本过渡 ========== -->
<Transition name="fade" mode="out-in">
<component :is="currentView" />
</Transition>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
<!-- ========== 列表过渡 ========== -->
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</TransitionGroup>
<style>
.list-enter-active, .list-leave-active { transition: all 0.5s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
.list-move { transition: transform 0.5s ease; } /* FLIP 动画 */
.list-leave-active { position: absolute; } /* 离开时脱离文档流 */
</style>
<!-- ========== JavaScript 钩子 ========== -->
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
:css="false"
>
<div v-if="show">动画内容</div>
</Transition>
<script setup>
function onEnter(el, done) {
gsap.to(el, { opacity: 1, duration: 0.5, onComplete: done })
}
function onLeave(el, done) {
gsap.to(el, { opacity: 0, duration: 0.5, onComplete: done })
}
</script>
<!-- ========== 路由过渡 ========== -->
<router-view v-slot="{ Component, route }">
<Transition :name="route.meta.transition || 'fade'" mode="out-in">
<component :is="Component" />
</Transition>
</router-view>
💡 面试加分点:
<TransitionGroup>使用 FLIP 动画技术实现列表项的平滑移动。mode="out-in"让旧元素先离开、新元素再进入,避免两个元素同时存在。结合 GSAP 等动画库可实现复杂动画。
23. Vue3 项目最佳实践
bash
src/
├── api/ # API 请求封装
│ ├── modules/ # 按模块分
│ │ ├── user.ts
│ │ └── product.ts
│ └── request.ts # axios 实例和拦截器
├── assets/ # 静态资源
├── components/ # 通用组件(全局复用)
│ ├── ui/ # 基础 UI 组件
│ └── business/ # 业务通用组件
├── composables/ # 组合函数(useXxx)
│ ├── useFetch.ts
│ ├── useAuth.ts
│ └── usePermission.ts
├── directives/ # 自定义指令
├── layouts/ # 布局组件
├── pages/ (views/) # 页面组件
├── router/ # 路由配置
│ ├── index.ts
│ ├── guards.ts # 导航守卫
│ └── routes/ # 路由模块
├── stores/ # Pinia 状态管理
│ ├── user.ts
│ └── app.ts
├── styles/ # 全局样式
├── types/ # TypeScript 类型
├── utils/ # 工具函数
├── App.vue
└── main.ts
javascript
// ========== 1. Props 规范 ==========
// ✅ TypeScript 类型声明 + withDefaults
interface Props {
title: string
status: 'active' | 'inactive'
list?: Item[]
}
const props = withDefaults(defineProps<Props>(), {
list: () => []
})
// ========== 2. Composable 规范 ==========
// ✅ 命名以 use 开头,返回 ref(方便解构)
export function usePagination(fetchFn: (params: PageParams) => Promise<PageResult>) {
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const list = ref<any[]>([])
const loading = ref(false)
const fetch = async () => {
loading.value = true
try {
const { data, total: t } = await fetchFn({ page: page.value, pageSize: pageSize.value })
list.value = data
total.value = t
} finally { loading.value = false }
}
watch([page, pageSize], fetch, { immediate: true })
return { page, pageSize, total, list, loading, refetch: fetch }
}
// ========== 3. 状态管理选择 ==========
// 简单状态 → composable + provide/inject
// 全局共享 → Pinia store
// 服务端状态 → TanStack Query (vue-query)
// ========== 4. 编码规范 ==========
// ✅ 组件名:PascalCase 多单词(UserProfile, TodoList)
// ✅ composable:useFetch, useAuth, usePermission
// ✅ store:useUserStore, useCartStore
// ✅ Props:camelCase(驼峰)
// ✅ Emit:kebab-case(短横线)
// ✅ 模板中组件:PascalCase 或 kebab-case
💡 面试加分点: 项目架构能力是高级前端的核心------组件拆分原则(单一职责)、composable 提取时机(逻辑复用 ≥ 2 个组件)、状态管理选型(简单用 composable,复杂用 Pinia,服务端缓存用 vue-query)。
24. Vue3 与 Vite
| 特性 | Vite | Vue CLI (Webpack) |
|---|---|---|
| 启动速度 | ✅ 毫秒级(原生 ESM) | ❌ 秒级(打包后启动) |
| HMR 速度 | ✅ 极快(按需编译) | 慢(全量构建) |
| 构建工具 | Rollup | Webpack |
| 配置复杂度 | 简单 | 复杂 |
| 生态 | 快速发展 | 成熟但停止维护 |
javascript
// ========== vite.config.ts ==========
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
// 自动导入 Vue API 和组件
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
],
resolve: {
alias: { '@': '/src' }
},
server: {
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true }
}
},
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
elementPlus: ['element-plus']
}
}
}
}
})
// ========== 环境变量 ==========
// .env.development
// VITE_API_URL=http://localhost:3000
// .env.production
// VITE_API_URL=https://api.example.com
// 使用
const apiUrl = import.meta.env.VITE_API_URL
💡 面试加分点: Vite 的核心原理------开发时利用浏览器原生 ES Module,按需编译(不打包),所以启动极快。生产构建用 Rollup。
unplugin-auto-import和unplugin-vue-components是 Vue3 项目的标配插件。
25. Vue3 的 provide/inject 详解
核心概念
provide/inject 是 Vue3 中跨层级组件通信的首选方案 ,祖先组件 provide 数据,后代组件 inject 注入,中间层无需逐级传递。
| 特性 | Vue2 provide/inject | Vue3 provide/inject |
|---|---|---|
| 响应式 | ❌ 默认不响应(除非传对象引用) | ✅ 传 ref/reactive 即可响应 |
| 类型安全 | ❌ | ✅ 配合 InjectionKey 泛型 |
| 使用位置 | Options API | Composition API / Options API |
| 适用场景 | 跨层级主题/配置 | 组件库设计、跨层级状态共享 |
typescript
// ========== 1. 基本用法 ==========
// 祖先组件
import { provide, ref, readonly } from 'vue'
const theme = ref('dark')
const userInfo = reactive({ name: 'Tom', role: 'admin' })
// ✅ 提供响应式数据
provide('theme', theme)
// ✅ 提供只读数据(防止子组件直接修改)
provide('userInfo', readonly(userInfo))
// ✅ 提供修改方法(单向数据流)
provide('updateTheme', (val: string) => { theme.value = val })
// 后代组件(任意深度)
import { inject } from 'vue'
const theme = inject('theme') // Ref<string>
const userInfo = inject('userInfo') // DeepReadonly<{...}>
const updateTheme = inject('updateTheme') // (val: string) => void
// ========== 2. 默认值和必填校验 ==========
// 有默认值
const theme = inject('theme', 'light') // 未提供时用 'light'
const list = inject('list', () => [], true) // 工厂函数默认值(第三参数 true 表示是工厂)
// 无默认值(如果未 provide,值为 undefined)
const data = inject('data') // string | undefined
类型安全的 InjectionKey
typescript
// ========== 3. 使用 InjectionKey 实现类型安全(推荐) ==========
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface UserInfo {
name: string
role: 'admin' | 'editor' | 'viewer'
permissions: string[]
}
// ✅ 定义带类型的 key(Symbol 避免命名冲突)
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
export const userKey: InjectionKey<UserInfo> = Symbol('user')
export const updateThemeKey: InjectionKey<(val: string) => void> = Symbol('updateTheme')
// 祖先组件
import { themeKey, userKey, updateThemeKey } from '@/types/injection-keys'
provide(themeKey, theme) // ✅ 类型自动推断,传错类型会报错
provide(userKey, userInfo)
// 后代组件
const theme = inject(themeKey)! // Ref<string>(! 断言非空)
const user = inject(userKey) // UserInfo | undefined
实际应用场景
typescript
// ========== 4. 组件库设计:Form + FormItem ==========
// Form.vue
const formData = reactive({})
const rules = ref({})
provide('formContext', {
model: formData,
rules: rules,
validate: () => { /* 校验逻辑 */ },
resetFields: () => { /* 重置逻辑 */ }
})
// FormItem.vue(不管嵌套多深都能拿到)
const formContext = inject('formContext')!
// 使用 formContext.model、formContext.rules 等
// ========== 5. provide/inject vs Pinia 选型 ==========
// ✅ 用 provide/inject:
// - 组件库内部通信(Form/Tabs/Accordion 等)
// - 局部的主题/配置共享
// - 不需要跨页面持久化的数据
// ✅ 用 Pinia:
// - 全局状态(用户信息、购物车、权限)
// - 需要 DevTools 调试
// - 需要持久化(localStorage/sessionStorage)
// - 需要在组件外(路由守卫、API 拦截器)访问
💡 面试加分点: provide/inject 的最佳实践:1)用 Symbol 作为 key (避免命名冲突);2)用 InjectionKey 泛型 (类型安全);3)provide readonly 数据 + 修改方法 (单向数据流,防止子组件越权修改);4)组件库设计中大量使用(如 Element Plus 的 Form/Table/Tabs 内部通信都用 provide/inject)。和 Pinia 的区别:provide/inject 是"组件树局部共享",Pinia 是"全局状态管理"。
26. Vue3 的 shallowRef / shallowReactive / toRaw / markRaw
为什么需要这些 API?
Vue3 默认对对象做深层响应式代理,但有些场景不需要深度监听,反而会带来性能开销。
| API | 作用 | 适用场景 |
|---|---|---|
| shallowRef | 只监听 .value 的赋值,不深层代理 |
大型对象/类实例/第三方库对象 |
| shallowReactive | 只代理对象第一层属性 | 只关心顶层属性变化 |
| toRaw | 获取响应式对象的原始对象 | 传给第三方库/深拷贝/性能敏感操作 |
| markRaw | 标记对象永远不会变成响应式 | 类实例/不可变大数据/第三方库实例 |
| triggerRef | 手动触发 shallowRef 的依赖更新 | 修改了 shallowRef 深层数据后手动通知 |
typescript
// ========== 1. shallowRef:只监听 .value 赋值 ==========
import { shallowRef, triggerRef } from 'vue'
const bigList = shallowRef([
{ id: 1, name: 'item1', details: { /* 大量嵌套数据 */ } },
{ id: 2, name: 'item2', details: { /* 大量嵌套数据 */ } },
// ... 几千条
])
// ❌ 不会触发更新(修改了深层属性,但 .value 引用没变)
bigList.value[0].name = 'new name'
// ✅ 方式1:替换整个 .value 才会触发
bigList.value = [...bigList.value]
// ✅ 方式2:修改后手动触发
bigList.value[0].name = 'new name'
triggerRef(bigList) // 手动通知依赖更新
// ========== 2. shallowReactive:只代理第一层 ==========
import { shallowReactive } from 'vue'
const state = shallowReactive({
count: 0, // ✅ 响应式(第一层)
nested: {
deep: 'hello' // ❌ 非响应式(深层)
}
})
state.count++ // ✅ 触发更新
state.nested.deep = 'hi' // ❌ 不触发更新
state.nested = { deep: 'hi' } // ✅ 替换整个属性才触发
toRaw 和 markRaw
typescript
// ========== 3. toRaw:获取原始对象(去掉 Proxy) ==========
import { reactive, toRaw } from 'vue'
const state = reactive({ name: 'Tom', age: 18 })
// 传给第三方库时(避免 Proxy 干扰)
const raw = toRaw(state) // 返回原始对象,没有 Proxy 包裹
chart.binbindData(raw) // ✅ ECharts/D3 等库接收纯对象
// 性能敏感的操作(跳过 Proxy 的 getter/setter 拦截)
const rawList = toRaw(bigList.value)
const result = rawList.filter(item => item.active) // ✅ 更快,不会触发 track
// 深拷贝时(避免拷贝 Proxy)
const copy = JSON.parse(JSON.stringify(toRaw(state)))
// ========== 4. markRaw:永远不变成响应式 ==========
import { markRaw, reactive } from 'vue'
class MyChart {
binbindData(data) { /* ... */ }
bindbindEvents() { /* ... */ }
bindDestroy() { /* ... */ }
}
// ✅ 标记为"永不响应式"
const chart = markRaw(new MyChart())
const state = reactive({
chart, // ✅ chart 不会被转为 Proxy(即使放在 reactive 里)
data: [1, 2, 3] // ✅ 正常响应式
})
// 常见需要 markRaw 的对象:
// - ECharts / D3 实例
// - 大型不可变数据(地图 GeoJSON、字典表等)
// - 类实例(new SomeClass())
// - window / document 等浏览器对象
性能优化实战
typescript
// ========== 实际场景:大列表 + ECharts ==========
import { shallowRef, markRaw } from 'vue'
import * as echarts from 'echarts'
// ✅ 大列表用 shallowRef(不需要监听每条数据的深层变化)
const tableData = shallowRef<TableRow[]>([])
// ✅ ECharts 实例用 markRaw(不需要响应式)
const chartInstance = shallowRef<echarts.ECharts | null>(null)
onMounted(() => {
chartInstance.value = markRaw(echarts.init(chartRef.value!))
})
// 更新数据时替换引用
const fetchData = async () => {
const data = await api.getList()
tableData.value = data // ✅ 整体替换,触发更新
chartInstance.value?.setOption({ series: [{ data }] })
}
onUnmounted(() => {
chartInstance.value?.dispose()
})
💡 面试加分点: 这组 API 的核心思想是"按需响应式 "------不是所有数据都需要深度监听。大列表用
shallowRef(只关心整体替换),第三方库实例用markRaw(不需要响应式),传给外部库时用toRaw(去掉 Proxy 壳)。这是 Vue3 性能优化的重要手段,面试中提到说明你对响应式系统理解深入。
27. Vue3 的异步组件(defineAsyncComponent)
基本用法
typescript
// ========== 1. 基本异步加载 ==========
import { defineAsyncComponent } from 'vue'
// 最简写法
const AsyncComp = defineAsyncComponent(() => import('./HeavyComponent.vue'))
// 完整配置
const AsyncCompWithOptions = defineAsyncComponent({
// 加载函数
loader: () => import('./HeavyComponent.vue'),
// 加载中显示的组件
loadingComponent: LoadingSpinner,
// 加载失败显示的组件
errorComponent: ErrorDisplay,
// 显示 loading 前的延迟(避免闪烁),默认 200ms
delay: 200,
// 超时时间(超时后显示 errorComponent)
timeout: 10000,
// 是否可挂起(配合 Suspense)
suspensible: false,
// 错误时的回调
onError(error, retry, fail, attempts) {
if (error.message.includes('fetch') && attempts <= 3) {
retry() // 网络错误自动重试(最多 3 次)
} else {
fail() // 其他错误直接失败
}
}
})
// ========== 2. 在路由中使用(路由懒加载) ==========
const routes = [
{
path: '/dashboard',
// 这本质上就是异步组件
component: () => import('@/views/Dashboard.vue')
},
{
path: '/admin',
// 带 webpackChunkName / vite 的 chunk 命名
component: () => import(/* webpackChunkName: "admin" */ '@/views/Admin.vue')
}
]
配合 Suspense 使用
html
<!-- ========== 3. 配合 Suspense(推荐) ========== -->
<template>
<Suspense>
<!-- 异步组件(或内部有 async setup 的组件) -->
<template #default>
<AsyncDashboard />
</template>
<!-- 加载中的 fallback -->
<template #fallback>
<div class="loading">
<Spinner />
<p>加载中...</p>
</div>
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncDashboard = defineAsyncComponent(
() => import('./Dashboard.vue')
)
</script>
<!-- ========== 4. async setup 的组件(天然异步组件) ========== -->
<!-- Dashboard.vue -->
<script setup>
// 组件内有顶层 await → 自动变成异步组件
const data = await fetch('/api/dashboard').then(r => r.json())
const config = await fetch('/api/config').then(r => r.json())
</script>
<template>
<div>{{ data.title }}</div>
</template>
与 Vue2 异步组件的对比
javascript
// ========== Vue2 异步组件写法 ==========
// 简写
Vue.component('async-comp', () => import('./MyComp.vue'))
// 高级配置
Vue.component('async-comp', () => ({
component: import('./MyComp.vue'),
loading: LoadingComp,
error: ErrorComp,
delay: 200,
timeout: 3000
}))
// ========== Vue3 的改进 ==========
// 1. 用 defineAsyncComponent 包裹(更明确)
// 2. 支持 onError 回调(可以重试)
// 3. 支持 suspensible 选项(配合 Suspense)
// 4. loader 选项(名称更语义化)
💡 面试加分点: 异步组件的本质是代码分割 (Code Splitting),让组件的 JS 代码在需要时才加载,而不是打包到主 bundle 中。路由懒加载
() => import('./View.vue')就是最常见的异步组件用法。Vue3 的defineAsyncComponent比 Vue2 更强大------支持加载重试(onError + retry)、超时控制、与 Suspense 配合。<script setup>中使用顶层await会自动变成异步组件,需要父组件用<Suspense>包裹。
28. Vue3.4+ 的 defineModel
为什么需要 defineModel?
Vue3 中实现 v-model 需要手动定义 props + emit,代码比较繁琐。defineModel(Vue 3.4+)极大简化了这个流程。
html
<!-- ========== 之前的写法(Vue 3.0 - 3.3):繁琐 ========== -->
<script setup>
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// 需要手动同步
const updateValue = (val: string) => {
emit('update:modelValue', val)
}
</script>
<template>
<input :value="props.modelValue" @input="updateValue($event.target.value)" />
</template>
<!-- 父组件使用 -->
<!-- <MyInput v-model="username" /> -->
html
<!-- ========== defineModel 写法(Vue 3.4+):极简 ========== -->
<script setup>
// ✅ 一行代码搞定!返回一个 ref,可直接读写
const modelValue = defineModel<string>()
// 等价于自动帮你:
// 1. 声明 prop: modelValue
// 2. 声明 emit: update:modelValue
// 3. 返回一个可读写的 ref(写入时自动 emit)
</script>
<template>
<!-- ✅ 直接用 v-model 绑定这个 ref -->
<input v-model="modelValue" />
</template>
<!-- 父组件使用(完全一样) -->
<!-- <MyInput v-model="username" /> -->
进阶用法
html
<!-- ========== 1. 带选项的 defineModel ========== -->
<script setup>
// 必填 + 默认值
const title = defineModel<string>('title', {
required: true,
default: '未命名'
})
// 带校验
const count = defineModel<number>('count', {
default: 0,
validator: (value) => value >= 0
})
</script>
<!-- 父组件 -->
<!-- <MyComp v-model:title="pageTitle" v-model:count="num" /> -->
<!-- ========== 2. 多个 v-model ========== -->
<script setup>
// 默认 model
const modelValue = defineModel<string>() // v-model
// 具名 model
const firstName = defineModel<string>('firstName') // v-model:firstName
const lastName = defineModel<string>('lastName') // v-model:lastName
</script>
<template>
<input v-model="firstName" placeholder="名" />
<input v-model="lastName" placeholder="姓" />
<p>全名:{{ firstName }} {{ lastName }}</p>
</template>
<!-- 父组件 -->
<!-- <NameInput v-model:firstName="first" v-model:lastName="last" /> -->
<!-- ========== 3. 带修饰符的 defineModel ========== -->
<script setup>
// v-model.capitalize="text"
const [modelValue, modifiers] = defineModel<string>({
// 定义 set 转换器:写入前自动处理
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
</script>
<template>
<input v-model="modelValue" />
</template>
<!-- 父组件 -->
<!-- <MyInput v-model.capitalize="text" /> -->
实际应用:封装表单组件
html
<!-- ========== 封装一个通用的搜索输入框 ========== -->
<!-- SearchInput.vue -->
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
const keyword = defineModel<string>({ default: '' })
const emit = defineEmits<{ search: [keyword: string] }>()
// 防抖搜索
watchDebounced(keyword, (val) => {
emit('search', val)
}, { debounce: 300 })
</script>
<template>
<div class="search-input">
<input
v-model="keyword"
placeholder="请输入搜索关键词"
@keyup.enter="emit('search', keyword)"
/>
<button @click="keyword = ''">清空</button>
</div>
</template>
<!-- 父组件使用 -->
<!-- <SearchInput v-model="searchText" @search="handleSearch" /> -->
💡 面试加分点:
defineModel是 Vue 3.4 最重要的 DX 改进------将 v-model 的 props + emit 模板代码从 5-6 行压缩到 1 行。它返回的是一个 ref,读取时等于读 prop,写入时自动触发 emit 。支持多 v-model(defineModel('name'))、修饰符(modifiers)、校验器(validator)。在封装表单组件库时,defineModel能让代码量减少 60% 以上。
29. 高频综合面试题
Q: Vue3 中如何实现权限控制?
javascript
// ========== 1. 路由级权限(动态路由) ==========
// 登录后根据角色动态添加路由
const asyncRoutes = [
{ path: '/admin', component: Admin, meta: { roles: ['admin'] } },
{ path: '/editor', component: Editor, meta: { roles: ['admin', 'editor'] } }
]
function generateRoutes(roles: string[]) {
return asyncRoutes.filter(route =>
!route.meta?.roles || route.meta.roles.some(role => roles.includes(role))
)
}
// 在路由守卫中动态添加
router.beforeEach(async (to) => {
const userStore = useUserStore()
if (userStore.token && !userStore.routes.length) {
const routes = generateRoutes(userStore.roles)
routes.forEach(route => router.addRoute(route))
userStore.setRoutes(routes)
return to.fullPath // 重新导航
}
})
// ========== 2. 按钮级权限(指令 + composable) ==========
// composables/usePermission.ts
export function usePermission() {
const userStore = useUserStore()
const hasPermission = (permission: string | string[]) => {
const perms = Array.isArray(permission) ? permission : [permission]
return perms.some(p => userStore.permissions.includes(p))
}
return { hasPermission }
}
// 权限指令
const vPermission = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
const { hasPermission } = usePermission()
if (!hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
}
}
// 使用
// <button v-permission="'user:delete'">删除</button>
// const { hasPermission } = usePermission()
// <button v-if="hasPermission('user:delete')">删除</button>
Q: 如何封装 Axios 请求?
typescript
// api/request.ts
import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { useUserStore } from '@/stores/user'
import router from '@/router'
const service = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 15000
})
// 请求拦截器
service.interceptors.request.use((config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const { code, data, message } = response.data
if (code === 0) return data
// 业务错误
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
(error) => {
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
router.push({ name: 'Login', query: { redirect: router.currentRoute.value.fullPath } })
} else if (error.response?.status === 403) {
router.push({ name: 'Forbidden' })
} else {
ElMessage.error(error.message || '网络错误')
}
return Promise.reject(error)
}
)
// 封装请求方法
export function get<T>(url: string, params?: object): Promise<T> {
return service.get(url, { params })
}
export function post<T>(url: string, data?: object): Promise<T> {
return service.post(url, data)
}
// api/modules/user.ts
export const userApi = {
login: (data: LoginParams) => post<LoginResult>('/auth/login', data),
getUserInfo: () => get<UserInfo>('/user/info'),
getUsers: (params: PageParams) => get<PageResult<User>>('/users', params)
}
💡 面试加分点: 权限控制分三层:路由级(动态路由 addRoute)、页面级(路由守卫 meta.roles)、按钮级(v-permission 指令 / usePermission composable)。Axios 封装要处理 token 注入、401 自动登出、业务错误码统一处理。