Vue 核心原理与响应式系统

中等 🟡Vue 生态
10 个标签
预计阅读时间:168 分钟
Vue响应式数据绑定Composition API组件通信虚拟DOM生命周期v-model插槽nextTick

Vue 核心原理与响应式系统

Vue.js 是一个渐进式 JavaScript 框架,其核心特性是响应式系统,理解其原理对于掌握 Vue 至关重要。Vue的响应式系统是其最核心的特性之一,它使得数据变化能够自动反映到视图上,极大地简化了前端开发。Vue 2和Vue 3使用了不同的响应式实现方式,各有优劣。

本文将从"是什么、为什么、怎么做"三个维度,系统地剖析 Vue 的响应式原理、数据绑定、组件系统、插槽、生命周期、虚拟 DOM 与 Diff 算法、nextTick 异步更新、v-model 双向绑定原理、事件修饰符等核心知识点。每个知识点都会遵循"概念 → 为什么重要 → 原理 → 代码示例 → 真实案例 → 数据对比 → 常见坑 → 最佳实践"的结构展开,帮助你在面试和实战中都能游刃有余。

📖 全文导读

响应式系统原理(Object.defineProperty vs Proxy)
依赖收集与派发更新的完整链路
Composition API:ref / reactive / computed / watch / watchEffect
指令系统:内置指令与自定义指令
数据绑定与 v-model 的底层实现
组件通信的七种方式
插槽:匿名、命名、作用域、动态插槽
生命周期:Vue 2 与 Vue 3 的映射
虚拟 DOM 与 Diff 算法
nextTick 与异步更新队列
事件修饰符与按键修饰符
性能优化与最佳实践总结

💚 响应式系统原理

响应式(Reactivity)指的是:当数据发生变化时,与之关联的视图或计算逻辑能够自动更新,而无需手动操作 DOM。可以把它类比成 Excel 表格——当你修改了 A1 单元格的值,所有引用了 A1 的公式单元格都会自动重新计算。Vue 的响应式系统就是要在 JavaScript 里实现这种"自动重算"的能力。

为什么重要:

在没有响应式的时代(如 jQuery 时代),开发者需要手动监听事件、手动读取数据、手动更新 DOM,这种"命令式"编程方式代码冗长且极易出错。响应式系统让开发者只需关注"数据是什么",而不必关心"如何更新视图",这是声明式 UI 框架的基石。据统计,采用响应式框架后,典型中后台管理系统的 DOM 操作相关代码量可减少约 60%~80%。

Object.defineProperty (Vue 2):

Vue 2使用Object.defineProperty的getter和setter来实现响应式系统。这种方式通过遍历data选项中的所有属性,将每个属性转换为getter/setter,从而在属性被访问或修改时触发相应的依赖收集和派发更新。Object.defineProperty的缺点是数组索引和新增属性无法被自动检测到,需要使用Vue.set或this.$set方法手动添加响应式。此外,Object.defineProperty无法检测到对象属性的删除,需要使用Vue.delete方法。

javascriptCode
// Vue 2 响应式实现原理
function defineReactive(obj, key, val) {
  const dep = new Dep(); // 每个属性都有一个依赖收集器

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 依赖收集:将当前的Watcher添加到依赖列表
      if (Dep.target) {
        dep.depend();
      }
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 派发更新:通知所有依赖的Watcher重新渲染
      dep.notify();
    }
  });
}

// 依赖收集器
class Dep {
  constructor() {
    this.subs = []; // 存储所有Watcher
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target);
    }
  }

  notify() {
    this.subs.forEach(sub => sub.update());
  }
}

// 观察者
class Watcher {
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 触发getter,进行依赖收集
    Dep.target = this;
    this.value = vm[expr];
    Dep.target = null;
  }

  update() {
    const newVal = this.vm[this.expr];
    this.cb(newVal);
  }
}

// Observer:将对象的所有属性转换为响应式
class Observer {
  constructor(obj) {
    this.walk(obj);
  }

  walk(obj) {
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key]);
    });
  }
}

// Vue 2 响应式的局限性
const vm = new Vue({
  data: {
    items: ['a', 'b', 'c'],
    user: { name: 'Alice' }
  }
});

// ❌ 无法检测到数组索引赋值
vm.items[0] = 'x'; // 不是响应式的
// ✅ 使用 Vue.set
Vue.set(vm.items, 0, 'x'); // 响应式的

// ❌ 无法检测到数组长度修改
vm.items.length = 2; // 不是响应式的
// ✅ 使用 splice
vm.items.splice(2); // 响应式的

// ❌ 无法检测到新增属性
vm.user.age = 25; // 不是响应式的
// ✅ 使用 Vue.set
Vue.set(vm.user, 'age', 25); // 响应式的

Vue 2 数组的响应式处理:

由于 Object.defineProperty 无法直接监听数组下标的变化,Vue 2 采用了"重写数组原型方法"的策略。它拦截了 7 个会改变原数组的方法(push、pop、shift、unshift、splice、sort、reverse),在这些方法执行后手动触发派发更新。

javascriptCode
// Vue 2 数组响应式的实现原理(简化版)
const arrayProto = Array.prototype;
// 基于数组原型创建一个新对象
const arrayMethods = Object.create(arrayProto);

// 需要被重写的 7 个变更方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
];

methodsToPatch.forEach(method => {
  // 缓存原始方法
  const original = arrayProto[method];

  Object.defineProperty(arrayMethods, method, {
    value: function mutator(...args) {
      // 先执行原始的数组方法,得到结果
      const result = original.apply(this, args);
      const ob = this.__ob__; // Observer 实例

      // 对于新增元素的方法,需要把新元素也变成响应式
      let inserted;
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args;
          break;
        case 'splice':
          inserted = args.slice(2);
          break;
      }
      if (inserted) ob.observeArray(inserted);

      // 手动触发派发更新
      ob.dep.notify();
      return result;
    },
    enumerable: false,
    writable: true,
    configurable: true
  });
});

Proxy (Vue 3):

Vue 3使用ES6 Proxy实现响应式系统,解决了Vue 2的所有局限性。Proxy可以直接代理整个对象而非对象的属性,支持数组索引检测、新增属性检测、删除属性检测等。Proxy是懒加载的,只有当属性被访问时才创建响应式连接,性能更好。Vue 3还引入了reactive、ref、computed等响应式API,提供了更灵活的响应式编程方式。

javascriptCode
// Vue 3 响应式实现原理
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;
    }
  });
}

// 依赖收集
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 dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }

  dep.add(activeEffect);
}

// 派发更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

// effect 函数
function effect(fn) {
  activeEffect = fn;
  fn();
  activeEffect = null;
}

// Vue 3 响应式的优势
const state = reactive({
  items: ['a', 'b', 'c'],
  user: { name: 'Alice' }
});

// ✅ 可以检测到数组索引赋值
state.items[0] = 'x'; // 响应式的

// ✅ 可以检测到数组长度修改
state.items.length = 2; // 响应式的

// ✅ 可以检测到新增属性
state.user.age = 25; // 响应式的

// ✅ 可以检测到删除属性
delete state.user.name; // 响应式的

为什么 Vue 3 用 Reflect 而不是直接操作 target:

在 Proxy 的 get/set 陷阱中使用 Reflect.get/Reflect.set 而非 target[key],主要是为了正确处理 receiver(接收者),保证在存在继承和 getter 的情况下 this 指向正确的代理对象,从而让依赖收集不会遗漏。这是一个容易被忽视但非常重要的细节。

javascriptCode
// 为什么需要 Reflect + receiver
const parent = {
  get fullName() {
    // 这里访问了 this.firstName
    return this.firstName + ' ' + this.lastName;
  }
};

const child = {
  firstName: 'Tom',
  lastName: 'Smith'
};
Object.setPrototypeOf(child, parent);

// 如果 get 陷阱里用 target[key],访问 fullName 时
// this 会指向 parent 而不是代理,导致 firstName/lastName 的依赖收集不到
// 使用 Reflect.get(target, key, receiver) 则会把 this 正确绑定到代理对象
const proxyChild = new Proxy(child, {
  get(target, key, receiver) {
    track(target, key);
    return Reflect.get(target, key, receiver); // receiver 是代理本身
  }
});

依赖收集流程:

依赖收集是Vue响应式系统的核心机制。当组件渲染时,会访问数据属性,触发getter,将当前正在执行的副作用函数(effect)收集到该属性的依赖列表中。当属性被修改时,触发setter,通知所有依赖该属性的副作用函数重新执行,从而更新视图。这个过程是自动的,开发者无需手动管理依赖关系。

javascriptCode
// 依赖收集流程示例
const state = reactive({ count: 0 });

// 组件渲染函数
function render() {
  // 访问 state.count,触发 getter
  // 将 render 函数添加到 count 的依赖列表
  console.log('Count:', state.count);
}

// 收集依赖
effect(render); // render 被添加到 count 的依赖列表

// 修改数据,触发更新
state.count++; // 触发 setter,执行 render 函数

依赖收集的数据结构详解:

Vue 3 的依赖关系用一个三层结构存储:WeakMap → Map → Set。最外层的 WeakMap 以响应式对象为 key,值是一个 Map;这个 Map 以对象的属性名为 key,值是一个 Set;Set 中存放着所有依赖该属性的 effect。使用 WeakMap 是为了在对象被回收时自动释放依赖,避免内存泄漏;使用 Set 是为了自动去重,同一个 effect 不会被重复收集。

javascriptCode
// 依赖关系的完整数据结构
// targetMap 结构示意:
// WeakMap {
//   reactiveObj1 => Map {
//     'count' => Set { effect1, effect2 },
//     'name'  => Set { effect3 }
//   },
//   reactiveObj2 => Map {
//     'price' => Set { effect4 }
//   }
// }

const state1 = reactive({ count: 0, name: 'a' });
const state2 = reactive({ price: 100 });

effect(() => document.title = String(state1.count)); // effect1 依赖 state1.count
effect(() => console.log(state1.name));              // effect3 依赖 state1.name
effect(() => console.log(state2.price));             // effect4 依赖 state2.price

// 修改 state1.count 只会触发 effect1,不会误触 effect3/effect4
// 这就是"精确依赖追踪",是 Vue 3 性能优于 Vue 2 的关键之一
state1.count++;

真实案例:一个电商购物车的响应式更新

设想一个电商购物车页面,商品列表 items 是响应式数据,总价 totalPrice 是计算属性。当用户修改某个商品的数量时,只有依赖了该商品数量的计算逻辑和视图片段会更新,其他无关的商品行不会重新渲染。在 Vue 2 中,如果直接通过下标修改数组元素(如 items[0].count = 2),总价不会更新,这是许多初学者踩过的坑;在 Vue 3 中则完全没有这个问题。

javascriptCode
// Vue 3 购物车响应式示例
import { reactive, computed } from 'vue';

const cart = reactive({
  items: [
    { id: 1, name: 'iPhone', price: 6999, count: 1 },
    { id: 2, name: 'AirPods', price: 1299, count: 2 }
  ]
});

// 总价是计算属性,依赖每个商品的 price 和 count
const totalPrice = computed(() =>
  cart.items.reduce((sum, item) => sum + item.price * item.count, 0)
);

console.log(totalPrice.value); // 6999 + 1299*2 = 9597

// Vue 3 中直接修改下标属性即可触发更新
cart.items[0].count = 3;
console.log(totalPrice.value); // 6999*3 + 1299*2 = 23595 ✅ 自动更新

Vue 2 与 Vue 3 响应式对比:

| 对比维度 | Vue 2 (defineProperty) | Vue 3 (Proxy) |

| --- | --- | --- |

| 拦截粒度 | 属性级别 | 对象级别 |

