Vue核心原理
响应式原理
Vue 最核心的能力是「数据驱动视图」——修改数据后,视图自动更新,开发者不需要手动操作 DOM。这背后依赖的就是响应式系统。Vue2 和 Vue3 分别采用了不同的底层方案,面试中几乎必问。
Vue2:Object.defineProperty
Vue2 在初始化阶段遍历 data 对象的所有属性,通过 Object.defineProperty 把每个属性转换为 getter/setter。核心流程分三步:劫持属性 → 依赖收集 → 派发更新。
function defineReactive(obj, key, val) {
const dep = new Dep(); // 每个属性对应一个 Dep 实例
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend(); // 收集当前 Watcher
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
dep.notify(); // 通知所有 Watcher 更新
},
});
}
依赖收集:Dep 与 Watcher
- Dep(Dependency):每个响应式属性都有一个 Dep 实例,内部维护一个
subs数组,存放所有依赖该属性的 Watcher。 - Watcher:每个组件渲染函数对应一个渲染 Watcher。当组件 render 执行时,会读取 data 中的属性,触发 getter,Dep 就把当前 Watcher 收集进
subs。 - 派发更新:属性被修改时触发 setter,Dep 遍历
subs通知每个 Watcher 执行更新。
整个过程可以概括为:getter 收集依赖,setter 触发更新。
数组的响应式处理
Object.defineProperty 无法拦截数组下标的直接赋值和 length 修改。Vue2 的做法是重写数组原型上的 7 个变异方法:
const arrayMethods = Object.create(Array.prototype);
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
methodsToPatch.forEach((method) => {
const original = arrayMethods[method];
Object.defineProperty(arrayMethods, method, {
value(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
// push/unshift/splice 可能新增元素,需要对新元素做响应式处理
let inserted;
if (method === 'push' || method === 'unshift') inserted = args;
if (method === 'splice') inserted = args.slice(2);
if (inserted) ob.observeArray(inserted);
ob.dep.notify(); // 手动触发更新
return result;
},
});
});
这也是为什么 Vue2 中直接 this.arr[0] = 'new' 不会触发视图更新,必须用 Vue.set 或 splice 的原因。
Vue2 响应式的局限性
- 初始化时需要递归遍历整个 data 对象,属性多时有性能开销。
- 无法检测对象属性的新增和删除(需要
Vue.set/Vue.delete)。 - 无法原生拦截数组下标赋值和
length修改。
Vue3:Proxy + Reflect
Vue3 用 ES6 的 Proxy 替代了 Object.defineProperty,从「逐属性劫持」升级为「整个对象代理」。
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key); // 依赖收集
const result = Reflect.get(target, key, receiver);
// 如果值是对象,惰性递归代理
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;
},
});
}
effect / track / trigger
Vue3 的依赖收集机制重新设计,不再是 Dep/Watcher,而是 effect + targetMap:
- effect:副作用函数,类似 Vue2 的 Watcher。组件的渲染函数会被包装成一个 effect。
- track:在 getter 中调用,将当前 effect 收集到
targetMap中。targetMap是一个WeakMap<target, Map<key, Set<effect>>>的三层结构。 - trigger:在 setter 中调用,从
targetMap中找到对应 key 的所有 effect 并执行。
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 deps = depsMap.get(key);
if (!deps) depsMap.set(key, (deps = new Set()));
deps.add(activeEffect);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
if (effects) effects.forEach((effect) => effect());
}
为什么用 Reflect 配合 Proxy
Proxy 的 handler 中使用 Reflect 而不是直接操作 target[key],原因有两个:
- 保证 this 指向正确:
Reflect.get(target, key, receiver)的第三个参数receiver确保 getter 中的this指向代理对象而不是原始对象。 - 返回值语义一致:
Reflect.set返回布尔值表示操作是否成功,与 Proxy handler 的返回值约定一致。
defineProperty vs Proxy 对比
| 维度 | Object.defineProperty | Proxy |
|---|---|---|
| 劫持粒度 | 单个属性 | 整个对象 |
| 新增/删除属性 | 无法检测,需 Vue.set | 原生支持 |
| 数组支持 | 需重写变异方法 | 原生拦截所有操作 |
| 深层嵌套 | 初始化递归遍历 | 惰性代理(访问时才递归) |
| 性能 | 初始化开销大,运行时无额外开销 | 初始化快,运行时有 Proxy 开销(极小) |
| 兼容性 | IE9+ | 不支持 IE |
| Map/Set/WeakMap | 不支持 | 原生支持 |
面试中被追问「Vue3 为什么换 Proxy」,核心回答三点:覆盖更全(新增/删除/数组)、性能更优(惰性代理)、代码更简洁(不用逐属性遍历)。
虚拟 DOM
什么是虚拟 DOM
虚拟 DOM(Virtual DOM)是用普通 JavaScript 对象来描述真实 DOM 结构的一层抽象。每个 VNode 对应一个真实 DOM 节点,包含标签名、属性、子节点等信息。
// 一个 <div class="box"><span>hello</span></div> 对应的 VNode 结构
const vnode = {
tag: 'div',
props: { class: 'box' },
children: [
{
tag: 'span',
props: null,
children: 'hello',
},
],
};
为什么需要虚拟 DOM
直接操作真实 DOM 的代价很高。浏览器中一个 DOM 节点是重量级对象,包含几百个属性和方法,频繁的 DOM 操作会触发样式计算、布局、绘制等流程。
虚拟 DOM 解决的核心问题是:将多次 DOM 操作合并为最小化的必要更新。
具体来说:
- 批量更新:数据变化后,先在 JS 层生成新的 VNode 树,与旧 VNode 树做 diff,计算出最小差异,再一次性 patch 到真实 DOM。
- 跨平台能力:VNode 是纯 JS 对象,可以渲染到浏览器 DOM、Native(Weex)、Canvas、SSR 字符串等不同目标。
- 声明式编程:开发者只需描述「界面应该长什么样」,框架负责计算「怎么从旧状态变到新状态」。
需要注意的是,虚拟 DOM 并不是「比直接操作 DOM 更快」。手动做最精确的 DOM 操作永远是最快的,但虚拟 DOM 提供了一种性能下限足够高、心智负担足够低的方案——在绝大多数场景下,开发效率和运行效率之间取得了很好的平衡。
Diff 算法
Diff 算法是虚拟 DOM 的核心,它决定了「如何高效地找出新旧 VNode 树的差异」。
同级比较策略
传统的树 diff 算法时间复杂度是 O(n³),在前端场景中不可接受。Vue(和 React)都采用了一个关键假设:跨层级的 DOM 移动极少发生。基于这个假设,diff 只在同级节点之间比较,时间复杂度降为 O(n)。
比较策略遵循以下顺序:
- 判断是否是相同节点(
sameVnode):标签名相同、key 相同、是否是注释节点等条件一致,才认为是"同一个节点",进入 patch 流程。 - 不是同一个节点:直接销毁旧节点,创建新节点挂载。
- 是同一个节点:进入
patchVnode,对比属性、文本、子节点的差异并更新。
双端对比(Vue2 的 patch 策略)
当新旧节点都有子节点列表时,Vue2 使用双端对比算法:维护四个指针,分别指向新旧子节点列表的头尾。
旧子节点: [A, B, C, D]
↑ ↑
oldStart oldEnd
新子节点: [D, A, B, C]
↑ ↑
newStart newEnd
每轮循环做四次比较:
- 旧头 vs 新头:如果匹配,两个头指针右移。
- 旧尾 vs 新尾:如果匹配,两个尾指针左移。
- 旧头 vs 新尾:如果匹配,说明旧头节点移到了右边,把对应 DOM 移动到旧尾之后。
- 旧尾 vs 新头:如果匹配,说明旧尾节点移到了左边,把对应 DOM 移动到旧头之前。
四次都不匹配时,用 key 在旧子节点中查找,找到则移动,找不到则创建新节点。循环结束后,处理可能剩余的新增或删除节点。
双端对比的优势在于:对「头部插入」「尾部插入」「整体翻转」等常见操作场景,可以用最少的 DOM 操作完成更新。
key 的作用
key 是 diff 算法中判断「两个节点是否是同一个」的核心依据。没有 key 时,Vue 只能按顺序逐个对比;有 key 时,可以精确识别哪些节点是移动、新增或删除。
<!-- 没有 key:删除第一项时,Vue 会认为每个节点的内容都变了,逐个更新 -->
<li v-for="item in list">{{ item.name }}</li>
<!-- 有 key:Vue 能识别出是第一个节点被删除,其他节点位置不变 -->
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
为什么不推荐用 index 做 key
用数组下标做 key 在列表发生「非尾部」增删时会出问题。假设原始列表是 [A, B, C],对应 key 为 [0, 1, 2]。删除 A 后,列表变为 [B, C],对应 key 为 [0, 1]。
此时 diff 算法会认为:
- key=0 的节点从 A 变成了 B → patch 更新内容
- key=1 的节点从 B 变成了 C → patch 更新内容
- key=2 的节点消失 → 删除
本来只需要删除一个 DOM 节点,结果变成了更新两个 + 删除一个,共三次 DOM 操作。更严重的是,如果列表项包含有状态的子组件(比如输入框),状态会错位——B 会继承 A 的输入框内容。
什么时候 index 做 key 没问题:列表是纯展示、不会增删排序、没有有状态子组件时,用 index 是安全的。
nextTick 原理
在 Vue 中修改数据后,DOM 不会立刻更新。Vue 将 DOM 更新推迟到当前事件循环的「下一个 tick」执行,把同一轮中所有数据变更合并为一次 DOM 更新,避免重复渲染。
nextTick 就是 Vue 提供的工具方法,让你在 DOM 更新完成后执行回调。
this.message = '更新了';
// 此时 DOM 还没变
this.$nextTick(() => {
// DOM 已经更新完成
console.log(this.$el.textContent); // '更新了'
});
异步更新队列
当响应式数据变化触发 setter 后,Vue 不会立即执行 Watcher 的更新函数,而是把 Watcher 推入一个异步队列,并做去重处理——同一个 Watcher 在一轮事件循环中只会入队一次。然后通过 nextTick 在微任务阶段统一刷新队列。
// 简化的调度逻辑
const queue = [];
let waiting = false;
function queueWatcher(watcher) {
if (queue.includes(watcher)) return; // 去重
queue.push(watcher);
if (!waiting) {
waiting = true;
nextTick(flushSchedulerQueue); // 下一个微任务批量执行
}
}
function flushSchedulerQueue() {
queue.sort((a, b) => a.id - b.id); // 父组件先于子组件更新
for (const watcher of queue) {
watcher.run();
}
queue.length = 0;
waiting = false;
}
微任务与降级策略
nextTick 的本质是将回调放入微任务队列。Vue2 内部的实现经历过多次调整,最终采用的降级策略如下:
- Promise.then(首选):原生支持微任务,优先级最高。
- MutationObserver:利用 DOM 变动观察器触发微任务回调,在不支持 Promise 的环境中使用。
- setImmediate:仅 IE 和 Node.js 支持,属于宏任务但执行时机早于 setTimeout。
- setTimeout(fn, 0)(兜底):所有环境都支持,但时机最晚。
// Vue2 nextTick 核心实现(简化)
let timerFunc;
if (typeof Promise !== 'undefined') {
const resolved = Promise.resolve();
timerFunc = () => resolved.then(flushCallbacks);
} else if (typeof MutationObserver !== 'undefined') {
let counter = 1;
const observer = new MutationObserver(flushCallbacks);
const textNode = document.createTextNode(String(counter));
observer.observe(textNode, { characterData: true });
timerFunc = () => {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
} else if (typeof setImmediate !== 'undefined') {
timerFunc = () => setImmediate(flushCallbacks);
} else {
timerFunc = () => setTimeout(flushCallbacks, 0);
}
Vue3 简化了这套逻辑,直接使用 Promise.resolve().then(),不再做降级处理——因为 Vue3 已经放弃了 IE 支持。
面试高频追问
Q:为什么优先使用微任务而不是宏任务?
微任务在当前宏任务执行完毕后、浏览器渲染之前执行。如果用 setTimeout(宏任务),DOM 更新的回调可能被推迟到下一次渲染之后,用户可能看到一帧「旧状态」的画面闪烁。微任务能保证在同一帧内完成数据变更和 DOM 更新。
Q:nextTick 返回的是什么?
Vue2.1+ 和 Vue3 中,nextTick 不传回调时返回一个 Promise,可以配合 async/await 使用:
async handleClick() {
this.show = true;
await this.$nextTick();
this.$refs.input.focus(); // DOM 已更新,可以安全操作
}
keep-alive 原理
<keep-alive> 是 Vue 内置的抽象组件,用于缓存不活动的组件实例,避免重复销毁和重建带来的性能开销。典型场景是 Tab 切换、路由缓存。
基本用法
<keep-alive>
<component :is="currentTab" />
</keep-alive>
<!-- 配合 vue-router -->
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
include / exclude
keep-alive 通过 include 和 exclude 属性控制哪些组件需要缓存。匹配规则优先检查组件的 name 选项。
<!-- 只缓存 Home 和 About 组件 -->
<keep-alive include="Home,About">
<router-view />
</keep-alive>
<!-- 支持正则 -->
<keep-alive :include="/^Tab/">
<component :is="currentTab" />
</keep-alive>
<!-- 支持数组 -->
<keep-alive :exclude="['Login', 'Register']">
<router-view />
</keep-alive>
当 include 或 exclude 发生变化时,keep-alive 会在 updated 钩子中遍历缓存,将不再匹配的组件实例销毁并从缓存中移除。
LRU 缓存策略
keep-alive 可以通过 max 属性限制最大缓存数量。当缓存的组件数超过 max 时,采用 LRU(Least Recently Used,最近最少使用) 策略淘汰最久没有被访问的组件。
内部实现维护了两个数据结构:
- cache:一个对象(Vue3 中是 Map),以组件的 key 为键,存储 VNode 实例。
- keys:一个数组(Vue3 中是 Set),记录缓存 key 的访问顺序。
// LRU 淘汰的简化逻辑
function cacheVNode(key, vnode) {
if (cache[key]) {
// 已存在:将 key 移到末尾(标记为最近使用)
remove(keys, key);
keys.push(key);
} else {
// 不存在:加入缓存
cache[key] = vnode;
keys.push(key);
// 超出 max,淘汰最久未使用的(数组头部)
if (max && keys.length > parseInt(max)) {
const staleKey = keys[0];
pruneCacheEntry(cache, staleKey, keys);
}
}
}
每次命中缓存时,对应的 key 会被移到 keys 数组的末尾;淘汰时删除数组头部的 key 对应的组件实例,并调用其 $destroy 方法。
activated / deactivated 生命周期
被 keep-alive 缓存的组件不会触发 mounted / unmounted(因为没有真正销毁和重建),取而代之的是两个专用钩子:
- activated:组件从缓存中被激活(重新显示)时调用。首次挂载时也会调用,在
mounted之后。 - deactivated:组件被缓存(从视图中移除但未销毁)时调用。
<script>
export default {
activated() {
// 重新进入页面时刷新数据
this.fetchLatestData();
},
deactivated() {
// 离开页面时清理定时器,避免后台空转
clearInterval(this.timer);
},
};
</script>
实际开发中,activated 常用于列表页返回时刷新数据或恢复滚动位置;deactivated 常用于暂停视频播放、清理定时器、取消未完成的请求等。
keep-alive 的渲染机制
keep-alive 自身不渲染任何 DOM 节点(abstract: true),它的 render 函数只做三件事:
- 获取默认插槽中的第一个子组件 VNode。
- 检查该组件是否匹配
include/exclude。 - 若命中缓存,直接返回缓存的 VNode 并更新 LRU 顺序;否则缓存当前 VNode 后返回。
在 patch 阶段,Vue 检测到 VNode 带有 keepAlive 标记时,不会走正常的 mount 流程,而是直接将缓存的 DOM 元素重新插入父容器。
MVVM 模型
MVVM(Model-View-ViewModel)是 Vue 的架构思想基础,理解它有助于从全局视角看 Vue 的设计。
三层结构
- Model(模型层):应用的数据和业务逻辑。在 Vue 中对应组件的
data、props、computed以及 Vuex/Pinia 等状态管理。 - View(视图层):用户看到的界面。在 Vue 中对应模板(template)编译后生成的 DOM。
- ViewModel(视图模型层):连接 Model 和 View 的桥梁。在 Vue 中就是组件实例本身——它通过响应式系统监听 Model 的变化,自动更新 View;同时通过事件监听(
v-on)将用户的交互传递回 Model。
数据绑定的双向流转
Model ──(响应式 + 虚拟DOM)──▸ View
▴ │
└──────(事件 / v-model)──────────┘
- Model → View:数据变化时,响应式系统触发依赖更新,经过虚拟 DOM diff 后 patch 到真实 DOM。这就是前文讲的响应式 + 虚拟 DOM 的完整链路。
- View → Model:用户在界面上的操作(输入、点击)通过 DOM 事件被 ViewModel 捕获,更新 Model 中的数据。
v-model本质上是v-bind:value+v-on:input的语法糖。
与 MVC / MVP 的区别
| 模式 | 数据流向 | View 与 Model 的关系 |
|---|---|---|
| MVC | Controller 接收输入 → 更新 Model → Model 通知 View | View 直接读 Model |
| MVP | Presenter 中转所有逻辑 | View 和 Model 完全隔离 |
| MVVM | ViewModel 自动同步 | View 通过数据绑定与 Model 同步,无需手动操作 |
Vue 的核心价值在于:开发者只需维护 Model,ViewModel(Vue 实例)自动处理 Model 到 View 的同步,大幅减少手动 DOM 操作的代码量和出错概率。
总结
Vue 核心原理可以串成一条完整链路:响应式系统监听数据变化 → 触发组件重新渲染 → 生成新的虚拟 DOM → Diff 算法计算最小更新 → Patch 到真实 DOM。
面试中的关键记忆点:
- Vue2 响应式:
Object.defineProperty逐属性劫持,Dep/Watcher 收集和派发依赖,数组通过重写 7 个变异方法实现响应式,无法检测属性新增/删除。 - Vue3 响应式:
Proxy整对象代理,effect/track/trigger 三件套,惰性递归代理,原生支持新增/删除/数组/Map/Set。 - 虚拟 DOM:用 JS 对象描述 DOM 结构,批量计算最小更新,附带跨平台能力。
- Diff 算法:同级比较 O(n),Vue2 双端对比四次比较,key 是节点身份标识。
- index 做 key 的问题:非尾部增删时导致多余更新和状态错位。
- nextTick:异步更新队列 + 微任务(Promise.then),合并同轮数据变更为一次 DOM 更新。
- keep-alive:缓存组件实例,LRU 淘汰策略,activated/deactivated 替代 mounted/unmounted。
- MVVM:Model-View-ViewModel,响应式 + 虚拟 DOM 实现自动的数据到视图同步。