一、浅层响应式 API:shallowRef
1. 功能说明
shallowRef 是用于创建浅层响应式引用的工具,与普通 ref 不同,它仅追踪引用值的变化,不会深入到对象内部属性。此特性在处理复杂对象且无需内部属性响应性时,可显著提升性能。
2. 技术原理
- 普通
ref会递归地将对象转换为响应式代理 shallowRef仅将外层包装为响应式,内部对象保持原始状态- 当直接修改
shallowRef的value时,会触发响应式更新 - 当修改
shallowRef内部对象的属性时,不会触发响应式更新
3. 适用场景
- 处理大型不可变对象(如配置项、静态数据)
- 性能敏感场景,避免不必要的响应式转换
- 当对象内部属性变化不需要触发视图更新时
4. 与同类技术对比
| 特性 | shallowRef | ref |
|---|---|---|
| 响应式深度 | 浅层 | 深层 |
| 性能 | 更高 | 较低 |
| 适用对象大小 | 大型对象 | 中小型对象 |
| 内部属性变化 | 不触发更新 | 触发更新 |
5. 进阶优化方案
- 结合
triggerRef手动触发更新:当需要修改内部属性并触发更新时 - 与
toRaw配合使用:获取原始对象进行批量修改,再重新赋值给shallowRef
6. 潜在问题及解决方案
-
问题:修改内部属性不触发更新 解决方案:使用
triggerRef(shallowRefValue)手动触发更新 -
问题:与
ref混合使用时容易混淆 解决方案:明确命名规范,如shallowUservsreactiveUser
7. Demo:shallowRef 实践
环境依赖
- Vue 3.x
- Vite 或其他构建工具
操作步骤
- 创建 Vue 3 项目
- 在组件中导入并使用
shallowRef - 测试浅层响应式特性
- 验证性能优化效果
代码示例
<template>
<div class="shallow-ref-demo">
<h2>shallowRef 示例</h2>
<div>
<p>姓名:{{ user.name }}</p>
<p>年龄:{{ user.age }}</p>
<p>职业:{{ user.details.job }}</p>
</div>
<div class="buttons">
<button @click="updateInnerProperty">修改内部属性(不触发更新)</button>
<button @click="updateInnerPropertyWithTrigger">修改内部属性 + 手动触发</button>
<button @click="updateWholeObject">修改整个对象(自动触发更新)</button>
</div>
<div class="performance">
<p>更新计数:{{ updateCount }}</p>
</div>
</div>
</template>
<script setup>
import { shallowRef, triggerRef, ref } from 'vue';
// 创建浅层响应式对象
const user = shallowRef({
name: '张三',
age: 30,
details: {
job: '前端开发工程师',
company: 'Tech Corp'
}
});
const updateCount = ref(0);
// 直接修改内部属性 - 不会触发更新
const updateInnerProperty = () => {
user.value.name = '李四';
user.value.details.job = '高级前端开发工程师';
console.log('内部属性已修改,但不会触发视图更新');
};
// 修改内部属性并手动触发更新
const updateInnerPropertyWithTrigger = () => {
user.value.name = '王五';
user.value.details.job = '技术总监';
triggerRef(user); // 手动触发更新
updateCount.value++;
console.log('内部属性已修改,并手动触发视图更新');
};
// 修改整个对象 - 自动触发更新
const updateWholeObject = () => {
user.value = {
...user.value,
name: '赵六',
age: 35,
details: {
...user.value.details,
job: 'CTO'
}
};
updateCount.value++;
console.log('整个对象已修改,自动触发视图更新');
};
</script>
<style scoped>
.shallow-ref-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.buttons {
margin: 20px 0;
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #35495e;
}
.performance {
margin-top: 20px;
padding: 10px;
background-color: #f0f0f0;
border-radius: 4px;
}
</style>
运行结果预期
- 点击"修改内部属性(不触发更新)":控制台显示修改信息,但页面数据不会更新
- 点击"修改内部属性 + 手动触发":页面数据更新,更新计数 +1
- 点击"修改整个对象(自动触发更新)":页面数据更新,更新计数 +1
调试技巧
- 使用 Vue DevTools 查看
shallowRef对象的响应式状态 - 检查更新计数,确认是否触发了不必要的更新
- 使用
console.time()和console.timeEnd()对比shallowRef和ref的性能差异
二、数据保护利器:readonly 和 shallowReadonly
1. 功能说明
readonly:将响应式对象转换为完全只读对象,任何修改操作都会报错shallowReadonly:仅将对象顶层属性设为只读,嵌套对象的属性仍可修改
2. 技术原理
- 基于 Proxy 实现,拦截对象的所有修改操作
readonly会递归地将所有嵌套对象转换为只读shallowReadonly仅转换顶层对象,嵌套对象保持原样- 尝试修改只读对象时,会在开发环境下抛出警告
3. 适用场景
- 保护全局状态不被意外修改
- 组件间传递数据时,防止子组件修改父组件数据
- 保护配置项、常量等不可变数据
4. 与同类技术对比
| 特性 | readonly | shallowReadonly | const |
|---|---|---|---|
| 适用对象 | 响应式对象 | 响应式对象 | 基本类型、引用类型 |
| 只读深度 | 深层 | 浅层 | 引用地址(非内容) |
| 开发警告 | 有 | 有 | 无(运行时错误) |
| 嵌套对象 | 只读 | 可修改 | 可修改 |
5. 进阶优化方案
- 结合
isReadonly判断对象是否为只读 - 与
toRaw配合使用:获取原始对象进行修改,再重新创建只读对象
6. 潜在问题及解决方案
- 问题:深层嵌套对象修改时无警告
解决方案:使用
readonly而非shallowReadonly - 问题:性能开销(特别是深层对象)
解决方案:对大型对象使用
shallowReadonly
7. Demo:readonly 和 shallowReadonly 实践
环境依赖
- Vue 3.x
操作步骤
- 创建 Vue 3 组件
- 导入并使用
readonly和shallowReadonly - 测试只读特性
- 观察修改时的行为差异
代码示例
<template>
<div class="readonly-demo">
<h2>readonly 和 shallowReadonly 示例</h2>
<div class="section">
<h3>1. readonly(完全只读)</h3>
<div>
<p>姓名:{{ readonlyUser.name }}</p>
<p>年龄:{{ readonlyUser.age }}</p>
<p>职业:{{ readonlyUser.details.job }}</p>
</div>
<div class="buttons">
<button @click="tryUpdateReadonly">尝试修改顶层属性</button>
<button @click="tryUpdateReadonlyNested">尝试修改嵌套属性</button>
</div>
</div>
<div class="section">
<h3>2. shallowReadonly(浅层只读)</h3>
<div>
<p>姓名:{{ shallowReadonlyUser.name }}</p>
<p>年龄:{{ shallowReadonlyUser.age }}</p>
<p>职业:{{ shallowReadonlyUser.details.job }}</p>
</div>
<div class="buttons">
<button @click="tryUpdateShallowReadonly">尝试修改顶层属性</button>
<button @click="tryUpdateShallowReadonlyNested">尝试修改嵌套属性</button>
</div>
</div>
<div class="logs">
<h3>操作日志</h3>
<ul>
<li v-for="(log, index) in logs" :key="index" :class="log.type">
{{ log.message }}
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { reactive, readonly, shallowReadonly, ref } from 'vue';
// 原始响应式对象
const originalUser = reactive({
name: '张三',
age: 30,
details: {
job: '前端开发工程师',
company: 'Tech Corp'
}
});
// 完全只读对象
const readonlyUser = readonly(originalUser);
// 浅层只读对象
const shallowReadonlyUser = shallowReadonly(originalUser);
const logs = ref([]);
// 添加日志
const addLog = (message, type = 'info') => {
logs.value.push({ message, type });
// 只保留最近10条日志
if (logs.value.length > 10) {
logs.value.shift();
}
};
// 尝试修改 readonly 顶层属性
const tryUpdateReadonly = () => {
try {
readonlyUser.name = '李四';
addLog('成功修改 readonly 顶层属性', 'success');
} catch (error) {
addLog('修改 readonly 顶层属性失败:' + error.message, 'error');
}
};
// 尝试修改 readonly 嵌套属性
const tryUpdateReadonlyNested = () => {
try {
readonlyUser.details.job = '高级前端开发工程师';
addLog('成功修改 readonly 嵌套属性', 'success');
} catch (error) {
addLog('修改 readonly 嵌套属性失败:' + error.message, 'error');
}
};
// 尝试修改 shallowReadonly 顶层属性
const tryUpdateShallowReadonly = () => {
try {
shallowReadonlyUser.name = '李四';
addLog('成功修改 shallowReadonly 顶层属性', 'success');
} catch (error) {
addLog('修改 shallowReadonly 顶层属性失败:' + error.message, 'error');
}
};
// 尝试修改 shallowReadonly 嵌套属性
const tryUpdateShallowReadonlyNested = () => {
try {
shallowReadonlyUser.details.job = '高级前端开发工程师';
addLog('成功修改 shallowReadonly 嵌套属性', 'success');
} catch (error) {
addLog('修改 shallowReadonly 嵌套属性失败:' + error.message, 'error');
}
};
</script>
<style scoped>
.readonly-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.section {
margin: 20px 0;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
}
.buttons {
margin: 10px 0;
display: flex;
gap: 10px;
}
button {
padding: 6px 12px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #35495e;
}
.logs {
margin-top: 20px;
padding: 15px;
background-color: #f0f0f0;
border-radius: 6px;
}
.logs ul {
list-style: none;
padding: 0;
margin: 0;
}
.logs li {
padding: 5px 0;
}
.logs li.error {
color: red;
}
.logs li.success {
color: green;
}
.logs li.info {
color: blue;
}
</style>
运行结果预期
- 点击"尝试修改 readonly 顶层属性":控制台显示警告,修改失败
- 点击"尝试修改 readonly 嵌套属性":控制台显示警告,修改失败
- 点击"尝试修改 shallowReadonly 顶层属性":控制台显示警告,修改失败
- 点击"尝试修改 shallowReadonly 嵌套属性":修改成功,页面数据更新
调试技巧
- 观察浏览器控制台的警告信息
- 使用 Vue DevTools 查看对象的只读状态
- 对比原始对象、readonly 对象和 shallowReadonly 对象的行为差异
三、自动追踪依赖:watchEffect(含停止、暂停、恢复操作)
1. 功能说明
watchEffect 是强大的响应式 API,可自动追踪响应式数据的依赖,在依赖变化时重新执行副作用函数。与 watch 的区别在于无需显式指定依赖项,适合数据同步和副作用管理;同时支持停止、暂停、恢复侦听器的操作。
2. 技术原理
- 基于响应式系统的依赖追踪机制
- 在首次执行时收集所有访问的响应式依赖
- 当依赖变化时,重新执行副作用函数
- 支持通过返回清理函数处理资源释放
- 提供
stop、pause、resume方法控制执行
3. 适用场景
- 数据同步:如本地存储与状态同步
- 副作用管理:如 DOM 操作、网络请求
- 自动计算衍生值
- 复杂的依赖关系场景
4. 与同类技术对比
| 特性 | watchEffect | watch | computed |
|---|---|---|---|
| 依赖指定 | 自动 | 显式 | 自动 |
| 执行时机 | 立即执行 | 可配置 | 延迟执行 |
| 副作用 | 支持 | 支持 | 不支持 |
| 控制方法 | stop/pause/resume | stop | 无 |
| 适用场景 | 复杂依赖 | 特定依赖 | 计算属性 |
5. 进阶优化方案
- 使用清理函数:在副作用函数中返回清理逻辑
- 结合
onInvalidate:处理异步操作的取消 - 与
effectScope配合使用:批量管理多个副作用
6. 潜在问题及解决方案
- 问题:不必要的重新执行
解决方案:使用
watch显式指定依赖,或优化副作用函数 - 问题:内存泄漏
解决方案:及时调用
stop()或使用effectScope - 问题:异步操作竞态条件
解决方案:使用
onInvalidate取消之前的异步操作
7. Demo:watchEffect 实践
环境依赖
- Vue 3.x
操作步骤
- 创建 Vue 3 组件
- 导入并使用
watchEffect - 测试自动依赖追踪
- 验证控制方法(停止、暂停、恢复)
- 测试清理函数
代码示例
<template>
<div class="watch-effect-demo">
<h2>watchEffect 示例</h2>
<div class="section">
<h3>1. 自动依赖追踪</h3>
<div class="controls">
<label>
计数器:
<input type="number" v-model.number="count" />
</label>
<label>
乘数:
<input type="number" v-model.number="multiplier" />
</label>
</div>
<div class="result">
<p>结果:{{ result }}</p>
<p>执行次数:{{ executionCount }}</p>
</div>
</div>
<div class="section">
<h3>2. 控制方法</h3>
<div class="buttons">
<button @click="stopEffect" :disabled="isStopped">停止</button>
<button @click="pauseEffect" :disabled="isPaused || isStopped">暂停</button>
<button @click="resumeEffect" :disabled="!isPaused || isStopped">恢复</button>
<button @click="restartEffect">重启</button>
</div>
<div class="status">
<p>状态:{{ status }}</p>
</div>
</div>
<div class="section">
<h3>3. 清理函数</h3>
<div class="controls">
<label>
搜索关键词:
<input type="text" v-model="searchKeyword" placeholder="输入搜索内容" />
</label>
</div>
<div class="search-results">
<p v-if="searchLoading">搜索中...</p>
<ul v-else>
<li v-for="result in searchResults" :key="result.id">{{ result.text }}</li>
<li v-if="searchResults.length === 0 && searchKeyword">无结果</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watchEffect } from 'vue';
// 基础示例
const count = ref(0);
const multiplier = ref(2);
const result = ref(0);
const executionCount = ref(0);
// 控制方法示例
let effectControl;
const isStopped = ref(false);
const isPaused = ref(false);
const status = ref('运行中');
// 搜索示例
const searchKeyword = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
// 1. 基础 watchEffect
const basicEffect = watchEffect(() => {
result.value = count.value * multiplier.value;
executionCount.value++;
console.log('基础 watchEffect 执行,结果:', result.value);
});
// 2. 可控 watchEffect
const startEffect = () => {
effectControl = watchEffect(() => {
console.log('可控 watchEffect 执行,count:', count.value, 'multiplier:', multiplier.value);
});
isStopped.value = false;
isPaused.value = false;
status.value = '运行中';
};
startEffect();
// 停止效果
const stopEffect = () => {
effectControl.stop();
isStopped.value = true;
status.value = '已停止';
};
// 暂停效果
const pauseEffect = () => {
effectControl.pause();
isPaused.value = true;
status.value = '已暂停';
};
// 恢复效果
const resumeEffect = () => {
effectControl.resume();
isPaused.value = false;
status.value = '运行中';
};
// 重启效果
const restartEffect = () => {
stopEffect();
startEffect();
};
// 3. 带清理函数的 watchEffect
watchEffect((onInvalidate) => {
if (!searchKeyword.value) {
searchResults.value = [];
return;
}
searchLoading.value = true;
// 模拟异步搜索
const timer = setTimeout(() => {
searchResults.value = [
{ id: 1, text: `搜索结果 1: ${searchKeyword.value}` },
{ id: 2, text: `搜索结果 2: ${searchKeyword.value}` },
{ id: 3, text: `搜索结果 3: ${searchKeyword.value}` }
];
searchLoading.value = false;
}, 500);
// 清理函数:当副作用重新执行或停止时调用
onInvalidate(() => {
clearTimeout(timer);
searchLoading.value = false;
console.log('清理之前的搜索请求');
});
});
</script>
<style scoped>
.watch-effect-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.section {
margin: 20px 0;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
}
.controls {
margin: 10px 0;
display: flex;
gap: 20px;
align-items: center;
}
label {
display: flex;
align-items: center;
gap: 5px;
}
input {
padding: 5px;
border: 1px solid #ccc;
border-radius: 4px;
}
.result {
margin: 10px 0;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
.buttons {
margin: 10px 0;
display: flex;
gap: 10px;
}
button {
padding: 6px 12px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover:not(:disabled) {
background-color: #35495e;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.status {
margin: 10px 0;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
.search-results {
margin: 10px 0;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
}
.search-results ul {
list-style: none;
padding: 0;
margin: 0;
}
.search-results li {
padding: 5px 0;
border-bottom: 1px solid #eee;
}
</style>
运行结果预期
- 修改计数器或乘数:结果自动更新,执行次数增加
- 点击暂停:状态变为"已暂停",修改数据不会触发执行
- 点击恢复:状态变为"运行中",恢复自动执行
- 点击停止:状态变为"已停止",需重启才能恢复
- 输入搜索关键词:显示搜索中,500ms后显示结果,快速输入时会取消之前的请求
调试技巧
- 观察浏览器控制台的日志输出
- 使用 Vue DevTools 查看 watchEffect 的依赖关系
- 测试快速输入搜索关键词,观察清理函数的作用
- 检查网络请求是否被正确取消
四、性能优化神器:v-memo
1. 功能说明
v-memo 是用于优化列表渲染性能的指令,允许在模板中缓存列表项的渲染,仅当指定依赖项发生变化时,才重新渲染对应列表项。该指令对频繁更新的长列表性能提升显著。
2. 技术原理
- 基于虚拟 DOM 的 diff 算法优化
- 为列表项提供自定义缓存 key
- 当缓存 key 不变时,跳过该列表项的 diff 和渲染
- 可与
v-for配合使用,指定需要比较的依赖项
3. 适用场景
- 大型列表渲染(如表格、长列表)
- 频繁更新的列表(如实时数据展示)
- 列表项渲染成本较高的场景
4. 与同类技术对比
| 特性 | v-memo | v-once | v-for + key |
|---|---|---|---|
| 缓存策略 | 条件缓存 | 永久缓存 | 基于 key 复用 |
| 适用场景 | 频繁更新列表 | 静态内容 | 动态列表 |
| 渲染更新 | 依赖变化时更新 | 永不更新 | key 变化时更新 |
| 性能提升 | 显著 | 高 | 基础 |
5. 进阶优化方案
- 结合
v-for的 key 使用:确保唯一标识 - 精确指定依赖项:避免不必要的重新渲染
- 与
computed配合使用:优化计算逻辑
6. 潜在问题及解决方案
- 问题:缓存 key 设计不当导致更新不及时 解决方案:仔细分析需要响应的依赖项
- 问题:过度使用导致内存占用增加 解决方案:仅在性能敏感场景使用
7. Demo:v-memo 实践
环境依赖
- Vue 3.x
- Vite 或其他构建工具
操作步骤
- 创建 Vue 3 组件
- 实现一个大型列表
- 对比使用
v-memo和不使用v-memo的性能差异 - 测试不同依赖项配置的效果
代码示例
<template>
<div class="v-memo-demo">
<h2>v-memo 性能优化示例</h2>
<div class="controls">
<button @click="addRandomItem">添加随机项</button>
<button @click="updateAllItems">更新所有项</button>
<button @click="toggleTheme">切换主题</button>
<div class="stats">
<p>列表项数量:{{ items.length }}</p>
<p>渲染耗时对比:</p>
<ul>
<li>无 v-memo:{{ renderTimeWithoutMemo }}ms</li>
<li>有 v-memo:{{ renderTimeWithMemo }}ms</li>
<li>性能提升:{{ performanceImprovement }}%</li>
</ul>
</div>
</div>
<div class="comparison">
<div class="column">
<h3>1. 无 v-memo(普通列表)</h3>
<ul class="list" :class="theme">
<li
v-for="item in items"
:key="item.id"
class="list-item"
@click="updateItem(item)"
>
<div class="item-id">{{ item.id }}</div>
<div class="item-content">
<div class="item-title">{{ item.title }}</div>
<div class="item-value">{{ item.value }}</div>
<div class="item-timestamp">{{ item.timestamp }}</div>
</div>
<button class="item-button" @click.stop="deleteItem(item.id)">删除</button>
</li>
</ul>
</div>
<div class="column">
<h3>2. 有 v-memo(优化列表)</h3>
<ul class="list" :class="theme">
<li
v-for="item in items"
:key="item.id"
v-memo="[item.id, item.value, theme]"
class="list-item"
@click="updateItem(item)"
>
<div class="item-id">{{ item.id }}</div>
<div class="item-content">
<div class="item-title">{{ item.title }}</div>
<div class="item-value">{{ item.value }}</div>
<div class="item-timestamp">{{ item.timestamp }}</div>
</div>
<button class="item-button" @click.stop="deleteItem(item.id)">删除</button>
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue';
// 初始化数据
const items = ref([]);
const theme = ref('light');
const renderTimeWithoutMemo = ref(0);
const renderTimeWithMemo = ref(0);
// 初始化 1000 个列表项
for (let i = 0; i < 1000; i++) {
items.value.push(createItem(i));
}
// 创建列表项
function createItem(id) {
return {
id,
title: `Item ${id}`,
value: Math.random() * 1000,
timestamp: new Date().toLocaleTimeString()
};
}
// 计算性能提升
const performanceImprovement = computed(() => {
if (renderTimeWithoutMemo.value === 0) return 0;
const improvement = ((renderTimeWithoutMemo.value - renderTimeWithMemo.value) / renderTimeWithoutMemo.value) * 100;
return Math.round(improvement);
});
// 测量渲染时间
async function measureRenderTime() {
// 触发重新渲染
await nextTick();
// 模拟测量(实际项目中可使用 performance API)
// 这里使用 setTimeout 模拟不同渲染时间
renderTimeWithoutMemo.value = Math.floor(Math.random() * 50) + 20; // 20-70ms
renderTimeWithMemo.value = Math.floor(Math.random() * 10) + 5; // 5-15ms
}
// 控制方法
const addRandomItem = () => {
const newId = items.value.length;
items.value.push(createItem(newId));
measureRenderTime();
};
const updateAllItems = () => {
items.value.forEach(item => {
item.value = Math.random() * 1000;
item.timestamp = new Date().toLocaleTimeString();
});
measureRenderTime();
};
const updateItem = (item) => {
item.value = Math.random() * 1000;
item.timestamp = new Date().toLocaleTimeString();
measureRenderTime();
};
const deleteItem = (id) => {
items.value = items.value.filter(item => item.id !== id);
measureRenderTime();
};
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
measureRenderTime();
};
// 初始测量
measureRenderTime();
</script>
<style scoped>
.v-memo-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.controls {
margin: 20px 0;
padding: 15px;
background-color: #f0f0f0;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #35495e;
}
.stats {
margin-left: auto;
background-color: white;
padding: 10px;
border-radius: 4px;
border: 1px solid #ddd;
}
.stats ul {
list-style: none;
padding: 0;
margin: 5px 0 0 0;
}
.stats li {
font-size: 12px;
margin: 2px 0;
}
.comparison {
display: flex;
gap: 20px;
overflow: hidden;
}
.column {
flex: 1;
overflow: auto;
height: 500px;
border: 1px solid #eee;
border-radius: 6px;
padding: 10px;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.list-item {
display: flex;
align-items: center;
padding: 10px;
margin: 5px 0;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.list-item:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.item-id {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #42b983;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-right: 10px;
}
.item-content {
flex: 1;
}
.item-title {
font-weight: bold;
margin-bottom: 5px;
}
.item-value {
color: #666;
font-size: 14px;
}
.item-timestamp {
font-size: 12px;
color: #999;
margin-top: 3px;
}
.item-button {
padding: 4px 8px;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.item-button:hover {
background-color: #c0392b;
}
/* 主题样式 */
.list.light {
background-color: white;
}
.list.light .list-item {
background-color: #f9f9f9;
border: 1px solid #eee;
}
.list.dark {
background-color: #333;
}
.list.dark .list-item {
background-color: #444;
border: 1px solid #555;
color: white;
}
.list.dark .item-value,
.list.dark .item-timestamp {
color: #ccc;
}
</style>
运行结果预期
- 列表项数量会随着添加操作增加
- 切换主题时,使用
v-memo的列表渲染更快 - 更新单个项时,只有该特定项重新渲染
- 性能提升百分比会显示使用
v-memo后的优化效果
调试技巧
- 使用浏览器开发者工具的 Performance 面板分析渲染时间
- 观察 Vue DevTools 中的组件更新情况
- 测试不同数量级的列表项(100、1000、10000)
- 尝试不同的
v-memo依赖项配置,观察性能变化
五、简化组件双向绑定:defineModel()
1. 功能说明
defineModel() 是 Vue3.4 引入的新 API,用于简化父子组件间的双向绑定,无需显式定义 props 和 emits,可直接操作父组件传递的 v-model 数据。支持多个 v-model 和参数定义。
2. 技术原理
- 基于
props和emits的语法糖 - 自动生成
modelValueprop 和update:modelValue事件 - 支持自定义 prop 名称和事件名称
- 内部使用响应式代理实现双向绑定
3. 适用场景
- 表单组件开发
- 需要双向绑定的自定义组件
- 多值双向绑定场景
- 简化组件 API 设计
4. 与同类技术对比
| 特性 | defineModel() | 传统 v-model |
|---|---|---|
| 代码简洁度 | 高 | 中 |
| 配置复杂度 | 低 | 高 |
| 多 v-model 支持 | 原生支持 | 需要手动配置 |
| 类型安全性 | 好 | 一般 |
| 适用 Vue 版本 | 3.4+ | 3.0+ |
5. 进阶优化方案
- 结合 TypeScript 使用:提供类型定义
- 与
computed配合使用:添加自定义逻辑 - 支持默认值:通过第二个参数配置
6. 潜在问题及解决方案
- 问题:Vue 版本兼容性问题 解决方案:确保使用 Vue 3.4+ 版本
- 问题:与传统 v-model 混用导致混淆 解决方案:统一组件库的 API 设计
7. Demo:defineModel() 实践
环境依赖
- Vue 3.4+
操作步骤
- 创建 Vue 3.4+ 项目
- 实现父子组件
- 使用
defineModel()实现双向绑定 - 测试单值和多值双向绑定
代码示例
<!-- ParentComponent.vue -->
<template>
<div class="parent-component">
<h2>父组件</h2>
<div class="data-display">
<p>用户名:{{ userName }}</p>
<p>邮箱:{{ email }}</p>
<p>年龄:{{ age }}</p>
<p>主题:{{ theme }}</p>
</div>
<h3>1. 基本双向绑定</h3>
<ChildComponent v-model="userName" />
<h3>2. 带参数的双向绑定</h3>
<ChildComponentWithParams
v-model:email="email"
v-model:age="age"
/>
<h3>3. 多值双向绑定</h3>
<MultiValueComponent
v-model="userName"
v-model:email="email"
v-model:age="age"
v-model:theme="theme"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
import ChildComponentWithParams from './ChildComponentWithParams.vue';
import MultiValueComponent from './MultiValueComponent.vue';
const userName = ref('前端开发爱好者');
const email = ref('user@example.com');
const age = ref(30);
const theme = ref('light');
</script>
<style scoped>
.parent-component {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.data-display {
margin: 20px 0;
padding: 15px;
background-color: #f0f0f0;
border-radius: 6px;
}
h3 {
margin-top: 30px;
padding-top: 10px;
border-top: 1px solid #eee;
}
</style>
<!-- ChildComponent.vue -->
<template>
<div class="child-component">
<label for="username">用户名:</label>
<input
id="username"
type="text"
v-model="modelValue"
placeholder="输入用户名"
/>
<div class="info">
<p>组件内部值:{{ modelValue }}</p>
<p>字符数:{{ modelValue.length }}</p>
</div>
</div>
</template>
<script setup>
// 基本使用:无需显式定义 props 和 emits
const modelValue = defineModel();
</script>
<style scoped>
.child-component {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
margin: 10px 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 300px;
margin-bottom: 10px;
}
.info {
font-size: 14px;
color: #666;
background-color: #f9f9f9;
padding: 10px;
border-radius: 4px;
}
</style>
<!-- ChildComponentWithParams.vue -->
<template>
<div class="child-component">
<div class="field">
<label for="email">邮箱:</label>
<input
id="email"
type="email"
v-model="email"
placeholder="输入邮箱"
/>
</div>
<div class="field">
<label for="age">年龄:</label>
<input
id="age"
type="number"
v-model="age"
placeholder="输入年龄"
min="0"
max="120"
/>
</div>
<div class="info">
<p>邮箱:{{ email }}</p>
<p>年龄:{{ age }}</p>
<p>是否成年:{{ age >= 18 ? '是' : '否' }}</p>
</div>
</div>
</template>
<script setup>
// 带参数的 v-model
const email = defineModel('email');
const age = defineModel('age');
</script>
<style scoped>
.child-component {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
margin: 10px 0;
}
.field {
margin: 10px 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 300px;
}
.info {
font-size: 14px;
color: #666;
background-color: #f9f9f9;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
}
</style>
<!-- MultiValueComponent.vue -->
<template>
<div class="multi-value-component">
<h4>多值双向绑定示例</h4>
<div class="field">
<label for="multi-username">用户名:</label>
<input
id="multi-username"
type="text"
v-model="modelValue"
placeholder="输入用户名"
/>
</div>
<div class="field">
<label for="multi-email">邮箱:</label>
<input
id="multi-email"
type="email"
v-model="email"
placeholder="输入邮箱"
/>
</div>
<div class="field">
<label for="multi-age">年龄:</label>
<input
id="multi-age"
type="number"
v-model="age"
placeholder="输入年龄"
min="0"
max="120"
/>
</div>
<div class="field">
<label>主题:</label>
<div class="theme-options">
<label>
<input
type="radio"
v-model="theme"
value="light"
/>
浅色
</label>
<label>
<input
type="radio"
v-model="theme"
value="dark"
/>
深色
</label>
</div>
</div>
<div class="actions">
<button @click="resetForm">重置表单</button>
<button @click="randomizeValues">随机值</button>
</div>
</div>
</template>
<script setup>
// 多值双向绑定
const modelValue = defineModel();
const email = defineModel('email');
const age = defineModel('age');
const theme = defineModel('theme');
// 重置表单
const resetForm = () => {
modelValue.value = '';
email.value = '';
age.value = 0;
theme.value = 'light';
};
// 随机值
const randomizeValues = () => {
const names = ['张三', '李四', '王五', '赵六', '钱七'];
modelValue.value = names[Math.floor(Math.random() * names.length)];
email.value = `user${Math.floor(Math.random() * 1000)}@example.com`;
age.value = Math.floor(Math.random() * 50) + 18;
theme.value = Math.random() > 0.5 ? 'light' : 'dark';
};
</script>
<style scoped>
.multi-value-component {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
margin: 10px 0;
background-color: #f9f9f9;
}
h4 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
}
.field {
margin: 15px 0;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input[type="text"],
input[type="email"],
input[type="number"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 300px;
}
.theme-options {
display: flex;
gap: 20px;
margin-top: 5px;
}
.theme-options label {
font-weight: normal;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.actions {
margin-top: 20px;
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #35495e;
}
</style>
运行结果预期
- 修改任何子组件中的输入,父组件的数据会实时更新
- 父组件的数据变化也会反映到子组件中
- 多值双向绑定可以同时处理多个数据字段
- 重置和随机值按钮可以批量修改数据
调试技巧
- 使用 Vue DevTools 查看组件间的数据传递
- 检查事件面板,观察
update:modelValue事件的触发 - 测试不同类型的输入(文本、数字、单选按钮)
- 尝试在父组件中直接修改数据,观察子组件的更新
六、顶层 await:简化异步操作
1. 功能说明
Vue3 支持顶层 await,允许在模块顶层直接使用 await,无需将其包裹在异步函数中,适用于模块加载时需执行异步操作的场景。
2. 技术原理
- 基于 ES Module 的顶层 await 特性
- 模块加载时会等待异步操作完成
- 不会阻塞其他模块的加载
- 适用于
script setup语法
3. 适用场景
- 模块初始化时的数据加载
- 配置文件的异步加载
- 依赖其他异步资源的组件
- 简化异步逻辑,避免嵌套
4. 与同类技术对比
| 特性 | 顶层 await | 传统异步函数 |
|---|---|---|
| 语法简洁度 | 高 | 中 |
| 嵌套层级 | 无 | 可能多层 |
| 模块加载 | 等待完成 | 异步执行 |
| 适用场景 | 模块初始化 | 组件内异步操作 |
| 兼容性 | 现代浏览器 | 广泛支持 |
5. 进阶优化方案
- 结合
Suspense组件使用:处理异步组件加载 - 使用
try/catch处理错误:确保异步操作的健壮性 - 与
async setup()配合使用:兼容旧版本 Vue
6. 潜在问题及解决方案
- 问题:模块加载时间过长 解决方案:合理设计异步操作,避免不必要的等待
- 问题:错误处理不当导致模块加载失败
解决方案:使用
try/catch包裹顶层 await
7. Demo:顶层 await 实践
环境依赖
- Vue 3.x
- 现代浏览器(支持 ES Module 顶层 await)
操作步骤
- 创建 Vue 3 组件
- 使用顶层 await 加载数据
- 实现错误处理
- 与 Suspense 组件配合使用
代码示例
<template>
<div class="top-level-await-demo">
<h2>顶层 await 示例</h2>
<h3>1. 基本顶层 await</h3>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="data-display">
<h4>用户信息</h4>
<p>姓名:{{ userData.name }}</p>
<p>邮箱:{{ userData.email }}</p>
<p>年龄:{{ userData.age }}</p>
<p>地址:{{ userData.address.city }}, {{ userData.address.street }}</p>
</div>
<h3>2. 带错误处理的顶层 await</h3>
<div class="weather-container">
<h4>天气信息</h4>
<div v-if="weatherLoading" class="loading">加载中...</div>
<div v-else-if="weatherError" class="error">{{ weatherError }}</div>
<div v-else class="weather-data">
<div class="weather-main">
<div class="weather-icon">{{ weatherData.weather[0].icon }}</div>
<div class="weather-temp">{{ Math.round(weatherData.main.temp) }}°C</div>
<div class="weather-desc">{{ weatherData.weather[0].description }}</div>
</div>
<div class="weather-details">
<p>湿度:{{ weatherData.main.humidity }}%</p>
<p>气压:{{ weatherData.main.pressure }} hPa</p>
<p>风速:{{ weatherData.wind.speed }} m/s</p>
<p>城市:{{ weatherData.name }}</p>
</div>
</div>
</div>
<h3>3. 与 Suspense 配合使用</h3>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<div class="loading">组件加载中,请稍候...</div>
</template>
</Suspense>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import AsyncComponent from './AsyncComponent.vue';
// 1. 基本顶层 await
const userData = ref(null);
const loading = ref(true);
const error = ref(null);
// 模拟 API 调用
const fetchUserData = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
id: 1,
name: '张三',
email: 'zhangsan@example.com',
age: 30,
address: {
street: '科技路 123 号',
city: '北京',
country: '中国'
}
});
}, 1000);
});
};
// 顶层 await 使用
onMounted(async () => {
try {
userData.value = await fetchUserData();
} catch (err) {
error.value = '加载用户数据失败:' + err.message;
} finally {
loading.value = false;
}
});
// 2. 带错误处理的顶层 await
const weatherData = ref(null);
const weatherLoading = ref(true);
const weatherError = ref(null);
const fetchWeatherData = async () => {
try {
// 模拟 API 调用,可能失败
const shouldFail = Math.random() > 0.7; // 30% 概率失败
if (shouldFail) {
throw new Error('天气 API 服务不可用');
}
return new Promise((resolve) => {
setTimeout(() => {
resolve({
coord: { lon: 116.4074, lat: 39.9042 },
weather: [{ id: 800, main: 'Clear', description: '晴朗', icon: '☀️' }],
base: 'stations',
main: { temp: 22.5, feels_like: 21.8, temp_min: 20, temp_max: 25, pressure: 1013, humidity: 60 },
visibility: 10000,
wind: { speed: 3.5, deg: 180 },
clouds: { all: 0 },
dt: 1625097600,
sys: { type: 1, id: 9606, country: 'CN', sunrise: 1625068800, sunset: 1625115600 },
timezone: 28800,
id: 1816670,
name: '北京',
cod: 200
});
}, 1500);
});
} catch (err) {
throw err;
}
};
onMounted(async () => {
try {
weatherData.value = await fetchWeatherData();
} catch (err) {
weatherError.value = '加载天气数据失败:' + err.message;
} finally {
weatherLoading.value = false;
}
});
</script>
<style scoped>
.top-level-await-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.loading {
padding: 20px;
background-color: #f0f0f0;
border-radius: 6px;
text-align: center;
color: #666;
font-style: italic;
}
.error {
padding: 20px;
background-color: #ffebee;
border-radius: 6px;
color: #c62828;
border: 1px solid #ef9a9a;
}
.data-display {
padding: 15px;
background-color: #f9f9f9;
border-radius: 6px;
border: 1px solid #eee;
}
.data-display h4 {
margin-top: 0;
margin-bottom: 15px;
}
.weather-container {
margin: 20px 0;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
}
.weather-container h4 {
margin-top: 0;
margin-bottom: 15px;
}
.weather-data {
display: flex;
gap: 20px;
align-items: center;
}
.weather-main {
text-align: center;
}
.weather-icon {
font-size: 48px;
margin-bottom: 10px;
}
.weather-temp {
font-size: 32px;
font-weight: bold;
margin-bottom: 5px;
}
.weather-desc {
font-size: 16px;
color: #666;
text-transform: capitalize;
}
.weather-details {
flex: 1;
}
.weather-details p {
margin: 5px 0;
font-size: 14px;
}
</style>
<!-- AsyncComponent.vue -->
<template>
<div class="async-component">
<h4>异步加载的组件</h4>
<div class="posts-container">
<h5>最新文章</h5>
<ul class="posts-list">
<li v-for="post in posts" :key="post.id" class="post-item">
<div class="post-title">{{ post.title }}</div>
<div class="post-body">{{ post.body }}</div>
</li>
</ul>
</div>
<div class="comments-container">
<h5>最新评论</h5>
<ul class="comments-list">
<li v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-author">{{ comment.name }} ({{ comment.email }})</div>
<div class="comment-body">{{ comment.body }}</div>
</li>
</ul>
</div>
</div>
</template>
<script setup>
// 组件内的顶层 await
// 模拟加载文章数据
const fetchPosts = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
id: 1,
title: 'Vue3 新特性详解',
body: 'Vue3 带来了许多令人兴奋的新特性,包括 Composition API、Teleport、Suspense 等。'
},
{
id: 2,
title: '深入理解响应式系统',
body: 'Vue3 的响应式系统基于 Proxy 实现,提供了更好的性能和更强大的功能。'
},
{
id: 3,
title: '组件设计最佳实践',
body: '良好的组件设计可以提高代码的可维护性和复用性,本文介绍一些最佳实践。'
}
]);
}, 2000);
});
};
// 模拟加载评论数据
const fetchComments = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
id: 1,
name: '张三',
email: 'zhangsan@example.com',
body: '这篇文章写得很好,学到了很多东西!'
},
{
id: 2,
name: '李四',
email: 'lisi@example.com',
body: '感谢分享,期待更多优质内容。'
}
]);
}, 1500);
});
};
// 并行加载数据
const [posts, comments] = await Promise.all([fetchPosts(), fetchComments()]);
</script>
<style scoped>
.async-component {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
background-color: #f9f9f9;
}
.async-component h4 {
margin-top: 0;
margin-bottom: 20px;
}
.posts-container,
.comments-container {
margin-bottom: 20px;
padding: 10px;
background-color: white;
border-radius: 4px;
border: 1px solid #ddd;
}
h5 {
margin-top: 0;
margin-bottom: 15px;
color: #333;
}
.posts-list,
.comments-list {
list-style: none;
padding: 0;
margin: 0;
}
.post-item,
.comment-item {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.post-item:last-child,
.comment-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.post-title {
font-weight: bold;
margin-bottom: 5px;
}
.post-body {
font-size: 14px;
color: #666;
line-height: 1.4;
}
.comment-author {
font-size: 12px;
font-weight: bold;
margin-bottom: 5px;
color: #42b983;
}
.comment-body {
font-size: 14px;
color: #666;
line-height: 1.4;
}
</style>
运行结果预期
- 页面加载时会显示加载状态
- 1秒后显示用户信息
- 1.5秒后显示天气信息(可能成功或失败,30%概率失败)
- 2秒后显示异步组件内容,包含文章和评论
- 天气信息可能会显示错误状态,演示错误处理
调试技巧
- 使用浏览器开发者工具的 Network 面板分析请求
- 观察控制台的错误信息
- 尝试修改
shouldFail变量的值,测试不同场景 - 与传统的
async/await写法进行对比
七、动态组件:
1. 功能说明
<component> 是用于动态渲染组件的标签,支持在同一位置加载不同组件,提升用户体验,分为基本用法和高级用法(异步组件)。
2. 技术原理
- 基于 Vue 的组件系统和虚拟 DOM
- 通过
is属性动态绑定组件名称或组件对象 - 支持同步和异步组件加载
- 可与
keep-alive配合使用,缓存组件状态
3. 适用场景
- 标签页切换
- 动态表单
- 条件渲染不同组件
- 异步加载大型组件
- 插件系统或可扩展架构
4. 与同类技术对比
| 特性 | v-if/v-else-if | keep-alive + component | |
|---|---|---|---|
| 动态切换 | 支持 | 支持 | 支持 |
| 组件缓存 | 可选 | 不支持 | 支持 |
| 异步加载 | 支持 | 不直接支持 | 支持 |
| 代码简洁度 | 高 | 中 | 中 |
| 性能 | 高 | 中 | 高 |
| 适用场景 | 频繁切换 | 条件渲染 | 需缓存状态的切换 |
5. 进阶优化方案
- 结合
keep-alive使用:缓存不活跃的组件,减少重新渲染开销 - 使用
component的is属性绑定组件对象而非名称:提高类型安全性 - 与异步组件配合
Suspense:实现优雅的加载状态
6. 潜在问题及解决方案
- 问题:动态组件频繁切换导致性能问题
解决方案:使用
keep-alive缓存组件状态 - 问题:异步组件加载失败
解决方案:使用
onErrorCaptured捕获错误,或在Suspense中提供错误状态
7. Demo:动态组件实践
环境依赖
- Vue 3.x
操作步骤
- 创建多个组件
- 使用
<component>动态渲染 - 实现组件切换逻辑
- 测试同步和异步组件加载
- 验证
keep-alive的缓存效果
代码示例
<template>
<div class="dynamic-component-demo">
<h2>动态组件示例</h2>
<div class="controls">
<h3>1. 基本动态组件</h3>
<div class="button-group">
<button
v-for="component in availableComponents"
:key="component.name"
@click="currentComponent = component.name"
:class="{ active: currentComponent === component.name }"
>
{{ component.label }}
</button>
</div>
<div class="component-container">
<component :is="currentComponent" />
</div>
<h3>2. 带缓存的动态组件</h3>
<div class="button-group">
<button
v-for="component in availableComponents"
:key="component.name"
@click="cachedComponent = component.name"
:class="{ active: cachedComponent === component.name }"
>
{{ component.label }}
</button>
</div>
<div class="component-container">
<keep-alive>
<component :is="cachedComponent" />
</keep-alive>
</div>
<h3>3. 异步动态组件</h3>
<div class="button-group">
<button
v-for="component in asyncComponents"
:key="component.name"
@click="loadAsyncComponent(component.name)"
:disabled="loadingAsync"
>
{{ component.label }} <span v-if="loadingAsync && currentAsyncComponent === component.name">(加载中...)</span>
</button>
</div>
<div class="component-container">
<Suspense>
<template #default>
<component :is="currentAsyncComponent" v-if="currentAsyncComponent" />
<div v-else class="placeholder">请选择一个组件加载</div>
</template>
<template #fallback>
<div class="loading">组件加载中,请稍候...</div>
</template>
</Suspense>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue';
// 导入同步组件
import ComponentA from './ComponentA.vue';
import ComponentB from './ComponentB.vue';
import ComponentC from './ComponentC.vue';
// 定义异步组件
const AsyncComponentD = defineAsyncComponent(() => import('./AsyncComponentD.vue'));
const AsyncComponentE = defineAsyncComponent(() => import('./AsyncComponentE.vue'));
// 可用组件列表
const availableComponents = [
{ name: 'ComponentA', label: '组件 A' },
{ name: 'ComponentB', label: '组件 B' },
{ name: 'ComponentC', label: '组件 C' }
];
// 异步组件列表
const asyncComponents = [
{ name: 'AsyncComponentD', label: '异步组件 D' },
{ name: 'AsyncComponentE', label: '异步组件 E' }
];
// 当前激活的组件
const currentComponent = ref('ComponentA');
const cachedComponent = ref('ComponentA');
const currentAsyncComponent = ref(null);
const loadingAsync = ref(false);
// 加载异步组件
const loadAsyncComponent = async (componentName) => {
loadingAsync.value = true;
try {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
currentAsyncComponent.value = componentName;
} catch (error) {
console.error('加载异步组件失败:', error);
} finally {
loadingAsync.value = false;
}
};
</script>
<style scoped>
.dynamic-component-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
}
.controls {
display: flex;
flex-direction: column;
gap: 20px;
}
.button-group {
display: flex;
gap: 10px;
margin: 10px 0;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background-color: #35495e;
}
button.active {
background-color: #35495e;
font-weight: bold;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.component-container {
border: 1px solid #eee;
border-radius: 6px;
padding: 20px;
min-height: 150px;
background-color: #f9f9f9;
}
.loading {
text-align: center;
color: #666;
padding: 20px;
font-style: italic;
}
.placeholder {
text-align: center;
color: #999;
padding: 20px;
font-style: italic;
}
</style>
<!-- ComponentA.vue -->
<template>
<div class="component-a">
<h4>组件 A</h4>
<p>这是一个简单的组件 A</p>
<div class="counter">
<p>计数:{{ count }}</p>
<button @click="count++">增加</button>
<button @click="count--">减少</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<style scoped>
.component-a {
padding: 15px;
background-color: #e3f2fd;
border-radius: 6px;
border: 1px solid #bbdefb;
}
.component-a h4 {
margin-top: 0;
color: #1565c0;
}
.counter {
margin-top: 15px;
padding: 10px;
background-color: white;
border-radius: 4px;
display: flex;
align-items: center;
gap: 10px;
}
.counter button {
padding: 4px 8px;
font-size: 12px;
}
</style>
<!-- ComponentB.vue -->
<template>
<div class="component-b">
<h4>组件 B</h4>
<p>这是一个简单的组件 B</p>
<div class="input-section">
<label for="input-b">输入内容:</label>
<input
id="input-b"
type="text"
v-model="inputValue"
placeholder="请输入..."
/>
<p>输入结果:{{ inputValue }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const inputValue = ref('');
</script>
<style scoped>
.component-b {
padding: 15px;
background-color: #f3e5f5;
border-radius: 6px;
border: 1px solid #e1bee7;
}
.component-b h4 {
margin-top: 0;
color: #7b1fa2;
}
.input-section {
margin-top: 15px;
}
.input-section label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.input-section input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 200px;
margin-bottom: 10px;
}
</style>
<!-- ComponentC.vue -->
<template>
<div class="component-c">
<h4>组件 C</h4>
<p>这是一个简单的组件 C</p>
<div class="checkbox-section">
<h5>选择兴趣爱好:</h5>
<label v-for="hobby in hobbies" :key="hobby.value">
<input
type="checkbox"
v-model="selectedHobbies"
:value="hobby.value"
/>
{{ hobby.label }}
</label>
<p>已选择:{{ selectedHobbies.join(', ') || '无' }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const hobbies = [
{ label: '阅读', value: 'reading' },
{ label: '运动', value: 'sports' },
{ label: '音乐', value: 'music' },
{ label: '旅行', value: 'travel' }
];
const selectedHobbies = ref([]);
</script>
<style scoped>
.component-c {
padding: 15px;
background-color: #e8f5e8;
border-radius: 6px;
border: 1px solid #c8e6c9;
}
.component-c h4 {
margin-top: 0;
color: #2e7d32;
}
.checkbox-section {
margin-top: 15px;
}
.checkbox-section h5 {
margin: 0 0 10px 0;
font-size: 14px;
}
.checkbox-section label {
display: block;
margin: 5px 0;
cursor: pointer;
}
</style>
<!-- AsyncComponentD.vue -->
<template>
<div class="async-component-d">
<h4>异步组件 D</h4>
<p>这是一个异步加载的组件 D</p>
<div class="async-content">
<p>加载时间:{{ loadTime }}</p>
<p>随机数:{{ randomNumber }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const loadTime = ref('');
const randomNumber = ref(0);
onMounted(() => {
loadTime.value = new Date().toLocaleTimeString();
randomNumber.value = Math.floor(Math.random() * 1000);
});
</script>
<style scoped>
.async-component-d {
padding: 15px;
background-color: #fff3e0;
border-radius: 6px;
border: 1px solid #ffe0b2;
}
.async-component-d h4 {
margin-top: 0;
color: #ef6c00;
}
.async-content {
margin-top: 15px;
padding: 10px;
background-color: white;
border-radius: 4px;
}
</style>
<!-- AsyncComponentE.vue -->
<template>
<div class="async-component-e">
<h4>异步组件 E</h4>
<p>这是一个异步加载的组件 E</p>
<div class="async-data">
<h5>模拟数据</h5>
<ul>
<li v-for="item in data" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const data = ref([]);
onMounted(async () => {
// 模拟异步数据加载
await new Promise(resolve => setTimeout(resolve, 300));
data.value = [
{ id: 1, name: '数据项 1' },
{ id: 2, name: '数据项 2' },
{ id: 3, name: '数据项 3' },
{ id: 4, name: '数据项 4' }
];
});
</script>
<style scoped>
.async-component-e {
padding: 15px;
background-color: #fce4ec;
border-radius: 6px;
border: 1px solid #f8bbd0;
}
.async-component-e h4 {
margin-top: 0;
color: #c2185b;
}
.async-data {
margin-top: 15px;
}
.async-data h5 {
margin: 0 0 10px 0;
font-size: 14px;
}
.async-data ul {
list-style: none;
padding: 0;
margin: 0;
background-color: white;
border-radius: 4px;
padding: 10px;
}
.async-data li {
padding: 5px 0;
border-bottom: 1px solid #eee;
}
.async-data li:last-child {
border-bottom: none;
}
</style>
运行结果预期
- 点击不同的组件按钮,会在同一位置渲染对应的组件
- 基本动态组件切换时,组件状态会重置
- 带缓存的动态组件切换时,组件状态会保留
- 异步组件加载时会显示加载状态
- 异步组件加载完成后会显示其内容
调试技巧
- 使用 Vue DevTools 观察组件的创建和销毁
- 对比带缓存和不带缓存的组件行为差异
- 观察网络面板,确认异步组件的加载情况
- 尝试修改异步组件的加载延迟,测试不同场景
八、空间传送门:
1. 功能说明
<Teleport> 用于将组件内容渲染到指定 DOM 节点中,可解决弹窗、下拉菜单等组件的层级和样式问题。
2. 技术原理
- 基于虚拟 DOM 的渲染机制
- 将组件的虚拟 DOM 渲染到指定的真实 DOM 节点
- 保持组件的逻辑上下文不变
- 仅改变组件的渲染位置
3. 适用场景
- 弹窗组件
- 下拉菜单
- 通知消息
- 模态对话框
- 需要突破父组件样式限制的组件
4. 与同类技术对比
| 特性 | Teleport | 普通组件 | Portal (React) |
|---|---|---|---|
| 渲染位置 | 指定 DOM 节点 | 父组件内 | 指定 DOM 节点 |
| 逻辑上下文 | 保持不变 | 保持不变 | 保持不变 |
| 样式隔离 | 突破父组件限制 | 受父组件限制 | 突破父组件限制 |
| 适用场景 | 弹窗、菜单等 | 普通组件 | 弹窗、菜单等 |
| Vue 原生支持 | 是 | 是 | 否 |
5. 进阶优化方案
- 结合
v-if使用:控制传送内容的显示和隐藏 - 与
transition配合使用:实现平滑的过渡效果 - 动态指定
to属性:根据条件渲染到不同位置
6. 潜在问题及解决方案
- 问题:传送目标节点不存在 解决方案:确保目标节点在组件渲染前已经存在,或使用条件渲染
- 问题:样式冲突 解决方案:使用 CSS Modules 或 scoped 样式,避免全局样式冲突
7. Demo:Teleport 实践
环境依赖
- Vue 3.x
操作步骤
- 创建包含 Teleport 的组件
- 指定传送目标
- 实现显示/隐藏逻辑
- 添加过渡效果
- 测试不同传送目标
代码示例
<template>
<div class="teleport-demo">
<h2>Teleport 示例</h2>
<div class="demo-section">
<h3>1. 基本 Teleport</h3>
<button @click="showModal = true">打开模态框</button>
<Teleport to="body">
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h4>模态框标题</h4>
<button class="close-btn" @click="closeModal">×</button>
</div>
<div class="modal-body">
<p>这是一个使用 Teleport 渲染的模态框,它被传送到了 body 元素中,不受父组件样式限制。</p>
<p>当前点击次数:{{ clickCount }}</p>
<button @click="clickCount++" class="count-btn">增加计数</button>
</div>
<div class="modal-footer">
<button @click="closeModal" class="close-btn">关闭</button>
</div>
</div>
</div>
</Teleport>
</div>
<div class="demo-section">
<h3>2. 带过渡效果的 Teleport</h3>
<button @click="showNotification = true">显示通知</button>
<Teleport to=".notification-container">
<Transition name="slide">
<div v-if="showNotification" class="notification">
<div class="notification-content">
<span class="notification-icon">ℹ️</span>
<p>{{ notificationMessage }}</p>
</div>
<button class="notification-close" @click="hideNotification">×</button>
</div>
</Transition>
</Teleport>
<div class="notification-container"></div>
</div>
<div class="demo-section">
<h3>3. 动态 Teleport 目标</h3>
<div class="target-options">
<label>
<input
type="radio"
v-model="teleportTarget"
value="body"
/>
传送到 body
</label>
<label>
<input
type="radio"
v-model="teleportTarget"
value=".custom-target"
/>
传送到自定义容器
</label>
</div>
<button @click="showFloatingPanel = true">显示浮动面板</button>
<Teleport :to="teleportTarget">
<div v-if="showFloatingPanel" class="floating-panel">
<h4>浮动面板</h4>
<p>这个面板被传送到了:{{ teleportTarget === 'body' ? 'body' : '自定义容器' }}</p>
<button @click="showFloatingPanel = false">关闭</button>
</div>
</Teleport>
<div class="custom-target"></div>
</div>
<div class="demo-section">
<h3>4. 嵌套 Teleport</h3>
<button @click="showNestedModal = true">打开嵌套模态框</button>
<!-- 外层模态框 -->
<Teleport to="body">
<div v-if="showNestedModal" class="modal-overlay" @click.self="closeNestedModal">
<div class="modal nested-modal">
<div class="modal-header">
<h4>外层模态框</h4>
<button class="close-btn" @click="closeNestedModal">×</button>
</div>
<div class="modal-body">
<p>这是外层模态框,内部包含一个嵌套的模态框。</p>
<button @click="showInnerModal = true">打开内层模态框</button>
<!-- 内层模态框 -->
<Teleport to="body">
<div v-if="showInnerModal" class="modal-overlay inner-overlay" @click.self="closeInnerModal">
<div class="modal inner-modal">
<div class="modal-header">
<h4>内层模态框</h4>
<button class="close-btn" @click="closeInnerModal">×</button>
</div>
<div class="modal-body">
<p>这是内层模态框,它也被传送到了 body 元素中。</p>
<p>内层模态框可以独立于外层模态框关闭。</p>
</div>
<div class="modal-footer">
<button @click="closeInnerModal" class="close-btn">关闭内层</button>
</div>
</div>
</div>
</Teleport>
</div>
<div class="modal-footer">
<button @click="closeNestedModal" class="close-btn">关闭外层</button>
</div>
</div>
</div>
</Teleport>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
// 基本模态框
const showModal = ref(false);
const clickCount = ref(0);
const closeModal = () => {
showModal.value = false;
};
// 通知消息
const showNotification = ref(false);
const notificationMessage = ref('这是一条通知消息!');
const hideNotification = () => {
showNotification.value = false;
};
// 动态目标
const teleportTarget = ref('body');
const showFloatingPanel = ref(false);
// 嵌套模态框
const showNestedModal = ref(false);
const showInnerModal = ref(false);
const closeNestedModal = () => {
showNestedModal.value = false;
showInnerModal.value = false;
};
const closeInnerModal = () => {
showInnerModal.value = false;
};
</script>
<style>
/* 全局样式,用于演示 Teleport 效果 */
.notification-container {
position: fixed;
top: 20px;
right: 20px;
width: 300px;
z-index: 1000;
}
.custom-target {
position: fixed;
bottom: 20px;
left: 20px;
width: 200px;
height: 200px;
border: 2px dashed #42b983;
border-radius: 8px;
z-index: 1000;
}
</style>
<style scoped>
.teleport-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
max-width: 800px;
margin: 0 auto;
position: relative;
/* 添加一些复杂样式,测试 Teleport 突破样式限制 */
overflow: hidden;
background-color: #f9f9f9;
}
.demo-section {
margin: 20px 0;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
background-color: white;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background-color: #35495e;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 500px;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background-color: #f5f5f5;
border-bottom: 1px solid #eee;
}
.modal-header h4 {
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background-color: #eee;
color: #333;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 15px;
background-color: #f5f5f5;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.count-btn {
margin-top: 10px;
}
/* 通知样式 */
.notification {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid #42b983;
}
.notification-content {
display: flex;
align-items: center;
gap: 10px;
}
.notification-icon {
font-size: 20px;
}
.notification-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
padding: 0;
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.notification-close:hover {
background-color: #eee;
color: #333;
}
/* 浮动面板样式 */
.floating-panel {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
padding: 15px;
width: 250px;
}
.floating-panel h4 {
margin-top: 0;
margin-bottom: 10px;
color: #42b983;
}
/* 目标选项样式 */
.target-options {
margin: 10px 0;
display: flex;
gap: 20px;
}
.target-options label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
/* 嵌套模态框样式 */
.nested-modal {
max-width: 600px;
}
.inner-overlay {
background-color: rgba(0, 0, 0, 0.7);
}
.inner-modal {
max-width: 400px;
border: 2px solid #42b983;
}
/* 过渡效果 */
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-leave-to {
transform: translateX(100%);
opacity: 0;
}
</style>
运行结果预期
- 点击"打开模态框":在页面中央显示模态框,不受父组件样式限制
- 点击"显示通知":在页面右上角显示通知消息,带过渡效果
- 选择不同的传送目标,点击"显示浮动面板":面板会渲染到所选目标位置
- 点击"打开嵌套模态框":显示外层模态框,再点击"打开内层模态框":显示内层模态框
调试技巧
- 使用浏览器开发者工具查看 DOM 结构,确认 Teleport 内容的渲染位置
- 检查样式,确认 Teleport 内容是否突破了父组件的样式限制
- 测试嵌套 Teleport 的行为
- 尝试修改传送目标,观察不同结果
九、隐形容器:Fragment
1. 功能说明
Fragment 允许组件模板中无需根节点,减少多余 DOM 节点,提升渲染性能,对列表组件尤为实用。
2. 技术原理
- 基于虚拟 DOM 的特性
- 允许模板中存在多个根节点
- 渲染时不会生成额外的 DOM 元素
- 保持组件的逻辑结构不变
3. 适用场景
- 列表项组件
- 表格行组件
- 需要返回多个同级元素的组件
- 减少 DOM 层级,提升性能
4. 与同类技术对比
| 特性 | Fragment | 普通根节点 | 数组返回 |
|---|---|---|---|
| 根节点要求 | 无 | 必须有一个 | 无 |
| DOM 节点数量 | 少 | 多 | 少 |
| 渲染性能 | 高 | 中 | 高 |
| 模板语法 | 简洁 | 常规 | 复杂 |
| Vue 原生支持 | 是 | 是 | 否 |
5. 进阶优化方案
- 与
v-for配合使用:渲染多个列表项 - 与
v-if配合使用:条件渲染多个元素 - 结合
key属性:在列表渲染中提供唯一标识
6. 潜在问题及解决方案
- 问题:模板中忘记使用 Fragment,导致渲染错误 解决方案:确保模板中要么有一个根节点,要么使用 Fragment
- 问题:样式应用问题 解决方案:使用 CSS 选择器时,注意 Fragment 不会生成 DOM 节点
7. Demo:Fragment 实践
环境依赖
- Vue 3.x
操作步骤
- 创建使用 Fragment 的组件
- 实现多根节点模板
- 测试列表渲染
- 验证 DOM 结构
代码示例
<template>
<div class="fragment-demo">
<h2>Fragment 示例</h2>
<div class="demo-section">
<h3>1. 基本 Fragment</h3>
<p>下面的组件使用了 Fragment,没有额外的根节点:</p>
<BasicFragmentComponent />
</div>
<div class="demo-section">
<h3>2. Fragment 与 v-for</h3>
<p>使用 Fragment 渲染列表项,减少多余 DOM 节点:</p>
<FragmentListComponent :items="listItems" />
</div>
<div class="demo-section">
<h3>3. Fragment 与 v-if</h3>
<p>条件渲染多个元素:</p>
<FragmentWithIfComponent :show="showContent" />
<button @click="showContent = !showContent">
{{ showContent ? '隐藏' : '显示' }} 内容
</button>
</div>
<div class="demo-section">
<h3>4. 表格中的 Fragment</h3>
<p>在表格中使用 Fragment 渲染行和列:</p>
<table class="fragment-table">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>年龄</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<TableFragmentComponent
v-for="user in users"
:key="user.id"
:user="user"
/>
</tbody>
</table>
</div>
<div class="demo-section">
<h3>5. DOM 结构对比</h3>
<div class="dom-comparison">
<div class="comparison-item">
<h4>使用 Fragment</h4>
<div class="dom-preview">
<div class="dom-node">父组件</div>
<div class="dom-node fragment-child">子元素 1</div>
<div class="dom-node fragment-child">子元素 2</div>
<div class="dom-node fragment-child">子元素 3</div>
</div>
</div>
<div class="comparison-item">
<h4>不使用 Fragment</h4>
<div class="dom-preview">
<div class="dom-node">父组件</div>
<div class="dom-node">根元素(多余)</div>
<div class="dom-node">子元素 1</div>
<div class="dom-node">子元素 2</div>
<div class="dom-node">子元素 3</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import BasicFragmentComponent from './BasicFragmentComponent.vue';
import FragmentListComponent from './FragmentListComponent.vue';
import FragmentWithIfComponent from './FragmentWithIfComponent.vue';
import TableFragmentComponent from './TableFragmentComponent.vue';
const showContent = ref(true);
const listItems = [
{ id: 1, title: '项目 1', content: '这是项目 1 的内容' },
{ id: 2, title: '项目 2', content: '这是项目 2 的内容' },
{ id: 3, title: '项目 3', content: '这是项目 3 的内容' }
];
const users = [
{ id: 1, name: '张三', age: 30 },
{ id: 2, name: '李四', age: 25 },
{ id: 3, name: '王五', age: 35 }
];
</script>
<style scoped>
.fragment-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
max-width: 800px;
margin: 0 auto;
}
.demo-section {
margin: 20px 0;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
background-color: white;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background-color: #35495e;
}
/* 表格样式 */
.fragment-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.fragment-table th,
.fragment-table td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
}
.fragment-table th {
background-color: #f5f5f5;
font-weight: bold;
}
.fragment-table tr:nth-child(even) {
background-color: #f9f9f9;
}
/* DOM 结构对比样式 */
.dom-comparison {
display: flex;
gap: 20px;
margin-top: 10px;
}
.comparison-item {
flex: 1;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
background-color: #f9f9f9;
}
.comparison-item h4 {
margin-top: 0;
margin-bottom: 10px;
font-size: 14px;
color: #42b983;
}
.dom-preview {
padding: 10px;
background-color: white;
border-radius: 4px;
border: 1px solid #ddd;
}
.dom-node {
padding: 5px;
margin: 2px 0;
background-color: #e3f2fd;
border-radius: 3px;
font-size: 12px;
position: relative;
}
.fragment-child {
margin-left: 20px;
background-color: #e8f5e8;
}
.dom-node::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 1px;
background-color: #999;
}
.fragment-child::before {
left: -20px;
}
</style>
<!-- BasicFragmentComponent.vue -->
<template>
<!-- 没有根节点,直接使用 Fragment -->
<h4>Fragment 组件</h4>
<p>这是第一个段落</p>
<p>这是第二个段落</p>
<div class="fragment-content">
<p>这是包含在 div 中的内容</p>
<button @click="count++">点击次数:{{ count }}</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
<style scoped>
.fragment-content {
margin: 10px 0;
padding: 10px;
background-color: #f0f0f0;
border-radius: 4px;
}
button {
padding: 4px 8px;
font-size: 12px;
}
</style>
<!-- FragmentListComponent.vue -->
<template>
<!-- 使用 Fragment 渲染列表项,没有额外的 ul 或 div 包装 -->
<template v-for="item in items" :key="item.id">
<div class="list-item">
<h5>{{ item.title }}</h5>
<p>{{ item.content }}</p>
</div>
<hr />
</template>
</template>
<script setup>
const props = defineProps({
items: {
type: Array,
required: true
}
});
</script>
<style scoped>
.list-item {
margin: 10px 0;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}
.list-item h5 {
margin-top: 0;
margin-bottom: 5px;
color: #42b983;
}
hr {
border: none;
border-top: 1px solid #ddd;
margin: 10px 0;
}
</style>
<!-- FragmentWithIfComponent.vue -->
<template>
<!-- 使用 Fragment 条件渲染多个元素 -->
<template v-if="show">
<h4>条件显示的内容</h4>
<p>这是条件渲染的第一个段落</p>
<p>这是条件渲染的第二个段落</p>
<div class="conditional-content">
<p>这是条件渲染的额外内容</p>
</div>
</template>
<p v-else>内容已隐藏</p>
</template>
<script setup>
const props = defineProps({
show: {
type: Boolean,
default: false
}
});
</script>
<style scoped>
.conditional-content {
margin: 10px 0;
padding: 10px;
background-color: #fff3e0;
border-radius: 4px;
}
</style>
<!-- TableFragmentComponent.vue -->
<template>
<!-- 在表格中使用 Fragment 渲染行 -->
<tr>
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.age }}</td>
<td>
<button @click="handleEdit">编辑</button>
<button @click="handleDelete">删除</button>
</td>
</tr>
<!-- 可以根据条件渲染额外的行 -->
<tr v-if="user.age > 30" class="highlight-row">
<td colspan="4">
<span class="warning">注意:该用户年龄超过 30 岁</span>
</td>
</tr>
</template>
<script setup>
const props = defineProps({
user: {
type: Object,
required: true
}
});
const handleEdit = () => {
console.log('编辑用户:', props.user);
};
const handleDelete = () => {
console.log('删除用户:', props.user);
};
</script>
<style scoped>
.highlight-row {
background-color: #fff3e0 !important;
}
.warning {
color: #f57c00;
font-weight: bold;
}
button {
padding: 4px 8px;
margin-right: 5px;
font-size: 12px;
background-color: #2196f3;
}
button:hover {
background-color: #1976d2;
}
button:last-child {
background-color: #f44336;
}
button:last-child:hover {
background-color: #d32f2f;
}
</style>
运行结果预期
- 基本 Fragment 组件会渲染多个元素,没有额外的根节点
- Fragment 与 v-for 配合使用,会渲染多个列表项,每个列表项后有一条分割线
- 点击"显示/隐藏内容"按钮,可以切换条件渲染的多个元素
- 表格中会渲染用户数据,年龄超过 30 岁的用户会显示额外的提示行
- DOM 结构对比会显示使用和不使用 Fragment 的差异
调试技巧
- 使用浏览器开发者工具查看 DOM 结构,确认 Fragment 不会生成额外的 DOM 节点
- 对比使用和不使用 Fragment 的组件渲染结果
- 测试不同条件下的渲染行为
- 观察表格渲染中的行结构
十、自定义指令:封装可重用逻辑(v-debounce 实现)
1. 功能说明
自定义指令用于封装可重用逻辑,以下示例实现防抖指令 v-debounce,可控制事件触发频率(如按钮点击防抖)。
2. 技术原理
- 基于 Vue 的指令系统
- 通过钩子函数(mounted、updated、unmounted)实现指令逻辑
- 可以访问元素、绑定值、修饰符等
- 支持全局和局部注册
3. 适用场景
- 表单输入防抖
- 按钮点击防抖
- 滚动事件节流
- 窗口 resize 事件节流
- 其他需要控制事件频率的场景
4. 与同类技术对比
| 特性 | 自定义指令 | 组件 | 普通事件处理 |
|---|---|---|---|
| 复用性 | 高 | 高 | 低 |
| 适用场景 | DOM 操作、事件处理 | 复杂 UI 组件 | 简单事件处理 |
| 代码简洁度 | 高 | 中 | 低 |
| 性能 | 高 | 中 | 中 |
| 学习曲线 | 中 | 低 | 低 |
5. 进阶优化方案
- 支持多种事件类型:通过指令参数指定事件类型
- 支持自定义延迟时间:通过修饰符或绑定值指定
- 支持立即执行:添加修饰符控制是否立即执行
- 与 TypeScript 配合使用:提供类型定义
6. 潜在问题及解决方案
- 问题:指令逻辑复杂,难以维护 解决方案:将复杂逻辑拆分为多个函数,或考虑使用组件
- 问题:内存泄漏
解决方案:在
unmounted钩子中清理事件监听器和定时器
7. Demo:自定义指令实践
环境依赖
- Vue 3.x
操作步骤
- 创建自定义指令
- 注册指令(全局或局部)
- 在模板中使用指令
- 测试不同配置
- 验证防抖效果
代码示例
<template>
<div class="custom-directive-demo">
<h2>自定义指令示例</h2>
<div class="demo-section">
<h3>1. 基本防抖指令</h3>
<p>点击按钮,300ms 内多次点击只会执行一次:</p>
<button v-debounce="handleClick">点击我(防抖)</button>
<p>点击次数:{{ clickCount }}</p>
<p>执行次数:{{ executeCount }}</p>
</div>
<div class="demo-section">
<h3>2. 自定义延迟时间</h3>
<p>点击按钮,1000ms 内多次点击只会执行一次:</p>
<button v-debounce:click="{ handler: handleClick, delay: 1000 }">点击我(1秒防抖)</button>
</div>
<div class="demo-section">
<h3>3. 输入框防抖</h3>
<p>输入内容,500ms 内没有输入才会执行搜索:</p>
<div class="input-group">
<input
type="text"
v-model="searchKeyword"
v-debounce:input="handleSearch"
placeholder="输入搜索关键词..."
/>
<p v-if="searchLoading">搜索中...</p>
<p v-else-if="searchResults.length > 0">搜索结果:{{ searchResults.length }} 项</p>
<p v-else-if="searchKeyword">无搜索结果</p>
</div>
</div>
<div class="demo-section">
<h3>4. 立即执行防抖</h3>
<p>点击按钮,立即执行一次,然后 500ms 内不再执行:</p>
<button v-debounce:click.immediate="handleClick">点击我(立即执行)</button>
</div>
<div class="demo-section">
<h3>5. 多种事件类型</h3>
<p>鼠标移动防抖,100ms 内只执行一次:</p>
<div
class="mouse-tracker"
v-debounce:mousemove="handleMouseMove"
v-debounce:click="handleClick"
>
<p>鼠标位置:{{ mousePosition.x }}, {{ mousePosition.y }}</p>
<p>移动次数:{{ mouseMoveCount }}</p>
<p>点击这个区域测试多种事件防抖</p>
</div>
</div>
<div class="demo-section">
<h3>6. 指令生命周期演示</h3>
<p>动态添加/移除指令,观察生命周期:</p>
<button @click="toggleDirective">
{{ hasDirective ? '移除' : '添加' }} 指令
</button>
<div v-if="hasDirective">
<button v-debounce="handleClick">带指令的按钮</button>
</div>
<div v-else>
<button @click="handleClick">普通按钮</button>
</div>
<div class="lifecycle-logs">
<h4>指令生命周期日志:</h4>
<ul>
<li v-for="(log, index) in lifecycleLogs" :key="index" class="log-item">
{{ log }}
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { vDebounce } from './directives/debounce';
// 注册局部指令
const app = {
directives: {
debounce: vDebounce
}
};
// 基本示例
const clickCount = ref(0);
const executeCount = ref(0);
const handleClick = () => {
clickCount.value++;
executeCount.value++;
console.log('点击事件执行');
};
// 搜索示例
const searchKeyword = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
const handleSearch = () => {
if (!searchKeyword.value) {
searchResults.value = [];
return;
}
searchLoading.value = true;
// 模拟搜索请求
setTimeout(() => {
searchResults.value = [
{ id: 1, text: `搜索结果 1: ${searchKeyword.value}` },
{ id: 2, text: `搜索结果 2: ${searchKeyword.value}` }
];
searchLoading.value = false;
}, 300);
};
// 鼠标移动示例
const mousePosition = ref({ x: 0, y: 0 });
const mouseMoveCount = ref(0);
const handleMouseMove = (event) => {
mousePosition.value = {
x: event.clientX,
y: event.clientY
};
mouseMoveCount.value++;
};
// 生命周期示例
const hasDirective = ref(true);
const lifecycleLogs = ref([]);
const toggleDirective = () => {
hasDirective.value = !hasDirective.value;
};
</script>
<style scoped>
.custom-directive-demo {
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
max-width: 800px;
margin: 0 auto;
}
.demo-section {
margin: 20px 0;
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
background-color: white;
}
button {
padding: 8px 16px;
background-color: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
background-color: #35495e;
}
.input-group {
margin: 10px 0;
}
.input-group input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
width: 300px;
margin-bottom: 10px;
}
.mouse-tracker {
margin: 10px 0;
padding: 20px;
background-color: #f0f0f0;
border-radius: 4px;
min-height: 100px;
cursor: crosshair;
border: 2px dashed #42b983;
}
.mouse-tracker p {
margin: 5px 0;
}
.lifecycle-logs {
margin-top: 15px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
.lifecycle-logs h4 {
margin-top: 0;
margin-bottom: 10px;
font-size: 14px;
color: #42b983;
}
.lifecycle-logs ul {
list-style: none;
padding: 0;
margin: 0;
}
.log-item {
padding: 5px;
margin: 2px 0;
background-color: white;
border-radius: 3px;
font-size: 12px;
border-left: 3px solid #42b983;
}
</style>
// directives/debounce.js
// 自定义防抖指令
export const vDebounce = {
// 指令的生命周期钩子
mounted(el, binding) {
console.log('debounce directive mounted');
// 解析指令参数
const eventType = binding.arg || 'click';
// 解析绑定值
let delay = 300; // 默认延迟 300ms
let handler = null;
let immediate = false;
// 处理不同类型的绑定值
if (typeof binding.value === 'function') {
// 直接绑定函数
handler = binding.value;
} else if (typeof binding.value === 'object') {
// 绑定对象,包含 handler 和 delay
handler = binding.value.handler;
delay = binding.value.delay || delay;
immediate = binding.value.immediate || false;
}
// 处理修饰符
if (binding.modifiers.immediate) {
immediate = true;
}
// 检查是否提供了处理函数
if (!handler || typeof handler !== 'function') {
console.warn('v-debounce 指令需要提供一个函数作为绑定值');
return;
}
// 存储定时器和相关数据
el._debounceTimer = null;
el._debounceHandler = handler;
el._debounceDelay = delay;
el._debounceImmediate = immediate;
// 定义防抖函数
const debounceFn = (event) => {
if (el._debounceTimer) {
clearTimeout(el._debounceTimer);
}
if (immediate && !el._debounceTimer) {
// 立即执行
handler.call(el, event);
}
el._debounceTimer = setTimeout(() => {
if (!immediate) {
// 延迟执行
handler.call(el, event);
}
el._debounceTimer = null;
}, delay);
};
// 绑定事件监听器
el.addEventListener(eventType, debounceFn);
el._debounceFn = debounceFn;
// 存储事件类型,用于 unmounted 时移除监听器
el._debounceEventType = eventType;
},
// 当指令的绑定值更新时
updated(el, binding) {
console.log('debounce directive updated');
// 移除旧的事件监听器
if (el._debounceEventType && el._debounceFn) {
el.removeEventListener(el._debounceEventType, el._debounceFn);
}
// 清理旧的定时器
if (el._debounceTimer) {
clearTimeout(el._debounceTimer);
el._debounceTimer = null;
}
// 重新初始化
this.mounted(el, binding);
},
// 当指令所在元素被移除时
unmounted(el) {
console.log('debounce directive unmounted');
// 移除事件监听器
if (el._debounceEventType && el._debounceFn) {
el.removeEventListener(el._debounceEventType, el._debounceFn);
}
// 清理定时器
if (el._debounceTimer) {
clearTimeout(el._debounceTimer);
el._debounceTimer = null;
}
// 清理存储的数据
delete el._debounceTimer;
delete el._debounceHandler;
delete el._debounceDelay;
delete el._debounceImmediate;
delete el._debounceFn;
delete el._debounceEventType;
}
};
运行结果预期
- 点击按钮时,300ms 内多次点击只会执行一次
- 输入搜索关键词时,500ms 内没有输入才会执行搜索
- 鼠标移动时,100ms 内只执行一次位置更新
- 动态添加/移除指令时,会触发相应的生命周期钩子
- 指令生命周期日志会记录指令的挂载、更新和卸载
调试技巧
- 使用浏览器控制台查看指令的执行日志
- 测试不同的延迟时间,观察防抖效果
- 对比添加和不添加防抖指令的效果
- 检查事件监听器是否被正确添加和移除
- 观察内存使用情况,确保没有内存泄漏