Vue 高级特性与核心原理
Vue 高级特性与核心原理
Vue.js 作为现代前端框架,拥有许多强大的高级特性。深入理解这些特性对于掌握 Vue 至关重要,也是面试中的高频考点。本文将从虚拟 DOM、响应式系统、编译优化到内置组件、自定义指令、渲染函数、插件开发等多个维度,系统地拆解 Vue 的底层实现与工程实践。
阅读本文前,建议你已经熟悉 Vue 的基础用法(模板语法、组件、生命周期、Vue Router、Vuex/Pinia)。本文的目标不是重复基础,而是回答"为什么"和"怎么实现"这两个层面的问题。我们会尽量做到:先讲概念,再讲为什么重要,接着深入原理,然后给出可运行的代码示例,配合真实案例、性能数据与对比表格,最后总结常见坑与最佳实践。
目录概览
---
一、虚拟 DOM 与 Diff 算法
1.1 虚拟 DOM 概念
虚拟 DOM(Virtual DOM)是 Vue 的核心机制之一,它是一个用 JavaScript 对象来描述真实 DOM 树结构的抽象层。你可以把它类比为"施工图纸":真实 DOM 是盖好的房子,改动房子的成本很高(浏览器的重排重绘代价昂贵),而改图纸(JavaScript 对象)几乎没有成本。当数据变化时,Vue 不会直接推倒重建房子,而是先在内存里画出新的图纸,与旧图纸做对比(Diff),算出"只需要改哪几面墙",最后把这批最小改动一次性施工到真实 DOM 上。
为什么重要:
直接操作 DOM 有两个问题。第一,DOM API 本身较慢,尤其是触发重排(reflow)的操作,如读取 offsetWidth、修改布局相关样式等。第二,手动操作 DOM 时开发者很难保证"最小更新",容易出现整块重渲染。虚拟 DOM 把"计算差异"这件事交给框架,用少量的 JavaScript 计算换取大量 DOM 操作的节省,同时带来了跨平台能力(同一份虚拟 DOM 可以渲染到浏览器、SSR 字符串、原生移动端如 Weex/NativeScript)。
虚拟 DOM 的优势总结:
需要澄清一个常见误区:虚拟 DOM 并不总是比手写命令式 DOM 操作快。它的价值在于"在可维护性可接受的前提下,提供一个足够快的更新下限"。一个经过精心手写优化的原生 DOM 更新可能比虚拟 DOM 更快,但代价是代码难以维护、容易出 bug。
// 虚拟 DOM 节点结构(VNode)
const vnode = {
tag: 'div',
props: {
id: 'app',
class: 'container'
},
children: [
{
tag: 'h1',
props: {},
children: [{ text: 'Hello Vue' }]
},
{
tag: 'p',
props: { class: 'description' },
children: [{ text: 'Virtual DOM Example' }]
}
]
};
// 渲染函数:把 VNode 转成真实 DOM
function render(vnode) {
// 文本节点
if (vnode.text !== undefined) {
return document.createTextNode(vnode.text);
}
const el = document.createElement(vnode.tag);
// 设置属性
if (vnode.props) {
Object.keys(vnode.props).forEach(key => {
el.setAttribute(key, vnode.props[key]);
});
}
// 处理子节点
if (vnode.children) {
vnode.children.forEach(child => {
if (child.text !== undefined) {
el.appendChild(document.createTextNode(child.text));
} else {
el.appendChild(render(child));
}
});
}
return el;
}1.2 真实 VNode 的字段远比示例复杂
Vue 3 内部的 VNode 结构包含大量字段,用于支撑优化。理解这些字段有助于读懂源码:
// Vue 3 VNode 关键字段(简化)
const realVNode = {
type: 'div', // 元素类型 / 组件对象 / Fragment / Text / Comment
props: { id: 'app' }, // 属性、事件、指令等
key: 'a', // Diff 时的唯一标识
ref: null, // 模板 ref
children: [], // 子节点
shapeFlag: 9, // 位运算标记:元素/组件/文本子节点/数组子节点等
patchFlag: 1, // 编译期生成的动态标记,运行时按需 patch
dynamicProps: ['id'], // 动态属性名列表
dynamicChildren: [], // 收集的动态子节点(Block Tree 的核心)
el: null, // 对应的真实 DOM 元素
component: null, // 组件实例
appContext: null // 应用上下文
};其中 shapeFlag 和 patchFlag 都用位运算存储多种状态,这是 Vue 3 性能优化的关键设计——用一个整数的不同二进制位表达多个布尔标记,判断时用按位与,极快且省内存。
// shapeFlag 位运算枚举(源码简化)
const ShapeFlags = {
ELEMENT: 1, // 00000001 普通元素
FUNCTIONAL_COMPONENT: 1 << 1, // 00000010 函数式组件
STATEFUL_COMPONENT: 1 << 2, // 00000100 有状态组件
TEXT_CHILDREN: 1 << 3, // 00001000 文本子节点
ARRAY_CHILDREN: 1 << 4, // 00010000 数组子节点
SLOTS_CHILDREN: 1 << 5, // 00100000 插槽子节点
TELEPORT: 1 << 6,
SUSPENSE: 1 << 7,
COMPONENT_KEPT_ALIVE: 1 << 8,
COMPONENT_SHOULD_KEEP_ALIVE: 1 << 9
};
// 判断一个 vnode 是否是有状态组件
function isStatefulComponent(vnode) {
return (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) > 0;
}
// 组合标记:既是元素又有数组子节点
const flag = ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN; // 000100011.3 Diff 算法原理
Vue 的 Diff 算法采用了同层比较策略,时间复杂度为 O(n)。传统的树 Diff 算法复杂度是 O(n^3),对于前端场景不可接受。Vue 做了两个"启发式"假设来降低复杂度:第一,只比较同一层级的节点(跨层级的移动被当作删除+新增处理),第二,通过 key 来追踪节点身份,尽可能复用现有 DOM 节点。
Diff 算法的核心步骤:
// 简化的 Diff 算法
function diff(oldVnode, newVnode) {
// 如果节点类型不同,直接替换
if (oldVnode.tag !== newVnode.tag) {
return { type: 'REPLACE', node: render(newVnode) };
}
const patches = [];
// 比较属性
const propsPatches = diffProps(oldVnode.props, newVnode.props);
if (propsPatches.length > 0) {
patches.push({ type: 'PROPS', patches: propsPatches });
}
// 比较子节点
const childrenPatches = diffChildren(oldVnode.children, newVnode.children);
if (childrenPatches.length > 0) {
patches.push({ type: 'CHILDREN', patches: childrenPatches });
}
return patches;
}
// 属性比较
function diffProps(oldProps, newProps) {
const patches = [];
const allProps = { ...oldProps, ...newProps };
Object.keys(allProps).forEach(key => {
if (oldProps[key] !== newProps[key]) {
patches.push({
key,
oldValue: oldProps[key],
newValue: newProps[key]
});
}
});
return patches;
}
// 子节点比较(简化版)
function diffChildren(oldChildren, newChildren) {
const patches = [];
const maxLength = Math.max(oldChildren.length, newChildren.length);
for (let i = 0; i < maxLength; i++) {
const oldChild = oldChildren[i];
const newChild = newChildren[i];
if (!oldChild) {
patches.push({ type: 'ADD', index: i, node: newChild });
} else if (!newChild) {
patches.push({ type: 'REMOVE', index: i });
} else {
const childPatch = diff(oldChild, newChild);
if (childPatch) {
patches.push({ type: 'UPDATE', index: i, patch: childPatch });
}
}
}
return patches;
}1.4 Vue 2 的双端 Diff 算法
Vue 2 的核心 Diff 逻辑在 updateChildren 函数中,使用四个指针进行双端比较。它每一轮会尝试四种匹配,命中任意一种就移动对应指针,避免了大量的节点移动。
// Vue 2 双端 Diff 核心逻辑(简化还原)
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (sameVnode(oldStartVnode, newStartVnode)) {
// 1. 头头比较
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 2. 尾尾比较
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 3. 头尾比较:旧头移动到旧尾之后
patchVnode(oldStartVnode, newEndVnode);
parentElm.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 4. 尾头比较:旧尾移动到旧头之前
patchVnode(oldEndVnode, newStartVnode);
parentElm.insertBefore(oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 5. 四种都没命中:用 key 建立映射查找
const key = newStartVnode.key;
const idxInOld = findIdxInOld(oldCh, key, oldStartIdx, oldEndIdx);
if (idxInOld === undefined) {
// 全新节点,创建并插入
parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.el);
} else {
const vnodeToMove = oldCh[idxInOld];
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined;
parentElm.insertBefore(vnodeToMove.el, oldStartVnode.el);
}
newStartVnode = newCh[++newStartIdx];
}
}
// 处理剩余节点
if (oldStartIdx > oldEndIdx) {
// 新增剩余的新节点
for (let i = newStartIdx; i <= newEndIdx; i++) {
parentElm.appendChild(createElm(newCh[i]));
}
} else if (newStartIdx > newEndIdx) {
// 删除剩余的旧节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) parentElm.removeChild(oldCh[i].el);
}
}
}
// 判断是否是同一个节点:key 与 tag 都相同
function sameVnode(a, b) {
return a.key === b.key && a.tag === b.tag;
}1.5 Vue 3 的快速 Diff 与最长递增子序列(LIS)
Vue 3 的 Diff 借鉴了 ivi 和 inferno 的思路,称为"快速 Diff"。它先做头部预处理和尾部预处理(把两端相同的节点快速 patch 掉),然后对中间乱序的部分建立"新节点在旧节点中的索引映射",最后通过求解最长递增子序列,找出"不需要移动"的节点集合,从而把 DOM 移动次数降到最低。
为什么用 LIS:
在中间乱序区,我们已经知道每个新节点应该复用哪个旧 DOM。剩下的问题是"如何用最少的移动,把这些 DOM 排成新顺序"。如果一批节点在旧序列中的相对顺序已经是递增的,它们就不需要移动。最长递增子序列正是"最多有多少个节点可以保持不动",其余节点才需要移动,这样移动次数最小。
// 求最长递增子序列的下标(Vue 3 源码同款算法:贪心 + 二分 + 回溯)
function getSequence(arr) {
const p = arr.slice(); // 记录前驱下标,用于回溯
const result = [0]; // 存储 LIS 的下标
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
// 当前值比结果序列最后一个大,直接追加
p[i] = j;
result.push(i);
continue;
}
// 二分查找:找到第一个大于等于 arrI 的位置替换
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
// 回溯得到正确的下标序列
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
// 示例
console.log(getSequence([2, 3, 1, 5, 6, 8, 7, 9, 4]));
// 输出的是最长递增子序列对应的下标下面是 Vue 3 patchKeyedChildren 中处理乱序区的核心思路(简化):
// Vue 3 处理乱序中间区(简化还原)
function patchUnkeyedMiddle(oldCh, newCh, s1, s2, e1, e2, container) {
// 1. 为新节点建立 key -> index 映射
const keyToNewIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
keyToNewIndexMap.set(newCh[i].key, i);
}
const toBePatched = e2 - s2 + 1;
// newIndexToOldIndexMap[i] = 0 表示新节点没有对应旧节点(需新建)
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
let moved = false;
let maxNewIndexSoFar = 0;
// 2. 遍历旧节点,找到它在新序列中的位置
for (let i = s1; i <= e1; i++) {
const oldVnode = oldCh[i];
const newIndex = keyToNewIndexMap.get(oldVnode.key);
if (newIndex === undefined) {
// 旧节点在新序列中不存在,卸载
unmount(oldVnode);
} else {
// 记录映射,+1 是为了区分"未匹配(0)"和"下标为0"
newIndexToOldIndexMap[newIndex - s2] = i + 1;
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex;
} else {
// 出现逆序,说明需要移动
moved = true;
}
patch(oldVnode, newCh[newIndex], container);
}
}
// 3. 求 LIS,得到不需要移动的节点下标集合
const increasingSeq = moved ? getSequence(newIndexToOldIndexMap) : [];
let j = increasingSeq.length - 1;
// 4. 从后往前遍历,插入或移动节点
for (let i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = newCh[nextIndex];
const anchor = nextIndex + 1 < newCh.length ? newCh[nextIndex + 1].el : null;
if (newIndexToOldIndexMap[i] === 0) {
// 全新节点
patch(null, nextChild, container, anchor);
} else if (moved) {
if (j < 0 || i !== increasingSeq[j]) {
// 不在 LIS 中,需要移动
container.insertBefore(nextChild.el, anchor);
} else {
// 在 LIS 中,保持不动
j--;
}
}
}
}1.6 Vue 3 的编译时优化
Vue 3 引入了编译时优化,在编译阶段就标记出静态节点和动态节点,运行时可以跳过静态节点的比较。这是 Vue 3 相比 Vue 2 性能提升的最大来源之一。主要优化包括:
// Vue 2 编译结果:所有节点每次都参与创建与比较
function render() {
return h('div', { id: 'app' }, [
h('h1', {}, this.title), // 动态内容
h('p', { class: 'static' }, 'Static text') // 静态内容也要比较
]);
}
// Vue 3 编译结果(带静态提升 + PatchFlag)
const _hoisted_1 = h('p', { class: 'static' }, 'Static text'); // 提升到外部,只创建一次
function render() {
return h('div', { id: 'app' }, [
// 1 是 PatchFlag.TEXT,表示只有文本是动态的
h('h1', {}, this.title, 1 /* TEXT */),
_hoisted_1 // 静态节点直接复用,不参与 Diff
]);
}PatchFlags 是一组位标记,编译器根据模板分析生成,运行时据此只更新变化的部分:
// PatchFlags 枚举(源码简化)
const PatchFlags = {
TEXT: 1, // 动态文本内容
CLASS: 1 << 1, // 动态 class
STYLE: 1 << 2, // 动态 style
PROPS: 1 << 3, // 动态属性(非 class/style)
FULL_PROPS: 1 << 4, // 有动态 key,需要全量 diff props
HYDRATE_EVENTS: 1 << 5,
STABLE_FRAGMENT: 1 << 6,
KEYED_FRAGMENT: 1 << 7,
UNKEYED_FRAGMENT: 1 << 8,
NEED_PATCH: 1 << 9,
DYNAMIC_SLOTS: 1 << 10,
HOISTED: -1, // 静态提升节点
BAIL: -2 // 退出优化模式
};
// 运行时按 patchFlag 精准更新
function patchElement(oldVnode, newVnode) {
const el = (newVnode.el = oldVnode.el);
const flag = newVnode.patchFlag;
if (flag & PatchFlags.TEXT) {
if (oldVnode.children !== newVnode.children) {
el.textContent = newVnode.children;
}
}
if (flag & PatchFlags.CLASS) {
if (oldVnode.props.class !== newVnode.props.class) {
el.className = newVnode.props.class;
}
}
if (flag & PatchFlags.STYLE) {
patchStyle(el, oldVnode.props.style, newVnode.props.style);
}
// ... 其余按位判断
}Block Tree 是"树结构拍平"的载体。带有动态内容的节点会作为一个 Block,它的 dynamicChildren 收集了子树中所有动态节点。Diff 时不再递归遍历整棵树,而是直接遍历这个扁平数组:
// Block 的概念:openBlock 开启收集,createBlock 结束收集
function render() {
return (openBlock(), createBlock('div', null, [
createVNode('span', null, '静态'),
createVNode('span', null, msg, 1 /* TEXT */) // 会被收集进 dynamicChildren
]));
}
// Diff 时只对比 dynamicChildren,静态的 span 完全跳过1.7 真实案例:为什么不能用 index 作为 key
一个高频的线上 bug:用数组下标 index 作为 v-for 的 key,在列表中间插入或删除元素、或列表内含有表单输入时,会导致状态错乱。
<!-- 错误示范:用 index 作为 key -->
<template>
<ul>
<li v-for="(item, index) in list" :key="index">
<input v-model="item.value" />
{{ item.name }}
</li>
</ul>
</template>假设列表有 3 项,用户在第一个 input 里输入了内容,然后在头部插入一个新项。由于 key 是 index,Vue 认为"第 0 个节点还是第 0 个节点,只是内容变了",于是复用了原来第 0 个 DOM(包括其中 input 的 DOM 状态),只更新文本。结果就是用户输入的内容"串位"到了新插入的项上。正确做法是使用稳定唯一的业务 id:
<template>
<ul>
<li v-for="item in list" :key="item.id">
<input v-model="item.value" />
{{ item.name }}
</li>
</ul>
</template>---
二、响应式系统深度解析
2.1 概念:什么是响应式
响应式(Reactivity)是指"当数据变化时,依赖这些数据的地方(视图、计算属性、侦听器)自动更新"。类比 Excel 表格:单元格 C1 写公式 =A1+B1,当你改动 A1,C1 会自动重算。响应式系统要解决两个核心问题:一是"谁依赖了这个数据"(依赖收集,track),二是"数据变了要通知谁"(派发更新,trigger)。
2.2 Vue 2 响应式局限性
Vue 2 使用 Object.defineProperty 劫持每个属性的 getter/setter 来实现响应式,存在以下局限性:
// Vue 2 响应式原理(简化)
function defineReactive(obj, key, val) {
const dep = new Dep(); // 每个属性一个依赖收集器
observe(val); // 递归处理嵌套对象
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend(); // 依赖收集
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
observe(newVal); // 新值也要变成响应式
dep.notify(); // 派发更新
}
});
}
function observe(obj) {
if (typeof obj !== 'object' || obj === null) return;
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key]);
});
}// Vue 2 响应式问题示例
const vm = new Vue({
data: {
obj: { a: 1 },
arr: [1, 2, 3]
}
});
// 以下操作不会触发视图更新
vm.obj.b = 2; // 新增属性
delete vm.obj.a; // 删除属性
vm.arr[0] = 100; // 修改数组索引
vm.arr.length = 2; // 修改数组长度
// 解决方案
Vue.set(vm.obj, 'b', 2); // 添加响应式属性
Vue.delete(vm.obj, 'a'); // 删除响应式属性
vm.arr.splice(0, 1, 100); // 通过重写的数组方法响应式修改Vue 2 对数组的处理是"重写数组的 7 个变更方法"(push、pop、shift、unshift、splice、sort、reverse),拦截这些方法调用来触发更新,这也是为什么直接改索引不生效、但用 splice 生效的原因。
// Vue 2 数组方法拦截(简化)
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
methodsToPatch.forEach(method => {
const original = arrayProto[method];
Object.defineProperty(arrayMethods, method, {
value: function (...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
// 对新增元素做响应式处理
let inserted;
if (method === 'push' || method === 'unshift') inserted = args;
else if (method === 'splice') inserted = args.slice(2);
if (inserted) ob.observeArray(inserted);
ob.dep.notify(); // 触发更新
return result;
}
});
});2.3 Vue 3 Proxy 的优势
Vue 3 使用 Proxy 实现响应式,解决了 Vue 2 的所有问题:
// Vue 3 Proxy 响应式(简化)
function createReactive(target, isReadonly = false) {
return new Proxy(target, {
get(target, key, receiver) {
// 依赖收集
if (!isReadonly) {
track(target, key);
}
const result = Reflect.get(target, key, receiver);
// 懒代理:只有访问时才代理嵌套对象
if (typeof result === 'object' && result !== null) {
return createReactive(result, isReadonly);
}
return result;
},
set(target, key, value, receiver) {
if (isReadonly) {
console.warn(\`Cannot set \${String(key)} on readonly object\`);
return true;
}
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
// 派发更新(只有真正变化才触发)
if (oldValue !== value) {
trigger(target, key);
}
return result;
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key);
const result = Reflect.deleteProperty(target, key);
if (hadKey && result) {
trigger(target, key);
}
return result;
},
has(target, key) {
track(target, key);
return Reflect.has(target, key);
},
ownKeys(target) {
track(target, 'iterate');
return Reflect.ownKeys(target);
}
});
}
// 使用示例
const state = createReactive({
obj: { a: 1 },
arr: [1, 2, 3]
});
// 以下操作都会触发视图更新
state.obj.b = 2; // 新增属性
delete state.obj.a; // 删除属性
state.arr[0] = 100; // 修改数组索引
state.arr.length = 2; // 修改数组长度2.4 effect 与依赖收集的完整实现
Vue 3 响应式的核心是 effect(副作用函数)。渲染、计算属性、侦听器本质上都是 effect。effect 运行时会读取响应式数据,触发 getter,从而把自己作为依赖被收集;数据变化时触发 setter,通知所有依赖它的 effect 重新运行。
// effect 与 track/trigger 的完整实现(教学版)
let activeEffect = null;
const effectStack = [];
// 依赖存储结构:WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap();
function effect(fn, options = {}) {
const effectFn = () => {
try {
activeEffect = effectFn;
effectStack.push(effectFn);
cleanup(effectFn); // 每次运行前清理旧依赖,避免分支切换残留
return fn();
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1] || null;
}
};
effectFn.deps = []; // 记录该 effect 被哪些依赖集合收集
effectFn.options = options; // scheduler 等配置
if (!options.lazy) {
effectFn();
}
return effectFn;
}
function cleanup(effectFn) {
effectFn.deps.forEach(dep => dep.delete(effectFn));
effectFn.deps.length = 0;
}
// 依赖收集
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);
activeEffect.deps.push(dep); // 反向记录,便于 cleanup
}
// 派发更新
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
// 复制一份避免遍历时增删导致死循环
const effectsToRun = new Set(dep);
effectsToRun.forEach(effectFn => {
// 避免自身触发自身导致无限递归
if (effectFn === activeEffect) return;
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn); // 交给调度器
} else {
effectFn();
}
});
}2.5 scheduler 与异步更新队列
如果每次数据变化都同步重新运行 effect,一次操作里连改 100 次数据就会渲染 100 次。Vue 通过 scheduler 把 effect 的执行"调度"到微任务队列,做去重与批处理,一个 tick 内只渲染一次。这就是"异步更新队列"的本质。
// 异步更新队列(简化还原 Vue 的调度)
const queue = new Set();
let isFlushing = false;
const resolvedPromise = Promise.resolve();
let currentFlushPromise = null;
function queueJob(job) {
queue.add(job); // Set 天然去重
if (!isFlushing) {
isFlushing = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
}
}
function flushJobs() {
try {
queue.forEach(job => job());
} finally {
isFlushing = false;
queue.clear();
currentFlushPromise = null;
}
}
// nextTick:在下一次 DOM 更新后执行回调
function nextTick(fn) {
const p = currentFlushPromise || resolvedPromise;
return fn ? p.then(fn) : p;
}
// 配合 effect 使用
const state = createReactive({ count: 0 });
effect(() => {
console.log('render count:', state.count);
}, {
scheduler: queueJob // 变化时不立即执行,加入队列
});
state.count++;
state.count++;
state.count++;
// 由于批处理,render 只会在微任务里执行一次,输出最终值在组件中使用 nextTick 的典型场景:修改数据后需要读取更新后的 DOM。
import { ref, nextTick } from 'vue';
export default {
setup() {
const list = ref([]);
const scrollRef = ref(null);
async function addItem(item) {
list.value.push(item);
// 此刻 DOM 尚未更新,直接读高度是旧值
await nextTick();
// DOM 已更新,可以安全地滚动到底部
scrollRef.value.scrollTop = scrollRef.value.scrollHeight;
}
return { list, scrollRef, addItem };
}
};2.6 computed 计算属性的原理
计算属性本质是一个带缓存的 effect。它内部用一个 dirty 标记实现"惰性求值 + 缓存":只有依赖变化时才把 dirty 置为 true,下次访问 value 才重新计算,否则直接返回缓存值。
// computed 原理实现
function computed(getter) {
let value; // 缓存值
let dirty = true; // 是否需要重新计算
const effectFn = effect(getter, {
lazy: true,
scheduler() {
// 依赖变化时不立即计算,只标记为脏
if (!dirty) {
dirty = true;
// 通知依赖了该 computed 的外层 effect
trigger(obj, 'value');
}
}
});
const obj = {
get value() {
if (dirty) {
value = effectFn(); // 重新计算并缓存
dirty = false;
}
// 让读取 computed.value 的外层 effect 也收集依赖
track(obj, 'value');
return value;
}
};
return obj;
}
// 使用
const state = createReactive({ a: 1, b: 2 });
const sum = computed(() => {
console.log('computing...');
return state.a + state.b;
});
console.log(sum.value); // computing... 3
console.log(sum.value); // 3(直接读缓存,不再计算)
state.a = 10;
console.log(sum.value); // computing... 12(依赖变了,重新计算)2.7 ref vs reactive 的区别与选择
ref 和 reactive 都能创建响应式数据,但适用场景不同。核心区别在于:reactive 只能包裹对象/数组/集合类型,且不能整体替换(会丢失响应性);ref 可以包裹任意类型(包括原始值),通过 .value 访问,可以整体替换。
import { ref, reactive, toRefs, isRef } from 'vue';
// ref:包裹原始值,本质是一个含 value 访问器的对象
const count = ref(0);
count.value++; // 必须通过 .value
// ref 内部原理(简化)
function myRef(value) {
const wrapper = {
get value() {
track(wrapper, 'value');
return value;
},
set value(newVal) {
if (newVal !== value) {
value = newVal;
trigger(wrapper, 'value');
}
}
};
return wrapper;
}
// reactive:包裹对象
const state = reactive({ count: 0, user: { name: 'Vue' } });
state.count++; // 直接访问
// 坑1:reactive 整体替换会丢失响应性
let obj = reactive({ a: 1 });
obj = reactive({ a: 2 }); // 原来的引用失去响应,模板还指向旧对象
// 坑2:解构 reactive 会丢失响应性
const { count: c } = state; // c 只是普通值,不再响应
// 正确做法:用 toRefs 保持响应性
const { count: c2 } = toRefs(state); // c2 是 ref,保持响应选型建议:组合式函数(composable)返回值优先用 ref(便于解构与整体替换);一组强关联的状态用 reactive 更简洁。团队内保持统一约定最重要。
2.8 shallow 与 readonly 系列 API
Vue 提供了一组"浅层"和"只读"的响应式 API,用于性能优化和数据保护。
import {
shallowRef,
shallowReactive,
readonly,
shallowReadonly,
markRaw,
toRaw
} from 'vue';
// shallowRef:只有 .value 的替换是响应式,内部属性变化不触发
const state = shallowRef({ count: 0 });
state.value.count++; // 不触发更新
state.value = { count: 1 }; // 触发更新(整体替换)
// 典型用途:存储大型不可变数据(如 ECharts 实例、大数组)避免深度代理开销
const chartInstance = shallowRef(null);
// shallowReactive:只有第一层属性是响应式
const obj = shallowReactive({ a: 1, nested: { b: 2 } });
obj.a = 10; // 触发更新
obj.nested.b = 20; // 不触发更新
// readonly:深度只读,任何修改都会警告
const config = readonly({ apiUrl: '/api', timeout: 3000 });
config.timeout = 5000; // 开发环境警告,修改无效
// markRaw:标记对象永远不被代理(第三方库实例常用)
const rawInstance = markRaw(new SomeThirdPartyClass());
const s = reactive({ instance: rawInstance }); // instance 不会被代理
// toRaw:拿到代理背后的原始对象
const original = toRaw(state.value);性能案例:一个渲染 10000 行的大表格,如果整个数据用 reactive 深度代理,初始化和更新都很慢。改用 shallowRef 存储数据、手动触发替换,可显著降低响应式开销。
import { shallowRef, triggerRef } from 'vue';
const bigList = shallowRef(loadTenThousandRows());
function updateRow(index, patch) {
// 直接改内部数据(不会自动触发)
Object.assign(bigList.value[index], patch);
// 手动触发一次更新
triggerRef(bigList);
}---
三、内置组件
3.1 Teleport 传送门
Teleport 允许把组件的一部分模板"传送"到 DOM 树的其他位置渲染,同时保持组件的逻辑归属不变。类比"寄快递":内容还是你写的、逻辑还是你的组件管的,但实际展示位置被送到了另一个地方(如 body 下)。
为什么重要:
模态框、通知、下拉菜单这类组件,逻辑上属于某个深层子组件,但视觉上需要脱离父级的 overflow: hidden、z-index 层叠上下文、transform 等约束。传统做法要手动 appendChild 到 body,破坏了组件封装。Teleport 优雅地解决了这个问题。
<template>
<button @click="open = true">打开弹窗</button>
<!-- 内容传送到 body 下,避免被父级样式裁剪 -->
<Teleport to="body">
<div v-if="open" class="modal-mask">
<div class="modal">
<h3>{{ title }}</h3>
<slot />
<button @click="open = false">关闭</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue';
const open = ref(false);
const title = 'Teleport 弹窗';
</script>
<style scoped>
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
</style>Teleport 支持动态目标与禁用(disabled 时内容留在原地,常用于响应式布局,桌面端传送、移动端原地):
<template>
<Teleport :to="target" :disabled="isMobile">
<div class="tooltip">提示内容</div>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue';
const isMobile = computed(() => window.innerWidth < 768);
const target = ref('#tooltip-container');
</script>3.2 Suspense 异步依赖协调
Suspense 是一个(当前仍为实验性)内置组件,用于协调异步依赖。它允许在等待多个异步组件或 async setup 解析完成时,先展示一个 fallback(加载态),全部就绪后再一次性展示内容,避免"闪烁式"的分块加载。
<template>
<Suspense>
<!-- 默认插槽:异步内容 -->
<template #default>
<UserProfile :id="userId" />
<UserPosts :id="userId" />
</template>
<!-- fallback 插槽:加载中占位 -->
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>
<script setup>
import { ref } from 'vue';
const userId = ref(1);
</script>配合 async setup 的子组件:
<!-- UserProfile.vue -->
<script setup>
const props = defineProps({ id: Number });
// 顶层 await:让该组件成为异步组件,Suspense 会等待它
const res = await fetch(\`/api/users/\${props.id}\`);
const user = await res.json();
</script>
<template>
<div class="profile">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</template>配合错误捕获:
<script setup>
import { ref, onErrorCaptured } from 'vue';
const error = ref(null);
onErrorCaptured((err) => {
error.value = err;
return false; // 阻止继续向上传播
});
</script>
<template>
<div v-if="error" class="error">加载失败:{{ error.message }}</div>
<Suspense v-else>
<template #default><AsyncContent /></template>
<template #fallback><Spinner /></template>
</Suspense>
</template>3.3 KeepAlive 组件缓存
KeepAlive 用于缓存组件实例,避免组件在切换时被销毁重建,从而保留其状态(如表单输入、滚动位置、请求结果)。类比"给房间贴上暂离牌"而不是拆掉重盖,回来时一切照旧。
<template>
<!-- 缓存所有被包裹的动态组件 -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
<!-- 只缓存指定组件 -->
<KeepAlive :include="['TabA', 'TabB']">
<component :is="currentTab" />
</KeepAlive>
<!-- 排除指定组件 -->
<KeepAlive :exclude="['TabC']">
<component :is="currentTab" />
</KeepAlive>
<!-- 限制最大缓存数量,超出按 LRU 淘汰 -->
<KeepAlive :max="10">
<component :is="currentTab" />
</KeepAlive>
</template>被 KeepAlive 缓存的组件会多出两个生命周期钩子:activated(进入激活)与 deactivated(离开缓存)。注意:缓存组件切换回来时不会再触发 mounted,需要"重新拉数据"的逻辑要放在 activated 里。
<script setup>
import { onActivated, onDeactivated, ref } from 'vue';
const list = ref([]);
onActivated(() => {
// 每次被激活(切回来)时执行,适合刷新数据
console.log('组件被激活,刷新最新数据');
fetchLatest();
});
onDeactivated(() => {
// 被切走但未销毁时执行,适合暂停定时器、保存草稿
console.log('组件被缓存,暂停轮询');
});
function fetchLatest() { /* ... */ }
</script>结合路由缓存的常见写法(Vue Router 4):
<template>
<router-view v-slot="{ Component }">
<KeepAlive :include="cachedViews">
<component :is="Component" :key="$route.fullPath" />
</KeepAlive>
</router-view>
</template>
<script setup>
import { ref } from 'vue';
// 通常配合 tab 页签管理动态维护该列表
const cachedViews = ref(['ListPage', 'DetailPage']);
</script>3.4 Transition 与 TransitionGroup 动画
Transition 为单个元素/组件的进入和离开提供过渡动画;TransitionGroup 为列表的增删和排序提供动画(含 FLIP 位移动画)。它会在合适的时机自动添加/移除一组 CSS 类名。
<template>
<button @click="show = !show">切换</button>
<Transition name="fade">
<p v-if="show">Hello Transition</p>
</Transition>
</template>
<script setup>
import { ref } from 'vue';
const show = ref(true);
</script>
<style>
/* 进入动画的起点、终点 */
.fade-enter-from { opacity: 0; transform: translateY(10px); }
.fade-enter-active { transition: all 0.3s ease; }
.fade-enter-to { opacity: 1; transform: translateY(0); }
/* 离开动画 */
.fade-leave-from { opacity: 1; }
.fade-leave-active { transition: all 0.3s ease; }
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
</style>Transition 的 JavaScript 钩子,适合接入 GSAP 等动画库:
<template>
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
:css="false"
>
<div v-if="show" class="box" />
</Transition>
</template>
<script setup>
import { ref } from 'vue';
const show = ref(true);
function onBeforeEnter(el) {
el.style.opacity = 0;
}
function onEnter(el, done) {
// 手动控制动画,结束时调用 done
let opacity = 0;
const timer = setInterval(() => {
opacity += 0.1;
el.style.opacity = opacity;
if (opacity >= 1) {
clearInterval(timer);
done();
}
}, 20);
}
function onLeave(el, done) {
setTimeout(done, 300);
}
</script>TransitionGroup 实现列表动画,move-class 是 FLIP 位移动画的关键:
<template>
<button @click="shuffle">打乱</button>
<TransitionGroup name="list" tag="ul">
<li v-for="item in list" :key="item.id" class="list-item">
{{ item.name }}
</li>
</TransitionGroup>
</template>
<script setup>
import { ref } from 'vue';
const list = ref([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
{ id: 3, name: 'C' }
]);
function shuffle() {
list.value = list.value.sort(() => Math.random() - 0.5);
}
</script>
<style>
.list-item { transition: all 0.4s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); }
.list-leave-active { position: absolute; } /* 让离开元素脱离文档流,避免跳动 */
/* move-class:元素位置改变时的平滑位移(FLIP) */
.list-move { transition: transform 0.4s ease; }
</style>---
四、异步组件与懒加载
4.1 异步组件
异步组件可以按需加载组件,减少初始包体积。它返回一个 Promise,Vue 在需要渲染时才真正加载对应代码块。
// Vue 2 异步组件
Vue.component('async-component', function (resolve, reject) {
// 特殊 require() 告诉 webpack 自动将构建代码分割成单独的包
require(['./my-component.vue'], resolve);
});
// Vue 3 异步组件
import { defineAsyncComponent } from 'vue';
const AsyncComponent = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
);
// 带加载状态、错误处理、重试的异步组件
const AsyncComponentWithLoading = defineAsyncComponent({
loader: () => import('./components/MyComponent.vue'),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200, // 延迟 200ms 才显示 loading,避免闪烁
timeout: 3000, // 超时后显示 errorComponent
// 加载失败时的重试逻辑
onError(error, retry, fail, attempts) {
if (error.message.includes('fetch') && attempts <= 3) {
retry(); // 网络错误,重试
} else {
fail();
}
}
});4.2 路由懒加载
结合 Vue Router 实现路由级别的代码分割,是首屏优化最有效的手段之一。
// 路由懒加载
const routes = [
{
path: '/home',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
// webpackChunkName 注释可命名 chunk,便于分析与合并
component: () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
},
{
path: '/user/:id',
name: 'User',
component: () => import(/* webpackChunkName: "user" */ '@/views/User.vue')
}
];4.3 组件级懒加载与 Intersection Observer
除了路由,还可以对"首屏之下"的重组件做可视区懒加载,进入视口才加载。
<template>
<div ref="placeholder">
<component :is="LazyComp" v-if="LazyComp" />
<div v-else class="skeleton">加载中...</div>
</div>
</template>
<script setup>
import { ref, onMounted, shallowRef } from 'vue';
const placeholder = ref(null);
const LazyComp = shallowRef(null);
onMounted(() => {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
// 进入视口才加载重组件
import('./HeavyChart.vue').then((mod) => {
LazyComp.value = mod.default;
});
observer.disconnect();
}
});
observer.observe(placeholder.value);
});
</script>---
五、自定义指令
5.1 概念与生命周期
自定义指令(Custom Directive)用于封装对底层 DOM 的直接操作,弥补模板无法覆盖的低层需求,如自动聚焦、权限控制、懒加载图片、点击外部关闭、拖拽等。可以把它理解成"针对单个 DOM 元素的可复用行为插件"。
Vue 3 指令的生命周期钩子(对齐组件生命周期):created、beforeMount、mounted、beforeUpdate、updated、beforeUnmount、unmounted。
// 全局自定义指令:自动聚焦
app.directive('focus', {
mounted(el) {
el.focus();
}
});
// 完整钩子说明
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) {
// 元素卸载后,适合清理事件监听
}
};5.2 实战:v-permission 权限指令
// 根据用户权限控制元素显示/移除
import { useUserStore } from '@/stores/user';
const permissionDirective = {
mounted(el, binding) {
const store = useUserStore();
const required = binding.value; // 例如 'user:delete'
if (!store.permissions.includes(required)) {
// 无权限:直接从 DOM 移除(比 v-if 更彻底,避免残留)
el.parentNode && el.parentNode.removeChild(el);
}
}
};
// 使用:<button v-permission="'user:delete'">删除</button>5.3 实战:v-click-outside 点击外部关闭
const clickOutside = {
mounted(el, binding) {
el.__clickOutsideHandler__ = (event) => {
// 点击的不是元素本身也不是其子元素时触发回调
if (!(el === event.target || el.contains(event.target))) {
binding.value(event);
}
};
document.addEventListener('click', el.__clickOutsideHandler__);
},
unmounted(el) {
// 清理,避免内存泄漏
document.removeEventListener('click', el.__clickOutsideHandler__);
delete el.__clickOutsideHandler__;
}
};
// 使用:<div v-click-outside="closeDropdown">...</div>5.4 实战:v-lazy 图片懒加载指令
const lazyImage = {
mounted(el, binding) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value; // 真正的图片地址
el.classList.add('loaded');
observer.unobserve(el);
}
});
}, { rootMargin: '50px' });
observer.observe(el);
el.__lazyObserver__ = observer;
},
unmounted(el) {
el.__lazyObserver__ && el.__lazyObserver__.disconnect();
}
};
// 使用:<img v-lazy="imageUrl" src="placeholder.png" />5.5 实战:v-longpress 长按指令(含修饰符与参数)
const longpress = {
mounted(el, binding) {
// binding.arg 参数:如 v-longpress:800 表示 800ms
const duration = Number(binding.arg) || 500;
let timer = null;
const start = (e) => {
if (e.type === 'click' && e.button !== 0) return;
timer = setTimeout(() => binding.value(e), duration);
};
const cancel = () => {
clearTimeout(timer);
timer = null;
};
el.__lp__ = { start, cancel };
el.addEventListener('mousedown', start);
el.addEventListener('touchstart', start);
['click', 'mouseout', 'touchend', 'touchcancel'].forEach((evt) =>
el.addEventListener(evt, cancel)
);
},
unmounted(el) {
const { start, cancel } = el.__lp__ || {};
if (start) {
el.removeEventListener('mousedown', start);
el.removeEventListener('touchstart', start);
}
// 省略其余解绑
}
};
// 使用:<button v-longpress:1000="onLongPress">长按1秒</button>---
六、渲染函数 h、JSX 与函数式组件
6.1 为什么需要渲染函数
模板语法足够声明式且性能好(可被编译优化),但在需要高度动态、以编程方式生成结构的场景(如根据 level 动态生成 h1~h6、复杂表格列渲染、组件库底层)时,渲染函数提供了完整的 JavaScript 表达能力。
import { h } from 'vue';
// h(类型, 属性, 子节点)
export default {
props: { level: { type: Number, default: 1 } },
setup(props, { slots }) {
return () =>
h(
'h' + props.level, // 动态标签名
{ class: 'heading' },
slots.default ? slots.default() : '默认标题'
);
}
};h 函数的多种调用形式:
// 无属性
h('div', 'hello');
// 带属性
h('div', { id: 'app', class: ['a', 'b'] }, 'hello');
// 带事件(on + 大写事件名)
h('button', { onClick: () => console.log('clicked') }, '点我');
// 子节点为数组
h('ul', [
h('li', '第一项'),
h('li', '第二项')
]);
// 渲染组件
import MyComponent from './MyComponent.vue';
h(MyComponent, { msg: 'hi', onCustomEvent: handler });
// 带插槽的组件(第三个参数为返回 vnode 的函数对象)
h(MyComponent, null, {
default: () => h('span', '默认插槽'),
header: (props) => h('h2', props.title)
});6.2 JSX 写法
配合 @vue/babel-plugin-jsx,可以在 Vue 中使用 JSX,更接近 React 的书写体验,适合逻辑密集的渲染。
import { defineComponent, ref } from 'vue';
export default defineComponent({
setup() {
const count = ref(0);
const list = ref(['a', 'b', 'c']);
return () => (
<div class="container">
<p>count: {count.value}</p>
<button onClick={() => count.value++}>加一</button>
<ul>
{list.value.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
{count.value > 3 ? <strong>超过3了</strong> : null}
</div>
);
}
});6.3 函数式组件
函数式组件是无状态、无实例的组件,只接收 props 并返回 vnode,渲染开销更小。Vue 3 中直接用一个普通函数即可定义。
// 函数式组件:一个纯函数
import { h } from 'vue';
const FunctionalButton = (props, { slots, emit }) => {
return h(
'button',
{
class: ['btn', \`btn-\${props.type}\`],
onClick: () => emit('click')
},
slots.default ? slots.default() : props.text
);
};
// 声明 props 与 emits(可选)
FunctionalButton.props = {
type: { type: String, default: 'primary' },
text: String
};
FunctionalButton.emits = ['click'];
export default FunctionalButton;6.4 实战:动态渲染的表格列
import { h, defineComponent } from 'vue';
export default defineComponent({
props: {
columns: Array, // [{ key, title, render?: (row) => vnode }]
data: Array
},
setup(props) {
return () =>
h('table', { class: 'data-table' }, [
h('thead', [
h('tr', props.columns.map((col) =>
h('th', { key: col.key }, col.title)
))
]),
h('tbody', props.data.map((row) =>
h('tr', { key: row.id }, props.columns.map((col) =>
h('td', { key: col.key },
// 支持自定义 render 函数,否则渲染纯文本
col.render ? col.render(row) : row[col.key]
)
))
))
]);
}
});---
七、自定义 v-model 与组件双向绑定
7.1 v-model 的本质
v-model 是"属性绑定 + 事件监听"的语法糖。在 Vue 3 中,组件上的 v-model 默认对应 modelValue 属性和 update:modelValue 事件。理解这一点就能自由定制。
<!-- 父组件 -->
<CustomInput v-model="text" />
<!-- 等价于 -->
<CustomInput
:modelValue="text"
@update:modelValue="text = $event"
/><!-- 子组件 CustomInput.vue -->
<script setup>
defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>7.2 具名 v-model 与多个 v-model
Vue 3 支持在同一组件上使用多个具名 v-model。
<!-- 父组件:同时双向绑定 firstName 和 lastName -->
<UserName
v-model:first-name="first"
v-model:last-name="last"
/><!-- 子组件 UserName.vue -->
<script setup>
defineProps(['firstName', 'lastName']);
const emit = defineEmits(['update:firstName', 'update:lastName']);
</script>
<template>
<input
:value="firstName"
@input="emit('update:firstName', $event.target.value)"
/>
<input
:value="lastName"
@input="emit('update:lastName', $event.target.value)"
/>
</template>7.3 自定义 v-model 修饰符
自定义修饰符通过 modelModifiers 传入,可实现如 .capitalize、.trim 等自定义处理。
<!-- 使用:<MyInput v-model.capitalize="text" /> -->
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
});
const emit = defineEmits(['update:modelValue']);
function onInput(e) {
let value = e.target.value;
// 检测到 capitalize 修饰符则首字母大写
if (props.modelModifiers.capitalize && value) {
value = value.charAt(0).toUpperCase() + value.slice(1);
}
emit('update:modelValue', value);
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
</template>7.4 defineModel 新写法(Vue 3.4+)
Vue 3.4 起提供 defineModel 宏,大幅简化自定义 v-model。
<script setup>
// 一行搞定:既是 prop 又能触发 update 事件
const model = defineModel();
// 具名 + 默认值 + 类型
const count = defineModel('count', { type: Number, default: 0 });
</script>
<template>
<input v-model="model" />
<button @click="count++">count: {{ count }}</button>
</template>---
八、插件开发与生态扩展
8.1 插件概念
插件(Plugin)是为 Vue 应用添加全局功能的标准方式,如注册全局组件、指令、混入,提供全局方法或属性,注入 provide 等。一个插件是一个带 install 方法的对象,或一个函数。
// 定义一个 Toast 插件
import ToastComponent from './Toast.vue';
import { createApp, h, ref } from 'vue';
const ToastPlugin = {
install(app, options = {}) {
// 1. 注册全局组件
app.component('Toast', ToastComponent);
// 2. 提供全局方法
const message = ref('');
const visible = ref(false);
function show(text, duration = 2000) {
message.value = text;
visible.value = true;
setTimeout(() => (visible.value = false), duration);
}
// 3. 挂到全局属性,模板中可用 $toast
app.config.globalProperties.$toast = show;
// 4. 通过 provide 供 inject 使用
app.provide('toast', { show, message, visible });
}
};
export default ToastPlugin;
// main.js 使用
// import { createApp } from 'vue';
// import App from './App.vue';
// createApp(App).use(ToastPlugin, { position: 'top' }).mount('#app');8.2 在组合式 API 中使用插件能力
import { inject } from 'vue';
export function useToast() {
const toast = inject('toast');
if (!toast) {
throw new Error('useToast 必须在安装了 ToastPlugin 后使用');
}
return toast;
}
// 组件中
// const toast = useToast();
// toast.show('保存成功');8.3 实战:一个简易的国际化插件
import { reactive } from 'vue';
function createI18n(options) {
const state = reactive({
locale: options.locale || 'zh',
messages: options.messages || {}
});
function t(key) {
const msg = state.messages[state.locale] || {};
// 支持 a.b.c 形式的嵌套 key
return key.split('.').reduce((obj, k) => (obj ? obj[k] : undefined), msg) || key;
}
return {
install(app) {
app.config.globalProperties.$t = t;
app.provide('i18n', { state, t, setLocale: (l) => (state.locale = l) });
}
};
}
// 使用
const i18n = createI18n({
locale: 'zh',
messages: {
zh: { hello: '你好', nav: { home: '首页' } },
en: { hello: 'Hello', nav: { home: 'Home' } }
}
});
// app.use(i18n);
// 模板中:{{ $t('nav.home') }}---
九、组件通信高级模式
9.1 跨层级通信
除了 props 和 emit,Vue 还提供了多种跨层级通信方式:
// Vue 3 setup 语法下的 provide/inject
// 祖先组件
import { provide, ref, readonly } from 'vue';
const theme = ref('light');
function updateTheme(newTheme) {
theme.value = newTheme;
}
// 提供只读值 + 修改方法,防止后代直接篡改(单向数据流)
provide('theme', readonly(theme));
provide('updateTheme', updateTheme);// 后代组件
import { inject } from 'vue';
const theme = inject('theme', 'light'); // 第二个参数为默认值
const updateTheme = inject('updateTheme');
function toggleTheme() {
updateTheme(theme.value === 'light' ? 'dark' : 'light');
}选项式 API 的 provide/inject 写法:
<!-- provide/inject 示例(选项式) -->
<!-- 祖先组件 -->
<script>
export default {
provide() {
return {
theme: this.theme,
updateTheme: this.updateTheme
};
},
data() {
return { theme: 'light' };
},
methods: {
updateTheme(newTheme) {
this.theme = newTheme;
}
}
};
</script>
<!-- 后代组件 -->
<script>
export default {
inject: ['theme', 'updateTheme'],
methods: {
toggleTheme() {
this.updateTheme(this.theme === 'light' ? 'dark' : 'light');
}
}
};
</script>9.2 作用域插槽高级模式
作用域插槽允许父组件访问子组件的数据,实现"逻辑在子、渲染在父"的灵活组合,是组件库设计的核心模式(无渲染组件 Renderless Component)。
<!-- 子组件:DataTable -->
<template>
<table>
<thead>
<tr>
<th v-for="column in columns" :key="column.key">
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.id">
<td v-for="column in columns" :key="column.key">
<slot :name="column.key" :row="row" :value="row[column.key]">
{{ row[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
props: {
columns: Array,
data: Array
}
};
</script>
<!-- 父组件:使用作用域插槽 -->
<template>
<DataTable :columns="columns" :data="users">
<template #name="{ row, value }">
<router-link :to="'/user/' + row.id">{{ value }}</router-link>
</template>
<template #email="{ value }">
<a :href="'mailto:' + value">{{ value }}</a>
</template>
<template #actions="{ row }">
<button @click="editUser(row)">编辑</button>
<button @click="deleteUser(row)">删除</button>
</template>
</DataTable>
</template>9.3 无渲染组件(Renderless Component)
无渲染组件只封装逻辑、不渲染具体 UI,通过作用域插槽把数据和方法交给使用者渲染。
<!-- MouseTracker.vue:只提供鼠标坐标逻辑 -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
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));
</script>
<template>
<!-- 不渲染任何具体 UI,把数据通过插槽交出去 -->
<slot :x="x" :y="y" />
</template>
<!-- 使用者自由决定如何渲染 -->
<!--
<MouseTracker v-slot="{ x, y }">
鼠标位置:{{ x }}, {{ y }}
</MouseTracker>
-->9.4 attrs 透传与包装组件
<!-- 二次封装 el-input,把未声明的属性/事件透传下去 -->
<script setup>
// inheritAttrs: false 时手动控制透传目标
defineOptions({ inheritAttrs: false });
</script>
<template>
<div class="my-input-wrapper">
<label>{{ label }}</label>
<!-- v-bind="$attrs" 把父级传入的所有未声明属性/事件透传到内部 input -->
<input v-bind="$attrs" class="my-input" />
</div>
</template>---
十、混入(Mixins)与组合式 API
10.1 Mixins 的优缺点
Mixins 是 Vue 2 的代码复用方式,但存在明显缺点:
// Mixin 示例
const formMixin = {
data() {
return {
formData: {},
errors: {}
};
},
methods: {
validateField(field, rules) {
// 验证逻辑
},
submitForm() {
// 提交逻辑
}
}
};
export default {
mixins: [formMixin],
data() {
return {
formData: {
// 与 mixin 同名,组件的 data 优先,容易踩坑
username: '',
password: ''
}
};
}
};10.2 Composition API 的优势
Composition API(组合式 API)解决了 Mixins 的所有问题:
// Composable 示例:useForm.js
import { ref, reactive } from 'vue';
export function useForm(initialData = {}) {
const formData = reactive({ ...initialData });
const errors = ref({});
const isSubmitting = ref(false);
function validateField(field, rules) {
// 验证逻辑
const value = formData[field];
for (const rule of rules) {
if (rule.required && !value) {
errors.value[field] = rule.message || \`\${field} 不能为空\`;
return false;
}
}
delete errors.value[field];
return true;
}
async function submitForm(submitFn) {
isSubmitting.value = true;
try {
await submitFn(formData);
} catch (e) {
errors.value = e.errors || {};
} finally {
isSubmitting.value = false;
}
}
return {
formData,
errors,
isSubmitting,
validateField,
submitForm
};
}// 组件中使用(setup 语法)
import { useForm } from './useForm';
const { formData, errors, isSubmitting, submitForm } = useForm({
username: '',
password: ''
});
async function onSubmit() {
await submitForm(async (data) => {
await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(data)
});
});
}10.3 更多可复用 Composable 示例
// useFetch.js:封装数据请求
import { ref, watchEffect, toValue } from 'vue';
export function useFetch(urlRef) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
watchEffect(async () => {
const url = toValue(urlRef); // 支持传入 ref、getter 或普通值
if (!url) return;
loading.value = true;
error.value = null;
try {
const res = await fetch(url);
data.value = await res.json();
} catch (e) {
error.value = e;
} finally {
loading.value = false;
}
});
return { data, error, loading };
}// useLocalStorage.js:响应式 localStorage
import { ref, watch } from 'vue';
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key);
const state = ref(stored ? JSON.parse(stored) : defaultValue);
watch(
state,
(val) => {
localStorage.setItem(key, JSON.stringify(val));
},
{ deep: true }
);
return state;
}// useDebounce.js:防抖组合函数
import { ref, watch } from 'vue';
export function useDebounce(valueRef, delay = 300) {
const debounced = ref(valueRef.value);
let timer = null;
watch(valueRef, (val) => {
clearTimeout(timer);
timer = setTimeout(() => {
debounced.value = val;
}, delay);
});
return debounced;
}---
十一、性能优化与真实案例
11.1 长列表虚拟滚动
渲染上万条数据时,一次性创建上万个 DOM 会导致严重卡顿。虚拟滚动只渲染可视区域内的少量 DOM,滚动时动态替换内容。
<template>
<div ref="container" class="viewport" @scroll="onScroll">
<!-- 占位撑起总高度,形成滚动条 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
<!-- 只渲染可视区节点,用 transform 定位 -->
<div class="content" :style="{ transform: 'translateY(' + offsetY + 'px)' }">
<div
v-for="item in visibleItems"
:key="item.id"
class="row"
:style="{ height: itemHeight + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
const props = defineProps({ items: Array });
const itemHeight = 40;
const container = ref(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
const totalHeight = computed(() => props.items.length * itemHeight);
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight));
const visibleCount = computed(() => Math.ceil(viewportHeight.value / itemHeight) + 2);
const visibleItems = computed(() =>
props.items.slice(startIndex.value, startIndex.value + visibleCount.value)
);
const offsetY = computed(() => startIndex.value * itemHeight);
function onScroll(e) {
scrollTop.value = e.target.scrollTop;
}
onMounted(() => {
viewportHeight.value = container.value.clientHeight;
});
</script>
<style scoped>
.viewport { height: 400px; overflow-y: auto; position: relative; }
.content { position: absolute; top: 0; left: 0; right: 0; }
</style>真实数据:某后台管理系统的日志页面,原本一次渲染 5000 行需要约 1200ms 且滚动明显掉帧,接入虚拟滚动后首屏渲染降到约 30ms,滚动稳定在 60fps。
11.2 v-once 与 v-memo
<template>
<!-- v-once:只渲染一次,之后视为静态,永不更新 -->
<header v-once>
<h1>{{ siteTitle }}</h1>
</header>
<!-- v-memo:仅当依赖数组变化时才更新该子树,用于超大列表精细控制 -->
<div
v-for="item in list"
:key="item.id"
v-memo="[item.id, item.selected]"
>
<!-- 只有 item.id 或 item.selected 变化时才重渲染此项 -->
<ExpensiveItem :item="item" />
</div>
</template>11.3 合理拆分组件与稳定 props
大组件的任意状态变化都会导致整个组件重新渲染。将频繁变化的部分拆成独立子组件,能把重渲染范围限制在局部。同时避免在模板中传入每次都新建的对象/内联函数作为 props,否则子组件会被无谓更新(Vue 3 的 cacheHandlers 可缓存内联事件,但对象字面量仍会变化)。
<!-- 不推荐:每次渲染都创建新对象/新函数 -->
<Child :config="{ a: 1 }" :onClick="() => doSomething()" />
<!-- 推荐:提到 setup 中稳定引用 -->
<script setup>
const config = { a: 1 };
function handleClick() { doSomething(); }
</script>
<Child :config="config" :onClick="handleClick" />11.4 Vue 2 vs Vue 3 性能与体积对比
下表为官方基准与社区实测的量级参考(具体数字随版本与场景浮动,仅用于说明趋势):
| 对比维度 | Vue 2 | Vue 3 | 提升幅度 |
| --- | --- | --- | --- |
| 运行时体积(gzip 压缩后) | 约 22 至 23 KB | 约 13 至 16 KB(Tree-shaking 后可更小) | 体积减少约 40% |
| 初始渲染速度 | 基准 | 约快 40% 至 55% | 明显提升 |
| 更新渲染速度 | 基准 | 约快 130% 至 200% | 大幅提升 |
| 内存占用 | 基准 | 约减少 50% | 更省内存 |
| 响应式实现 | Object.defineProperty | Proxy | 支持更多数据结构 |
| 响应式初始化开销 | 递归全量劫持 | 懒代理按需劫持 | 大对象更快 |
| Tree-shaking 支持 | 弱(全局 API 挂在 Vue 上) | 强(按需引入) | 未用特性可摇掉 |
| TypeScript 支持 | 一般(需额外装饰器) | 原生优秀 | 类型推导更好 |
11.5 编译优化带来的更新性能
同一个含大量静态内容的页面,Vue 3 的静态提升 + Block Tree + PatchFlag 让更新只触达动态节点。假设一个页面有 1000 个节点、其中仅 20 个动态,Vue 2 更新时需遍历比较约 1000 个节点,而 Vue 3 只需遍历 dynamicChildren 中的 20 个动态节点,理论上 Diff 工作量减少约 98%。
| 场景 | Vue 2 参与 Diff 节点数 | Vue 3 参与 Diff 节点数 | 说明 |
| --- | --- | --- | --- |
| 1000 节点仅 20 动态 | 约 1000 | 约 20 | Block Tree 跳过静态节点 |
| 纯静态大段落 | 每次都比较 | 0(静态提升复用) | 静态节点只创建一次 |
| 动态列表带 key | 双端 Diff | 快速 Diff + LIS | 移动次数最小化 |
---
十二、常见坑与排查清单
| 场景 | 坑 | 正确做法 |
| --- | --- | --- |
| v-for 的 key | 用 index 做 key 导致状态错乱 | 用稳定唯一业务 id |
| reactive 解构 | 解构后失去响应性 | 用 toRefs 或直接访问 |
| reactive 整体替换 | 重新赋值丢失响应 | 用 ref 包裹或逐字段更新 |
| watch 深层对象 | 默认不深度监听 | 加 deep: true 或监听 getter |
| 修改后立即读 DOM | 读到旧 DOM | await nextTick 后再读 |
| KeepAlive 刷新数据 | 放在 mounted 不生效 | 放在 activated |
| 自定义指令绑定事件 | unmounted 未解绑 | 在 unmounted 清理监听 |
| 大对象响应式 | 深度代理开销大 | 用 shallowRef/markRaw |
| props 传内联对象 | 子组件被无谓更新 | 提到 setup 稳定引用 |
| 组件上 v-model | 误以为绑 value | Vue 3 默认绑 modelValue |
12.1 watch 深度监听与惰性执行
import { watch, ref, reactive } from 'vue';
const state = reactive({ user: { name: 'Vue', age: 3 } });
// 坑:直接监听 reactive 的某个深层属性不生效
// watch(state.user.name, ...) // 错误:传入的是一个普通字符串
// 正确:用 getter 函数
watch(
() => state.user.name,
(newVal, oldVal) => console.log('name 变化', oldVal, '->', newVal)
);
// 深度监听整个对象
watch(
() => state.user,
(val) => console.log('user 变化', val),
{ deep: true, immediate: true } // immediate 立即执行一次
);12.2 内存泄漏排查
组件里注册的全局监听、定时器、第三方实例,必须在卸载时清理,否则组件销毁后仍占用内存并可能报错。
import { onMounted, onUnmounted } from 'vue';
onMounted(() => {
const timer = setInterval(poll, 1000);
const onResize = () => layout();
window.addEventListener('resize', onResize);
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener('resize', onResize);
});
});---
十三、最佳实践
---
十四、总结
Vue 的高级特性并非孤立的知识点,而是围绕"声明式、响应式、高性能"这三个目标层层递进的完整体系。虚拟 DOM 提供了跨平台与最小更新的基础,Diff 算法(Vue 2 双端、Vue 3 快速 Diff + LIS)保证了更新效率,编译时优化(静态提升、PatchFlag、Block Tree)把运行时开销进一步压到最低;响应式系统(Proxy、effect、scheduler、computed 缓存)让数据驱动视图既自动又高效;内置组件、自定义指令、渲染函数、插件机制则从工程层面提供了强大的扩展能力。
| 主题 | 核心要点 | 关键 API/机制 | Vue 3 改进 |
| --- | --- | --- | --- |
| 虚拟 DOM | JS 对象描述 DOM,最小更新 | VNode、shapeFlag | patchFlag、Block Tree |
| Diff 算法 | 同层比较、key 复用 | 双端 Diff | 快速 Diff + LIS |
| 编译优化 | 静态与动态分离 | 静态提升、缓存事件 | PatchFlag、dynamicChildren |
| 响应式 | 依赖收集与派发更新 | track/trigger、effect | Proxy 替代 defineProperty |
| 计算属性 | 惰性求值 + 缓存 | dirty 标记 | 更精细的依赖追踪 |
| 异步更新 | 批处理去重 | scheduler、nextTick | 微任务队列 |
| ref/reactive | 原始值 vs 对象 | .value、toRefs | shallow/readonly 系列 |
| Teleport | 内容传送 | to、disabled | 全新内置组件 |
| Suspense | 异步依赖协调 | default/fallback | 全新内置组件 |
| KeepAlive | 组件状态缓存 | include/exclude/max | activated/deactivated |
| 过渡动画 | 进入离开与列表动画 | Transition、TransitionGroup | 更完善的 JS 钩子 |
| 异步组件 | 按需加载减体积 | defineAsyncComponent | 重试、超时、错误组件 |
| 自定义指令 | 封装 DOM 操作 | mounted/unmounted | 生命周期对齐组件 |
| 渲染函数 | 编程式生成结构 | h、JSX | 函数式组件更轻量 |
| 自定义 v-model | 组件双向绑定 | modelValue、defineModel | 多 v-model、修饰符 |
| 插件 | 全局能力扩展 | install、provide | 组合式配合 inject |
| 代码复用 | 逻辑聚合与共享 | Composables | 取代 Mixins |
掌握这些原理,不仅能在面试中从容应对底层追问,更能在实际项目中做出正确的架构与性能取舍:什么时候该用 shallowRef、为什么列表要用稳定 key、大页面如何靠编译优化和组件拆分保持流畅、复杂交互如何用无渲染组件和作用域插槽解耦。真正理解"为什么这样设计",才能把 Vue 用到极致。