跳到正文
前端知识库
Vue

Vue 3 响应式原理与资深面试

从 Composition API 深入到依赖追踪、渲染调度、组件身份、状态架构、SSR 与性能诊断。

12 分钟Vue · Composition API · 响应式 · 性能 · 面试

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 访问。

refreactive

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)
  )
})

watchwatchEffect 用于产生副作用,例如持久化、请求或调用外部 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-ifv-for。需要过滤时先通过计算属性得到目标列表。

生命周期与模板引用

const searchInput = ref(null)

onMounted(() => {
  searchInput.value?.focus()
})

DOM、定时器和订阅属于生命周期副作用。创建资源后,应在 onUnmounted 中释放。

组件实例与状态隔离

Vue 2 的组件 data 必须写成返回对象的函数。每次创建组件实例时都会调用它,从而得到独立状态;若多个实例引用同一个对象,一个输入框或计数器的修改就会泄漏到其他实例。根实例只有一个,才可以使用对象形式的 data

Vue 3 的 setup 与组合式函数遵循同一原则:在组件实例创建期间调用的 refreactive 和 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
  • 大型静态对象可考虑 shallowRefmarkRaw,避免不必要的深层代理。
  • 组件按职责拆分,但不要只为减少代码行数制造碎片。
  • 路由页面和重型组件使用动态导入。
  • 通过 Vue Devtools 和浏览器 Performance 面板确认瓶颈。

缓存组件与路由页面边界

keep-alive 会缓存动态组件实例。缓存页切换时使用 activated / deactivated 感知恢复和停用,不能把它等同于重新 mounted 或已经 unmounted;轮询、订阅和过期数据要根据缓存策略主动处理。includeexcludemax 应围绕组件名称、页面 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 同步触发,不批处理,频繁数组修改时可能造成大量执行,应谨慎。

computedwatchwatchEffect 深入

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 中直接使用 windowlocalStorage 等浏览器 API。
  • 随机数、当前时间、时区与语言格式需要注入稳定初值。
  • 有效 HTML 嵌套必须正确,避免浏览器纠正 DOM 后无法匹配。
  • 用户专属状态按请求隔离,不能复用模块级单例。

hydration mismatch 可能让 Vue 修补局部 DOM,并带来性能或状态异常。不要简单压制警告,应找到不确定输出的来源。客户端专属内容可在 mounted 后显示,但要为 SSR 阶段提供稳定占位并避免布局偏移。

性能诊断与优化层级

优化顺序建议:

  1. 用浏览器 Performance 确认慢在网络、JavaScript、布局还是绘制。
  2. 用 Vue Devtools 看组件为什么更新、更新耗时和响应式依赖。
  3. 减少数据量:分页、窗口化、按路由拆包。
  4. 缩小响应式范围:不可变大数据配合 shallowRef,第三方实例使用 markRaw
  5. 稳定 props,避免子组件接收每次都新建且无意义变化的对象。
  6. 最后再使用 v-memo 等局部跳过机制,并验证收益。

拆组件不一定减少更新:子组件仍会在 props 变化时渲染,但合理边界能隔离依赖、延迟加载和复用。性能结论必须同时看框架 render 成本和浏览器最终 DOM/布局成本。

资深面试追问

refreactive 怎么选?

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 中应用状态按请求隔离,首屏输出保持确定性。