| 数组下标 | 无法检测 | 可检测 |

| 新增属性 | 需 Vue.set | 直接可用 |

| 删除属性 | 需 Vue.delete | 直接可用 |

| 初始化开销 | 递归遍历全部属性 | 懒代理,访问时才递归 |

| Map/Set 支持 | 不支持 | 支持 |

| 内存占用 | 较高 | 较低 |

| 浏览器兼容 | IE9+ | 不支持 IE |

常见坑:

Vue 2 中给对象新增属性不生效,忘记用 this.$set。
Vue 2 中通过 length 或下标改数组不生效。
Vue 3 中对 reactive 对象整体重新赋值会丢失响应式(应替换属性而非替换引用)。
解构 reactive 对象会丢失响应式,需要用 toRefs。

最佳实践:

Vue 3 项目统一使用 reactive/ref,避免混用 Vue 2 的 data 写法思维。
大数组或大对象若无需响应式,用 markRaw 跳过代理以节省性能。
需要解构时配合 toRefs 保持响应式连接。

💻 代码示例:Vue 响应式实现

🔄 Vue 2 响应式实现

javascriptCode
// 简化的 Vue 2 响应式实现
function defineReactive(obj, key, val) {
  const dep = new Dep();

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 依赖收集
      if (Dep.target) {
        dep.depend();
      }
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 派发更新
      dep.notify();
    }
  });
}

class Dep {
  constructor() {
    this.subs = [];
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target);
    }
  }

  notify() {
    this.subs.forEach(sub => sub.update());
  }
}

Dep.target = null;

Vue 3 响应式实现

javascriptCode
// Vue 3 使用 Proxy 实现响应式
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key);
      const result = Reflect.get(target, key, receiver);
      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;
    }
  });
}

// 依赖收集
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 dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }

  dep.add(activeEffect);
}

// 派发更新
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

// 使用示例
const state = reactive({
  count: 0,
  name: 'Vue'
});

effect(() => {
  console.log(`Count: ${state.count}`);
});

state.count++; // 触发更新

支持嵌套 effect 与清理的完整版

javascriptCode
// 更完整的 effect 实现:支持嵌套、清理旧依赖、调度器
const effectStack = [];
let activeEffect = null;

function cleanup(effectFn) {
  // 从所有依赖集合中移除自己,避免遗留的旧依赖
  effectFn.deps.forEach(dep => dep.delete(effectFn));
  effectFn.deps.length = 0;
}

function effect(fn, options = {}) {
  const effectFn = () => {
    cleanup(effectFn);       // 每次执行前先清理旧依赖
    activeEffect = effectFn;
    effectStack.push(effectFn);
    const res = fn();
    effectStack.pop();
    // 恢复上一个 activeEffect(支持嵌套 effect)
    activeEffect = effectStack[effectStack.length - 1];
    return res;
  };
  effectFn.deps = [];        // 记录该 effect 被哪些 dep 收集
  effectFn.options = options;
  if (!options.lazy) {
    effectFn();
  }
  return effectFn;
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;
  // 用新 Set 避免边遍历边增删导致死循环
  const depsToRun = new Set(dep);
  depsToRun.forEach(effectFn => {
    // 避免自身触发自身造成栈溢出
    if (effectFn === activeEffect) return;
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn); // 交给调度器
    } else {
      effectFn();
    }
  });
}

⚡ Composition API 响应式 API 详解

Composition API 是 Vue 3 引入的一套基于函数的组合式 API,用来替代 Vue 2 的 Options API(data/methods/computed/watch 分块写法)。它把同一逻辑关注点的代码组织在一起,解决了大型组件中相关逻辑被拆散到不同选项、难以复用的问题。

为什么重要:

在 Options API 中,一个功能(如"搜索防抖")的代码往往被拆到 data、methods、watch、mounted 等多个选项里,随着组件变大,跳来跳去阅读非常痛苦,逻辑复用只能靠 mixin(存在命名冲突、来源不清晰等问题)。Composition API 允许把一个功能封装成一个可复用的组合函数(composable),这是 Vue 3 生态大量出现 useXxx 工具函数的根本原因。

ref:包装基本类型的响应式

ref 用来创建一个响应式的引用,通常用于基本类型(number、string、boolean)。它返回一个带 .value 属性的对象,读写都要通过 .value。为什么需要 .value?因为 JavaScript 无法拦截对一个原始值变量的赋值,只能把值包进对象、用对象的 getter/setter 来实现响应式。在模板中使用 ref 会自动解包,无需写 .value。

javascriptCode
import { ref, isRef, unref } from 'vue';

// 创建响应式引用
const count = ref(0);
const message = ref('hello');
const isVisible = ref(true);

// 读写必须通过 .value
console.log(count.value); // 0
count.value++;
console.log(count.value); // 1

// ref 也可以包装对象,内部会用 reactive 处理
const user = ref({ name: 'Alice', age: 20 });
user.value.age = 21; // 响应式

// 工具函数
console.log(isRef(count));   // true
console.log(unref(count));   // 等价于 isRef(count) ? count.value : count
javascriptCode
// ref 的简化实现原理
function ref(value) {
  return new RefImpl(value);
}

class RefImpl {
  constructor(value) {
    this._value = convert(value); // 若是对象则转 reactive
    this.__v_isRef = true;
  }
  get value() {
    track(this, 'value'); // 依赖收集
    return this._value;
  }
  set value(newVal) {
    if (newVal !== this._value) {
      this._value = convert(newVal);
      trigger(this, 'value'); // 派发更新
    }
  }
}

function convert(value) {
  return typeof value === 'object' && value !== null
    ? reactive(value)
    : value;
}

reactive:包装对象的响应式

reactive 用于创建对象/数组的响应式代理,返回的是一个 Proxy。它不需要 .value,直接像普通对象一样使用。但它有两个限制:不能包装基本类型;对整个对象重新赋值会丢失响应式(因为丢掉了原来的 Proxy 引用)。

javascriptCode
import { reactive, toRefs, toRef } from 'vue';

const state = reactive({
  count: 0,
  user: { name: 'Bob' },
  list: [1, 2, 3]
});

state.count++;           // 响应式
state.user.name = 'Tom'; // 嵌套对象也是响应式
state.list.push(4);      // 数组方法响应式

// ❌ 错误:整体重新赋值会断开响应式
// state = reactive({ count: 100 }); // 丢失原有代理

// ❌ 错误:解构会丢失响应式
// const { count } = state; // count 是普通值,不再响应

// ✅ 正确:用 toRefs 解构保持响应式
const { count, user } = toRefs(state);
count.value++; // 依然响应式,且会同步回 state.count

// ✅ 用 toRef 单独转换某个属性
const countRef = toRef(state, 'count');

ref 与 reactive 对比:

| 对比维度 | ref | reactive |

| --- | --- | --- |

| 适用类型 | 基本类型和对象 | 仅对象和数组 |

| 访问方式 | 需要 .value | 直接访问 |

| 模板中 | 自动解包 | 直接使用 |

| 重新赋值 | 可整体替换 value | 整体替换丢失响应式 |

| 解构 | 直接解构可保持 | 需 toRefs |

| 底层实现 | RefImpl 类 | Proxy |

computed:计算属性

computed 用于声明一个依赖其他响应式数据、并带缓存的派生值。它的核心特性是"惰性求值 + 缓存":只有当依赖变化时才会重新计算,否则多次访问都返回缓存结果。这与普通方法(method)每次调用都执行不同,是性能优化的重要手段。

javascriptCode
import { ref, computed } from 'vue';

const price = ref(100);
const quantity = ref(2);

// 只读计算属性
const total = computed(() => {
  console.log('计算 total'); // 只在依赖变化时打印
  return price.value * quantity.value;
});

console.log(total.value); // 打印"计算 total",输出 200
console.log(total.value); // 直接返回缓存 200,不再打印
quantity.value = 3;       // 依赖变化,缓存失效
console.log(total.value); // 重新计算,输出 300

// 可写计算属性(带 get/set)
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed({
  get() {
    return `${firstName.value} ${lastName.value}`;
  },
  set(newValue) {
    const parts = newValue.split(' ');
    firstName.value = parts[0];
    lastName.value = parts[1];
  }
});

fullName.value = 'Jane Smith'; // 触发 set
console.log(firstName.value);  // Jane
console.log(lastName.value);   // Smith

computed 与 method 的性能对比:

假设一个列表页有 1000 条数据,需要对其进行排序后展示。如果用 method,每次模板重新渲染(哪怕是无关数据变化)都会重新执行排序;用 computed 则只在原始列表变化时排一次。实测在一个含 10000 项、每秒触发 60 次无关更新的场景中,computed 相比 method 可减少约 99% 的排序计算次数。

javascriptCode
// 反例:用 method 每次渲染都重排
export default {
  methods: {
    sortedList() {
      // 每次模板访问 sortedList() 都会执行,即使 list 没变
      return [...this.list].sort((a, b) => a.value - b.value);
    }
  }
};

// 正例:用 computed 缓存
export default {
  computed: {
    sortedList() {
      // 只有 this.list 变化时才重排
      return [...this.list].sort((a, b) => a.value - b.value);
    }
  }
};

watch:侦听器

watch 用于侦听一个或多个响应式数据源,在其变化时执行副作用(如发请求、操作 localStorage)。它可以拿到变化前后的新旧值,支持深度侦听(deep)、立即执行(immediate)、以及控制回调触发时机(flush)。

javascriptCode
import { ref, reactive, watch } from 'vue';

const keyword = ref('');
const user = reactive({ name: 'Tom', profile: { age: 20 } });

// 1. 侦听单个 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`关键词从 ${oldVal} 变为 ${newVal}`);
});

// 2. 侦听 reactive 对象的某个属性(需用 getter 函数)
watch(
  () => user.name,
  (newVal, oldVal) => {
    console.log(`用户名: ${oldVal} -> ${newVal}`);
  }
);

// 3. 侦听多个数据源
watch([keyword, () => user.name], ([newKw, newName], [oldKw, oldName]) => {
  console.log('多源变化', newKw, newName);
});

// 4. 深度侦听
watch(
  () => user.profile,
  (newVal) => {
    console.log('profile 深层变化', newVal);
  },
  { deep: true }
);

// 5. 立即执行 + 深度
watch(
  user,
  (newVal) => {
    console.log('user 任意属性变化', newVal);
  },
  { immediate: true, deep: true }
);

真实案例:搜索框防抖请求

搜索框输入时需要请求后端,但不能每输入一个字符就请求一次。用 watch 侦听关键词,配合防抖,并在组件卸载或关键词再次变化时取消上一次请求。watch 的回调第三个参数 onCleanup 正是用来注册"清理逻辑"的。

javascriptCode
import { ref, watch } from 'vue';

const keyword = ref('');
const results = ref([]);

watch(keyword, (newKw, oldKw, onCleanup) => {
  let canceled = false;
  // 注册清理函数:当下一次触发或组件卸载时执行
  onCleanup(() => {
    canceled = true; // 标记本次请求作废
  });

  const timer = setTimeout(async () => {
    const res = await fetch(`/api/search?q=${encodeURIComponent(newKw)}`);
    const data = await res.json();
    if (!canceled) {
      results.value = data; // 只有未被取消才写入
    }
  }, 300);

  onCleanup(() => clearTimeout(timer));
});

watchEffect:自动收集依赖的侦听器

watchEffect 会立即执行一次传入的函数,并自动追踪函数内用到的所有响应式依赖,当任一依赖变化时重新执行。它不需要显式指定侦听源,也拿不到旧值,适合"副作用逻辑本身就把依赖用全了"的场景。

javascriptCode
import { ref, watchEffect } from 'vue';

const count = ref(0);
const step = ref(1);

