深入理解 Vue 中的插槽:概念、原理与应用

10 阅读2分钟

一、插槽是什么

插槽是 Vue.js 中用于组件内容分发的机制,它允许你在组件内部预留位置,让使用该组件的人可以传入自定义内容。这就好比在组件中预留了一个「坑」,使用组件的人可以往这个坑里填入任何内容。

插槽的核心价值在于实现组件的复用性灵活性。通过插槽,我们可以将组件的外壳与内容分离,使得同一个组件可以适应不同的使用场景。

二、插槽的基本用法

2.1 简单插槽

<!-- ChildComponent.vue -->
<template>
  <div class="card">
    <h2>卡片标题</h2>
    <slot></slot>
  </div>
</template>
<!-- ParentComponent.vue -->
<template>
  <ChildComponent>
    <p>这是通过插槽传入的内容</p>
  </ChildComponent>
</template>

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

渲染结果:

<div class="card">
  <h2>卡片标题</h2>
  <p>这是通过插槽传入的内容</p>
</div>

2.2 插槽的默认内容

当使用组件时没有传入任何内容,插槽可以显示默认内容:

<!-- Button.vue -->
<template>
  <button class="btn">
    <slot>默认按钮文字</slot>
  </button>
</template>
<!-- 使用 -->
<Button>点击我</Button>  <!-- 显示「点击我」 -->
<Button />               <!-- 显示「默认按钮文字」 -->

2.3 具名插槽

当一个组件需要多个插槽位置时,可以使用具名插槽:

<!-- BaseLayout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>
<!-- 使用具名插槽 -->
<BaseLayout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <p>主内容区域</p>
  
  <template #footer>
    <p>页脚信息</p>
  </template>
</BaseLayout>

2.4 作用域插槽

作用域插槽允许插槽内容访问子组件的数据:

<!-- UserList.vue -->
<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot :user="user" :index="$index"></slot>
    </li>
  </ul>
</template>

<script setup>
const users = ref([
  { id: 1, name: '张三', email: 'zhangsan@example.com' },
  { id: 2, name: '李四', email: 'lisi@example.com' }
]);
</script>
<!-- 使用作用域插槽 -->
<UserList v-slot="{ user, index }">
  <span>{{ index + 1 }}. {{ user.name }} - {{ user.email }}</span>
</UserList>

三、插槽的高级用法

3.1 动态插槽名

Vue 3 支持动态插槽名:

<template>
  <div>
    <slot :name="dynamicSlotName"></slot>
  </div>
</template>

<script setup>
const dynamicSlotName = ref('header');
</script>
<MyComponent>
  <template #[dynamicSlotName]>
    <p>动态插槽内容</p>
  </template>
</MyComponent>

3.2 插槽的解构

作用域插槽可以解构:

<!-- 使用解构 -->
<UserList v-slot="{ user: person, index: i }">
  <span>{{ i + 1 }}. {{ person.name }}</span>
</UserList>

3.3 默认插槽简写

当只有默认插槽时,可以不使用 template

<!-- 完整写法 -->
<BaseLayout>
  <template #default>
    <p>内容</p>
  </template>
</BaseLayout>

<!-- 简写(仅默认插槽) -->
<BaseLayout>
  <p>内容</p>
</BaseLayout>

3.4 具名插槽的缩写

Vue 3 支持 v-slot 的缩写 #

<!-- 完整写法 -->
<BaseLayout>
  <template v-slot:header>
    <h1>标题</h1>
  </template>
</BaseLayout>

<!-- 缩写 -->
<BaseLayout>
  <template #header>
    <h1>标题</h1>
  </template>
</BaseLayout>

四、插槽的实际应用场景

4.1 卡片组件

<!-- Card.vue -->
<template>
  <div class="card" :class="{ 'card--hoverable': hoverable }">
    <div v-if="$slots.header || title" class="card__header">
      <slot name="header">
        <h3 class="card__title">{{ title }}</h3>
      </slot>
    </div>
    
    <div class="card__body">
      <slot></slot>
    </div>
    
    <div v-if="$slots.footer" class="card__footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script setup>
defineProps({
  title: String,
  hoverable: {
    type: Boolean,
    default: false
  }
});
</script>
<!-- 使用 -->
<Card title="用户信息" :hoverable="true">
  <template #header>
    <div class="flex justify-between">
      <h3>用户详情</h3>
      <span class="badge">VIP</span>
    </div>
  </template>
  
  <p>姓名:张三</p>
  <p>邮箱:zhangsan@example.com</p>
  
  <template #footer>
    <button class="btn btn-primary">编辑</button>
  </template>
