Vue 面试真题补充:源码、场景与工程实践
整理 Vue 面试资料中的实例初始化、响应式、组件通信、编译更新、路由权限、keep-alive、SSR 与 Vue3 迁移要点。
Vue 面试真题补充:源码、场景与工程实践
本文根据编号 3《Vue 面试真题》整理,保留可验证的源码链路和工程场景,并与项目已有的 Vue 基础文章互补。
1. 从 new Vue 到首屏渲染
Vue2 实例的启动可以按下面的链路回答:
new Vue(options)调用_init。- 合并全局配置、组件配置和实例配置,初始化生命周期、事件和渲染能力。
- 执行
beforeCreate,此时data、props、methods和computed尚未完成初始化。 - 初始化
props、methods、data、computed和watch,建立响应式关系。 - 执行
created,此时可以访问数据和方法,但还不能保证已经有真实 DOM。 $mount获取挂载节点;没有render时,将模板编译为render函数和静态渲染函数。mountComponent创建渲染Watcher,执行_render生成 VNode,再由_update调用patch创建或更新 DOM。- 首次挂载完成后执行
mounted。
因此,created 适合准备数据或发起请求,依赖 DOM 尺寸、焦点或第三方 DOM 插件的逻辑应放在 mounted 或 this.$nextTick 中。(知识点:生命周期与模板引用)
2. Vue2 响应式的依赖链
Vue2 通过 Object.defineProperty 为对象属性定义 getter 和 setter。可以把一次更新简化为:
读取响应式属性 -> getter -> Dep 收集当前 Watcher
修改响应式属性 -> setter -> Dep.notify()
通知 Watcher -> 进入更新队列 -> 重新 render -> patch DOM
一个简化的依赖对象如下:
class Dep {
constructor() {
this.subscribers = new Set()
}
depend(watcher) {
this.subscribers.add(watcher)
}
notify() {
this.subscribers.forEach((watcher) => watcher.update())
}
}
Vue2 的限制来自“只能拦截已经定义的属性”:
- 直接给对象新增属性不会自动建立依赖,需要
Vue.set(target, key, value),或创建新对象替换。 - 通过索引修改数组、直接修改
length不能可靠触发更新,应使用splice或响应式数组方法。 - 深层对象会被递归观察,数据量很大时要控制响应式范围。
Vue3 使用 Proxy 拦截对象的读取、写入、删除和属性判断,因此新增属性和数组索引的覆盖更完整;但深层对象仍然会产生代理和依赖成本,不能把所有数据无差别做成深层响应式。(知识点:响应式依赖追踪原理)
3. 为什么组件的 data 必须是函数
根实例可以写对象形式的 data,组件必须返回对象的函数:
export default {
data() {
return { count: 0 }
},
}
如果组件 data 直接引用同一个对象,多个实例会共享同一份状态。Vue 在初始化组件时调用 data 函数,并为每个实例得到独立对象;这既避免状态串扰,也使每个实例能独立建立响应式依赖。(知识点:组件实例与状态隔离)
4. 组件通信怎么选
| 场景 | 首选方式 | 说明 |
|---|---|---|
| 父传子 | props |
单向输入,子组件不直接改写 |
| 子传父 | $emit |
子组件发出事件,父组件决定如何更新 |
| 访问子组件实例 | ref |
只用于聚焦、播放、调用公开方法等命令式场景 |
| 跨多层透传 | provide/inject |
适合主题、表单上下文等稳定依赖 |
| 多模块共享状态 | Vuex/Pinia | 需要可追踪的状态和变更流程 |
| 老项目临时解耦 | EventBus | 要提供取消监听,否则容易泄漏和重复响应 |
| 透传未声明属性 | $attrs、$listeners |
Vue2 常见;Vue3 中监听器已并入 $attrs |
$parent、$children 可以访问组件关系,但会把组件强耦合到具体层级,不应作为常规通信方案。
子组件事件示例:
<!-- Parent.vue -->
<Child @change="handleChange" />
// Child.vue
this.$emit('change', nextValue)
.sync 是 Vue2 中对“prop + update 事件”的语法糖,本质仍然是单向数据流和事件回传:(知识点:组件通信与透传边界)
<Child :title.sync="title" />
this.$emit('update:title', nextTitle)
5. 模板编译、VNode 与 key
模板更新不是直接逐条执行 DOM 操作,而是经过:
template -> AST -> render function -> VNode -> patch -> DOM
key 是列表项的稳定身份,不是为了消除警告而随便添加的属性:
- 优先使用后端或业务数据中的唯一 ID。
- 不要使用随机数,否则每次渲染都会被视为全新节点。
- 只有列表不会插入、删除、排序时,索引才可能作为退化方案。
:key="Date.now()"会强制销毁并重建组件,适合明确需要重置内部状态的场景,不能当作常规性能优化。(知识点:模板编译与虚拟 DOM)
v-if 与 v-for
不要把 v-if 和 v-for 放在同一个元素上表达筛选逻辑。筛选应放到计算属性中,或用 template v-if 包住 v-for:
<template v-if="visible">
<li v-for="item in items" :key="item.id">
{{ item.title }}
</li>
</template>
v-if 与 v-show
v-if会创建和销毁子树,适合切换不频繁或初始化成本较高的内容。v-show只切换display,适合频繁切换且需要保留组件状态的内容。(知识点:条件与列表)
6. 事件与 v-model 修饰符
常见事件修饰符的语义应能直接说清:
.stop调用stopPropagation,阻止冒泡。.prevent调用preventDefault,阻止默认行为。.self只在事件目标是当前元素时触发。.capture使用捕获阶段监听。.once只触发一次。
v-model 的常见修饰符:
.lazy从change而不是每次input更新。.trim去掉字符串两端空白。.number尝试将输入转换成数字,转换失败时保留原值。
修饰符组合应服务于明确的交互需求,不要把阻止默认行为和阻止冒泡混为一谈。(知识点:v-model)
7. 更新队列与 nextTick
Vue 会把同一轮事件循环中的多次状态修改合并到异步队列,并对同一个 watcher 去重。同步读取 DOM 时,DOM 可能仍是旧值:
this.message = 'updated'
console.log(this.$el.textContent) // 仍可能是旧内容
this.$nextTick(() => {
console.log(this.$el.textContent) // 更新后的内容
})
Vue2 优先使用原生 Promise 的微任务,其次会尝试 MutationObserver 等机制,最后才回退到定时器。nextTick 解决的是“等待本轮 DOM 更新完成”,不是让任意异步请求变同步。(知识点:调度队列与 DOM 更新时间)
8. Mixins 与选项合并
Mixins 会把选项合并进组件,常见规则包括:
- 生命周期钩子通常合并成数组,先执行 mixin,再执行组件自身钩子。
methods、props、computed等对象选项会合并,组件同名项覆盖 mixin 同名项。data会合并为独立对象,组件字段与 mixin 字段同名时以组件为准。
Mixins 的问题是来源隐式、命名容易冲突、依赖关系不明显。新代码优先使用 Vue3 composable;必须维护旧 mixin 时,应使用明确前缀并限制职责。(知识点:组合式 API 的代码复用)
9. keep-alive 的缓存边界
keep-alive 是抽象组件,通过组件名称和 key 管理缓存:
<keep-alive :include="['UserList']" :max="10">
<router-view />
</keep-alive>
include、exclude可以用字符串、正则或数组匹配组件名。max控制缓存数量,超出后通常淘汰最早的缓存项。- 缓存组件切换时触发
activated和deactivated,不等同于mounted和destroyed。 - 列表页的查询条件、滚动位置和请求取消策略要与缓存生命周期配套,否则可能恢复过期数据。(知识点:缓存组件与路由页面边界)
10. 路由权限与 history 部署
常见权限链路是:读取 token -> 路由守卫判断白名单 -> 获取角色/权限 -> 动态注册可访问路由 -> 放行或跳转登录/无权限页。
守卫必须保证每条分支只调用一次 next(Vue Router 3)或返回一次导航结果(Vue Router 4),否则会出现重复导航或死循环。
使用 history 模式时,服务端必须把未知路径回退到 index.html:
location / {
try_files $uri $uri/ /index.html;
}
否则刷新 /detail/42 时,服务器会把它当成真实文件路径而返回 404。hash 模式不需要服务端重写,但 URL 和 SEO 能力有取舍。(知识点:缓存组件与路由页面边界)
11. 错误处理与请求拦截
组件树中的渲染、生命周期和 watcher 错误可以通过 errorCaptured 向上捕获;全局兜底可配置 Vue.config.errorHandler。错误边界应记录组件名、路由、用户操作和版本信息,不能只显示一个“系统异常”。
Axios 拦截器适合统一注入 token、处理错误码和刷新凭证:
const api = axios.create({ baseURL: '/api' })
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${getToken()}`
return config
})
api.interceptors.response.use(
(response) => response.data,
(error) => Promise.reject(error),
)
请求取消必须与组件生命周期绑定,避免离开页面后仍回写已销毁组件。新代码优先使用 AbortController;旧 Axios 版本可使用 CancelToken。(知识点:错误处理、请求取消与幂等提交)
11.1 401 刷新时如何挂起并重放请求
多个请求同时收到 401 时,只允许一个刷新请求在途,其余请求等待同一个 Promise。刷新成功后,为每个原请求写入新凭证并重放;刷新失败则统一清理会话并回到登录流程。刷新接口应使用不带该响应拦截器的客户端,或显式标记为跳过刷新,否则会递归触发。
let refreshPromise = null
function refreshOnce() {
if (!refreshPromise) {
// requestRefreshToken 使用独立的基础客户端,避免 401 再次进入本拦截器
refreshPromise = requestRefreshToken()
.catch((error) => {
clearSession()
redirectToLogin()
throw error
})
.finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
api.interceptors.response.use(
(response) => response.data,
async (error) => {
const original = error.config
const status = error.response?.status
const isRefreshRequest = original?.url?.endsWith('/auth/refresh')
if (status !== 401 || !original || original._retry || isRefreshRequest) {
throw error
}
original._retry = true
const accessToken = await refreshOnce()
original.headers = {
...(original.headers || {}),
Authorization: `Bearer ${accessToken}`,
}
return api(original)
},
)
_retry 只防止同一个请求无限重试,不等于业务幂等。若原请求已经在服务端成功、但响应在网络中丢失,自动重放一个创建或支付请求仍可能产生重复副作用,因此这类接口还需要幂等键。(知识点:错误处理、请求取消与幂等提交)
11.2 提交操作如何避免重复写入
前端禁用按钮、显示提交状态和复用在途 Promise 是交互层保护;真正的正确性必须由服务端保证。对创建订单、支付、发布等写操作,可以为一次用户意图生成一个 Idempotency-Key,重试时沿用同一个 key。服务端按“用户/租户 + key”保存处理中或已完成的结果,并配合唯一约束、事务和过期策略。
let submitting = false
async function submitOrder(payload, operationKey) {
if (submitting) return
submitting = true
const key = operationKey || crypto.randomUUID()
try {
return await api.post('/orders', payload, {
headers: { 'Idempotency-Key': key },
})
} finally {
submitting = false
}
}
不要在每次网络重试时重新生成 key,否则服务端无法识别它们属于同一次操作。取消请求也不能撤销已经在服务端执行的写入;错误提示和重试按钮应明确区分“未提交”“处理中”和“结果未知”。(知识点:错误处理、请求取消与幂等提交)
12. qiankun 微前端的调试、资源与卸载
12.1 本地调试多个子应用
让主应用和每个子应用分别启动,主应用的注册表在开发环境指向子应用的本地入口地址。排查顺序应是:入口 HTML 是否能访问、入口脚本和样式是否返回 2xx、生命周期是否按 bootstrap -> mount -> unmount 执行、路由切换后是否仍有旧应用副作用。开发服务器需要正确配置 CORS 和 source map,端口或 public path 不一致时不要先怀疑框架。
12.2 静态资源路径
子应用的异步 chunk、CSS、图片不能依赖主应用当前 URL 进行相对解析。Webpack 子应用可以在加载其他模块前设置运行时 public path:
// public-path.js,必须在可能触发异步 import 之前执行
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
Vite 或旧的多页应用应使用对应构建工具的 base/资源前缀配置,或在网关为资源提供稳定的绝对路径。全局劫持 appendChild、innerHTML 等 DOM 方法虽然能临时改写旧资源地址,但会影响主应用和其他子应用,应只作为隔离良好、可回滚的最后手段。
12.3 沙箱不是卸载清理的替代品
代理 window 主要隔离可代理的全局属性;直接给 document.body 添加事件、修改 body class、启动定时器或注册第三方订阅,仍需要子应用自己清理。把每个副作用的撤销函数集中保存,并在 unmount 中逐个执行:
const disposers = []
export function mount() {
const onResize = () => {}
window.addEventListener('resize', onResize)
disposers.push(() => window.removeEventListener('resize', onResize))
disposers.push(() => document.body.classList.remove('child-theme'))
}
export function unmount() {
while (disposers.length) {
const dispose = disposers.pop()
dispose?.()
}
// 同时停止轮询、Observer、WebSocket,并清理共享 store 的临时状态
}
qiankun 卸载后默认不保留子应用 DOM。需要 keep-alive 时,应把可恢复的查询条件和业务状态放到主应用或受控 store 中;若只是隐藏而不卸载,则要明确它会继续占用内存、监听器和连接。(知识点:微前端卸载与副作用清理)
13. 组件库二次封装与工程闭环
面试中可以这样概括:二次封装不是复制一层 API,而是建立稳定的业务契约。透传底层组件必要的 props、attrs 和事件,同时统一主题、权限、校验、loading/error/empty 状态与可访问性;组件暴露业务语义和明确扩展点,页面不要依赖内部 DOM 结构。
工程闭环至少包括:用单元测试覆盖纯逻辑和边界,用组件/集成测试覆盖受控状态、事件、键盘和异常状态;CI 执行类型检查、构建、测试和产物检查;用语义化版本、变更记录和可回滚的不可变产物管理发布。(知识点:组件库 API 设计)
14. 项目排障如何组织成可验证的回答
项目题可以按技术化的 STAR 组织:S(背景) -> T(目标) -> A(诊断与行动) -> R(结果),再补充后续动作:
- 背景:说明用户、页面规模、约束和影响范围,不要只说“页面有问题”。
- 目标:给出可观察的目标,例如降低白屏率、避免重复创建、缩短首屏交互时间。
- 诊断:描述如何用 Network、Performance、日志、错误监控或最小复现定位假设。
- 行动:说明改动、取舍和与后端/设计/测试的协作,区分临时止血与长期修复。
- 结果:给出前后指标、异常边界和回滚方式;没有真实数据时应明确说是验证计划,不要编造数字。
- 后续:补充监控、测试用例、文档或流程改进,证明问题不会只靠人工记忆避免。
例如“首页偶尔白屏”不能只回答“加缓存”:应先确认动态组件配置是否返回、资源请求是否失败、旧 HTML 是否引用了不存在的 chunk,再决定使用稳定资源版本、加载占位、失败降级和发布兼容窗口等措施。(知识点:PerformanceObserver 与前端监控 SDK)
15. Vue3 迁移高频点
beforeDestroy/destroyed对应onBeforeUnmount/onUnmounted。this不再是setup中组织逻辑的入口,状态和方法通过显式变量返回。Vue.set、Vue.delete不再需要,Proxy 会拦截新增和删除。filters被移除,使用方法或计算属性替代。$listeners合并进$attrs,组件应显式声明emits。v-model默认使用modelValue和update:modelValue。Teleport将弹窗、通知等内容挂载到指定 DOM 容器,解决层叠上下文和祖先overflow的限制。createRenderer允许把 Vue 运行时适配到非 DOM 容器,但需要实现节点创建、插入、删除和属性更新等宿主操作。(知识点:Vue3 迁移策略)
面试口述模板
回答 Vue 题目时可以按“现象 -> 原理 -> 取舍 -> 场景”展开:先给出结论,再说明响应式、渲染或调度机制,接着讲边界和代价,最后结合一个具体业务场景。源码题不要只背函数名,要能指出数据从哪里进入、依赖在哪里建立、更新如何到达 DOM。