// 自动收集 count 和 step 作为依赖
const stop = watchEffect(() => {
  console.log(`count=${count.value}, step=${step.value}`);
});

count.value++; // 触发重新执行
step.value = 2; // 也会触发

// 停止侦听
stop();

watch 与 watchEffect 对比:

| 对比维度 | watch | watchEffect |

| --- | --- | --- |

| 依赖指定 | 显式指定源 | 自动收集 |

| 初次执行 | 默认不执行 | 立即执行一次 |

| 新旧值 | 可拿到 | 拿不到旧值 |

| 适用场景 | 需要旧值/懒执行 | 副作用依赖多且需立即执行 |

| 惰性 | 支持 immediate | 天然立即执行 |

常见坑:

watch 侦听 reactive 对象默认是深度侦听,但侦听其属性需用 getter。
watchEffect 中的异步回调里访问的响应式数据不会被追踪(只追踪同步执行到的)。
忘记停止手动创建的 watch,可能导致内存泄漏(组件内自动随卸载停止,setup 外需手动停)。

最佳实践:

需要根据数据变化发请求、且要防抖/取消,用 watch + onCleanup。
只是"数据变就同步做点事"、无需旧值,用 watchEffect。
优先用 computed 表达派生数据,watch 只用于副作用。

🧭 指令系统

指令(Directive)是带有 v- 前缀的特殊 HTML 属性,作用是在表达式的值变化时,响应式地作用于 DOM。指令是 Vue 声明式渲染的重要组成部分,把"数据到 DOM"的映射用简洁的模板语法表达出来。

内置指令

vueCode
<template>
  <div>
    <!-- 条件渲染 -->
    <p v-if="score >= 90">优秀</p>
    <p v-else-if="score >= 60">及格</p>
    <p v-else>不及格</p>

    <!-- 显示/隐藏(切换 display,不销毁 DOM) -->
    <p v-show="isVisible">我会被 display:none 隐藏</p>

    <!-- 列表渲染,务必带 key -->
    <ul>
      <li v-for="(item, index) in list" :key="item.id">
        {{ index }} - {{ item.name }}
      </li>
    </ul>

    <!-- 遍历对象 -->
    <div v-for="(value, key, index) in obj" :key="key">
      {{ index }}. {{ key }}: {{ value }}
    </div>

    <!-- 文本与 HTML -->
    <span v-text="rawText"></span>
    <div v-html="rawHtml"></div>

    <!-- 属性与事件绑定 -->
    <img :src="imgUrl" @click="onClick" />

    <!-- 一次性渲染,之后不再更新 -->
    <p v-once>{{ neverChangeAgain }}</p>

    <!-- 跳过编译,原样输出 mustache -->
    <p v-pre>{{ 这里不会被编译 }}</p>

    <!-- 防止闪烁:编译前隐藏未渲染的 mustache -->
    <p v-cloak>{{ message }}</p>
  </div>
</template>

v-if 与 v-show 的区别与选型:

| 对比维度 | v-if | v-show |

| --- | --- | --- |

| 实现方式 | 动态增删 DOM | 切换 display |

| 初始渲染开销 | 条件为假时不渲染 | 总是渲染 |

| 切换开销 | 高(销毁重建) | 低(改样式) |

| 是否支持 template | 支持 | 不支持 |

| 适用场景 | 很少切换 | 频繁切换 |

常见坑:v-if 与 v-for 不要同用

在 Vue 2 中 v-for 优先级高于 v-if,会导致每次循环都判断条件,性能差;在 Vue 3 中 v-if 优先级更高,可能取不到循环变量。正确做法是用 computed 先过滤,或在外层包一个 template 承载 v-if。

vueCode
<!-- ❌ 反例:v-for 与 v-if 同元素 -->
<li v-for="user in users" v-if="user.active" :key="user.id">
  {{ user.name }}
</li>

<!-- ✅ 正例一:computed 预过滤 -->
<li v-for="user in activeUsers" :key="user.id">{{ user.name }}</li>

<script>
export default {
  computed: {
    activeUsers() {
      return this.users.filter(u => u.active);
    }
  }
};
</script>

<!-- ✅ 正例二:外层 template 承载 v-if -->
<template v-for="user in users" :key="user.id">
  <li v-if="user.active">{{ user.name }}</li>
</template>

自定义指令

当需要对底层 DOM 元素进行直接、可复用的操作(如自动聚焦、权限控制、懒加载图片、点击外部关闭)时,自定义指令是首选。指令提供了一组生命周期钩子,可以精确介入元素的挂载、更新、卸载。

javascriptCode
// Vue 3 自定义指令的完整钩子
const myDirective = {
  created(el, binding, vnode) {
    // 元素属性/事件绑定前
  },
  beforeMount(el, binding) {
    // 元素插入 DOM 前
  },
  mounted(el, binding) {
    // 元素插入 DOM 后(最常用)
    // binding.value 是指令的值,binding.arg 是参数,binding.modifiers 是修饰符
  },
  beforeUpdate(el, binding) {},
  updated(el, binding) {
    // 组件更新后
  },
  beforeUnmount(el, binding) {},
  unmounted(el, binding) {
    // 元素卸载后,做清理
  }
};

// 全局注册:自动聚焦指令
app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});
vueCode
<!-- 使用自定义指令 -->
<template>
  <div>
    <!-- v-focus:挂载后自动聚焦 -->
    <input v-focus />

    <!-- v-permission:无权限则移除元素 -->
    <button v-permission="'user:delete'">删除</button>

    <!-- v-click-outside:点击外部触发回调 -->
    <div v-click-outside="closeDropdown">下拉菜单</div>
  </div>
</template>
javascriptCode
// 真实案例:权限指令,控制按钮是否显示
app.directive('permission', {
  mounted(el, binding) {
    const requiredPerm = binding.value;              // 需要的权限码
    const userPerms = store.state.user.permissions;  // 当前用户权限
    if (!userPerms.includes(requiredPerm)) {
      // 无权限,直接从 DOM 中移除元素
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
});

// 真实案例:点击外部关闭指令
app.directive('click-outside', {
  mounted(el, binding) {
    el.__clickOutside__ = (event) => {
      // 点击的不是元素本身、也不在其内部,则执行回调
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event);
      }
    };
    document.addEventListener('click', el.__clickOutside__);
  },
  unmounted(el) {
    // 卸载时移除监听,防止内存泄漏
    document.removeEventListener('click', el.__clickOutside__);
    delete el.__clickOutside__;
  }
});

最佳实践:

只有涉及"直接操作 DOM"且需复用时才用自定义指令,能用组件解决优先用组件。
在 unmounted 钩子里务必清理事件监听、定时器、第三方实例,防止内存泄漏。

数据绑定

单向绑定:

v-bind 指令
从数据到视图的单向数据流
适合表单元素以外的场景

双向绑定:

v-model 指令
数据与视图的双向同步
本质是 v-bind 和 v-on 的语法糖

计算属性:

computed 选项
基于依赖自动缓存
只在依赖变化时重新计算

监听器:

watch 选项
响应数据变化
支持深度监听和立即执行

代码示例

数据绑定示例

vueCode
<template>
  <div>
    <!-- 单向绑定 -->
    <p>{{ message }}</p>
    <p v-text="message"></p>
    <div v-html="htmlContent"></div>

    <!-- 属性绑定 -->
    <img :src="imageUrl" :alt="imageAlt">
    <a :href="linkUrl">Link</a>
    <div :class="{ active: isActive }">Class Binding</div>
    <div :style="{ color: textColor }">Style Binding</div>

    <!-- 动态绑定多个属性 -->
    <div v-bind="objectOfAttrs"></div>

    <!-- 双向绑定 -->
    <input v-model="username" placeholder="Username">
    <textarea v-model="description"></textarea>
    <select v-model="selectedOption">
      <option value="option1">Option 1</option>
      <option value="option2">Option 2</option>
    </select>

    <!-- 复选框绑定数组 -->
    <input type="checkbox" value="a" v-model="checked">
    <input type="checkbox" value="b" v-model="checked">

    <!-- 单选框 -->
    <input type="radio" value="male" v-model="gender">
    <input type="radio" value="female" v-model="gender">

    <!-- 修饰符 -->
    <input v-model.lazy="username">
    <input v-model.number="age" type="number">
    <input v-model.trim="message">
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello, Vue!',
      htmlContent: '<strong>Bold Text</strong>',
      imageUrl: '/image.jpg',
      imageAlt: 'Image',
      linkUrl: 'https://example.com',
      isActive: true,
      textColor: 'red',
      username: '',
      description: '',
      selectedOption: 'option1',
      checked: [],
      gender: 'male',
      age: 0,
      objectOfAttrs: {
        id: 'container',
        class: 'wrapper'
      }
    };
  }
};
</script>

class 与 style 绑定进阶

class 与 style 的绑定支持对象语法和数组语法,是动态样式控制的核心。对象语法根据布尔值决定类名是否出现,数组语法用于同时应用多个类。

vueCode
<template>
  <div>
    <!-- 对象语法:布尔值决定类名 -->
    <div :class="{ active: isActive, 'text-danger': hasError }"></div>

    <!-- 数组语法:多个类 -->
    <div :class="[activeClass, errorClass]"></div>

    <!-- 数组中使用对象 -->
    <div :class="[isActive ? activeClass : '', errorClass]"></div>
    <div :class="[{ active: isActive }, errorClass]"></div>

    <!-- style 对象语法 -->
    <div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>

    <!-- style 数组语法:合并多个样式对象 -->
    <div :style="[baseStyles, overrideStyles]"></div>

    <!-- 自动添加浏览器前缀 -->
    <div :style="{ display: ['-webkit-box', 'flex'] }"></div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isActive: true,
      hasError: false,
      activeClass: 'active',
      errorClass: 'text-danger',
      activeColor: 'red',
      fontSize: 16,
      baseStyles: { color: 'blue', margin: '10px' },
      overrideStyles: { color: 'green' }
    };
  }
};
</script>

计算属性和监听器示例

vueCode
<template>
  <div>
    <p>First Name: {{ firstName }}</p>
    <p>Last Name: {{ lastName }}</p>
    <p>Full Name: {{ fullName }}</p>
    <p>Full Name (Computed): {{ fullNameComputed }}</p>

    <input v-model="firstName" placeholder="First Name">
    <input v-model="lastName" placeholder="Last Name">
  </div>
</template>

<script>
export default {
  data() {
    return {
      firstName: 'John',
      lastName: 'Doe',
      fullName: ''
    };
  },

  computed: {
    // 计算属性:基于依赖自动缓存
    fullNameComputed() {
      console.log('Computed property called');
      return `${this.firstName} ${this.lastName}`;
    },

    // 只读计算属性
    fullNameReadOnly: {
      get() {
        return `${this.firstName} ${this.lastName}`;
      }
    },

    // 可写计算属性
    fullNameWritable: {
      get() {
        return `${this.firstName} ${this.lastName}`;
      },
      set(newValue) {
        const names = newValue.split(' ');
        this.firstName = names[0];
        this.lastName = names[names.length - 1];
      }
    }
  },

  watch: {
    // 监听器:响应数据变化
    firstName(newVal, oldVal) {
      console.log(`First name changed from ${oldVal} to ${newVal}`);
      this.fullName = `${newVal} ${this.lastName}`;
    },

    lastName(newVal, oldVal) {
      console.log(`Last name changed from ${oldVal} to ${newVal}`);
      this.fullName = `${this.firstName} ${newVal}`;
    },

    // 深度监听
    user: {
      handler(newVal, oldVal) {
        console.log('User changed:', newVal);
      },
      deep: true
    },

    // 立即执行
    message: {
      handler(newVal) {
        console.log('Message:', newVal);
      },
      immediate: true
    }
  }
};
</script>