</Card>

4.2 列表组件

<!-- DataList.vue -->
<template>
  <div class="data-list">
    <div v-if="$slots.header" class="data-list__header">
      <slot name="header"></slot>
    </div>
    
    <div class="data-list__content">
      <div
        v-for="(item, index) in items"
        :key="getKey(item, index)"
        class="data-list__item"
      >
        <slot :item="item" :index="index" name="item"></slot>
      </div>
    </div>
    
    <div v-if="$slots.empty && items.length === 0" class="data-list__empty">
      <slot name="empty"></slot>
    </div>
    
    <div v-if="$slots.footer" class="data-list__footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  items: {
    type: Array,
    default: () => []
  },
  getKey: {
    type: Function,
    default: (item, index) => item.id ?? index
  }
});
</script>
<!-- 使用 -->
<DataList :items="users">
  <template #header>
    <h3>用户列表</h3>
  </template>
  
  <template #item="{ item, index }">
    <div class="user-item">
      <span class="index">{{ index + 1 }}</span>
      <span class="name">{{ item.name }}</span>
      <span class="email">{{ item.email }}</span>
    </div>
  </template>
  
  <template #empty>
    <p>暂无用户数据</p>
  </template>
  
  <template #footer>
    <Pagination :total="total" />
  </template>
</DataList>

4.3 弹窗组件

<!-- Modal.vue -->
<template>
  <Teleport to="body">
    <Transition name="modal">
      <div v-if="modelValue" class="modal-mask" @click.self="close">
        <div class="modal-container" :style="{ width: width }">
          <div class="modal__header">
            <slot name="header">
              <h3>{{ title }}</h3>
            </slot>
            <button v-if="closable" class="modal__close" @click="close">×</button>
          </div>
          
          <div class="modal__body">
            <slot></slot>
          </div>
          
          <div v-if="$slots.footer" class="modal__footer">
            <slot name="footer"></slot>
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup>
const props = defineProps({
  modelValue: Boolean,
  title: String,
  width: {
    type: String,
    default: '500px'
  },
  closable: {
    type: Boolean,
    default: true
  }
});

const emit = defineEmits(['update:modelValue']);

function close() {
  if (props.closable) {
    emit('update:modelValue', false);
  }
}
</script>
<!-- 使用 -->
<Modal v-model="showModal" title="编辑用户" width="600px">
  <form @submit.prevent="saveUser">
    <div class="form-group">
      <label>姓名</label>
      <input v-model="form.name" />
    </div>
    <div class="form-group">
      <label>邮箱</label>
      <input v-model="form.email" />
    </div>
  </form>
  
  <template #footer>
    <button @click="showModal = false">取消</button>
    <button class="primary" @click="saveUser">保存</button>
  </template>
</Modal>

4.4 表单组件

<!-- FormBuilder.vue -->
<template>
  <form class="form-builder" @submit.prevent="handleSubmit">
    <div
      v-for="field in fields"
      :key="field.name"
      class="form-field"
    >
      <label :for="field.name">{{ field.label }}</label>
      
      <slot
        :name="'field-' + field.type"
        :field="field"
        :value="formData[field.name]"
        :update="(val) => formData[field.name] = val"
      >
        <input
          :id="field.name"
          :type="field.type"
          :value="formData[field.name]"
          @input="formData[field.name] = $event.target.value"
        />
      </slot>
      
      <span v-if="errors[field.name]" class="error">{{ errors[field.name] }}</span>
    </div>
    
    <div class="form-actions">
      <slot name="actions" :submit="handleSubmit">
        <button type="submit">提交</button>
      </slot>
    </div>
  </form>
</template>

<script setup>
const props = defineProps({
  fields: {
    type: Array,
    required: true
  },
  initialData: {
    type: Object,
    default: () => ({})
  }
});

const formData = reactive({ ...props.initialData });
const errors = reactive({});

function handleSubmit() {
  // 验证和提交逻辑
}
</script>
<!-- 使用 -->
<FormBuilder :fields="formFields" :initial-data="initialData">
  <template #field-textarea="{ field, value, update }">
    <textarea
      :id="field.name"
      :value="value"
      @input="update($event.target.value)"
      rows="4"
    ></textarea>
  </template>
  
  <template #field-select="{ field, value, update }">
    <select :id="field.name" :value="value" @change="update($event.target.value)">
      <option v-for="opt in field.options" :key="opt.value" :value="opt.value">
        {{ opt.label }}
      </option>
    </select>
  </template>
  
  <template #actions="{ submit }">
    <button @click="submit">确认提交</button>
    <button type="button" @click="resetForm">重置</button>
  </template>
