Vue核心原理

23 分钟

响应式原理

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.setsplice 的原因。

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],原因有两个:

  1. 保证 this 指向正确Reflect.get(target, key, receiver) 的第三个参数 receiver 确保 getter 中的 this 指向代理对象而不是原始对象。
  2. 返回值语义一致Reflect.set 返回布尔值表示操作是否成功,与 Proxy handler 的返回值约定一致。

defineProperty vs Proxy 对比

维度Object.definePropertyProxy
劫持粒度单个属性整个对象
新增/删除属性无法检测,需 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 操作合并为最小化的必要更新

具体来说:

  1. 批量更新:数据变化后,先在 JS 层生成新的 VNode 树,与旧 VNode 树做 diff,计算出最小差异,再一次性 patch 到真实 DOM。
  2. 跨平台能力:VNode 是纯 JS 对象,可以渲染到浏览器 DOM、Native(Weex)、Canvas、SSR 字符串等不同目标。
  3. 声明式编程:开发者只需描述「界面应该长什么样」,框架负责计算「怎么从旧状态变到新状态」。

需要注意的是,虚拟 DOM 并不是「比直接操作 DOM 更快」。手动做最精确的 DOM 操作永远是最快的,但虚拟 DOM 提供了一种性能下限足够高、心智负担足够低的方案——在绝大多数场景下,开发效率和运行效率之间取得了很好的平衡。

Diff 算法

Diff 算法是虚拟 DOM 的核心,它决定了「如何高效地找出新旧 VNode 树的差异」。

同级比较策略

传统的树 diff 算法时间复杂度是 O(n³),在前端场景中不可接受。Vue(和 React)都采用了一个关键假设:跨层级的 DOM 移动极少发生。基于这个假设,diff 只在同级节点之间比较,时间复杂度降为 O(n)。

比较策略遵循以下顺序:

  1. 判断是否是相同节点sameVnode):标签名相同、key 相同、是否是注释节点等条件一致,才认为是"同一个节点",进入 patch 流程。
  2. 不是同一个节点:直接销毁旧节点,创建新节点挂载。
  3. 是同一个节点:进入 patchVnode,对比属性、文本、子节点的差异并更新。

双端对比(Vue2 的 patch 策略)

当新旧节点都有子节点列表时,Vue2 使用双端对比算法:维护四个指针,分别指向新旧子节点列表的头尾。

旧子节点: [A, B, C, D]
           ↑           ↑
        oldStart    oldEnd

新子节点: [D, A, B, C]
           ↑           ↑
        newStart    newEnd

每轮循环做四次比较:

  1. 旧头 vs 新头:如果匹配,两个头指针右移。
  2. 旧尾 vs 新尾:如果匹配,两个尾指针左移。
  3. 旧头 vs 新尾:如果匹配,说明旧头节点移到了右边,把对应 DOM 移动到旧尾之后。
  4. 旧尾 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 内部的实现经历过多次调整,最终采用的降级策略如下:

  1. Promise.then(首选):原生支持微任务,优先级最高。
  2. MutationObserver:利用 DOM 变动观察器触发微任务回调,在不支持 Promise 的环境中使用。
  3. setImmediate:仅 IE 和 Node.js 支持,属于宏任务但执行时机早于 setTimeout。
  4. 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 通过 includeexclude 属性控制哪些组件需要缓存。匹配规则优先检查组件的 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>

includeexclude 发生变化时,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 函数只做三件事:

  1. 获取默认插槽中的第一个子组件 VNode。
  2. 检查该组件是否匹配 include/exclude
  3. 若命中缓存,直接返回缓存的 VNode 并更新 LRU 顺序;否则缓存当前 VNode 后返回。

在 patch 阶段,Vue 检测到 VNode 带有 keepAlive 标记时,不会走正常的 mount 流程,而是直接将缓存的 DOM 元素重新插入父容器。

MVVM 模型

MVVM(Model-View-ViewModel)是 Vue 的架构思想基础,理解它有助于从全局视角看 Vue 的设计。

三层结构

  • Model(模型层):应用的数据和业务逻辑。在 Vue 中对应组件的 datapropscomputed 以及 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 的关系
MVCController 接收输入 → 更新 Model → Model 通知 ViewView 直接读 Model
MVPPresenter 中转所有逻辑View 和 Model 完全隔离
MVVMViewModel 自动同步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 实现自动的数据到视图同步。