🔌 v-model 双向绑定原理

v-model 是 Vue 中实现表单/组件双向绑定的语法糖。它本质上是"v-bind 绑定值 + v-on 监听更新事件"的组合。理解它的展开形式,才能正确地在自定义组件上实现双向绑定。

为什么重要:

表单是几乎每个应用都要处理的场景。搞清楚 v-model 的展开规则,才能写出可复用的表单组件(如自定义输入框、评分组件、开关组件),也能解决"父组件修改不生效""子组件改了父组件不知道"这类高频问题。

在原生元素上:

不同表单元素 v-model 绑定的属性和事件不同。文本框绑定 value + input 事件,复选框绑定 checked + change 事件,选择框绑定 value + change 事件。

vueCode
<!-- 这两种写法等价 -->
<input v-model="text" />
<input :value="text" @input="text = $event.target.value" />

在组件上(Vue 3):

Vue 3 中组件 v-model 默认使用 modelValue 属性和 update:modelValue 事件,并且支持多个 v-model 绑定(通过参数区分)。

vueCode
<!-- 父组件使用 -->
<template>
  <div>
    <!-- 单个 v-model -->
    <CustomInput v-model="username" />

    <!-- 展开形式 -->
    <CustomInput
      :model-value="username"
      @update:model-value="username = $event"
    />

    <!-- 多个 v-model(Vue 3 新特性) -->
    <UserForm v-model:name="name" v-model:age="age" />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import CustomInput from './CustomInput.vue';
import UserForm from './UserForm.vue';

const username = ref('');
const name = ref('Tom');
const age = ref(20);
</script>
vueCode
<!-- 子组件 CustomInput.vue:实现双向绑定 -->
<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
  />
</template>

<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script>
vueCode
<!-- 子组件 UserForm.vue:多个 v-model -->
<template>
  <div>
    <input
      :value="name"
      @input="$emit('update:name', $event.target.value)"
    />
    <input
      type="number"
      :value="age"
      @input="$emit('update:age', Number($event.target.value))"
    />
  </div>
</template>

<script setup>
defineProps(['name', 'age']);
defineEmits(['update:name', 'update:age']);
</script>

Vue 3.4+ 的 defineModel 简化写法:

Vue 3.4 引入了 defineModel 宏,让组件双向绑定的实现更简洁,无需手动声明 props 和 emits。

vueCode
<!-- 使用 defineModel(Vue 3.4+) -->
<template>
  <input v-model="model" />
</template>

<script setup>
// 一行搞定,model 既是响应式引用,修改它会自动触发 update 事件
const model = defineModel();
</script>

v-model 修饰符原理:

.lazy 把默认的 input 事件改为 change 事件(失焦或回车才同步);.number 把输入值转为数字;.trim 去除首尾空格。自定义组件也可以接收自定义修饰符。

vueCode
<!-- 自定义组件接收 v-model 修饰符 -->
<template>
  <input :value="modelValue" @input="onInput" />
</template>

<script setup>
const props = defineProps({
  modelValue: String,
  // 修饰符对象,如 v-model.capitalize 会得到 { capitalize: true }
  modelModifiers: { default: () => ({}) }
});
const emit = defineEmits(['update:modelValue']);

function onInput(e) {
  let value = e.target.value;
  // 应用自定义修饰符
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1);
  }
  emit('update:modelValue', value);
}
</script>

Vue 2 与 Vue 3 组件 v-model 对比:

| 对比维度 | Vue 2 | Vue 3 |

| --- | --- | --- |

| 默认属性 | value | modelValue |

| 默认事件 | input | update:modelValue |

| 多个绑定 | 不支持(需 .sync) | 支持 v-model:xxx |

| 自定义属性名 | model 选项 | v-model 参数 |

| 简化宏 | 无 | defineModel(3.4+) |

常见坑:

直接在子组件修改 props(modelValue)会报警告,必须通过 emit 通知父组件。
.number 修饰符对空字符串会返回空字符串而非数字,需注意边界。

组件系统

组件注册:

全局注册:Vue.component()
局部注册:components 选项
单文件组件:.vue 文件

组件通信:

props:父向子传递数据
emit:子向父传递事件
provide/inject:跨层级通信
event bus:事件总线
ref:父组件访问子组件实例
attrs:透传属性
Vuex/Pinia:状态管理:Vuex是Vue 2官方推荐的状态管理库,提供集中式状态管理、状态变更追踪、组件间通信等能力,采用单向数据流模式,Pinia是Vue 3官方推荐的状态管理库,相比Vuex更轻量、TypeScript支持更好、模块化更简洁,两者都是Vue项目中管理应用状态的常用选择

插槽:

匿名插槽
命名插槽
作用域插槽
动态插槽

组件通信七种方式对比

| 通信方式 | 方向 | 适用场景 |

| --- | --- | --- |

| props | 父到子 | 父传数据给子 |

| emit | 子到父 | 子通知父/回传数据 |

| provide/inject | 祖到孙 | 跨多层级传递 |

| ref | 父访问子 | 父调用子的方法 |

| attrs | 父到孙 | 属性透传 |

| event bus | 任意 | 简单跨组件(不推荐大型项目) |

| Pinia/Vuex | 任意 | 全局共享状态 |

代码示例

组件注册示例

javascriptCode
// 全局注册
Vue.component('my-component', {
  template: '<div>Global Component</div>'
});

// 局部注册
export default {
  components: {
    'my-component': MyComponent
  }
};

// 单文件组件
// MyComponent.vue
<template>
  <div>
    <h2>{{ title }}</h2>
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: 'MyComponent',
  props: {
    title: {
      type: String,
      required: true
    }
  }
};
</script>

props 的完整声明与校验

props 是父组件向子组件传递数据的通道,遵循"单向数据流"——父数据变化会流向子,但子不能直接修改。完整的 props 声明应包含类型、是否必填、默认值和自定义校验器,这能显著提升组件的健壮性和可维护性。

javascriptCode
export default {
  props: {
    // 基础类型检查
    title: String,

    // 多种可能类型
    id: [String, Number],

    // 必填的字符串
    name: {
      type: String,
      required: true
    },

    // 带默认值的数字
    age: {
      type: Number,
      default: 18
    },

    // 对象/数组默认值必须用工厂函数
    config: {
      type: Object,
      default: () => ({ theme: 'light' })
    },

    tags: {
      type: Array,
      default: () => []
    },

    // 自定义校验器
    status: {
      type: String,
      validator(value) {
        return ['pending', 'success', 'error'].includes(value);
      }
    }
  }
};

为什么对象/数组的默认值要用工厂函数:

如果直接写 default: {},所有使用该组件的实例会共享同一个对象引用,一个实例修改会影响其他实例。用工厂函数则每个实例都得到独立的对象,避免了这种隐蔽的 bug。

组件通信示例

vueCode
<!-- 父组件 -->
<template>
  <div>
    <h1>Parent Component</h1>
    <p>Message from child: {{ childMessage }}</p>

    <!-- Props -->
    <ChildComponent
      :parent-message="parentMessage"
      @child-event="handleChildEvent"
    />
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent
  },
  data() {
    return {
      parentMessage: 'Hello from parent',
      childMessage: ''
    };
  },
  methods: {
    handleChildEvent(message) {
      this.childMessage = message;
    }
  }
};
</script>

<!-- 子组件 -->
<template>
  <div>
    <h2>Child Component</h2>
    <p>Message from parent: {{ parentMessage }}</p>

    <button @click="sendMessage">Send Message to Parent</button>
  </div>
</template>

<script>
export default {
  props: {
    parentMessage: {
      type: String,
      required: true
    }
  },
  methods: {
    sendMessage() {
      this.$emit('child-event', 'Hello from child');
    }
  }
};
</script>

<!-- provide/inject 示例 -->
<!-- 祖先组件 -->
<template>
  <div>
    <ParentComponent />
  </div>
</template>

<script>
export default {
  provide() {
    return {
      theme: 'dark',
      toggleTheme: this.toggleTheme
    };
  },
  methods: {
    toggleTheme() {
      this.theme = this.theme === 'dark' ? 'light' : 'dark';
    }
  }
};
</script>

<!-- 后代组件 -->
<template>
  <div :class="theme">
    <h2>Descendant Component</h2>
    <button @click="toggleTheme">Toggle Theme</button>
  </div>
</template>

<script>
export default {
  inject: ['theme', 'toggleTheme']
};
</script>

Composition API 组件通信(script setup)

在 Vue 3 的 script setup 语法中,组件通信使用 defineProps、defineEmits、provide/inject、defineExpose 等编译器宏,更加简洁直观。

vueCode
<!-- 子组件:defineProps + defineEmits -->
<template>
  <div>
    <p>{{ title }}</p>
    <button @click="notify">通知父组件</button>
  </div>
</template>

<script setup>
// 声明 props
const props = defineProps({
  title: { type: String, default: '默认标题' }
});

// 声明 emits
const emit = defineEmits(['submit', 'cancel']);

function notify() {
  emit('submit', { time: Date.now(), from: 'child' });
}

// 暴露方法/属性给父组件通过 ref 访问
defineExpose({
  reset() {
    console.log('子组件被父组件调用 reset');
  }
});
</script>
vueCode
<!-- 父组件:通过 ref 访问子组件暴露的方法 -->
<template>
  <div>
    <ChildComponent ref="childRef" title="来自父级" @submit="onSubmit" />
    <button @click="callChild">调用子组件方法</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';

const childRef = ref(null);

function onSubmit(payload) {
  console.log('收到子组件提交', payload);
}

function callChild() {
  // 调用子组件通过 defineExpose 暴露的方法
  childRef.value.reset();
}
</script>
javascriptCode
// provide / inject 的 Composition API 写法
// 祖先组件
import { provide, ref, readonly } from 'vue';

const theme = ref('dark');
function toggleTheme() {
  theme.value = theme.value === 'dark' ? 'light' : 'dark';
}
// 提供只读值 + 修改方法,防止后代直接篡改
provide('theme', readonly(theme));
provide('toggleTheme', toggleTheme);

// 后代组件
import { inject } from 'vue';
const theme = inject('theme');
const toggleTheme = inject('toggleTheme', () => {}); // 第二参为默认值

真实案例:跨层级的主题切换

一个后台系统的顶层 App 组件维护 theme 状态,深层的按钮组件需要读取并切换主题。用 props 逐层传递会造成"props 逐层透传"的噩梦(prop drilling)。用 provide/inject,App 提供只读的 theme 和 toggleTheme,任意深度的后代都能直接注入使用,代码清爽且解耦。

常见坑:

provide 提供的响应式数据若直接暴露可写引用,后代可任意修改,破坏数据流可追溯性,应配合 readonly。
ref 访问子组件必须等子组件挂载后(onMounted 之后),且 script setup 组件默认不暴露任何内容,需 defineExpose。

插槽

插槽概念:

插槽(Slot)是一种内容分发机制,允许父组件向子组件的指定位置"插入"模板内容。可以把它类比成"预留的坑位"——子组件搭好框架并挖好坑,父组件决定坑里填什么。这让组件既能封装结构,又能保持内容的灵活性,是构建高复用布局组件(如卡片、弹窗、表格)的核心手段。

为什么重要:

没有插槽,一个"卡片"组件只能通过大量 props 传字符串,无法插入复杂的 DOM 结构和其他组件。插槽让组件的"内容"与"结构"解耦,是实现通用 UI 组件库(如 Element Plus、Ant Design Vue)的基础能力。

插槽分类:

匿名插槽:默认的单个内容坑位
命名插槽:多个具名坑位(header/body/footer)
作用域插槽:子组件把数据传回给父组件的插槽内容使用
动态插槽:插槽名可以是变量

