|
- ref
- 简单的对象
- 适合基本数据类型
- 需要通过.value访问和修改
- 绑定组件
- reactive
- 复杂的对象
- 适合对象和数组
- 直接访问和修改
- 仅数据,即只能绑定组件中的属性,而无法绑定整个组件
|
|
### 什么是模板引用(Template Refs)?
在 Vue 3 中,`ref` 在模板中有两种用途:
1. **响应式数据**:在 `<script>` 中使用 `ref()` 创建响应式变量
2. **模板引用**:在模板中使用 `ref="xxx"` 获取 DOM 元素或组件实例
### 模板引用的基本用法
```vue
<template>
<div>
<!-- 在模板中使用 ref 属性 -->
<input ref="inputRef" type="text" />
<button @click="focusInput">聚焦输入框</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
// 1. 创建一个 ref 变量,名称必须与模板中的 ref 属性值相同
const inputRef = ref<HTMLInputElement | null>(null);
// 2. 在组件挂载后,inputRef.value 会指向 DOM 元素
onMounted(() => {
console.log(inputRef.value); // <input type="text">
});
// 3. 使用 ref 操作 DOM
const focusInput = () => {
if (inputRef.value) {
inputRef.value.focus();
inputRef.value.value = 'Hello Vue!';
}
};
</script>
```
### 模板引用的工作原理
```vue
<template>
<!-- ref="container" 表示给这个 div 元素起一个引用名 -->
<div ref="container" class="scroll-container">
内容区域
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 创建一个同名的 ref 变量
const container = ref<HTMLElement | null>(null);
// 在组件挂载后:
// container.value === <div class="scroll-container">DOM 元素</div>
</script>
```
**关键点:**
- 模板中的 `ref="container"` 不是为 DOM 添加属性
- 而是告诉 Vue:"我想要引用这个元素"
- Vue 会在挂载后,将 DOM 元素赋值给 `container.value`
- 不会在渲染的 HTML 中出现 `ref` 属性
|
|
|
|
|
|
## `defineProps` 与普通响应式变量的区别
- defineProps
- 定义组件的 props,用于组件之间传值
- 子组件接收父组件传递的数据;
- 在slot中父组件接收子组件传递的数据
- 只能在 setup() 中使用
- 不需要 .value,直接访问
### 核心区别概览
| 特性 | `defineProps` 定义的属性 | 普通响应式变量(`ref`/`reactive`) |
|------|------------------------|----------------------------------|
| **数据来源** | 父组件传递 | 组件内部定义 |
| **可变性** | 只读(不应修改) | 可读写 |
| **响应式类型** | Proxy 对象(shallow readonly) | RefImpl 或 Proxy |
| **访问方式** | `props.xxx`(直接访问) | `xxx.value`(ref 需要解包) |
| **用途** | 接收外部配置 | 管理内部状态 |
| **生命周期** | 组件创建时由父组件传入 | 组件内部随时创建 |
### 实际开发建议
```vue
<script setup lang="ts">
// 推荐的数据管理模式
// 1. Props:只读的外部配置
const props = defineProps<{
config: {
pageSize: number;
maxItems: number;
};
}>();
// 2. State:可变的内部状态
const state = reactive({
currentPage: 1,
items: [] as any[],
loading: false
});
// 3. Computed:基于 props 和 state 的派生值
const canLoadMore = computed(() =>
state.items.length < props.config.maxItems && !state.loading
);
// 4. Methods:修改内部状态
async function loadMore() {
if (!canLoadMore.value) return;
state.loading = true;
try {
const newItems = await fetchItems(state.currentPage, props.config.pageSize);
state.items.push(...newItems);
state.currentPage++;
} finally {
state.loading = false;
}
}
</script>
```
### 快速决策表
| 问题 | 使用 |
|------|------|
| 需要从父组件接收配置? | `defineProps` |
| 需要在组件内部存储可变数据? | `ref` / `reactive` |
| 需要响应用户交互? | `ref` / `reactive` |
| 需要显示从外部传入的值? | `defineProps` |
| 需要管理异步操作状态? | `ref` (如 `loading`) |
| 需要基于其他值计算新值? | `computed` |
| 需要监听外部配置变化? | `watch(() => props.xxx)` |
### 关键要点总结
1. **`props.initialCount`**:
- 来自父组件,只读
- 直接访问,不需要 `.value`
- 用于接收外部配置
- 不应直接修改
2. **`count.value`**:
- 组件内部定义,可读写
- 需要 `.value` 访问
- 用于管理内部状态
- 可以随意修改
3. **数据流向**:
```
父组件数据 → props(只读) → 内部状态(可变) → 视图更新
```
4. **最佳实践**:
- 用 `props` 接收配置
- 用 `ref`/`reactive` 管理状态
- 用 `computed` 派生值
- 用 `watch` 同步 `props` 到内部状态
---
|
|
### 代码示例对比
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';
// defineProps:接收父组件传入的数据
const props = defineProps<{
initialCount?: number; // 来自父组件
maxCount?: number; // 来自父组件
}>();
// ref:组件内部状态
const count = ref(props.initialCount || 0);
const loading = ref(false);
// 错误:不应直接修改 props
function wrongIncrement() {
props.initialCount++; // TypeScript 可能会报错,运行时会警告
}
// 正确:修改内部状态
function correctIncrement() {
if (count.value < (props.maxCount ?? Infinity)) {
count.value++;
}
}
// 正确:使用 props 作为初始值,之后修改内部状态
onMounted(() => {
count.value = props.initialCount || 0;
});
</script>
```
|
|
### 详细分析
#### 1. 数据流向不同
```typescript
// defineProps:数据从父组件流向子组件(单向数据流)
const props = defineProps<{
initialCount: number;
}>();
// 父组件传入:<ChildComponent :initialCount="10" />
// 子组件接收:props.initialCount === 10
```
```typescript
// ref:数据在组件内部流动,完全可控
const count = ref(0);
count.value = 10; // 可以随意修改
```
#### 2. 响应式机制不同
```typescript
// defineProps 返回的是只读的 Proxy 对象
const props = defineProps<{ msg: string }>();
console.log(props.msg); // 直接访问,不需要 .value
// props.msg = 'new'; // ? 运行时警告:不应修改 props
```
```typescript
// ref 返回的是 RefImpl 对象,需要 .value 访问
const msg = ref('hello');
console.log(msg.value); // 需要 .value
msg.value = 'world'; // ? 可以修改
```
#### 3. 使用场景对比
##### `props.initialCount` 的使用场景
```vue
<script setup lang="ts">
// 场景 1:接收外部配置
const props = defineProps<{
pageSize: number; // 分页大小(父组件控制)
fetchData: () => void; // 回调函数(父组件提供)
}>();
// 场景 2:作为初始值
const currentPage = ref(1);
const pageSize = ref(props.pageSize); // 使用 props 初始化内部状态
// 场景 3:派生计算
const totalPages = computed(() => Math.ceil(total.value / props.pageSize));
</script>
```
##### `count.value` 的使用场景
```vue
<script setup lang="ts">
// 场景 1:管理组件内部状态
const count = ref(0);
const loading = ref(false);
// 场景 2:响应用户交互
function increment() {
count.value++; // 直接修改
}
// 场景 3:异步操作状态管理
async function loadData() {
loading.value = true;
try {
const data = await fetch('/api/data');
count.value = data.length;
} finally {
loading.value = false;
}
}
</script>
```
|
|
### ⚠️ 重要:Props 命名转换规则(kebab-case vs camelCase)
在 Vue 中,**HTML 属性名和组件 props 定义名之间存在自动转换规则**:
#### 命名转换对照表
| Props 定义(子组件) | 模板使用(父组件) | 转换规则 |
|-------------------|----------------|----------|
| `initialPageSize` | `:initial-page-size` | camelCase → kebab-case |
| `maxItems` | `:max-items` | camelCase → kebab-case |
| `userName` | `:user-name` | camelCase → kebab-case |
| `isLoading` | `:is-loading` | camelCase → kebab-case |
#### 详细说明
```vue
<!-- 子组件 ChildComponent.vue -->
<script setup lang="ts">
// 定义 props(使用 camelCase)
const props = defineProps<{
initialPageSize: number; // ← 驼峰命名
maxItems: number; // ← 驼峰命名
}>();
</script>
```
```vue
<!-- 父组件 Parent.vue -->
<template>
<!-- 使用子组件时,必须使用 kebab-case(短横线分隔) -->
<ChildComponent
:initial-page-size="20" <!-- ✅ 正确:短横线分隔 -->
:max-items="100" <!-- ✅ 正确:短横线分隔 -->
:initialPageSize="20" <!-- ⚠️ 不推荐:虽然能工作,但不规范 -->
:maxItems="100" <!-- ⚠️ 不推荐:虽然能工作,但不规范 -->
/>
</template>
```
#### 为什么需要这种转换?
**原因:HTML 属性不区分大小写**
```html
<!-- 在 HTML 中,这些写法是等价的: -->
<div initialpagesize="20"></div>
<div INITIALPAGESIZE="20"></div>
<div initialPageSize="20"></div>
<!-- 所以 Vue 采用短横线命名来区分单词边界 -->
<ChildComponent :initial-page-size="20" />
```
#### Vue 的自动转换机制
```javascript
// Vue 内部处理流程:
// 1. 父组件模板::initial-page-size="20"
// 2. Vue 解析:initialPageSize = "20"
// 3. 子组件接收:props.initialPageSize === 20
// 转换规则:kebab-case → camelCase
// initial-page-size → initialPageSize
// max-items → maxItems
// user-name → userName
// is-loading → isLoading
```
#### 完整示例对比
```vue
<!-- 子组件定义(TypeScript/JavaScript) -->
<script setup lang="ts">
const props = defineProps<{
// 使用 camelCase(驼峰命名)
userId: number;
userName: string;
isActive: boolean;
pageItemCount: number;
}>();
</script>
```
```vue
<!-- 父组件使用(HTML 模板) -->
<template>
<ChildComponent
:user-id="123" <!-- ✅ 推荐 -->
:user-name="张三" <!-- ✅ 推荐 -->
:is-active="true" <!-- ✅ 推荐 -->
:page-item-count="10" <!-- ✅ 推荐 -->
/>
<!-- 或者使用引号(完全等同于上面) -->
<ChildComponent
:userId="123" <!-- ⚠️ 不规范但可用 -->
:userName="张三" <!-- ⚠️ 不规范但可用 -->
/>
</template>
```
#### 特殊情况:全大写缩写词
```vue
<script setup lang="ts">
// 定义 props
const props = defineProps<{
userID: number; // 驼峰:ID 保持大写
httpRequestURL: string; // 驼峰:URL 保持大写
fontSize: number; // 驼峰:常规写法
}>();
</script>
<template>
<!-- 父组件使用 -->
<ChildComponent
:user-i-d="123" <!-- 转换:userID → user-i-d -->
:http-request-url="..." <!-- 转换:httpRequestURL → http-request-url -->
:font-size="16" <!-- 转换:fontSize → font-size -->
/>
</template>
```
#### 快速记忆表
| Props 定义(camelCase) | 模板使用(kebab-case) | 说明 |
|----------------------|---------------------|------|
| `myProp` | `:my-prop` | 小写字母开头,大写字母转短横线 |
| `userName` | `:user-name` | 首字母保持小写 |
| `isActive` | `:is-active` | is 前缀保持小写 |
| `pageItemCount` | `:page-item-count` | 多个驼峰都转换 |
| `userID` | `:user-i-d` | 全大写缩写词也拆分 |
| `fontSize` | `:font-size` | 简单驼峰直接转换 |
#### Vue 官方推荐
**✅ 推荐做法:**
- Props 定义使用 **camelCase**(JavaScript/TypeScript 规范)
- 模板使用使用 **kebab-case**(HTML 规范)
**❌ 不推荐:**
- 在模板中直接使用 camelCase(不规范)
- 在 Props 定义中使用 kebab-case(不符合 JavaScript 规范)
---
|
|
### 实际项目示例
```vue
<!-- 父组件 Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
const parentConfig = {
pageSize: 20,
maxItems: 100
};
</script>
<template>
<ChildComponent
:initial-page-size="parentConfig.pageSize"
:max-items="parentConfig.maxItems"
/>
</template>
```
```vue
<!-- 子组件 ChildComponent.vue -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// ? defineProps:接收父组件配置
const props = defineProps<{
initialPageSize: number;
maxItems: number;
}>();
// ? ref:组件内部状态
const currentPage = ref(1);
const pageSize = ref(props.initialPageSize);
const loading = ref(false);
// ? computed:基于 props 和 state 的派生值
const totalItems = computed(() => currentPage.value * pageSize.value);
const canLoadMore = computed(() => totalItems.value < props.maxItems);
// ? watch:监听 props 变化(父组件可能动态修改)
watch(() => props.initialPageSize, (newSize) => {
pageSize.value = newSize; // 同步更新内部状态
});
async function loadMore() {
if (!canLoadMore.value || loading.value) return;
loading.value = true;
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 1000));
currentPage.value++;
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<p>页面大小配置:{{ initialPageSize }}</p>
<p>当前页码:{{ currentPage }}</p>
<p>加载状态:{{ loading ? '加载中...' : '空闲' }}</p>
<button @click="loadMore" :disabled="!canLoadMore || loading">
加载更多
</button>
</div>
</template>
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#### ❌ 错误做法
```typescript
// 陷阱 1:尝试修改 props
const props = defineProps<{ value: number }>();
props.value++; // ❌ 运行时警告
// 陷阱 2:混淆 props 和 state
const count = props.initialCount; // ❌ 这不是响应式
count++; // 不会触发更新
// 陷阱 3:直接解构 props(失去响应式)
const { initialCount } = toRefs(props); // ✅ 正确
const { initialCount } = props; // ❌ 失去响应式
```
#### ✅ 正确做法
```typescript
// 最佳实践 1:使用 props 初始化内部状态
const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount);
// 最佳实践 2:监听 props 变化并同步
watch(() => props.initialCount, (newVal) => {
count.value = newVal;
});
// 最佳实践 3:使用 toRefs 保持响应式(如果需要解构)
import { toRefs } from 'vue';
const props = defineProps<{ initialCount: number; maxCount: number }>();
const { initialCount, maxCount } = toRefs(props);
// 最佳实践 4:使用 computed 派生 props
const doubledInitial = computed(() => props.initialCount * 2);
```
|
|
### `ref` vs `toRefs`:核心区别详解
前面的代码中,`ref` 和 `toRefs` 有本质的区别:
#### `ref` - 创建新的响应式引用
```typescript
const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount);
```
**特点:**
- `count` 是一个**全新的**响应式变量
- 它只是**复制**了 `props.initialCount` 的初始值
- 之后 `props.initialCount` 变化,**不会自动同步**到 `count`
- 需要手动 `watch` 才能保持同步
**适用场景:**
- 需要独立修改值,不受 props 影响
- 需要在 props 基础上进行计算或修改
- 创建组件内部的可变状态
#### `toRefs` - 保持响应式连接的解构
```typescript
import { toRefs } from 'vue';
const props = defineProps<{ initialCount: number; maxCount: number }>();
const { initialCount, maxCount } = toRefs(props);
```
**特点:**
- `initialCount` 和 `maxCount` 仍然是**原始 props 的引用**
- 它们**保持响应式连接**到 `props`
- 当 `props.initialCount` 变化时,解构出的 `initialCount` **会自动更新**
**适用场景:**
- 需要在模板或计算属性中简化 props 的访问
- 需要传递 props 给组合式函数
- 不需要修改值,只是读取使用
#### 对比表格
| 特性 | `ref(props.xxx)` | `toRefs(props)` |
|------|------------------|-----------------|
| 响应式连接 | ? 断开连接 | ? 保持连接 |
| props 变化时 | 不会自动更新 | 会自动更新 |
| 是否独立副本 | ? 是 | ? 否,仍是引用 |
| 能否修改 | ? 可以随意修改 | ? 不能直接修改 |
| 用途 | 创建独立状态 | 解构 props 保持响应式 |
| 需要 watch 同步 | ? 需要 | ? 不需要 |
#### 实际代码示例
```typescript
// ========== 场景 1: 使用 ref 创建独立状态 ==========
const props = defineProps<{ initialCount: number }>();
const count = ref(props.initialCount);
// count 可以自由修改
count.value++; // ? count 变成 2,但 props.initialCount 仍是 1
// props 变化不会自动同步 count
// 父组件传入新的 initialCount=10, count.value 仍是 2
// 需要手动 watch 保持同步
watch(() => props.initialCount, (newVal) => {
count.value = newVal; // 手动同步
});
// ========== 场景 2: 使用 toRefs 解构 props ==========
import { toRefs } from 'vue';
const props = defineProps<{ initialCount: number; maxCount: number }>();
const { initialCount, maxCount } = toRefs(props);
// initialCount 仍连接到 props
// 父组件传入新的 initialCount=10
// initialCount.value 会自动变成 10
// ? 不要直接修改,仍然是只读的
// initialCount.value++; // 运行时会警告
// ? 用于计算属性或模板中简化访问
const isValid = computed(() => initialCount.value < maxCount.value);
```
#### 选择决策树
```
需要使用 props?
│
├─ 需要修改值吗?
│ ├─ 是 → 使用 ref(props.xxx)
│ │ + 必要时配合 watch 同步
│ │
│ └─ 否 → 继续判断
│
├─ 只是需要简化访问?
│ ├─ 是 → 使用 toRefs(props)
│ │ + 保持响应式连接
│ │
│ └─ 否 → 直接使用 props.xxx
│
└─ 需要传递给组合式函数?
├─ 是 → 使用 toRefs(props)
│ + 避免响应式丢失
│
└─ 否 → 直接传递 props 或 props.xxx
```
#### 完整示例:对比两种用法
```vue
<script setup lang="ts">
import { ref, toRefs, watch, computed } from 'vue';
const props = defineProps<{
initialCount: number;
maxCount: number;
}>();
// ========== 使用 ref: 独立的内部状态 ==========
const internalCount = ref(props.initialCount);
// 需要手动同步
watch(() => props.initialCount, (newVal) => {
internalCount.value = newVal;
});
// 可以自由修改
function increment() {
internalCount.value++;
}
// ========== 使用 toRefs: 简化访问但保持连接 ==========
const { initialCount, maxCount } = toRefs(props);
// 不能直接修改,但会自动响应 props 的变化
const isValid = computed(() => initialCount.value < maxCount.value);
// 在模板中可以使用简化写法
// {{ initialCount }} 而不是 {{ props.initialCount }}
</script>
<template>
<div>
<!-- 使用 ref 创建的独立状态 -->
<p>内部计数: {{ internalCount }}</p>
<button @click="increment">增加</button>
<!-- 使用 toRefs 解构的 props -->
<p>初始值: {{ initialCount }}</p>
<p>最大值: {{ maxCount }}</p>
<p>是否有效: {{ isValid }}</p>
</div>
</template>
```
|
|
### 深入理解:为什么不能修改 props?
```typescript
// 理由 1:单向数据流
// 父组件 → props → 子组件
// 如果子组件修改 props,父组件的数据会被意外改变
// 理由 2:数据追踪困难
// 父组件传入 10 → 子组件改成 20 → 父组件重新传入 15
// 此时应该用 15 还是 20?逻辑混乱
// 理由 3:调试困难
// 如果到处都可以修改,调试时很难找到数据变化的源头
```
|
|
|
|
|
|
|
|
|
|
|
|
插槽(Slots)是允许你在组件中预留位置,让使用组件的父组件可以自定义内容。
#### 子组件定义
```vue
<!-- BaseButton.vue -->
<template>
<button class="btn">
<slot></slot> <!-- 默认插槽 -->
</button>
</template>
<script>
export default {
name: 'BaseButton'
}
</script>
```
#### 父组件使用
```vue
<template>
<div>
<!-- 基础使用 -->
<BaseButton>点击我</BaseButton>
<!-- 传递复杂内容 -->
<BaseButton>
<span>??</span>
<span>提交</span>
</BaseButton>
<!-- 传递HTML结构 -->
<BaseButton>
<strong>重要按钮</strong>
</BaseButton>
</div>
</template>
<script>
import BaseButton from './BaseButton.vue'
export default {
components: {
BaseButton
}
}
</script>
```
#### 子组件提供默认内容
```vue
<!-- Card.vue -->
<template>
<div class="card">
<h3> title </h3>
<slot>
<p>这是默认内容,当父组件不传递内容时显示</p>
</slot>
</div>
</template>
<script>
export default {
name: 'Card',
props: {
title: String
}
}
</script>
```
#### 父组件可以选择覆盖或不覆盖
```vue
<template>
<div>
<!-- 不传递内容,显示默认内容 -->
<Card title="提示卡片" />
<!-- 传递自定义内容 -->
<Card title="自定义卡片">
<p>这是自定义的内容</p>
<button>操作按钮</button>
</Card>
</div>
</template>
```
|
|
### 1. 定义具名插槽
#### 子组件
```vue
<!-- UserCard.vue -->
<template>
<div class="user-card">
<!-- 头部插槽 -->
<div class="header">
<slot name="header">
<h3>默认标题</h3>
</slot>
</div>
<!-- 默认插槽(无名字) -->
<div class="body">
<slot></slot>
</div>
<!-- 底部插槽 -->
<div class="footer">
<slot name="footer">
<p>默认底部</p>
</slot>
</div>
</div>
</template>
<script>
export default {
name: 'UserCard'
}
</script>
<style scoped>
.user-card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.header {
background-color: #f5f5f5;
padding: 10px;
}
.body {
padding: 15px;
}
.footer {
background-color: #f9f9f9;
padding: 10px;
border-top: 1px solid #eee;
}
</style>
```
### 2. 使用具名插槽
#### 父组件(使用 v-slot 指令)
```vue
<template>
<div>
<UserCard>
<!-- 使用 template 标签配合 v-slot -->
<template v-slot:header>
<h2>用户信息</h2>
</template>
<!-- 默认插槽不需要指定名字 -->
<p>姓名:张三</p>
<p>年龄:25岁</p>
<template v-slot:footer>
<button>编辑</button>
<button>删除</button>
</template>
</UserCard>
</div>
</template>
<script>
import UserCard from './UserCard.vue'
export default {
components: {
UserCard
}
}
</script>
```
### 3. 具名插槽的简写语法
```vue
<template>
<UserCard>
<!-- #header 是 v-slot:header 的简写 -->
<template #header>
<h2>用户信息</h2>
</template>
<p>姓名:李四</p>
<p>年龄:30岁</p>
<template #footer>
<button>保存</button>
</template>
</UserCard>
</template>
```
### 4. 综合示例:布局组件
```vue
<!-- Layout.vue -->
<template>
<div class="layout">
<aside class="sidebar">
<slot name="sidebar"></slot>
</aside>
<main class="main-content">
<header class="header">
<slot name="header"></slot>
</header>
<div class="content">
<slot></slot>
</main>
</div>
</template>
<!-- 使用 -->
<template>
<Layout>
<template #sidebar>
<nav>
<ul>
<li><a href="#">首页</a></li>
<li><a href="#">关于</a></li>
</ul>
</nav>
</template>
<template #header>
<h1>欢迎来到我的网站</h1>
</template>
<div class="article">
<p>主要内容区域...</p>
</div>
</Layout>
</template>
```
---
|
|
作用域就是子组件的属性,子组件有多个属性时,父组件可以选择性地接收其中的一个或多个属性来使用。
| 特性 | 无作用域插槽 | 有作用域插槽 |
|------|------------|------------|
| 子组件语法 | `<slot>` | `<slot :user="user">` |
| 父组件语法 | `<template #default>` | `<template #default="{ user }">` |
| 父组件访问数据 | 无法访问 | 可以访问 |
| 数据流向 | 父 → 子 | 子 → 父 |
| 使用场景 | 固定内容 | 需要基于子组件数据自定义渲染 |
在深入了解作用域插槽之前,先理解一个关键概念:
**为什么需要 `:user="user"`**
```vue
<!-- 子组件 UserList.vue -->
<template>
<div class="user-list">
<slot v-for="user in users" :user="user" :key="user.id">
<!-- user 在这里有什么作用? -->
</slot>
</div>
</template>
```
**详细解释:**
1. **`v-for="user in users"`**
- 这是子组件**内部**的循环变量
- 只有子组件能访问
- 父组件**无法直接使用**
2. **`:user="user"`**
- 这是**插槽 prop**(slot props)
- 将子组件的数据**暴露**给父组件
- 左边的 `user` 是 prop 名称
- 右边的 `user` 是要传递的数据值
3. **父组件中的 `#default="{ user }"`**
- `#default`:指向默认插槽
- `={ user }`:**解构赋值**,接收子组件传递的 prop
- 这里的 `user` 来自子组件的 `:user="user"`
**数据流动图示:**
```
┌─────────────────────────────────────────────────┐
│ 子组件 (UserList.vue) │
│ │
│ data() { │
│ return { │
│ users: [ │
│ { id: 1, name: '张三' }, ← 私有数据 │
│ { id: 2, name: '李四' } │
│ ] │
│ } │
│ } │
│ │
│ v-for="user in users" ─┐ │
│ │ │
│ :user="user" ──────────┼── 暴露给父组件 │
│ │ (slot prop) │
│ <slot :user="user" /> ─┘ │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ 父组件 │
│ │
│ <UserList> │
│ <template #default="{ user }"> ← 接收数据 │
│ {{ user.name }} ────── 使用子组件的数据 │
│ </template> │
│ </UserList> │
└─────────────────────────────────────────────────┘
```
### 1. 基础作用域插槽
#### 子组件传递数据
```vue
<!-- UserList.vue -->
<template>
<div class="user-list">
<slot v-for="user in users" :user="user" :key="user.id">
<!-- 默认渲染方式 -->
<div class="user-item">{{ user.name }}</div>
</slot>
</div>
</template>
<script>
export default {
name: 'UserList',
data() {
return {
users: [
{ id: 1, name: '张三', age: 25, email: 'zhangsan@example.com' },
{ id: 2, name: '李四', age: 30, email: 'lisi@example.com' },
{ id: 3, name: '王五', age: 28, email: 'wangwu@example.com' }
]
}
}
}
</script>
```
**代码解析:**
```vue
<!-- 上面的代码等价于 -->
<template>
<div class="user-list">
<!-- 第1次循环:user = { id: 1, name: '张三', ... } -->
<slot :user="{ id: 1, name: '张三', ... }">
<div class="user-item">张三</div>
</slot>
<!-- 第2次循环:user = { id: 2, name: '李四', ... } -->
<slot :user="{ id: 2, name: '李四', ... }">
<div class="user-item">李四</div>
</slot>
<!-- 第3次循环:user = { id: 3, name: '王五', ... } -->
<slot :user="{ id: 3, name: '王五', ... }">
<div class="user-item">王五</div>
</slot>
</div>
</template>
```
**关键点:**
- `v-for="user in users"`:在子组件内部循环,每次循环产生一个 `user` 变量
- `:user="user"`:将当前循环的 `user` 对象通过插槽 prop 传递给父组件
- 如果不加 `:user="user"`,父组件**无法访问**子组件的 `user` 数据
#### 父组件接收数据
```vue
<template>
<div>
<h2>用户列表</h2>
<UserList>
<template #default="{ user }">
<!--
{ user } 是解构语法
等价于接收 slotProps 参数,然后取出其中的 user 属性
完整写法:#default="slotProps" 然后使用 slotProps.user
-->
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>年龄:{{ user.age }}</p>
<p>邮箱:{{ user.email }}</p>
</div>
</template>
</UserList>
</div>
</template>
```
**父组件代码解析:**
```vue
<!-- 方式1:解构写法(推荐) -->
<template #default="{ user }">
{{ user.name }}
</template>
<!-- 方式2:完整写法 -->
<template #default="slotProps">
{{ slotProps.user.name }}
</template>
<!-- 方式3:不使用解构,访问整个 props 对象 -->
<template #default="props">
{{ props.user.name }}
</template>
```
**`#default="{ user }"` 详解:**
| 部分 | 含义 | 说明 |
|------|------|------|
| `#default` | 默认插槽 | 指向没有 `name` 属性的 `<slot>` |
| `=` | 绑定语法 | 将插槽 props 绑定到变量 |
| `{ user }` | 解构赋值 | 从 props 对象中提取 `user` 属性 |
| `user` | 变量名 | 在模板中使用的变量名 |
**为什么这样设计?**
```vue
<!-- 子组件定义了什么 -->
<slot :user="currentUser" :index="currentIndex" />
<!-- 父组件可以选择性地接收 -->
<template #default="{ user }"> <!-- 只接收 user -->
<template #default="{ user, index }"> <!-- 接收 user 和 index -->
<template #default="props"> <!-- 接收所有 props -->
```
这种设计让父组件可以:
1. 选择需要的数据
2. 自定义变量名
3. 灵活处理多个 props
### 2. 多个作用域属性
```vue
<!-- TodoItem.vue -->
<template>
<div class="todo-item">
<slot
:todo="todo"
:index="index"
:isCompleted="todo.completed"
:toggle="toggleTodo"
:remove="removeTodo"
>
<span :class="{ completed: todo.completed }">
{{ todo.text }}
</span>
</slot>
</div>
</template>
<script>
export default {
name: 'TodoItem',
props: {
todo: Object,
index: Number
},
methods: {
toggleTodo() {
this.$emit('toggle', this.index)
},
removeTodo() {
this.$emit('remove', this.index)
}
}
}
</script>
<style scoped>
.completed {
text-decoration: line-through;
color: #999;
}
</style>
```
**插槽 Props 详解:**
```vue
<!-- 子组件传递了 5 个 props -->
<slot
:todo="todo" ← 1. 传递整个 todo 对象
:index="index" ← 2. 传递索引值
:isCompleted="todo.completed" ← 3. 传递计算后的布尔值
:toggle="toggleTodo" ← 4. 传递方法
:remove="removeTodo" ← 5. 传递方法
/>
<!-- 这些 props 会被打包成一个对象: -->
{
todo: { text: '学习Vue', completed: false },
index: 0,
isCompleted: false,
toggle: function() { ... },
remove: function() { ... }
}
```
**父组件可以选择性地接收:**
```vue
<!-- 方式1:只接收需要的 props -->
<template #default="{ todo, isCompleted }">
<span :class="{ done: isCompleted }">
{{ todo.text }}
</span>
</template>
<!-- 方式2:接收所有 props(不使用解构) -->
<template #default="props">
<span>{{ props.todo.text }}</span>
<button @click="props.toggle">切换</button>
</template>
<!-- 方式3:重命名 props -->
<template #default="{ todo: task, index: idx }">
<!-- task 是 todo 的别名 -->
<!-- idx 是 index 的别名 -->
<div>{{ idx }}: {{ task.text }}</div>
</template>
```
### 2.1 对比示例:有无作用域插槽的区别
为了更清楚地理解作用域插槽的作用,让我们对比两种情况:
#### 没有作用域插槽(父组件无法访问子组件数据)
```vue
<!-- UserList.vue - 子组件 -->
<template>
<div class="user-list">
<!-- 没有传递任何 props -->
<slot v-for="user in users" :key="user.id">
<div class="user-item">{{ user.name }}</div>
</slot>
</div>
</template>
```
```vue
<!-- 父组件 - 无法访问 user 数据! -->
<template>
<UserList>
<template #default>
<!-- ? 这里无法访问 user 变量 -->
<!-- {{ user.name }} 会报错:user is not defined -->
<div>无法获取用户数据</div>
</template>
</UserList>
</template>
```
#### 使用作用域插槽(父组件可以访问子组件数据)
```vue
<!-- UserList.vue - 子组件 -->
<template>
<div class="user-list">
<!-- 通过 :user="user" 暴露数据 -->
<slot v-for="user in users" :user="user" :key="user.id">
<div class="user-item">{{ user.name }}</div>
</slot>
</div>
</template>
```
```vue
<!-- 父组件 - 可以访问 user 数据! -->
<template>
<UserList>
<template #default="{ user }">
<!-- ? 这里可以访问 user 变量 -->
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>年龄:{{ user.age }}</p>
<button @click="greet(user)">打招呼</button>
</div>
</template>
</UserList>
</template>
```
**关键区别总结:**
| 特性 | 无作用域插槽 | 有作用域插槽 |
|------|------------|------------|
| 子组件语法 | `<slot>` | `<slot :user="user">` |
| 父组件语法 | `<template #default>` | `<template #default="{ user }">` |
| 父组件访问数据 | 无法访问 | 可以访问 |
| 数据流向 | 父 → 子 | 子 → 父 |
| 使用场景 | 固定内容 | 需要基于子组件数据自定义渲染 |
|
|
#### 7.5.1 最内层:数据源组件
```vue
<!-- DataSource.vue -->
<template>
<slot name="data" :user="userInfo" :posts="postsList" :loading="isLoading" />
</template>
<script setup lang="ts">
import { ref } from 'vue';
const userInfo = ref({ name: '张三', age: 25 });
const postsList = ref([{ id: 1, title: '文章1' }]);
const isLoading = ref(false);
</script>
```
#### 7.5.2 中间层:包装组件
```vue
<!-- Wrapper.vue -->
<template>
<DataSource>
<!-- 接收数据 -->
<template #data="scopes">
<!-- scopes = { user: {...}, posts: [...], loading: false } -->
<!-- 可以添加额外数据 -->
<slot name="content" v-bind="scopes" :timestamp="Date.now()" />
</template>
</DataSource>
</template>
```
#### 7.5.3 最外层:使用组件
```vue
<!-- App.vue -->
<template>
<Wrapper>
<!-- 接收数据 -->
<template #content="scopes">
<!--
scopes = {
user: { name: '张三', age: 25 },
posts: [{ id: 1, title: '文章1' }],
loading: false,
timestamp: 1234567890 // Wrapper 组件添加的
}
-->
<div>
<h1>{{ scopes.user.name }}</h1>
<p>时间:{{ scopes.timestamp }}</p>
<ul>
<li v-for="post in scopes.posts" :key="post.id">
{{ post.title }}
</li>
</ul>
</div>
</template>
</Wrapper>
</template>
```
### 7.6 常见误区和注意事项
#### 7.6.1 误区1:认为 `scopes` 是组件的属性
```vue
<!-- 错误理解 -->
<template #sql="scopes">
<!-- 误以为 scopes 是 ComContainer 组件的一个属性 -->
</template>
```
**正确理解**:
- `scopes` 是**插槽参数对象**,由子组件通过 `v-bind` 传递
- 它不是组件的 `props` 或 `data`
- 只在插槽的作用域内有效
#### 7.6.2 误区2:混淆插槽名称和参数名称
```vue
<!-- 子组件 -->
<slot name="sql" :data="myData" />
<!-- 父组件 -->
<template #sql="scopes">
<!-- sql 是插槽名称,必须匹配 -->
<!-- scopes 是参数名称,可以自定义 -->
<div>{{ scopes.data }}</div>
</template>
<!-- 也可以改成其他名字 -->
<template #sql="myCustomName">
<div>{{ myCustomName.data }}</div>
</template>
```
#### 7.6.3 注意事项
1. **插槽名称必须匹配**
```vue
<!-- 子组件:name="sql" -->
<slot name="sql" />
<!-- 父组件:#sql 必须匹配 -->
<template #sql="scopes">
```
2. **参数名称可以自定义**
```vue
<template #sql="scopes"> <!-- 使用 scopes -->
<template #sql="data"> <!-- 使用 data -->
<template #sql="props"> <!-- 使用 props -->
```
3. **解构赋值简化代码**
```vue
<template #sql="{ sqlContent, queryId, database }">
<!-- 直接使用解构后的变量 -->
<div>{{ sqlContent }}</div>
<div>{{ queryId }}</div>
<div>{{ database }}</div>
</template>
```
4. **保留原始对象**
```vue
<template #sql="scopes">
<!-- 保留整个对象,方便传递给其他方法 -->
<button @click="handleRunSql(scopes)">Run</button>
</template>
```
|
|
|
|
|
|
|
|
|