Vue 核心原理与响应式系统
Vue 核心原理与响应式系统
Vue.js 是一个渐进式 JavaScript 框架,其核心特性是响应式系统,理解其原理对于掌握 Vue 至关重要。Vue的响应式系统是其最核心的特性之一,它使得数据变化能够自动反映到视图上,极大地简化了前端开发。Vue 2和Vue 3使用了不同的响应式实现方式,各有优劣。
本文将从"是什么、为什么、怎么做"三个维度,系统地剖析 Vue 的响应式原理、数据绑定、组件系统、插槽、生命周期、虚拟 DOM 与 Diff 算法、nextTick 异步更新、v-model 双向绑定原理、事件修饰符等核心知识点。每个知识点都会遵循"概念 → 为什么重要 → 原理 → 代码示例 → 真实案例 → 数据对比 → 常见坑 → 最佳实践"的结构展开,帮助你在面试和实战中都能游刃有余。
📖 全文导读
💚 响应式系统原理
响应式(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方法。
// 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),在这些方法执行后手动触发派发更新。
// 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,提供了更灵活的响应式编程方式。
// 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 指向正确的代理对象,从而让依赖收集不会遗漏。这是一个容易被忽视但非常重要的细节。
// 为什么需要 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,通知所有依赖该属性的副作用函数重新执行,从而更新视图。这个过程是自动的,开发者无需手动管理依赖关系。
// 依赖收集流程示例
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 不会被重复收集。
// 依赖关系的完整数据结构
// 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 中则完全没有这个问题。
// 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 响应式实现
🔄 Vue 2 响应式实现
// 简化的 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 响应式实现
// 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 与清理的完整版
// 更完整的 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。
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// 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 引用)。
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)每次调用都执行不同,是性能优化的重要手段。
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); // Smithcomputed 与 method 的性能对比:
假设一个列表页有 1000 条数据,需要对其进行排序后展示。如果用 method,每次模板重新渲染(哪怕是无关数据变化)都会重新执行排序;用 computed 则只在原始列表变化时排一次。实测在一个含 10000 项、每秒触发 60 次无关更新的场景中,computed 相比 method 可减少约 99% 的排序计算次数。
// 反例:用 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)。
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 正是用来注册"清理逻辑"的。
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 会立即执行一次传入的函数,并自动追踪函数内用到的所有响应式依赖,当任一依赖变化时重新执行。它不需要显式指定侦听源,也拿不到旧值,适合"副作用逻辑本身就把依赖用全了"的场景。
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 | 天然立即执行 |
常见坑:
最佳实践:
🧭 指令系统
指令(Directive)是带有 v- 前缀的特殊 HTML 属性,作用是在表达式的值变化时,响应式地作用于 DOM。指令是 Vue 声明式渲染的重要组成部分,把"数据到 DOM"的映射用简洁的模板语法表达出来。
内置指令
<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。
<!-- ❌ 反例: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 元素进行直接、可复用的操作(如自动聚焦、权限控制、懒加载图片、点击外部关闭)时,自定义指令是首选。指令提供了一组生命周期钩子,可以精确介入元素的挂载、更新、卸载。
// 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();
}
});<!-- 使用自定义指令 -->
<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>// 真实案例:权限指令,控制按钮是否显示
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__;
}
});最佳实践:
数据绑定
单向绑定:
双向绑定:
计算属性:
监听器:
代码示例
数据绑定示例
<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 的绑定支持对象语法和数组语法,是动态样式控制的核心。对象语法根据布尔值决定类名是否出现,数组语法用于同时应用多个类。
<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>计算属性和监听器示例
<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 事件。
<!-- 这两种写法等价 -->
<input v-model="text" />
<input :value="text" @input="text = $event.target.value" />在组件上(Vue 3):
Vue 3 中组件 v-model 默认使用 modelValue 属性和 update:modelValue 事件,并且支持多个 v-model 绑定(通过参数区分)。
<!-- 父组件使用 -->
<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><!-- 子组件 CustomInput.vue:实现双向绑定 -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script><!-- 子组件 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。
<!-- 使用 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 去除首尾空格。自定义组件也可以接收自定义修饰符。
<!-- 自定义组件接收 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 | 父到子 | 父传数据给子 |
| emit | 子到父 | 子通知父/回传数据 |
| provide/inject | 祖到孙 | 跨多层级传递 |
| ref | 父访问子 | 父调用子的方法 |
| attrs | 父到孙 | 属性透传 |
| event bus | 任意 | 简单跨组件(不推荐大型项目) |
| Pinia/Vuex | 任意 | 全局共享状态 |
代码示例
组件注册示例
// 全局注册
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 声明应包含类型、是否必填、默认值和自定义校验器,这能显著提升组件的健壮性和可维护性。
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。
组件通信示例
<!-- 父组件 -->
<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 等编译器宏,更加简洁直观。
<!-- 子组件: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><!-- 父组件:通过 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>// 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,任意深度的后代都能直接注入使用,代码清爽且解耦。
常见坑:
插槽
插槽概念:
插槽(Slot)是一种内容分发机制,允许父组件向子组件的指定位置"插入"模板内容。可以把它类比成"预留的坑位"——子组件搭好框架并挖好坑,父组件决定坑里填什么。这让组件既能封装结构,又能保持内容的灵活性,是构建高复用布局组件(如卡片、弹窗、表格)的核心手段。
为什么重要:
没有插槽,一个"卡片"组件只能通过大量 props 传字符串,无法插入复杂的 DOM 结构和其他组件。插槽让组件的"内容"与"结构"解耦,是实现通用 UI 组件库(如 Element Plus、Ant Design Vue)的基础能力。
插槽分类:
代码示例
<!-- 基础插槽 -->
<!-- 父组件 -->
<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>插槽默认内容与动态插槽名
<!-- 子组件:插槽提供默认内容(父组件不填时展示) -->
<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 传回给父组件,父组件自由定制单元格内容。
<!-- 子组件 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><!-- 父组件:定制"状态"列和"操作"列 -->
<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>插槽三种类型对比:
| 插槽类型 | 是否具名 | 能否传数据回父 | 典型场景 |
| --- | --- | --- | --- |
| 匿名插槽 | 否 | 否 | 单一内容坑位 |
| 命名插槽 | 是 | 否 | 布局组件多区域 |
| 作用域插槽 | 可具名 | 是 | 列表/表格自定义渲染 |
常见坑:
最佳实践:
生命周期
生命周期概念:
生命周期(Lifecycle)指的是一个组件从创建、挂载、更新到销毁的完整过程。Vue 在这些关键节点提供了"钩子函数",让开发者可以在特定时机执行自己的逻辑,比如在挂载后请求数据、在销毁前清理定时器。理解生命周期是避免内存泄漏、正确操作 DOM 的前提。
为什么重要:
如果在错误的时机操作 DOM(比如在 created 里访问尚未渲染的元素),会得到 null;如果忘记在销毁时清理定时器和事件监听,会造成内存泄漏和"幽灵回调"。生命周期钩子是这些问题的正确解决位置。
Vue 2 生命周期:
Vue 3 生命周期:
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 生命周期示例
<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 生命周期示例
<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 事件重绘、在数据变化时更新图表、在卸载时销毁实例并移除监听。任何一步遗漏都可能导致内存泄漏或"页面切走后图表还在后台重绘"的性能问题。
<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>常见坑:
keep-alive 缓存组件的特殊生命周期:
<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、原生渲染)。
// 虚拟 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 移动次数。
// 简化的 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 会采用"就地复用"策略——当列表顺序变化或中间插入删除时,会导致状态错乱(比如输入框的内容跑到了别的行)。
<!-- ❌ 反例:用 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 快的重要原因:
| 优化项 | Vue 2 | Vue 3 |
| --- | --- | --- |
| 静态节点 | 每次重新创建 | 静态提升,只创建一次 |
| Diff 粒度 | 全量对比 | Patch Flag 精准对比 |
| 遍历方式 | 递归整棵树 | Block Tree 跳过静态 |
| 事件处理函数 | 每次重建 | 缓存复用 |
| 初次渲染 | 基准 | 更快约 1.3~2 倍 |
| 更新性能 | 基准 | 更快约 1.3~2 倍 |
| 内存占用 | 基准 | 减少约 50% |
常见坑:
⏱️ nextTick 与异步更新队列
Vue 的 DOM 更新是异步的。当你连续修改多次响应式数据时,Vue 不会每次都同步更新 DOM,而是把这些变更放入一个队列,在同一个"tick"内去重后,于下一个微任务里统一更新一次。nextTick 就是用来在"DOM 完成更新之后"执行回调的工具。
为什么重要:
如果你改了数据后立刻去读取 DOM,会读到"更新前"的旧 DOM,因为更新还没发生。理解异步更新机制,才能知道"为什么改了数据 DOM 还没变",并用 nextTick 在正确的时机操作 DOM。同时,异步批量更新把 N 次数据变化合并为 1 次渲染,是 Vue 性能优化的关键设计。
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)在当前同步代码执行完后清空队列、批量刷新。
// 简化的异步更新队列实现
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 才是正确的。
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;
}常见坑:
🎯 事件处理与修饰符
Vue 用 v-on(简写 @)监听 DOM 事件。事件修饰符是 Vue 提供的一组以点开头的后缀,用来处理常见的事件细节(阻止默认行为、阻止冒泡、只触发一次等),让模板更简洁,也避免在方法里写样板代码。
为什么重要:
处理表单提交、按键快捷键、阻止事件冒泡这些需求非常普遍。修饰符把这些细节声明式地写在模板上,既减少了方法里的 event.preventDefault() 之类的样板代码,又让意图一目了然,还能避免忘记调用导致的 bug。
<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。
<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>常见坑:
最佳实践
🎨 Class 与 Style 绑定
模板中动态控制样式是最高频的需求之一。Vue 对 `class` 和 `style` 的绑定做了增强,支持对象语法和数组语法,比手动拼接字符串安全且直观。
1. Class 绑定的三种写法
<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 更清晰。
<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 绑定
<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 的本质区别
<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 的完整用法
<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 必须唯一稳定
<!-- 错误示范:用 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 不要同时用
<!-- 错误:v-if 在 v-for 中,每次遍历都要判断,且 Vue3 中 v-if 优先级更高会报错 -->
<li v-for="item in list" v-if="item.active" :key="item.id">{{ item.name }}</li><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 切换、动态表单、路由视图等。
<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 缓存组件状态
动态组件切换时,组件默认会被销毁重建,状态丢失。用 `
<template>
<!-- include/exclude 控制哪些组件被缓存,max 限制最大缓存数 -->
<KeepAlive :include="['TabHome', 'TabProfile']" :max="10">
<component :is="currentComponent" />
</KeepAlive>
</template>被缓存的组件会触发 `onActivated` 和 `onDeactivated` 两个专属生命周期,可在此做数据刷新、暂停/恢复定时器等。
<script setup>
import { onActivated, onDeactivated } from 'vue';
onActivated(() => {
console.log('组件被激活(从缓存中显示)');
// 可在此刷新数据
});
onDeactivated(() => {
console.log('组件被停用(进入缓存)');
// 可在此暂停定时器
});
</script>3. 异步组件 defineAsyncComponent
大型组件按需加载,减少首屏体积。配合 loading/error 状态提供更好体验。
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 处理异步依赖
`
<!-- 子组件 AsyncData.vue:setup 里直接 await -->
<script setup>
const data = await fetch('/api/data').then((r) => r.json());
</script>
<template>
<div>{{ data.title }}</div>
</template><!-- 父组件用 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` 层叠上下文限制的问题。
<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。
<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 列表的增删、排序添加动画。
<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 的多种数据源
<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 的配置项
<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 自动收集依赖
<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. 清理副作用
侦听器里若有异步操作或副作用(定时器、订阅),要在下次执行前或组件卸载时清理,防止竞态和泄漏。
<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 函数创建虚拟节点
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」,渲染函数则很自然。
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)
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 的三块结构
<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 字符串转成渲染函数,经历三步:
// 模板 <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. 同一组件的两种写法
<!-- 选项式 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><!-- 组合式 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
// 选项式的 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// 组合式的 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 + `