代码示例

vueCode
<!-- 基础插槽 -->
<!-- 父组件 -->
<template>
  <div>
    <MyComponent>
      <p>This is slot content</p>
    </MyComponent>
  </div>
</template>

<!-- 子组件 -->
<template>
  <div>
    <h2>My Component</h2>
    <slot></slot>
  </div>
</template>

<!-- 命名插槽 -->
<!-- 父组件 -->
<template>
  <div>
    <MyComponent>
      <template v-slot:header>
        <h1>Header Content</h1>
      </template>

      <template #default>
        <p>Default Content</p>
      </template>

      <template #footer>
        <p>Footer Content</p>
      </template>
    </MyComponent>
  </div>
</template>

<!-- 子组件 -->
<template>
  <div>
    <slot name="header"></slot>
    <slot></slot>
    <slot name="footer"></slot>
  </div>
</template>

<!-- 作用域插槽 -->
<!-- 父组件 -->
<template>
  <div>
    <MyComponent>
      <template #default="{ user, index }">
        <p>{{ index }}: {{ user.name }}</p>
      </template>
    </MyComponent>
  </div>
</template>

<!-- 子组件 -->
<template>
  <div>
    <slot
      v-for="(user, index) in users"
      :user="user"
      :index="index"
    ></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      users: [
        { name: 'Alice' },
        { name: 'Bob' }
      ]
    };
  }
};
</script>

插槽默认内容与动态插槽名

vueCode
<!-- 子组件:插槽提供默认内容(父组件不填时展示) -->
<template>
  <button>
    <slot>提交</slot>
  </button>
</template>

<!-- 父组件:动态插槽名 -->
<template>
  <MyComponent>
    <template #[dynamicSlotName]>
      内容会插入到名为 dynamicSlotName 值的插槽
    </template>
  </MyComponent>
</template>

<script setup>
import { ref } from 'vue';
const dynamicSlotName = ref('header'); // 可动态切换为 'footer' 等
</script>

真实案例:通用表格组件的作用域插槽

作用域插槽最典型的应用是数据表格组件:表格组件负责遍历数据、渲染行列结构,但每一列的具体渲染方式(比如"状态"列要显示彩色标签、"操作"列要放按钮)由使用者决定。子组件通过作用域插槽把当前行数据 row 传回给父组件,父组件自由定制单元格内容。

vueCode
<!-- 子组件 DataTable.vue -->
<template>
  <table>
    <tbody>
      <tr v-for="(row, rowIndex) in data" :key="row.id">
        <td v-for="col in columns" :key="col.key">
          <!-- 把 row、col、rowIndex 传回给父组件 -->
          <slot
            :name="col.key"
            :row="row"
            :value="row[col.key]"
            :index="rowIndex"
          >
            <!-- 默认渲染:直接显示值 -->
            {{ row[col.key] }}
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
defineProps({
  columns: { type: Array, required: true },
  data: { type: Array, required: true }
});
</script>
vueCode
<!-- 父组件:定制"状态"列和"操作"列 -->
<template>
  <DataTable :columns="columns" :data="users">
    <!-- 状态列:根据值显示不同标签 -->
    <template #status="{ value }">
      <span :class="value === 'active' ? 'tag-green' : 'tag-red'">
        {{ value === 'active' ? '启用' : '禁用' }}
      </span>
    </template>

    <!-- 操作列:放按钮,可访问整行数据 -->
    <template #actions="{ row }">
      <button @click="edit(row)">编辑</button>
      <button @click="remove(row.id)">删除</button>
    </template>
  </DataTable>
</template>

<script setup>
import { ref } from 'vue';
import DataTable from './DataTable.vue';

const columns = [
  { key: 'name', title: '姓名' },
  { key: 'status', title: '状态' },
  { key: 'actions', title: '操作' }
];
const users = ref([
  { id: 1, name: 'Alice', status: 'active' },
  { id: 2, name: 'Bob', status: 'inactive' }
]);

function edit(row) { console.log('编辑', row); }
function remove(id) { console.log('删除', id); }
</script>

插槽三种类型对比:

| 插槽类型 | 是否具名 | 能否传数据回父 | 典型场景 |

| --- | --- | --- | --- |

| 匿名插槽 | 否 | 否 | 单一内容坑位 |

| 命名插槽 | 是 | 否 | 布局组件多区域 |

| 作用域插槽 | 可具名 | 是 | 列表/表格自定义渲染 |

常见坑:

Vue 2 中 slot-scope 和 v-slot 语法不同,升级时需注意。
作用域插槽拿到的数据是子组件提供的,父组件访问不存在的字段会得到 undefined。
v-slot 只能用在 template 或组件标签上,不能直接用在普通元素上。

最佳实践:

布局类组件优先用命名插槽划分区域。
列表/表格类组件用作用域插槽把渲染权交给使用者。
为插槽提供合理的默认内容,提升组件易用性。

生命周期

生命周期概念:

生命周期(Lifecycle)指的是一个组件从创建、挂载、更新到销毁的完整过程。Vue 在这些关键节点提供了"钩子函数",让开发者可以在特定时机执行自己的逻辑,比如在挂载后请求数据、在销毁前清理定时器。理解生命周期是避免内存泄漏、正确操作 DOM 的前提。

为什么重要:

如果在错误的时机操作 DOM(比如在 created 里访问尚未渲染的元素),会得到 null;如果忘记在销毁时清理定时器和事件监听,会造成内存泄漏和"幽灵回调"。生命周期钩子是这些问题的正确解决位置。

Vue 2 生命周期:

beforeCreate:实例创建前
created:实例创建后
beforeMount:挂载前
mounted:挂载后
beforeUpdate:更新前
updated:更新后
beforeDestroy:销毁前
destroyed:销毁后

Vue 3 生命周期:

beforeCreate → setup()
created → setup()
beforeMount → onBeforeMount
mounted → onMounted
beforeUpdate → onBeforeUpdate
updated → onUpdated
beforeUnmount → onBeforeUnmount
unmounted → onUnmounted

Vue 2 与 Vue 3 生命周期映射表:

| Vue 2 选项式 | Vue 3 组合式 | 触发时机 | 常见用途 |

| --- | --- | --- | --- |

| beforeCreate | setup | 实例初始化前 | 一般不用 |

| created | setup | 数据可访问 | 初始化数据 |

| beforeMount | onBeforeMount | 挂载前 | 少用 |

| mounted | onMounted | DOM 已渲染 | 请求数据、操作 DOM |

| beforeUpdate | onBeforeUpdate | DOM 更新前 | 获取更新前状态 |

| updated | onUpdated | DOM 更新后 | 依赖新 DOM 的操作 |

| beforeUnmount | onBeforeUnmount | 卸载前 | 清理定时器/监听 |

| unmounted | onUnmounted | 卸载后 | 最终清理 |

代码示例

Vue 2 生命周期示例

vueCode
<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0
    };
  },

  beforeCreate() {
    console.log('beforeCreate: 实例创建前');
    // 无法访问 data、methods、computed
  },

  created() {
    console.log('created: 实例创建后');
    // 可以访问 data、methods、computed
    // 常用于初始化数据、发起网络请求
    this.fetchData();
  },

  beforeMount() {
    console.log('beforeMount: 挂载前');
    // DOM 还未渲染
  },

  mounted() {
    console.log('mounted: 挂载后');
    // DOM 已渲染
    // 常用于操作 DOM、初始化第三方库
    this.initChart();
  },

  beforeUpdate() {
    console.log('beforeUpdate: 更新前');
    // 数据变化,DOM 还未更新
  },

  updated() {
    console.log('updated: 更新后');
    // DOM 已更新
    // 避免在这里修改数据,可能导致无限循环
  },

  beforeDestroy() {
    console.log('beforeDestroy: 销毁前');
    // 清理定时器、事件监听器等
    this.cleanup();
  },

  destroyed() {
    console.log('destroyed: 销毁后');
    // 实例已销毁
  },

  methods: {
    increment() {
      this.count++;
    },

    fetchData() {
      // 发起网络请求
      fetch('/api/data')
        .then(response => response.json())
        .then(data => {
          this.data = data;
        });
    },

    initChart() {
      // 初始化图表
      this.chart = new Chart(this.$refs.chart, {
        // 配置项
      });
    },

    cleanup() {
      // 清理定时器
      if (this.timer) {
        clearInterval(this.timer);
      }

      // 清理图表
      if (this.chart) {
        this.chart.destroy();
      }
    }
  }
};
</script>

Vue 3 生命周期示例

vueCode
<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script>
import { ref, onBeforeMount, onMounted, onBeforeUpdate, onUpdated, onBeforeUnmount, onUnmounted } from 'vue';

export default {
  setup() {
    const count = ref(0);
    let timer = null;

    // 挂载前
    onBeforeMount(() => {
      console.log('onBeforeMount: 挂载前');
    });

    // 挂载后
    onMounted(() => {
      console.log('onMounted: 挂载后');
      // 初始化定时器
      timer = setInterval(() => {
        console.log('Timer tick');
      }, 1000);
    });

    // 更新前
    onBeforeUpdate(() => {
      console.log('onBeforeUpdate: 更新前');
    });

    // 更新后
    onUpdated(() => {
      console.log('onUpdated: 更新后');
    });

    // 卸载前
    onBeforeUnmount(() => {
      console.log('onBeforeUnmount: 卸载前');
    });

    // 卸载后
    onUnmounted(() => {
      console.log('onUnmounted: 卸载后');
      // 清理定时器
      if (timer) {
        clearInterval(timer);
      }
    });

    const increment = () => {
      count.value++;
    };

    return {
      count,
      increment
    };
  }
};
</script>

真实案例:图表组件的完整生命周期管理

一个数据看板里的图表组件是生命周期管理的典型场景:需要在挂载后初始化图表实例、监听窗口 resize 事件重绘、在数据变化时更新图表、在卸载时销毁实例并移除监听。任何一步遗漏都可能导致内存泄漏或"页面切走后图表还在后台重绘"的性能问题。

vueCode
<template>
  <div ref="chartRef" class="chart"></div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import * as echarts from 'echarts';

const props = defineProps({
  data: { type: Array, required: true }
});

const chartRef = ref(null);
let chartInstance = null;

function resizeHandler() {
  chartInstance && chartInstance.resize();
}

onMounted(() => {
  // 挂载后 DOM 可用,初始化图表
  chartInstance = echarts.init(chartRef.value);
  chartInstance.setOption(buildOption(props.data));
  // 监听窗口变化
  window.addEventListener('resize', resizeHandler);
});

// 数据变化时更新图表(不重建实例)
watch(
  () => props.data,
  (newData) => {
    chartInstance && chartInstance.setOption(buildOption(newData));
  },
  { deep: true }
);

onBeforeUnmount(() => {
  // 关键清理:移除监听、销毁实例,防止内存泄漏
  window.removeEventListener('resize', resizeHandler);
  chartInstance && chartInstance.dispose();
  chartInstance = null;
});

function buildOption(data) {
  return {
    xAxis: { type: 'category', data: data.map(d => d.label) },
    yAxis: { type: 'value' },
    series: [{ type: 'bar', data: data.map(d => d.value) }]
  };
}
</script>

常见坑:

在 created 里操作 DOM(如 this.$refs.xxx)会失败,应放到 mounted。
在 updated 里修改响应式数据可能造成无限更新循环。
忘记在 unmounted/beforeUnmount 清理定时器、事件监听、WebSocket,导致内存泄漏。
keep-alive 缓存的组件用 onActivated/onDeactivated 而非 onMounted/onUnmounted。

keep-alive 缓存组件的特殊生命周期:

vueCode
<template>
  <keep-alive>
    <component :is="currentView" />
  </keep-alive>
