Vue 面经
核心考点
1. 响应式原理
- Vue 2:Object.defineProperty 劫持属性 getter/setter;无法检测属性新增/删除,需用 Vue.set
- Vue 3:Proxy 代理整个对象,天然支持新增/删除属性,性能更好
2. Composition API vs Options API
| 对比项 | Options API | Composition API |
|---|---|---|
| 逻辑组织 | 按选项分散 | 按功能聚合 |
| 逻辑复用 | Mixins(易冲突) | Composables(清晰) |
| TypeScript | 较弱 | 完整支持 |
| 适合场景 | 简单组件 | 复杂/大型项目 |
3. Vue 3 重要新特性
- Proxy 响应式:性能提升,支持 Map/Set/Array
- Teleport:将组件渲染到 DOM 指定位置(如 Modal 挂到 body)
- Fragments:组件可有多个根节点
- setup() / script setup:更简洁的组合式写法
- Suspense:异步组件加载的内置支持
4. 生命周期对比
Vue 2 Vue 3 (Composition)
beforeCreate → setup()
created → setup()
beforeMount → onBeforeMount
mounted → onMounted
beforeUpdate → onBeforeUpdate
updated → onUpdated
beforeDestroy → onBeforeUnmount
destroyed → onUnmounted
5. computed vs watch vs watchEffect
- computed:依赖缓存,有返回值,用于派生状态
- watch:明确监听某个源,可获取新旧值,支持异步操作
- watchEffect:自动收集依赖,立即执行,适合副作用同步
6. 高频面试题
- v-model 原理:语法糖,绑定
:modelValue+@update:modelValue - nextTick 原理:异步队列(Promise → MutationObserver → setTimeout),DOM 更新后回调
- keep-alive:缓存组件实例,触发
activated/deactivated生命周期 - diff 算法:Vue3 采用最长递增子序列优化移动操作
- 为什么 data 是函数:防止组件实例间共享同一引用
💡 推荐阅读 Vue 3 官方文档 深入理解设计思路
Vue 3 响应式源码深入
响应式系统源码解析
1. 核心数据结构
// 依赖收集的核心映射
type Dep = Set<ReactiveEffect> // 订阅该属性的 effect 集合
const targetMap = new WeakMap<object, Map<string, Dep>>()
// WeakMap<target, Map<key, Set<effect>>>
// WeakMap 保证 target 被 GC 时自动清理
2. reactive / ref 区别
| reactive | ref | |
|---|---|---|
| 接受类型 | 对象/数组 | 任意值 |
| 访问方式 | 直接访问属性 | .value |
| 实现 | Proxy | getter/setter(对象内部用 reactive) |
| 解构 | 会丢失响应式 | toRefs() 保持响应式 |
3. track / trigger 流程
【读取属性】
get trap → track(target, key)
→ 找到当前 activeEffect
→ targetMap[target][key].add(activeEffect)
【修改属性】
set trap → trigger(target, key)
→ 取出 targetMap[target][key]
→ 遍历执行所有 effect
4. effect / computed / watch 关系
ReactiveEffect是核心类,computed/watch 都基于它- computed:懒执行,有
_dirty标记,依赖变化时只标记脏,访问时才重算 - watch:立即执行 effect,对比新旧值后调用回调
- watchEffect:直接执行 effect,自动收集内部响应式依赖
5. 响应式边界问题
// ❌ 解构丢失响应式
const { count } = reactive({ count: 0 })
// ✅ 用 toRefs
const state = reactive({ count: 0 })
const { count } = toRefs(state)
count.value++ // 仍然响应式
// ❌ 将响应式对象赋值给普通变量
let user = reactive({ name: 'A' })
user = { name: 'B' } // 丢失响应式!
// ✅ 修改属性而非替换对象
user.name = 'B'
6. shallowReactive / readonly / shallowReadonly
- shallowReactive:只代理第一层,深层不响应
- readonly:深层只读,set 时警告
- markRaw:标记对象不被转为响应式(如第三方实例)
7. 高频面试题
- Vue 3 为什么用 Proxy 替换 Object.defineProperty?
- 支持新增/删除属性、数组下标修改
- 懒代理嵌套对象(访问时才代理),性能更好
- 支持 Map/Set/WeakMap/WeakSet
- ref 内部为什么对对象用 reactive? 保持深层响应式
- computed 为什么是懒计算? 避免不被读取时白白计算
- watchEffect 和 watch 的 flush 时机? 默认
pre(DOM 更新前),post在 DOM 更新后
Vue 生态(Pinia / Vue Router / Nuxt)
Vue 生态核心面经
1. Pinia(替代 Vuex)
核心概念
export const useUserStore = defineStore('user', () => {
const name = ref('Alice')
const double = computed(() => name.value.repeat(2))
async function fetchUser(id: string) {
name.value = await api.getUser(id)
}
return { name, double, fetchUser }
})
| 对比 | Vuex 4 | Pinia |
|---|---|---|
| TypeScript | 较弱 | 完整支持 |
| 模块化 | namespace | 每个 store 独立 |
| Mutations | 必须 | 不需要,直接修改 |
| DevTools | 支持 | 支持(更好) |
- $patch:批量修改状态,触发一次订阅
- $subscribe:监听 store 变化(类似 vuex 的 subscribe)
- 插件系统:可扩展 store(如持久化插件 pinia-plugin-persistedstate)
2. Vue Router 4
导航守卫执行顺序
触发导航
→ 全局 beforeEach
→ 路由独享 beforeEnter
→ 组件 beforeRouteUpdate(复用组件)
→ 组件 beforeRouteEnter
→ 全局 afterEach
→ beforeRouteEnter 的 next(vm => ...) 回调
动态路由
router.addRoute({ path: '/admin', component: Admin })
router.removeRoute('admin') // 通过 name 删除
- 路由懒加载:
component: () => import('./views/Home.vue') - 滚动行为:
scrollBehavior(to, from, savedPosition)自定义 - 路由元信息:
meta: { requiresAuth: true }配合守卫做权限控制
3. Nuxt 3 核心
目录结构约定
pages/ → 自动生成路由
components/ → 自动导入组件
composables/ → 自动导入 composables
server/api/ → 服务端 API 路由
plugins/ → 客户端/服务端插件
middleware/ → 路由中间件
数据获取
// useFetch:SSR + CSR 通用,自动序列化
const { data } = await useFetch('/api/users')
// useAsyncData:更灵活的异步数据
const { data } = await useAsyncData('users', () => $fetch('/api/users'))
- Nitro 引擎:Nuxt 3 服务端,支持多部署目标(Node/Vercel/CF Workers)
- Island Architecture:
<NuxtIsland>服务端渲染的交互组件 - SEO:
useHead/useSeoMeta管理 meta 标签
4. VueUse
常用 composables:
useStorage— localStorage/sessionStorage 响应式绑定useFetch— 简单请求封装useIntersectionObserver— 懒加载/埋点useEventListener— 自动清理的事件绑定useVModel— 简化 v-model 实现
5. 高频面试题
- Pinia 如何实现跨 store 通信? 在一个 store 中直接导入并使用另一个 store
- Vue Router history 模式需要服务器配置什么? 所有路径返回
index.html,避免 404 - Nuxt SSR 如何避免服务端/客户端数据不一致(hydration mismatch)? 避免在服务端使用
window/document,用onMounted隔离客户端逻辑 - Vue 项目如何做权限控制? 路由 meta + 全局守卫 + 动态添加路由 + 指令(v-permission)
Vue 大型项目架构实战
Vue 大型项目设计
1. 目录结构设计
src/
api/ # API 层:请求封装、类型定义
modules/
user.ts
product.ts
request.ts # Axios 封装、拦截器
types.ts # 响应类型
assets/ # 静态资源
components/ # 通用组件
base/ # 基础组件(Button、Input、Modal)
business/ # 业务组件
composables/ # 可复用的 composition functions
directives/ # 自定义指令
layouts/ # 布局组件
router/ # 路由配置
modules/ # 按模块分割路由
guards.ts # 导航守卫
stores/ # Pinia stores
modules/
utils/ # 工具函数
views/ # 页面组件(对应路由)
2. 请求层设计
// request.ts:统一 Axios 封装
import axios from 'axios'
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
class Request {
private instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
timeout: 10000
})
constructor() {
this.setupInterceptors()
}
private setupInterceptors() {
// 请求拦截:注入 token
this.instance.interceptors.request.use(config => {
const token = useAuthStore().token
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 响应拦截:统一错误处理
this.instance.interceptors.response.use(
(res: AxiosResponse<ApiResponse>) => {
if (res.data.code !== 0) {
showError(res.data.message)
return Promise.reject(res.data)
}
return res.data.data
},
async error => {
if (error.response?.status === 401) {
await useAuthStore().refreshToken()
return this.instance(error.config) // 重试
}
return Promise.reject(error)
}
)
}
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.instance.get(url, config)
}
post<T>(url: string, data?: unknown): Promise<T> {
return this.instance.post(url, data)
}
}
export const request = new Request()
3. 权限系统设计
// 基于 RBAC 的权限体系
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const permissions = ref<string[]>([])
const roles = ref<string[]>([])
function hasPermission(perm: string) {
return permissions.value.includes(perm)
}
function hasRole(role: string) {
return roles.value.includes(role)
}
return { permissions, roles, hasPermission, hasRole }
})
// composables/usePermission.ts
export function usePermission(permission: string) {
const store = useAuthStore()
return computed(() => store.hasPermission(permission))
}
// 自定义指令 v-permission
app.directive('permission', {
mounted(el, binding) {
const store = useAuthStore()
if (!store.hasPermission(binding.value)) {
el.parentNode?.removeChild(el)
}
}
})
// 路由守卫
router.beforeEach(async (to, from) => {
const store = useAuthStore()
if (to.meta.requiresAuth && !store.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
if (to.meta.permission && !store.hasPermission(to.meta.permission)) {
return { name: '403' }
}
})
4. 动态路由(按权限加载)
// 登录后根据权限动态注册路由
async function setupDynamicRoutes(permissions: string[]) {
const allRoutes = import.meta.glob('./modules/*.ts', { eager: true })
const accessibleRoutes = filterRoutesByPermission(allRoutes, permissions)
accessibleRoutes.forEach(route => router.addRoute(route))
}
// 刷新页面后路由恢复(动态路由会在刷新后丢失)
router.beforeEach(async (to) => {
const store = useAuthStore()
if (store.isLoggedIn && !store.routesLoaded) {
await store.loadUserRoutes() // 重新加载路由
return to.fullPath // 重新导航到目标路由
}
})
5. 组件通信规范
父子通信:props + emits(类型安全)
跨层级:provide / inject + readonly 包裹
兄弟/跨页:Pinia store
复杂异步:组合 composables
// 类型安全的 provide/inject
const FormContextKey = Symbol() as InjectionKey<FormContext>
// 父组件
provide(FormContextKey, readonly(formContext))
// 子组件
const form = inject(FormContextKey)
if (!form) throw new Error('FormContext not provided')
6. 性能优化实践
// 1. 路由懒加载(分包)
const Dashboard = () => import('./views/Dashboard.vue')
// 2. 组件异步加载(大型业务组件)
const HeavyTable = defineAsyncComponent({
loader: () => import('./HeavyTable.vue'),
loadingComponent: Skeleton,
errorComponent: ErrorView,
delay: 200,
timeout: 3000
})
// 3. 长列表虚拟滚动
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(
items,
{ itemHeight: 50, overscan: 10 }
)
// 4. 计算属性缓存敏感数据
const expensiveData = computed(() => {
return heavyCalculation(rawData.value)
})
// 5. shallowRef 减少深层响应式
const tableData = shallowRef<Row[]>([]) // 不对数组元素建立深层响应
7. 资深面试题
- Vue 大型项目如何解决 TypeScript 类型推导问题?
- Volar 插件提供组件 props 类型推导
defineProps<{ title: string }>()泛型语法直接推导类型- API 响应类型用 Zod 做运行时校验 + 类型推导
- Pinia 如何在 SSR 中避免状态污染?
- 每次请求创建新的 Pinia 实例,不共享服务端状态
useStore(pinia)传入请求绑定的 pinia 实例
- 如何做 Vue 项目的国际化?
- vue-i18n 管理翻译文件,按语言懒加载
- 日期、数字用 Intl API 处理本地化格式
- 配合 CI/CD 自动提取新增翻译 key
Vue 编译器原理深入
模板编译全流程
1. 编译三阶段
模板字符串
-> Parse(词法+语法分析)-> AST(抽象语法树)
-> Transform(优化转换)-> 带 PatchFlag 的 JavaScript AST
-> Codegen(代码生成)-> render 函数字符串
2. Parse 阶段
- 有限状态机解析 HTML:Data -> Tag Open -> Tag Name -> Attribute
- 生成元素节点、文本节点、插值节点
// <div :class="cls">{{ msg }}</div> 解析后
{
type: NodeTypes.ELEMENT,
tag: 'div',
props: [{ name: 'class', exp: { content: 'cls' } }],
children: [{
type: NodeTypes.INTERPOLATION,
content: { type: NodeTypes.SIMPLE_EXPRESSION, content: 'msg' }
}]
}
3. Transform 阶段(核心优化)
静态提升(Static Hoisting)
// 模板:
// <ul>
// <li class="static">固定内容</li>
// <li>{{ item }}</li>
// </ul>
// 静态节点提升到 render 函数外
const _hoisted_1 = createVNode('li', { class: 'static' }, '固定内容')
function render() {
return createVNode('ul', null, [
_hoisted_1, // 直接复用,不重新创建
createVNode('li', null, ctx.item, PatchFlags.TEXT)
])
}
PatchFlag 靶向更新
// 标记节点哪部分是动态的
createVNode('div',
{ class: dynamicClass },
null,
PatchFlags.CLASS // 只有 class 是动态的,diff 时只比较 class
)
常见 PatchFlag 值:
TEXT = 1:动态文本CLASS = 2:动态 classSTYLE = 4:动态 stylePROPS = 8:动态非 class/style 属性CACHED = -1:静态节点,跳过所有 diff
Block Tree
- 每个含动态节点的模板区域是一个 Block
- Block 记录其内部所有动态子节点(dynamicChildren)
- Diff 时只遍历 dynamicChildren,跳过静态节点
v-if/v-for会创建新 Block,因为结构会变化
4. v-if / v-for 编译
// v-if 编译为三元表达式
// <div v-if="show">A</div><div v-else>B</div>
_ctx.show ? createVNode('div', null, 'A') : createVNode('div', null, 'B')
// v-for 编译为 renderList
// <li v-for="item in list" :key="item.id">{{ item.name }}</li>
renderList(_ctx.list, (item) =>
createVNode('li', { key: item.id }, toDisplayString(item.name), 1)
)
// v-model 编译为双向绑定
// <input v-model="val">
createVNode('input', {
value: _ctx.val,
onInput: $event => { _ctx.val = $event.target.value }
})
5. 事件缓存
// <button @click="handleClick">按钮</button>
// 编译后:handler 被缓存,避免每次渲染创建新函数
createVNode('button', {
onClick: _cache[0] || (_cache[0] = (...args) => _ctx.handleClick(...args))
})
// 内联 handler 会被包裹后缓存
6. 编译时 vs 运行时
| 版本 | 说明 | Bundle 大小 |
|---|---|---|
vue.runtime.esm | 仅运行时,无编译器 | 小(推荐,Vite/Webpack 用) |
vue.esm-bundler | 运行时 + 编译器 | 大(CDN 直接使用时) |
- 使用构建工具时:模板在构建阶段编译,运行时不需要编译器
- 直接
<script src>时:需要运行时编译器
7. 资深面试题
- Vue 3 编译优化为什么比 Vue 2 快?
- 静态提升:不变的节点不重复创建
- PatchFlag:diff 只比较变化的部分
- Block Tree:O(全树) -> O(动态节点数)
- 事件缓存:handler 不每次重新创建
- 为什么 v-if 和 v-for 不能同时使用?
- Vue 3 中 v-if 优先级高于 v-for,v-if 内无法访问 v-for 的变量
- 解决:外层包一个
<template v-for>或用 computed 过滤数据
- 自定义指令如何在编译层面优化?
- 通过
directiveTransforms在 Transform 阶段处理 - 可以将部分指令逻辑转为静态属性,避免运行时指令调用开销
- 通过
- 模板中
key的作用是什么?编译层面如何处理?- 用于标识节点身份,相同 key 的节点会尝试复用
- 编译时 key 被提取为 VNode 的第二个参数
v-for中动态 key 会生成 KEYED_FRAGMENT,告知 diff 算法使用带 key 的策略
Vue 性能优化深度实战
Vue 性能优化完整指南
1. 组件渲染优化
v-once 与 v-memo
<!-- v-once:只渲染一次,永不更新 -->
<ExpensiveComponent v-once />
<!-- v-memo:依赖的值不变则跳过重渲染 -->
<div v-for="item in list" :key="item.id" v-memo="[item.selected]">
{{ item.name }}
</div>
<!-- 组合使用:大列表时非常有效 -->
<RecycleScroller v-memo="[item.id, item.selected]">
<ExpensiveListItem :item="item" />
</RecycleScroller>
计算属性 vs 方法
<script setup>
// computed: 只在依赖变化时重计算,结果被缓存
const sortedList = computed(() =>
[...props.list].sort((a, b) => a.name.localeCompare(b.name))
)
// method: 每次渲染都执行
const getSorted = () => [...props.list].sort(...)
</script>
shallowRef / shallowReactive
// 大列表用 shallowRef 避免深层响应式的开销
const bigList = shallowRef([])
// 更新时需触发更新
bigList.value = [...bigList.value, newItem]
// 威尔用 markRaw 标记不需要响应式的对象
const map = markRaw(new Map())
2. 列表渲染优化
虚拟滚动(vue-virtual-scroller)
<template>
<RecycleScroller
class="scroller"
:items="list"
:item-size="60"
key-field="id"
>
<template #default="{ item }">
<ListItem :item="item" />
</template>
</RecycleScroller>
</template>
首屏渲染优化
<!-- 分批渲染大列表,首屏先展示 -->
<script setup>
const visibleCount = ref(50)
const visibleList = computed(() => list.value.slice(0, visibleCount.value))
onMounted(() => {
// 首屏渲染完成后再加载其余
nextTick(() => {
setTimeout(() => {
visibleCount.value = list.value.length
}, 300)
})
})
</script>
3. 路由与分包优化
// 路由懒加载 + 马鼓向加载
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'), // 分包
children: [
{
path: 'analytics',
component: () => import('./views/Analytics.vue')
}
]
}
]
// 预加载:导航预获取数据
router.beforeResolve(async to => {
if (to.meta.prefetch) {
await prefetchData(to.meta.prefetch)
}
})
分包命名
// webpackChunkName 命名 chunk
const UserProfile = () =>
import(/* webpackChunkName: "user" */ './views/UserProfile.vue')
// Vite 自动分包
// 所有动态导入自动分包
4. 缓存策略
keep-alive 深入
<template>
<!-- 路由级缓存 -->
<router-view v-slot="{ Component, route }">
<transition name="fade">
<keep-alive :include="cachedViews" :max="10">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup>
// 按需加入缓存列表
const cachedViews = ref(['Dashboard', 'UserList'])
// 切换路由时动态添加
router.afterEach((to) => {
if (to.meta.keepAlive) {
cachedViews.value.push(to.name)
}
})
</script>
keep-alive 的生命周期鋈子
<script setup>
// 组件被激活(从缓存中自展示)
onActivated(() => {
fetchLatestData() // 重新获取数据
})
// 组件被缓存
onDeactivated(() => {
cleanupResources() // 清理定时器、套接字等
})
</script>
5. 网络请求优化
// 并行请求多个接口
const [user, config, permissions] = await Promise.all([
fetchUser(),
fetchConfig(),
fetchPermissions()
])
// 请求去重:将相同请求合并
const pendingRequests = new Map()
function deduplicatedFetch(url: string) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url)
}
const promise = fetch(url).finally(() => {
pendingRequests.delete(url)
})
pendingRequests.set(url, promise)
return promise
}
// 请求预加载:Intersection Observer 视口内就加载
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
prefetchNextPage()
}
})
})
observer.observe(bottomRef.value)
6. 资深面试题
- Vue 3 为什么比 Vue 2 快?
- 编译器优化:静态提升 + PatchFlag + Block Tree
- Proxy 响应式:懒代理,访问时才建立响应式
- Tree-shaking 友好:只包含用到的模块
- v-for 的 key 如何影响性能?
- key 不变:全量更新,运行全量 diff
- key 稳定:精确匹配节点,只更新变化的部分
- key = index:列表尾部操作时没有差异,中间插入错误
- nextTick 的使用场景?
- 修改数据后,需访问更新后的 DOM
- 测如分页切换后的滚动位置重置
- 在 created 中操作 DOM(DOM 还未挂载)
- 如何定位 Vue 应用中的内存泄漏?
- 监听器在 beforeUnmount 中取消
- 定时器、EventBus、WebSocket 在生命周期结束时清理
- Vue DevTools 测量组件数量变化
- 浏览器 Memory Profiler 截图对比