</FormBuilder>

五、插槽与组合式 API

5.1 useSlots 钩子

Vue 3 提供了 useSlots 来访问插槽:

<script setup>
import { useSlots, useAttrs } from 'vue';

const slots = useSlots();
const attrs = useAttrs();

// 检查插槽是否存在
console.log(slots.default);    // 默认插槽
console.log(slots.header);     // 具名插槽 header
console.log(slots.footer);     // 具名插槽 footer

// 动态使用插槽
if (slots.custom) {
  console.log('custom 插槽存在');
}
</script>

5.2 渲染函数中使用插槽

// 使用渲染函数
h('div', [
  slots.header({ level: 1 }, 'Header content'),
  slots.default({ items: props.items }),
  slots.footer()
]);

5.3 高级:自定义渲染器

<script setup>
import { h } from 'vue';

const MyComponent = {
  setup(props, { slots }) {
    return () => {
      return h('div', { class: 'container' }, [
        slots.header?.() || h('h2', 'Default Header'),
        slots.default?.() || h('p', 'Default Content'),
        slots.footer?.() || null
      ]);
    };
  }
};
</script>

六、插槽的性能优化

6.1 避免不必要的重渲染

<!-- ❌ 错误:每次渲染都创建新函数 -->
<template>
  <ChildComponent>
    <template #default>
      <ExpensiveComponent :data="data" @update="(val) => handleUpdate(val)" />
    </template>
  </ChildComponent>
</template>

<script setup>
function handleUpdate(val) {
  // 处理更新
}
</script>

<!-- ✅ 正确:使用稳定的函数引用 -->
<template>
  <ChildComponent>
    <template #default>
      <ExpensiveComponent :data="data" @update="handleUpdate" />
    </template>
  </ChildComponent>
</template>

<script setup>
const handleUpdate = (val) => {
  // 处理更新
};
</script>

6.2 使用 v-memo 优化

<template>
  <div>
    <slot :items="items" :memo="memoValue"></slot>
  </div>
</template>

<script setup>
const items = ref([]);
const memoValue = computed(() => {
  return items.value.map(item => item.id);
});
</script>

七、常见问题与解决方案

7.1 插槽内容不显示

<!-- ❌ 错误:v-if 阻止了插槽渲染 -->
<template>
  <div>
    <slot v-if="false"></slot>
  </div>
</template>

<!-- ✅ 正确:使用 v-show -->
<template>
  <div>
    <slot v-show="true"></slot>
  </div>
</template>

7.2 作用域插槽的数据响应式

<!-- 确保传递的是响应式数据 -->
<template>
  <div>
    <slot :items="items"></slot>
  </div>
</template>

<script setup>
const items = ref([]);

// ❌ 错误:直接修改不会触发更新
items.value = newItems;

// ✅ 正确:使用响应式方式
items.value.splice(0, items.value.length, ...newItems);
</script>

7.3 动态组件的插槽

<template>
  <component :is="currentComponent">
    <template v-for="(_, name) in $slots" #[name]>
      <slot :name="name"></slot>
    </template>
  </component>
</template>

八、最佳实践总结

8.1 插槽命名规范

<!-- 推荐:使用清晰的命名 -->
<slot name="header"></slot>
<slot name="body"></slot>
<slot name="footer"></slot>
<slot name="actions"></slot>

<!-- 推荐:使用前缀区分 -->
<slot name="field-text"></slot>
<slot name="field-select"></slot>
<slot name="field-checkbox"></slot>

8.2 插槽文档化

<!-- Modal.vue -->
/**
 * @slot header - 弹窗头部内容
 * @slot default - 弹窗主体内容
 * @slot footer - 弹窗底部内容(通常放置操作按钮)
 */

8.3 插槽的版本兼容

// Vue 2 写法
<template>
  <slot></slot>
</template>

// Vue 3 写法(推荐)
<template>
  <slot></slot>
</template>

<script setup>
// Vue 3 组合式 API
</script>

九、总结

插槽是 Vue 组件化开发的核心概念之一,它提供了灵活的内容分发机制:

  • 基本插槽:用于简单的内容分发
  • 具名插槽:用于组件的多个位置
  • 作用域插槽:用于传递子组件数据给插槽内容
  • 动态插槽:用于运行时决定使用哪个插槽

掌握插槽的使用,可以让你构建出更加灵活、可复用的 Vue 组件。记住插槽的最佳实践,合理命名、文档化、避免不必要的重渲染,你的组件将会更加专业和高效。

如果这篇文章对你有帮助,欢迎点赞收藏。