</template>

<script setup>
import { onActivated, onDeactivated } from 'vue';

// 被缓存的组件在"激活"时触发,而非重新挂载
onActivated(() => {
  console.log('组件被激活,可刷新数据');
});

// 被缓存的组件"失活"时触发,而非卸载
onDeactivated(() => {
  console.log('组件失活,可暂停轮询');
});
</script>

🌳 虚拟 DOM 与 Diff 算法

虚拟 DOM(Virtual DOM)是用 JavaScript 对象来描述真实 DOM 结构的一种技术。每次数据变化时,Vue 先生成新的虚拟 DOM 树,再和旧树做对比(Diff),计算出最小的变更集,最后只把差异应用到真实 DOM 上。可以类比成"先在草稿纸上改设计图,对比出改了哪几处,再只动工那几处",而不是每次都推倒重建整栋楼。

为什么重要:

直接操作真实 DOM 非常昂贵——每次读写都可能触发浏览器的重排(reflow)和重绘(repaint)。虚拟 DOM 通过在内存中计算差异,把多次 DOM 操作合并成一次最小化的批量更新,既提升了性能,又让开发者用声明式的方式写 UI,无需手动 diff。同时虚拟 DOM 作为一层抽象,还让 Vue 能跨平台渲染(如 SSR、原生渲染)。

javascriptCode
// 虚拟 DOM 就是普通 JS 对象
const vnode = {
  tag: 'div',
  props: { id: 'app', class: 'container' },
  children: [
    { tag: 'h1', props: {}, children: 'Hello' },
    { tag: 'p', props: {}, children: 'World' }
  ]
};

// 渲染函数 h() 生成 vnode
import { h } from 'vue';
const vnode2 = h('div', { id: 'app' }, [
  h('h1', 'Hello'),
  h('p', 'World')
]);

Diff 算法核心策略:

Vue 的 Diff 采用了几个关键优化:只做同层比较(不跨层级移动,复杂度从 O(n^3) 降到 O(n));通过 key 判断节点是否可复用;对列表采用双端比较(头头、尾尾、头尾、尾头)加最长递增子序列算法,最小化 DOM 移动次数。

javascriptCode
// 简化的 Diff:同层比较 + key 复用
function patch(oldVnode, newVnode) {
  // 1. 标签不同,直接替换
  if (oldVnode.tag !== newVnode.tag) {
    replaceNode(oldVnode, newVnode);
    return;
  }
  // 2. 标签相同,复用元素,更新属性
  const el = (newVnode.el = oldVnode.el);
  updateProps(el, oldVnode.props, newVnode.props);
  // 3. 对比子节点
  updateChildren(el, oldVnode.children, newVnode.children);
}

// 更新属性:新增/修改/删除
function updateProps(el, oldProps = {}, newProps = {}) {
  // 设置新属性
  for (const key in newProps) {
    if (oldProps[key] !== newProps[key]) {
      el.setAttribute(key, newProps[key]);
    }
  }
  // 删除旧属性
  for (const key in oldProps) {
    if (!(key in newProps)) {
      el.removeAttribute(key);
    }
  }
}

key 的作用与"就地复用"陷阱:

在列表渲染中,key 帮助 Diff 算法准确识别哪些节点是"同一个",从而正确地复用、移动或删除。如果不写 key(或用 index 当 key),Vue 会采用"就地复用"策略——当列表顺序变化或中间插入删除时,会导致状态错乱(比如输入框的内容跑到了别的行)。

vueCode
<!-- ❌ 反例:用 index 作为 key,插入/删除会错乱 -->
<li v-for="(item, index) in list" :key="index">
  <input /> {{ item.name }}
</li>

<!-- ✅ 正例:用唯一稳定的 id 作为 key -->
<li v-for="item in list" :key="item.id">
  <input /> {{ item.name }}
</li>

真实案例:用错 key 导致的表单错乱

在一个可增删行的动态表单里,如果用数组 index 做 key,用户在第 2 行输入了内容后删除第 1 行,由于 index 重新排列,Vue 会复用原来第 1 行的 DOM 给新的第 1 行(原第 2 行),结果用户填的内容"错位"到了别的行。改用每行数据的唯一 id 做 key 后问题消失。这是生产环境非常高频的 bug。

Vue 3 编译期优化:

Vue 3 在编译模板时做了大量优化,这也是它比 Vue 2 快的重要原因:

静态提升(Static Hoisting):把不会变化的静态节点提取到渲染函数外,只创建一次。
Patch Flag:给动态节点打标记,Diff 时只对比标记的部分,跳过静态内容。
树结构打平(Block Tree):把动态节点收集到扁平数组,跳过静态节点的递归遍历。
事件缓存(Cache Handlers):内联事件处理函数被缓存,避免每次渲染重新创建。

| 优化项 | Vue 2 | Vue 3 |

| --- | --- | --- |

| 静态节点 | 每次重新创建 | 静态提升,只创建一次 |

| Diff 粒度 | 全量对比 | Patch Flag 精准对比 |

| 遍历方式 | 递归整棵树 | Block Tree 跳过静态 |

| 事件处理函数 | 每次重建 | 缓存复用 |

| 初次渲染 | 基准 | 更快约 1.3~2 倍 |

| 更新性能 | 基准 | 更快约 1.3~2 倍 |

| 内存占用 | 基准 | 减少约 50% |

常见坑:

认为"虚拟 DOM 一定比直接操作 DOM 快"是误解——它的价值在于"批量最小化更新 + 声明式开发体验",对极少量的确定 DOM 操作,直接操作反而更快。
列表用 index 或随机值当 key,导致复用错乱或性能下降。

⏱️ nextTick 与异步更新队列

Vue 的 DOM 更新是异步的。当你连续修改多次响应式数据时,Vue 不会每次都同步更新 DOM,而是把这些变更放入一个队列,在同一个"tick"内去重后,于下一个微任务里统一更新一次。nextTick 就是用来在"DOM 完成更新之后"执行回调的工具。

为什么重要:

如果你改了数据后立刻去读取 DOM,会读到"更新前"的旧 DOM,因为更新还没发生。理解异步更新机制,才能知道"为什么改了数据 DOM 还没变",并用 nextTick 在正确的时机操作 DOM。同时,异步批量更新把 N 次数据变化合并为 1 次渲染,是 Vue 性能优化的关键设计。

javascriptCode
import { ref, nextTick } from 'vue';

const count = ref(0);

async function update() {
  count.value = 1;
  count.value = 2;
  count.value = 3;
  // 此时 DOM 还是旧的(0),因为更新是异步批量的
  console.log('同步读取 DOM,仍是旧值');

  // 等待 DOM 更新完成
  await nextTick();
  console.log('DOM 已更新为 3');
  // 现在可以安全地读取/操作最新 DOM
}

// 回调写法
function updateWithCallback() {
  count.value++;
  nextTick(() => {
    console.log('DOM 更新后执行');
  });
}

异步更新队列的实现原理:

Vue 维护一个更新队列,多次触发同一个 watcher 时会去重(同一 tick 内只更新一次),然后通过微任务(Promise.then,降级到 setTimeout)在当前同步代码执行完后清空队列、批量刷新。

javascriptCode
// 简化的异步更新队列实现
const queue = [];
let isFlushing = false;
const p = Promise.resolve();

function nextTick(fn) {
  return fn ? p.then(fn) : p;
}

function queueJob(job) {
  // 去重:同一个 job 不重复入队
  if (!queue.includes(job)) {
    queue.push(job);
  }
  if (!isFlushing) {
    isFlushing = true;
    // 在微任务中批量执行
    p.then(flushJobs);
  }
}

function flushJobs() {
  // 按 id 排序,保证父组件先于子组件更新
  queue.sort((a, b) => a.id - b.id);
  for (const job of queue) {
    job();
  }
  queue.length = 0;
  isFlushing = false;
}

真实案例:获取更新后的元素尺寸

一个场景:点击"展开"后列表新增了很多项,需要立刻把滚动条滚到底部。如果在改数据后马上读取 scrollHeight,得到的是旧高度,滚动位置不对。必须在 nextTick 之后读取,此时 DOM 已渲染出新项,scrollHeight 才是正确的。

javascriptCode
import { ref, nextTick } from 'vue';

const listRef = ref(null);
const messages = ref([]);

async function addMessage(msg) {
  messages.value.push(msg);
  // 直接读取会得到旧高度
  await nextTick();
  // DOM 已更新,滚动到底部
  const el = listRef.value;
  el.scrollTop = el.scrollHeight;
}

常见坑:

改完数据同步读取 $refs 元素内容会拿到旧值,需 nextTick。
在 v-if 切换后立刻访问被条件渲染的元素会得到 null,需 nextTick 等元素渲染。

🎯 事件处理与修饰符

Vue 用 v-on(简写 @)监听 DOM 事件。事件修饰符是 Vue 提供的一组以点开头的后缀,用来处理常见的事件细节(阻止默认行为、阻止冒泡、只触发一次等),让模板更简洁,也避免在方法里写样板代码。

为什么重要:

处理表单提交、按键快捷键、阻止事件冒泡这些需求非常普遍。修饰符把这些细节声明式地写在模板上,既减少了方法里的 event.preventDefault() 之类的样板代码,又让意图一目了然,还能避免忘记调用导致的 bug。

vueCode
<template>
  <div>
    <!-- .stop:阻止事件冒泡 -->
    <div @click="onOuter">
      <button @click.stop="onInner">点我不冒泡</button>
    </div>

    <!-- .prevent:阻止默认行为 -->
    <form @submit.prevent="onSubmit">
      <button type="submit">提交</button>
    </form>

    <!-- .self:只有点击元素自身才触发 -->
    <div @click.self="onSelf">只有点空白处才触发</div>

    <!-- .once:只触发一次 -->
    <button @click.once="onOnce">只生效一次</button>

    <!-- .capture:使用捕获模式 -->
    <div @click.capture="onCapture">捕获阶段触发</div>

    <!-- .passive:提升滚动性能(不会调用 preventDefault) -->
    <div @scroll.passive="onScroll">滚动区域</div>

    <!-- 修饰符可串联 -->
    <a href="#" @click.stop.prevent="onLink">串联修饰符</a>

    <!-- 按键修饰符 -->
    <input @keyup.enter="onEnter" placeholder="回车提交" />
    <input @keyup.esc="onEsc" placeholder="Esc 取消" />
    <input @keyup.ctrl.s="onSave" placeholder="Ctrl+S 保存" />

    <!-- 鼠标按键修饰符 -->
    <button @click.right="onRightClick">右键</button>
    <button @click.middle="onMiddleClick">中键</button>

    <!-- 精确修饰符:仅按下指定键才触发 -->
    <button @click.exact="onExact">没有其他修饰键才触发</button>
  </div>
</template>

<script setup>
function onOuter() { console.log('外层'); }
function onInner() { console.log('内层,不冒泡'); }
function onSubmit() { console.log('提交,已阻止刷新'); }
function onSelf() { console.log('自身'); }
function onOnce() { console.log('只一次'); }
function onCapture() { console.log('捕获'); }
function onScroll() { console.log('滚动'); }
function onLink() { console.log('链接,阻止跳转和冒泡'); }
function onEnter() { console.log('回车'); }
function onEsc() { console.log('Esc'); }
function onSave() { console.log('保存'); }
function onRightClick() { console.log('右键'); }
function onMiddleClick() { console.log('中键'); }
function onExact() { console.log('精确匹配'); }
</script>

常用事件修饰符对照表:

| 修饰符 | 作用 | 等价原生写法 |

| --- | --- | --- |

| .stop | 阻止冒泡 | event.stopPropagation() |

| .prevent | 阻止默认 | event.preventDefault() |

