Vue 3 响应式原理与资深面试
从 Composition API 深入到依赖追踪、渲染调度、组件身份、状态架构、SSR 与性能诊断。
Vue 3 通过响应式依赖追踪更新界面。组件应围绕清晰的输入、输出和状态职责组织,而不是把所有逻辑堆进模板。
单文件组件
<script setup>
import { computed, ref } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>
<template>
<button type="button" @click="count++">
{{ count }} / {{ doubled }}
</button>
</template>
<style scoped>
button {
font: inherit;
}
</style>
<script setup> 会把顶层变量和导入直接暴露给模板。模板中 ref 会自动解包,JavaScript 中仍需通过 .value 访问。
ref 与 reactive
import { reactive, ref } from 'vue'
const query = ref('')
const filters = reactive({
category: 'all',
onlyPublished: true,
})
ref能包装任意值,适合独立状态,也是更统一的默认选择。reactive只接受对象,适合多个紧密关联字段。- 不要直接解构
reactive对象,否则属性可能失去响应性;需要解构时使用toRefs。
const { category, onlyPublished } = toRefs(filters)
响应式对象应通过属性修改,不要整体替换绑定到 reactive 的变量。
计算属性与侦听器
计算属性用于从响应式状态推导值,并按依赖缓存:
const visibleArticles = computed(() => {
const normalized = query.value.trim().toLowerCase()
return articles.value.filter((article) =>
article.title.toLowerCase().includes(normalized)
)
})
watch 和 watchEffect 用于产生副作用,例如持久化、请求或调用外部 API。
watch(query, async (nextQuery, _previousQuery, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
results.value = await search(nextQuery, controller.signal)
})
能用 computed 表达的数据不要用 watch 写回另一份状态,避免来源重复。
Props 与事件
<script setup>
const props = defineProps({
article: {
type: Object,
required: true,
},
})
const emit = defineEmits(['select'])
</script>
<template>
<article>
<h2>{{ props.article.title }}</h2>
<button type="button" @click="emit('select', props.article.id)">
阅读文章
</button>
</article>
</template>
Props 是只读输入。子组件不要直接修改 props,而应发送事件,由父组件更新状态。
TypeScript 项目可以使用类型声明:
<script setup lang="ts">
interface Article {
id: string
title: string
}
defineProps<{ article: Article }>()
const emit = defineEmits<{ select: [id: string] }>()
</script>
v-model
原生表单中的 v-model 组合了值绑定和输入事件:
<input v-model.trim="query" type="search" placeholder="搜索文档" />
自定义组件应把双向绑定理解为明确的 prop 与事件,而不是隐式共享状态。Vue 3.4+ 可使用 defineModel:
<script setup>
const model = defineModel({ default: '' })
</script>
<template>
<input v-model="model" />
</template>
条件与列表
v-if会创建和销毁节点,适合切换不频繁或初始可能不渲染的内容。v-show只切换display,适合频繁显示隐藏。v-for使用稳定业务 ID 作为key。
<ul v-if="articles.length">
<li v-for="article in articles" :key="article.id">
{{ article.title }}
</li>
</ul>
<p v-else>暂无文章</p>
避免在同一元素上同时使用 v-if 和 v-for。需要过滤时先通过计算属性得到目标列表。
生命周期与模板引用
const searchInput = ref(null)
onMounted(() => {
searchInput.value?.focus()
})
DOM、定时器和订阅属于生命周期副作用。创建资源后,应在 onUnmounted 中释放。
组件实例与状态隔离
Vue 2 的组件 data 必须写成返回对象的函数。每次创建组件实例时都会调用它,从而得到独立状态;若多个实例引用同一个对象,一个输入框或计数器的修改就会泄漏到其他实例。根实例只有一个,才可以使用对象形式的 data。
Vue 3 的 setup 与组合式函数遵循同一原则:在组件实例创建期间调用的 ref、reactive 和 composable 通常属于该实例;把响应式对象放到模块顶层则会变成共享单例。需要共享时应显式选择 store、provide/inject 或服务端缓存,而不是意外依赖模块变量。
组合式函数
组合式函数以 use 开头,封装可复用的响应式逻辑:
export function useLocalStorage(key, initialValue) {
const value = ref(initialValue)
onMounted(() => {
const stored = localStorage.getItem(key)
if (stored !== null) value.value = JSON.parse(stored)
})
watch(value, (nextValue) => {
localStorage.setItem(key, JSON.stringify(nextValue))
}, { deep: true })
return value
}
组合式函数复用逻辑,组件复用视图。不要让一个组合式函数隐式修改大量全局状态。
性能原则
- 使用稳定的列表
key。 - 大型静态对象可考虑
shallowRef或markRaw,避免不必要的深层代理。 - 组件按职责拆分,但不要只为减少代码行数制造碎片。
- 路由页面和重型组件使用动态导入。
- 通过 Vue Devtools 和浏览器 Performance 面板确认瓶颈。
缓存组件与路由页面边界
keep-alive 会缓存动态组件实例。缓存页切换时使用 activated / deactivated 感知恢复和停用,不能把它等同于重新 mounted 或已经 unmounted;轮询、订阅和过期数据要根据缓存策略主动处理。include、exclude 和 max 应围绕组件名称、页面 key 与内存预算设计,列表筛选条件和滚动位置也要明确是否恢复。
前端路由的权限判断应在进入受保护页面前完成,路由、菜单和按钮使用同一份权限模型。history 模式还要求服务器把未知业务路径回退到入口 HTML;否则直接刷新深层 URL 会得到服务器 404。hash 模式不需要该回退,但 URL 形态和 SEO 能力不同。
响应式依赖追踪原理
reactive 通过 Proxy 拦截属性读取和写入。某个响应式 effect 执行时,读取操作把“目标对象 + 属性键”与当前 effect 建立依赖;写入后,Vue 找到相关依赖并调度重新执行。
可以用简化模型理解,不要把它当作源码的完整实现:
const dependencies = new WeakMap()
let activeEffect
function track(target, key) {
if (!activeEffect) return
let targetMap = dependencies.get(target)
if (!targetMap) dependencies.set(target, (targetMap = new Map()))
let effects = targetMap.get(key)
if (!effects) targetMap.set(key, (effects = new Set()))
effects.add(activeEffect)
}
function trigger(target, key) {
dependencies.get(target)?.get(key)?.forEach((effect) => effect())
}
真实实现还要处理嵌套 effect、依赖清理、数组与集合、计算属性失效、调度队列和防止递归触发。面试重点是说明 Vue 追踪的是 effect 执行期间实际读取的属性,而不是定期深比较整个对象。
ref 用统一的 .value getter/setter 追踪依赖;对象值会进一步转成响应式对象。模板编译器可以自动解包 ref,普通 JavaScript 无法自动改写属性访问,因此仍需 .value。
代理身份与响应式边界
reactive(raw) !== raw,两者是代理与原对象。Vue 会缓存代理,同一个原对象通常返回相同代理,但在 Map key、第三方实例和身份比较中仍要明确使用哪一个。
const raw = { id: 'a-1' }
const state = reactive(raw)
state === raw // false
toRaw(state) === raw // true
toRaw 适合临时读取,不应长期持有并同时修改原对象,否则会绕开追踪。markRaw 用于不应代理的第三方实例或大型不可变对象。shallowRef 只追踪 .value 替换,适合不可变数据、外部状态机或图表实例。
解构丢失响应性的本质是读取后得到普通值,不再经过原代理属性:
const state = reactive({ count: 0 })
const { count } = state // count 是当时的 number
对象值若本身仍是代理,解构后内部属性可能继续响应,但绑定与原属性替换已经断开。toRef/toRefs 通过带 getter/setter 的 ref 保持这层连接。
调度队列与 DOM 更新时间
响应式写入通常不会每次立刻同步更新 DOM。Vue 把组件更新任务放入队列,同一 tick 内去重并按稳定顺序刷新,从而把多次状态修改合并为一次渲染。
count.value += 1
count.value += 1
await nextTick()
// 此时可读取更新后的 DOM
nextTick 等待当前刷新队列完成,不应成为修复状态建模问题的常规手段。watcher 的 flush 时机决定相对组件更新的位置:
pre是默认值,在组件 DOM 更新前执行。post在组件 DOM 更新后执行,适合读取更新后的 DOM。sync同步触发,不批处理,频繁数组修改时可能造成大量执行,应谨慎。
computed、watch 与 watchEffect 深入
computed 是惰性的缓存派生值:依赖未变时复用结果,依赖变化时先标记脏,下一次读取才重新计算。getter 应保持纯净。
watch 显式指定数据源,能获得新旧值并精确控制触发;watchEffect 立即运行并自动收集同步执行期间读取的依赖。异步回调中,第一个 await 之后才读取的值不会在当前同步收集阶段自动成为依赖。
watch(
() => route.params.id,
async (id, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
article.value = await loadArticle(id, controller.signal)
},
{ immediate: true }
)
深度 watch 会遍历嵌套属性以收集依赖,在大对象上成本高。优先监听具体 getter,或使用不可变数据和 shallowRef 让变更边界更清晰。
模板编译与虚拟 DOM
Vue 模板先编译为渲染函数。编译器知道哪些节点静态、哪些表达式动态,因此可以提升静态节点,并给动态节点增加 patch flag;运行时更新时集中检查动态部分,而不必把所有属性一视同仁。
条件和列表最终形成 VNode。key 表达兄弟节点身份,决定旧组件实例与新数据如何对应:
- 稳定 key 让输入状态、组件实例和 DOM 被正确复用或移动。
- 索引 key 在插入、删除和排序后可能把状态留给错误条目。
- 改变组件 key 可显式重置整棵局部子树。
Vue 的 keyed diff 会处理公共前后缀、建立 key 到索引的映射,并利用最长递增子序列减少需要移动的节点。面试不必背源码每个分支,但应能解释算法目标是正确复用身份,并尽量减少 DOM 移动。
组件通信与透传边界
组件公开契约通常由 props、emits、slots 和 expose 构成。provide/inject 适合插件、表单上下文或跨层依赖,不应用来隐式替代所有 props。
$attrs 包含未被 props/emits 消费的属性和监听器。单根组件会自动透传;多根组件需要显式决定落点。设计基础组件时必须明确 class、ARIA 和事件如何透传,否则封装可能破坏可访问性。
slot 内容在父组件作用域中求值,子组件可通过 scoped slot 把局部数据暴露给父级模板。作用域插槽适合无头组件,但复杂插槽协议要有稳定类型与状态说明。
不要滥用组件实例 ref 直接调用子组件内部方法。只有聚焦、测量、媒体控制等命令式能力适合通过 defineExpose 暴露;业务数据流仍优先 props 与事件。
状态架构与组合式函数生命周期
按作用域选择状态位置:局部交互放组件;跨少量层级使用 provide/inject;跨页面业务状态可使用 Pinia 等 store;服务端数据则需要缓存、失效和请求状态模型,不能只看作全局数组。
组合式函数每次调用通常创建独立状态。若在模块顶层创建响应式变量,它会成为所有调用者共享的单例;在 SSR 中还可能跨请求泄漏用户数据。服务端应为每个请求创建应用和 store 实例。
副作用最好在当前组件作用域中同步创建,使 Vue 能自动关联销毁。异步回调里稍后创建的 watcher 可能不再自动绑定生命周期,需要手动停止。复杂组合式函数可使用 effectScope 聚合内部 effect,并在统一时机释放。
export function useClock() {
const now = ref(Date.now())
let timer
onMounted(() => {
timer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => window.clearInterval(timer))
return readonly(now)
}
返回只读状态与明确命令函数,可以限制调用方随意破坏内部不变量。
错误处理、请求取消与幂等提交
组件内部错误可通过局部错误边界或 errorCaptured 记录上下文,全局兜底再通过应用级错误处理器上报;错误信息应包含路由、组件、用户操作和发布版本,而不是只弹出笼统提示。离开页面或依赖变化时,使用 AbortController 取消仍在途的请求,防止旧响应回写新页面状态。
并发 401 刷新令牌时应只保留一个刷新 Promise,其余请求等待结果再重放。创建订单、支付、发布等写操作即使前端禁用按钮,也仍需使用稳定的幂等键并由服务端保存处理结果;网络超时并不代表服务端没有成功执行。
微前端卸载与副作用清理
微前端的沙箱主要隔离部分全局变量,不能替代卸载清理。子应用注册的定时器、全局事件、Observer、WebSocket、body class 和第三方实例都要在 unmount 中撤销,并把清理函数集中管理。资源 public path、CSS 隔离与路由基座也需要在应用加载前确认;出现白屏时先检查入口、静态资源、生命周期和残留副作用,再判断框架问题。
SSR 与 Hydration
SSR 在服务端执行组件并输出 HTML,客户端 hydration 复用现有 DOM 并挂接交互。首次客户端渲染必须与服务端一致:
- 不在 render 中直接使用
window、localStorage等浏览器 API。 - 随机数、当前时间、时区与语言格式需要注入稳定初值。
- 有效 HTML 嵌套必须正确,避免浏览器纠正 DOM 后无法匹配。
- 用户专属状态按请求隔离,不能复用模块级单例。
hydration mismatch 可能让 Vue 修补局部 DOM,并带来性能或状态异常。不要简单压制警告,应找到不确定输出的来源。客户端专属内容可在 mounted 后显示,但要为 SSR 阶段提供稳定占位并避免布局偏移。
性能诊断与优化层级
优化顺序建议:
- 用浏览器 Performance 确认慢在网络、JavaScript、布局还是绘制。
- 用 Vue Devtools 看组件为什么更新、更新耗时和响应式依赖。
- 减少数据量:分页、窗口化、按路由拆包。
- 缩小响应式范围:不可变大数据配合
shallowRef,第三方实例使用markRaw。 - 稳定 props,避免子组件接收每次都新建且无意义变化的对象。
- 最后再使用
v-memo等局部跳过机制,并验证收益。
拆组件不一定减少更新:子组件仍会在 props 变化时渲染,但合理边界能隔离依赖、延迟加载和复用。性能结论必须同时看框架 render 成本和浏览器最终 DOM/布局成本。
资深面试追问
ref 和 reactive 怎么选?
ref 可承载任意值、替换语义清晰,适合作为统一默认;reactive 适合围绕同一对象进行属性更新。更关键的是数据是否需要整体替换、是否来自外部不可变源、是否要解构和向外暴露。选择后保持边界一致比死守单一规则更重要。(知识点:ref 与 reactive)
Vue 2 与 Vue 3 响应式有什么差异?
Vue 2 主要用 Object.defineProperty 改写初始化时已有属性,数组方法需要额外封装,新增属性存在限制;Vue 3 使用 Proxy,可拦截新增、删除、in、迭代和集合操作,并按需代理嵌套对象。Proxy 也意味着代理身份不同且无法原生支持旧浏览器。(知识点:代理身份与响应式边界)
computed 为什么能缓存?
它内部是带调度器的响应式 effect。首次读取执行 getter 并收集依赖;依赖触发时只把计算属性标为脏并通知消费者;下次读取时才重新计算,之后继续复用,直到依赖再次变化。(知识点:computed、watch 与 watchEffect 深入)
如何设计一个通用业务组件?
先区分领域组件与基础组件。明确最小 props、业务语义事件、受控与非受控状态、slot 扩展点和 attrs 透传;覆盖 loading/error/empty/disabled;保证键盘和 ARIA;避免暴露内部 DOM 结构;为大型内容定义性能边界。可配置项不是越多越通用。(知识点:组件通信与透传边界)
Pinia 与普通模块单例有什么区别?
Pinia 提供响应式 store、开发工具、插件、SSR 序列化和明确的应用注入边界。普通模块单例虽然简单,但测试隔离、热更新、服务端请求隔离和状态追踪都需自行处理。很小且纯客户端的稳定状态可以不引入 store,选择应匹配生命周期和协作成本。(知识点:状态架构与组合式函数生命周期)
实践检查
- 推导数据由
computed产生,不维护重复状态。 - Props 只读,组件变化通过明确事件向上传递。
watch中的请求和订阅可以取消或清理。- 列表 key 稳定,条件渲染方式符合切换频率。
- 组合式函数拥有清晰输入、输出和生命周期。
- 组件同时覆盖加载、错误、空数据与成功状态。
- 能从 track/trigger、调度队列和模板编译解释一次更新。
- 大型数据明确选择深响应、浅响应或原始对象边界。
- SSR 中应用状态按请求隔离,首屏输出保持确定性。