| .self | 仅自身触发 | 判断 event.target === el |

| .once | 只触发一次 | 手动移除监听 |

| .capture | 捕获模式 | addEventListener 第三参 true |

| .passive | 被动监听 | { passive: true } |

| .exact | 精确修饰键 | 判断按键组合 |

真实案例:模态框点击遮罩关闭

模态框(Modal)需要点击半透明遮罩层关闭,但点击对话框主体不能关闭。用 .self 修饰符可以优雅解决:只有点击遮罩本身(而非其子元素对话框)才触发关闭,无需在方法里判断 event.target。

vueCode
<template>
  <!-- 点击遮罩本身关闭,点击内部对话框不关闭 -->
  <div class="mask" @click.self="close">
    <div class="dialog">
      <h3>标题</h3>
      <p>对话框内容,点这里不会关闭</p>
      <button @click="close">关闭</button>
    </div>
  </div>
</template>

<script setup>
const emit = defineEmits(['close']);
function close() {
  emit('close');
}
</script>

常见坑:

.passive 和 .prevent 不能同用(passive 承诺不阻止默认,冲突)。
按键修饰符大小写敏感,如 .enter 对应 Enter 键;自定义按键需用 kebab-case(如 .page-down)。

最佳实践

优先使用 Composition API,把相关逻辑聚合、封装成可复用的 composable。
合理使用计算属性和监听器:派生数据用 computed,副作用用 watch。
组件拆分要合理,保持单一职责,避免"上帝组件"。
使用 TypeScript 提高代码质量,配合 defineProps 泛型获得类型推导。
合理使用 Vuex/Pinia 进行状态管理,Vue 3 优先选 Pinia。
避免直接修改 props,遵循单向数据流,通过 emit 通知父组件。
使用唯一稳定的 key 属性提高列表渲染性能,避免用 index。
合理使用 v-if 和 v-show:频繁切换用 v-show,很少切换用 v-if。
v-for 不与 v-if 同用,用 computed 预过滤。
及时清理定时器、事件监听、第三方实例,防止内存泄漏。
大列表考虑虚拟滚动,大对象无需响应式时用 markRaw。
需要在 DOM 更新后操作 DOM,用 nextTick。

🎨 Class 与 Style 绑定

模板中动态控制样式是最高频的需求之一。Vue 对 `class` 和 `style` 的绑定做了增强,支持对象语法和数组语法,比手动拼接字符串安全且直观。

1. Class 绑定的三种写法

vueCode
<script setup>
import { ref, computed } from 'vue';

const isActive = ref(true);
const hasError = ref(false);
const type = ref('primary');
</script>

<template>
  <!-- 对象语法:键是类名,值是布尔,为 true 才添加 -->
  <div :class="{ active: isActive, 'text-danger': hasError }">对象语法</div>

  <!-- 数组语法:数组元素是类名 -->
  <div :class="[isActive ? 'active' : '', 'base-class']">数组语法</div>

  <!-- 数组中嵌套对象(组合用法) -->
  <div :class="['base', { active: isActive, disabled: hasError }]">组合语法</div>

  <!-- 与静态 class 共存:最终会合并 -->
  <div class="static-class" :class="{ active: isActive }">合并 class</div>
</template>

推荐用 computed 抽离复杂逻辑: 当 class 判断条件较多时,写在模板里会很乱,抽到 computed 更清晰。

vueCode
<script setup>
import { ref, computed } from 'vue';

const status = ref('success');
const size = ref('large');

const classes = computed(() => ({
  'btn': true,
  [`btn-${status.value}`]: true,   // 动态类名
  [`btn-${size.value}`]: true,
  'btn-disabled': status.value === 'loading'
}));
</script>

<template>
  <button :class="classes">按钮</button>
</template>

2. Style 绑定

vueCode
<script setup>
import { ref } from 'vue';

const color = ref('red');
const fontSize = ref(16);
const styleObject = ref({ color: 'blue', fontWeight: 'bold' });
</script>

<template>
  <!-- 对象语法:CSS 属性用驼峰或短横线(需引号) -->
  <div :style="{ color: color, fontSize: fontSize + 'px' }">对象语法</div>

  <!-- 直接绑定样式对象 -->
  <div :style="styleObject">绑定对象</div>

  <!-- 数组语法:合并多个样式对象 -->
  <div :style="[styleObject, { textDecoration: 'underline' }]">数组语法</div>
</template>

自动前缀: Vue 会自动为需要浏览器前缀的 CSS 属性(如 transform)添加前缀,无需手动处理兼容性。

3. Class/Style 绑定对比

| 场景 | 推荐写法 | 说明 |

|------|----------|------|

| 单个条件类 | 对象语法 :class="{ a: cond }" | 最简洁 |

| 固定多个类 | 数组语法 | 可含静态和动态 |

| 复杂逻辑 | computed 返回对象 | 模板保持干净 |

| 动态类名 | 计算属性键 [`btn-${x}`] | 拼接类名 |

| 内联样式 | :style 对象语法 | 优先用 class,样式绑定用于动态计算值 |

🔀 条件渲染与列表渲染进阶

1. v-if / v-else-if / v-else 与 v-show 的本质区别

vueCode
<script setup>
import { ref } from 'vue';
const role = ref('admin');
const visible = ref(true);
</script>

<template>
  <!-- v-if 系列:条件为假时元素根本不渲染(不在 DOM 里) -->
  <div v-if="role === 'admin'">管理员面板</div>
  <div v-else-if="role === 'editor'">编辑面板</div>
  <div v-else>访客面板</div>

  <!-- v-show:始终渲染,只是切换 display:none -->
  <div v-show="visible">通过 display 控制显隐</div>
</template>

| 维度 | v-if | v-show |

|------|------|--------|

| 渲染方式 | 条件为假时不渲染 DOM | 始终渲染,切换 display |

| 初始开销 | 假时零开销 | 总要渲染 |

| 切换开销 | 高(销毁/重建组件) | 低(只改 CSS) |

| 支持 v-else | 支持 | 不支持 |

| 适用场景 | 很少切换、按权限渲染 | 频繁切换(tab、折叠) |

性能建议: 一个需要频繁切换(如每秒切换)的元素用 v-show,切换成本几乎为零;一个几乎不变、或初始可能不渲染的用 v-if,避免无谓的初始渲染。

2. v-for 的完整用法

vueCode
<script setup>
import { ref } from 'vue';

const list = ref([
  { id: 1, name: '苹果', price: 5 },
  { id: 2, name: '香蕉', price: 3 }
]);

const obj = ref({ name: '张三', age: 25, city: '北京' });
</script>

<template>
  <!-- 遍历数组,第二参数是索引 -->
  <li v-for="(item, index) in list" :key="item.id">
    {{ index + 1 }}. {{ item.name }} - ¥{{ item.price }}
  </li>

  <!-- 遍历对象:值、键、索引 -->
  <li v-for="(value, key, index) in obj" :key="key">
    {{ index }}. {{ key }}: {{ value }}
  </li>

  <!-- 遍历数字:1 到 5 -->
  <span v-for="n in 5" :key="n">{{ n }} </span>

  <!-- 用 template 包裹渲染多个元素而不产生额外 DOM -->
  <template v-for="item in list" :key="item.id">
    <dt>{{ item.name }}</dt>
    <dd>¥{{ item.price }}</dd>
  </template>
</template>

3. 为什么 key 必须唯一稳定

vueCode
<!-- 错误示范:用 index 做 key -->
<li v-for="(item, index) in list" :key="index">
  <input type="checkbox" /> {{ item.name }}
</li>

用 index 做 key,当列表发生插入、删除、排序时,index 会错位,导致 Vue 复用了错误的 DOM 节点。典型 bug:删除第一项后,勾选状态「串位」到了别的项。正确做法永远用数据的唯一 id。

4. v-for 与 v-if 不要同时用

vueCode
<!-- 错误:v-if 在 v-for 中,每次遍历都要判断,且 Vue3 中 v-if 优先级更高会报错 -->
<li v-for="item in list" v-if="item.active" :key="item.id">{{ item.name }}</li>
vueCode
<script setup>
import { computed, ref } from 'vue';
const list = ref([]);
// 正确:用 computed 预先过滤
const activeList = computed(() => list.value.filter((item) => item.active));
</script>

<template>
  <li v-for="item in activeList" :key="item.id">{{ item.name }}</li>
</template>

这样过滤逻辑只在依赖变化时计算一次(有缓存),而不是每次渲染每个元素都判断,性能更好,代码也更清晰。

🧩 动态组件与异步组件

1. 动态组件 component :is

用一个占位符根据条件切换渲染不同组件,常用于 tab 切换、动态表单、路由视图等。

vueCode
<script setup>
import { ref, shallowRef } from 'vue';
import TabHome from './TabHome.vue';
import TabProfile from './TabProfile.vue';
import TabSettings from './TabSettings.vue';

const tabs = {
  home: TabHome,
  profile: TabProfile,
  settings: TabSettings
};

// 用 shallowRef 存组件,避免不必要的深层响应式(组件对象无需响应式)
const currentTab = ref('home');
const currentComponent = shallowRef(TabHome);

function switchTab(name) {
  currentTab.value = name;
  currentComponent.value = tabs[name];
}
</script>

<template>
  <nav>
    <button v-for="(_, name) in tabs" :key="name" @click="switchTab(name)"
            :class="{ active: currentTab === name }">
      {{ name }}
    </button>
  </nav>

  <!-- :is 可以是组件对象,也可以是组件名字符串 -->
  <component :is="currentComponent" />
</template>

2. 用 KeepAlive 缓存组件状态

动态组件切换时,组件默认会被销毁重建,状态丢失。用 `` 缓存组件实例,切回时保留之前的状态(如已填的表单、滚动位置)。

vueCode
<template>
  <!-- include/exclude 控制哪些组件被缓存,max 限制最大缓存数 -->
  <KeepAlive :include="['TabHome', 'TabProfile']" :max="10">
    <component :is="currentComponent" />
  </KeepAlive>
</template>

被缓存的组件会触发 `onActivated` 和 `onDeactivated` 两个专属生命周期,可在此做数据刷新、暂停/恢复定时器等。

vueCode
<script setup>
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  console.log('组件被激活(从缓存中显示)');
  // 可在此刷新数据
});

onDeactivated(() => {
  console.log('组件被停用(进入缓存)');
  // 可在此暂停定时器
});
</script>

3. 异步组件 defineAsyncComponent

大型组件按需加载,减少首屏体积。配合 loading/error 状态提供更好体验。

javascriptCode
import { defineAsyncComponent } from 'vue';
import LoadingComponent from './Loading.vue';
import ErrorComponent from './Error.vue';

// 简单写法
const AsyncModal = defineAsyncComponent(() => import('./HeavyModal.vue'));

// 完整配置写法
const AsyncChart = defineAsyncComponent({
  loader: () => import('./Chart.vue'),
  loadingComponent: LoadingComponent,   // 加载中显示
  errorComponent: ErrorComponent,       // 加载失败显示
  delay: 200,                           // 延迟 200ms 才显示 loading(避免闪烁)
  timeout: 10000                        // 超过 10s 视为失败
});

4. Suspense 处理异步依赖

`` 是一个内置组件,用于协调异步组件树的加载状态,配合顶层 await 使用。

vueCode
<!-- 子组件 AsyncData.vue:setup 里直接 await -->
<script setup>
const data = await fetch('/api/data').then((r) => r.json());
</script>

<template>
  <div>{{ data.title }}</div>
</template>
vueCode
<!-- 父组件用 Suspense 包裹 -->
<template>
  <Suspense>
    <!-- 默认插槽:异步组件加载完成后显示 -->
    <template #default>
      <AsyncData />
    </template>
    <!-- fallback 插槽:加载中显示 -->
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

🌀 内置组件:Teleport 与 Transition

1. Teleport 传送门

把组件的一部分 DOM「传送」到指定位置(通常是 body),完美解决弹窗、通知、下拉菜单被父级 `overflow:hidden` 或 `z-index` 层叠上下文限制的问题。

vueCode
<script setup>
import { ref } from 'vue';
const showModal = ref(false);
</script>

<template>
  <button @click="showModal = true">打开弹窗</button>

  <!-- 无论这段代码写在多深的组件里,弹窗 DOM 都渲染到 body 下 -->
  <Teleport to="body">
    <div v-if="showModal" class="modal-overlay">
      <div class="modal">
        <h3>弹窗标题</h3>
        <p>Teleport 让弹窗脱离父级层叠上下文</p>
        <button @click="showModal = false">关闭</button>
      </div>
    </div>
  </Teleport>
</template>

2. Transition 单元素过渡

给元素的进入/离开添加动画。Vue 会在恰当时机自动添加/移除过渡 class。

vueCode
<script setup>
import { ref } from 'vue';
const show = ref(true);
</script>

<template>
  <button @click="show = !show">切换</button>

  <Transition name="fade">
    <p v-if="show">淡入淡出的内容</p>
  </Transition>
</template>

<style>
/* 进入的起点、离开的终点 */
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
/* 过渡过程 */
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}
</style>

Vue 会依次添加这些 class:`v-enter-from` → `v-enter-active` → `v-enter-to`(进入),`v-leave-from` → `v-leave-active` → `v-leave-to`(离开)。name 属性会替换前缀 `v-`。

3. TransitionGroup 列表过渡

给 v-for 列表的增删、排序添加动画。

vueCode
<script setup>
import { ref } from 'vue';
const items = ref([1, 2, 3, 4, 5]);

function shuffle() {
  items.value = items.value.sort(() => Math.random() - 0.5);
}
function remove(item) {
  items.value = items.value.filter((i) => i !== item);
}
</script>

<template>
  <button @click="shuffle">打乱</button>
  <!-- tag 指定渲染的容器标签 -->
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item" @click="remove(item)">
      {{ item }}
    </li>
  </TransitionGroup>
</template>

<style>
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
.list-enter-active,
.list-leave-active {
  transition: all 0.4s ease;
}
/* move 类让排序时其他元素平滑移动(FLIP 动画) */
.list-move {
  transition: transform 0.4s ease;
}
</style>

🔍 深入 watch 与 watchEffect

侦听器是处理「数据变化时执行副作用」的核心工具。理解它们的差异和配置项,能避免大量「监听不生效」「重复执行」「内存泄漏」的坑。

1. watch 的多种数据源

vueCode
<script setup>
import { ref, reactive, watch } from 'vue';

const count = ref(0);
const user = reactive({ name: '张三', address: { city: '北京' } });

// 侦听单个 ref
watch(count, (newVal, oldVal) => {
  console.log(`count: ${oldVal} → ${newVal}`);
});

// 侦听 getter 函数(侦听 reactive 的某个属性)
watch(
  () => user.name,
  (newName) => console.log('名字变了:', newName)
);

// 侦听多个源(数组),回调参数也是数组
watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
  console.log('count 或 name 变化了');
});

// 侦听整个 reactive 对象(自动深度侦听)
watch(user, (newUser) => {
  console.log('user 内部任何变化都触发');
});
</script>

2. watch 的配置项

vueCode
<script setup>
import { ref, watch } from 'vue';

const obj = ref({ nested: { value: 1 } });

watch(obj, (val) => {
  console.log('深度变化');
}, {
  deep: true,        // 深度侦听:内部嵌套属性变化也触发
  immediate: true,   // 立即执行:创建时先跑一次回调
  once: true,        // 只执行一次(Vue 3.4+)
  flush: 'post'      // 回调时机:'pre'(默认,DOM更新前) | 'post'(DOM更新后) | 'sync'(同步)
});
</script>

flush 时机说明: 默认 `pre` 在 DOM 更新前执行;需要在回调里访问更新后的 DOM 时用 `post`;`sync` 同步执行(谨慎使用,可能性能差)。

3. watchEffect 自动收集依赖

vueCode
<script setup>
import { ref, watchEffect } from 'vue';

const url = ref('/api/user/1');
const data = ref(null);

// 无需指定依赖,回调里用到的响应式数据自动被追踪
watchEffect(async () => {
  // url.value 被自动收集为依赖,url 变化就重新执行
  const res = await fetch(url.value);
  data.value = await res.json();
});
</script>

4. watch 与 watchEffect 对比

| 维度 | watch | watchEffect |

|------|-------|-------------|

| 依赖声明 | 显式指定源 | 自动收集回调内的依赖 |

| 初次执行 | 默认不执行(immediate 可开) | 立即执行一次 |

| 旧值 | 能拿到 oldValue | 拿不到旧值 |

| 适用场景 | 需要新旧对比、精确侦听 | 副作用依赖多个源、逻辑简单 |

| 惰性 | 惰性(源变才执行) | 非惰性 |

5. 清理副作用

侦听器里若有异步操作或副作用(定时器、订阅),要在下次执行前或组件卸载时清理,防止竞态和泄漏。

vueCode
<script setup>
import { ref, watchEffect } from 'vue';
const id = ref(1);

watchEffect((onCleanup) => {
  const controller = new AbortController();
  fetch(`/api/data/${id.value}`, { signal: controller.signal });

  // onCleanup 在下次执行前或停止侦听时调用
  onCleanup(() => {
    controller.abort(); // 取消上一次未完成的请求,避免竞态
  });
});
</script>

🖌️ 渲染函数与 JSX

模板最终会被编译成渲染函数(返回虚拟 DOM 的函数)。在需要高度动态、编程式生成结构时,可以直接写渲染函数或 JSX。

1. h 函数创建虚拟节点

javascriptCode
import { h } from 'vue';

// h(标签/组件, 属性对象, 子节点)
export default {
  setup() {
    return () => h('div', { class: 'container' }, [
      h('h1', { style: { color: 'red' } }, '标题'),
      h('p', null, '段落内容'),
      h('button', { onClick: () => alert('点击') }, '按钮')
    ]);
  }
};

2. 用渲染函数实现动态标题组件

模板难以优雅表达「根据 level 渲染 h1~h6」,渲染函数则很自然。

javascriptCode
import { h } from 'vue';

export default {
  props: { level: { type: Number, required: true } },
  setup(props, { slots }) {
    // 根据 level 动态决定标签名
    return () => h(
      `h${props.level}`,        // 'h1' ~ 'h6'
      {},
      slots.default?.()          // 渲染默认插槽内容
    );
  }
};

3. JSX 写法(需 @vitejs/plugin-vue-jsx)

jsxCode
import { ref } from 'vue';

export default {
  setup() {
    const count = ref(0);
    return () => (
      <div class="counter">
        <span>计数:{count.value}</span>
        <button onClick={() => count.value++}>加一</button>
      </div>
    );
  }
};

何时用渲染函数/JSX: 绝大多数场景模板更简洁、可优化性更好,应优先用模板。只有在结构高度动态(如根据配置递归渲染表单、动态标签)时,渲染函数/JSX 才更有优势。

📦 单文件组件(SFC)与编译原理

1. SFC 的三块结构

vueCode
<script setup>
// 逻辑:编译时会转换成标准的 setup 函数
import { ref } from 'vue';
const count = ref(0);
</script>

<template>
  <!-- 结构:编译成渲染函数 -->
  <button @click="count++">{{ count }}</button>
</template>

<style scoped>
/* 样式:scoped 让样式只作用于当前组件 */
button { color: blue; }
</style>

2. 模板编译的三个阶段

Vue 的模板编译器把 template 字符串转成渲染函数,经历三步:

1.解析(Parse):把模板字符串解析成 AST(抽象语法树),识别标签、指令、插值。
2.转换(Transform):对 AST 做优化标记,如静态提升、Patch Flag、事件缓存。
3.生成(Generate):把优化后的 AST 生成渲染函数代码字符串。
javascriptCode
// 模板 <div id="app">{{ msg }}</div> 大致编译为:
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from 'vue';

function render(_ctx) {
  return (_openBlock(), _createElementBlock('div', { id: 'app' },
    _toDisplayString(_ctx.msg),
    1 /* PatchFlag: TEXT,标记只有文本会变 */
  ));
}

3. 编译时优化:Vue 3 性能的关键

| 优化 | 说明 | 收益 |

|------|------|------|

| 静态提升 | 静态节点提到渲染函数外,只创建一次 | 减少重复创建 |

| Patch Flag | 标记动态部分类型(文本/class/props) | Diff 时只比对动态内容 |

| 事件缓存 | 内联事件处理器缓存,避免每次渲染新建函数 | 减少 GC |

| Block Tree | 收集动态节点到 block,跳过静态子树 | Diff 复杂度从 O(n) 降到 O(动态节点数) |

这些编译时优化让 Vue 3 相比 Vue 2 的渲染性能提升约 1.3~2 倍,初始渲染内存占用减少约 50%。

⚖️ 组合式 API 与选项式 API 全面对比

Vue 3 同时支持两种编写组件的风格,理解它们的差异有助于在团队中做出合适选择,也能读懂新老两种代码。

1. 同一组件的两种写法

vueCode
<!-- 选项式 API(Options API):按选项类型组织 -->
<script>
export default {
  data() {
    return { count: 0, message: 'Hello' };
  },
  computed: {
    double() {
      return this.count * 2;
    }
  },
  methods: {
    increment() {
      this.count++;
    }
  },
  mounted() {
    console.log('组件挂载');
  }
};
</script>
vueCode
<!-- 组合式 API(Composition API):按逻辑关注点组织 -->
<script setup>
import { ref, computed, onMounted } from 'vue';

const count = ref(0);
const message = ref('Hello');
const double = computed(() => count.value * 2);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log('组件挂载');
});
</script>

2. 两种 API 对比

| 维度 | 选项式 API | 组合式 API |

|------|-----------|-----------|

| 组织方式 | 按选项类型(data/methods/computed) | 按逻辑关注点聚合 |

| 逻辑复用 | mixins(易命名冲突、来源不清) | composables(清晰、无冲突) |

| TypeScript | 支持一般 | 支持优秀 |

| this 使用 | 依赖 this | 无 this,直接访问变量 |

| 代码量 | 略多样板 | 更精简 |

| 大组件可维护性 | 相关逻辑分散在不同选项 | 相关逻辑集中 |

| 学习曲线 | 平缓 | 需理解 ref/reactive |

| 适用 | 简单组件、初学者 | 复杂逻辑、大型项目 |

3. 逻辑复用:mixins vs composables

javascriptCode
// 选项式的 mixin 方案(不推荐,有隐患)
const mouseMixin = {
  data() {
    return { x: 0, y: 0 };
  },
  mounted() {
    window.addEventListener('mousemove', this.update);
  },
  methods: {
    update(e) {
      this.x = e.pageX;
      this.y = e.pageY;
    }
  }
};
// 问题:多个 mixin 的 data/methods 可能命名冲突,且组件里看不出 x/y 来自哪个 mixin
javascriptCode
// 组合式的 composable 方案(推荐)
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  function update(e) {
    x.value = e.pageX;
    y.value = e.pageY;
  }

  onMounted(() => window.addEventListener('mousemove', update));
  onUnmounted(() => window.removeEventListener('mousemove', update));

  return { x, y };
}

// 使用:来源清晰,可重命名避免冲突
// const { x, y } = useMouse();
// const { x: mouseX } = useMouse(); // 想重命名随意

结论: Vue 官方推荐新项目用组合式 API + `