Vue 3 组合式 API 中的 Slots 与 Attrs 实践

1. 概述

在 Vue 3 的组合式 API 中,可以通过 useSlots()useAttrs() 来访问组件的插槽和属性。

2. 使用 useSlots()

2.1 基本用法

<!-- SlotComponent.vue -->
<template>
  <div class="slot-container">
    <div class="header">
      <slot name="header">
        <h2>默认标题</h2>
      </slot>
    </div>
    
    <div class="content">
      <slot>
        <p>默认内容</p>
      </slot>
    </div>
    
    <div class="footer">
      <slot name="footer">
        <p>默认页脚</p>
      </slot>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useSlots, computed } from 'vue'

// 获取插槽对象
const slots = useSlots()

// 检查插槽是否存在
const hasHeaderSlot = computed(() => !!slots.header)
const hasDefaultSlot = computed(() => !!slots.default)
const hasFooterSlot = computed(() => !!slots.footer)

// 动态渲染逻辑
console.log('Header slot exists:', hasHeaderSlot.value)
console.log('Default slot exists:', hasDefaultSlot.value)
console.log('Footer slot exists:', hasFooterSlot.value)
</script>

<style scoped>
.slot-container {
  border: 1px solid #ccc;
  padding: 20px;
  margin: 10px 0;
}

.header {
  background: #f0f0f0;
  padding: 10px;
}

.footer {
  background: #f9f9f9;
  padding: 10px;
  margin-top: 10px;
}
</style>
<!-- ParentComponent.vue -->
<template>
  <div>
    <SlotComponent>
      <template #header>
        <h2>自定义标题</h2>
      </template>
      
      <p>这是自定义内容</p>
      
      <template #footer>
        <button @click="handleClick">自定义按钮</button>
      </template>
    </SlotComponent>
  </div>
</template>

<script setup lang="ts">
import SlotComponent from './SlotComponent.vue'

const handleClick = () => {
  console.log('按钮被点击')
}
</script>

2.2 动态插槽处理

<!-- DynamicSlotComponent.vue -->
<template>
  <div class="dynamic-slot">
    <div v-for="slotName in availableSlots" :key="slotName">
      <div v-if="slots[slotName]" class="slot-item">
        <h4>插槽: {{ slotName }}</h4>
        <slot :name="slotName"></slot>
      </div>
    </div>
    
    <!-- 处理动态插槽 -->
    <div v-if="hasDynamicSlots" class="dynamic-slots">
      <h4>动态插槽:</h4>
      <div
        v-for="slotName in dynamicSlotNames"
        :key="slotName"
        class="dynamic-slot-item"
      >
        <slot :name="slotName"></slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useSlots, computed, ref } from 'vue'

const slots = useSlots()

// 预定义的插槽名称
const predefinedSlots = ['header', 'content', 'footer', 'sidebar']

// 可用的插槽
const availableSlots = computed(() => {
  return predefinedSlots.filter(slotName => !!slots[slotName])
})

// 动态插槽(非预定义的)
const dynamicSlotNames = computed(() => {
  return Object.keys(slots).filter(
    slotName => !predefinedSlots.includes(slotName) && slotName !== 'default'
  )
})

const hasDynamicSlots = computed(() => dynamicSlotNames.value.length > 0)

// 调试信息
console.log('所有插槽:', Object.keys(slots))
console.log('可用插槽:', availableSlots.value)
console.log('动态插槽:', dynamicSlotNames.value)
</script>

<style scoped>
.dynamic-slot {
  border: 2px dashed #007bff;
  padding: 15px;
  margin: 10px 0;
}

.slot-item {
  border: 1px solid #28a745;
  padding: 10px;
  margin: 5px 0;
  background: #f8fff9;
}

.dynamic-slots {
  border: 1px solid #ffc107;
  padding: 10px;
  margin-top: 10px;
  background: #fffef0;
}

.dynamic-slot-item {
  border: 1px dashed #ffc107;
  padding: 8px;
  margin: 5px 0;
}
</style>
<!-- ParentDynamicComponent.vue -->
<template>
  <div>
    <DynamicSlotComponent>
      <template #header>
        <h3>头部内容</h3>
      </template>
      
      <template #content>
        <p>主要内容区域</p>
      </template>
      
      <template #customSection>
        <div style="color: red;">自定义区域内容</div>
      </template>
      
      <template #anotherCustom>
        <button>另一个自定义按钮</button>
      </template>
    </DynamicSlotComponent>
  </div>
</template>

<script setup lang="ts">
import DynamicSlotComponent from './DynamicSlotComponent.vue'
</script>

3. 使用 useAttrs()

3.1 基本属性处理

<!-- AttrsComponent.vue -->
<template>
  <div class="attrs-container" v-bind="filteredAttrs">
    <h3>属性组件</h3>
    
    <div class="attrs-display">
      <h4>接收到的属性:</h4>
      <pre>{{ JSON.stringify(attrs, null, 2) }}</pre>
    </div>
    
    <div class="filtered-attrs">
      <h4>过滤后的属性:</h4>
      <pre>{{ JSON.stringify(filteredAttrs, null, 2) }}</pre>
    </div>
    
    <!-- 手动绑定特定属性 -->
    <input
      :value="inputValue"
      v-bind="inputAttrs"
      @input="handleInput"
    />
  </div>
</template>

<script setup lang="ts">
import { useAttrs, computed, ref } from 'vue'

// 获取所有属性(包括 class 和 style)
const attrs = useAttrs()

const inputValue = ref('')

// 过滤掉不需要的属性
const filteredAttrs = computed(() => {
  const { class: className, style, ...rest } = attrs
  return rest
})

// 专门处理输入框属性
const inputAttrs = computed(() => {
  return {
    placeholder: attrs.placeholder || '默认占位符',
    type: attrs.type || 'text',
    disabled: attrs.disabled || false
  }
})

const handleInput = (event: Event) => {
  inputValue.value = (event.target as HTMLInputElement).value
  console.log('输入值:', inputValue.value)
}

// 监听属性变化
console.log('组件属性:', attrs)
</script>

<style scoped>
.attrs-container {
  border: 2px solid #6f42c1;
  padding: 20px;
  margin: 10px 0;
  background: #f8f9fa;
}

.attrs-display, .filtered-attrs {
  margin: 15px 0;
  padding: 10px;
  background: white;
  border: 1px solid #dee2e6;
}

pre {
  background: #f8f9fa;
  padding: 10px;
  border-radius: 4px;
  font-size: 12px;
}

input {
  width: 100%;
  padding: 8px;
  margin-top: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
</style>
<!-- ParentAttrsComponent.vue -->
<template>
  <div>
    <AttrsComponent
      id="custom-id"
      class="custom-class"
      style="color: blue;"
      data-test="test-data"
      placeholder="请输入内容"
      type="password"
      :disabled="isDisabled"
      custom-attr="自定义属性"
      @custom-event="handleCustomEvent"
    />
    
    <button @click="isDisabled = !isDisabled">
      {{ isDisabled ? '启用' : '禁用' }}输入框
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import AttrsComponent from './AttrsComponent.vue'

const isDisabled = ref(false)

const handleCustomEvent = (event: any) => {
  console.log('自定义事件:', event)
}
</script>

3.2 属性继承控制

<!-- InheritAttrsComponent.vue -->
<template>
  <div class="inherit-container">
    <h3>属性继承控制</h3>
    
    <!-- 手动控制属性继承 -->
    <div class="with-attrs" v-bind="filteredAttrs">
      这个元素继承了过滤后的属性
    </div>
    
    <!-- 不继承属性的元素 -->
    <div class="without-attrs">
      这个元素没有继承属性
    </div>
    
    <!-- 传递属性到子组件 -->
    <ChildComponent v-bind="childAttrs" />
  </div>
</template>

<script setup lang="ts">
import { useAttrs, computed } from 'vue'
import ChildComponent from './ChildComponent.vue'

// 禁用默认的属性继承
defineOptions({
  inheritAttrs: false
})

const attrs = useAttrs()

// 过滤出需要的属性
const filteredAttrs = computed(() => {
  const { class: _, style: __, ...rest } = attrs
  return {
    ...rest,
    title: '自定义标题'
  }
})

// 传递给子组件的属性
const childAttrs = computed(() => {
  return {
    'data-parent': '来自父组件',
    ...filteredAttrs.value
  }
})

console.log('所有属性:', attrs)
</script>

<style scoped>
.inherit-container {
  border: 2px solid #e83e8c;
  padding: 20px;
  margin: 10px 0;
}

.with-attrs {
  background: #d1ecf1;
  padding: 10px;
  margin: 10px 0;
  border: 1px solid #bee5eb;
}

.without-attrs {
  background: #f8d7da;
  padding: 10px;
  margin: 10px 0;
  border: 1px solid #f5c6cb;
}
</style>
<!-- ChildComponent.vue -->
<template>
  <div class="child-component" v-bind="attrs">
    <h4>子组件</h4>
    <pre>子组件属性: {{ JSON.stringify(attrs, null, 2) }}</pre>
  </div>
</template>

<script setup lang="ts">
import { useAttrs } from 'vue'

const attrs = useAttrs()
</script>

<style scoped>
.child-component {
  background: #fff3cd;
  padding: 10px;
  margin: 10px 0;
  border: 1px solid #ffeaa7;
}
</style>

4. Slots 和 Attrs 结合使用

4.1 高级组件模式

<!-- AdvancedComponent.vue -->
<template>
  <div class="advanced-component" v-bind="containerAttrs">
    <!-- 动态渲染插槽 -->
    <div class="slot-renderer">
      <div
        v-for="slotName in renderableSlots"
        :key="slotName"
        :class="`slot-${slotName}`"
      >
        <slot :name="slotName"></slot>
      </div>
    </div>
    
    <!-- 默认插槽 -->
    <div v-if="hasDefaultSlot" class="default-slot">
      <slot></slot>
    </div>
    
    <!-- 基于属性的条件渲染 -->
    <div v-if="showConditional" class="conditional-content">
      <h4>条件内容</h4>
      <p>这个内容基于属性显示: {{ attrs.condition }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useSlots, useAttrs, computed } from 'vue'

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

// 可渲染的插槽
const renderableSlots = computed(() => {
  return Object.keys(slots).filter(slotName => slotName !== 'default')
})

// 检查默认插槽
const hasDefaultSlot = computed(() => !!slots.default)

// 基于属性的条件
const showConditional = computed(() => {
  return attrs.condition !== undefined && attrs.condition !== 'false'
})

// 容器属性
const containerAttrs = computed(() => {
  const { class: className, style, ...rest } = attrs
  return {
    ...rest,
    'data-advanced': 'true',
    'data-slots-count': renderableSlots.value.length
  }
})

// 调试信息
console.log('高级组件 - 插槽:', Object.keys(slots))
console.log('高级组件 - 属性:', attrs)
</script>

<style scoped>
.advanced-component {
  border: 3px solid #20c997;
  padding: 20px;
  margin: 15px 0;
  background: #f0fdf4;
}

.slot-renderer {
  display: grid;
  gap: 10px;
  margin-bottom: 15px;
}

.default-slot {
  background: #e7f3ff;
  padding: 15px;
  border: 1px solid #b3d9ff;
}

.conditional-content {
  background: #fff3cd;
  padding: 10px;
  margin-top: 10px;
  border: 1px solid #ffeaa7;
}
</style>
<!-- ParentAdvancedComponent.vue -->
<template>
  <div>
    <AdvancedComponent
      id="advanced-demo"
      class="custom-advanced"
      style="font-size: 16px;"
      condition="show"
      data-version="1.0"
      :custom-prop="dynamicValue"
    >
      <template #header>
        <h2>高级头部</h2>
      </template>
      
      <template #content>
        <p>这是通过插槽传递的内容</p>
      </template>
      
      <template #actions>
        <button @click="handleAction">操作按钮</button>
      </template>
      
      <p>这是默认插槽的内容</p>
    </AdvancedComponent>
    
    <button @click="dynamicValue = !dynamicValue">
      切换属性: {{ dynamicValue }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import AdvancedComponent from './AdvancedComponent.vue'

const dynamicValue = ref(true)

const handleAction = () => {
  console.log('操作按钮被点击')
}
</script>

5. 实际应用场景

5.1 表单字段组件

<!-- FormField.vue -->
<template>
  <div class="form-field" v-bind="containerAttrs">
    <label v-if="label || slots.label" class="form-label">
      <slot name="label">{{ label }}</slot>
    </label>
    
    <div class="input-container">
      <slot name="prepend"></slot>
      
      <input
        v-bind="inputAttrs"
        :value="modelValue"
        @input="handleInput"
        @blur="handleBlur"
        class="form-input"
      />
      
      <slot name="append"></slot>
    </div>
    
    <div v-if="error || slots.error" class="error-message">
      <slot name="error">{{ error }}</slot>
    </div>
    
    <div v-if="slots.help" class="help-text">
      <slot name="help"></slot>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useSlots, useAttrs, computed } from 'vue'

interface Props {
  modelValue?: string
  label?: string
  error?: string
}

const props = defineProps<Props>()
const emit = defineEmits<{
  'update:modelValue': [value: string]
  'blur': []
}>()

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

// 输入框属性
const inputAttrs = computed(() => {
  const { class: _, style: __, label, error, ...inputProps } = attrs
  return {
    type: 'text',
    ...inputProps
  }
})

// 容器属性
const containerAttrs = computed(() => {
  return {
    class: [
      'form-field-wrapper',
      attrs.class,
      props.error ? 'has-error' : ''
    ].filter(Boolean).join(' '),
    style: attrs.style
  }
})

const handleInput = (event: Event) => {
  const value = (event.target as HTMLInputElement).value
  emit('update:modelValue', value)
}

const handleBlur = () => {
  emit('blur')
}
</script>

<style scoped>
.form-field {
  margin-bottom: 1rem;
}

.form-label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}

.input-container {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.form-input {
  flex: 1;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 1rem;
}

.form-input:focus {
  outline: none;
  border-color: #007bff;
}

.error-message {
  color: #dc3545;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

.help-text {
  color: #6c757d;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

.has-error .form-input {
  border-color: #dc3545;
}
</style>
<!-- FormDemo.vue -->
<template>
  <div class="form-demo">
    <FormField
      v-model="username"
      label="用户名"
      placeholder="请输入用户名"
      required
      class="custom-field"
    >
      <template #prepend>
        <span>👤</span>
      </template>
    </FormField>
    
    <FormField
      v-model="email"
      label="邮箱"
      type="email"
      :error="emailError"
    >
      <template #label>
        <span style="color: red;">*</span>
        邮箱地址
      </template>
      
      <template #help>
        <p>请输入有效的邮箱地址</p>
      </template>
    </FormField>
    
    <FormField
      v-model="password"
      label="密码"
      type="password"
    >
      <template #append>
        <button type="button" @click="togglePassword">
          {{ showPassword ? '隐藏' : '显示' }}
        </button>
      </template>
    </FormField>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import FormField from './FormField.vue'

const username = ref('')
const email = ref('')
const password = ref('')
const showPassword = ref(false)

const emailError = computed(() => {
  if (!email.value) return '邮箱不能为空'
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
    return '邮箱格式不正确'
  }
  return ''
})

const togglePassword = () => {
  showPassword.value = !showPassword.value
}
</script>

6. 最佳实践和注意事项

6.1 性能考虑

<script setup lang="ts">
import { useSlots, useAttrs, computed, watchEffect } from 'vue'

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

// 好的做法:使用 computed
const hasHeaderSlot = computed(() => !!slots.header)

// 不好的做法:在模板中直接调用
// {{ !!slots.header }}

// 监听属性变化(谨慎使用)
watchEffect(() => {
  // 只在必要时监听
  if (attrs.disabled) {
    console.log('组件被禁用')
  }
})

// 属性过滤的最佳实践
const inputAttrs = computed(() => {
  // 明确列出需要的属性
  const { 
    type = 'text',
    placeholder = '',
    disabled = false,
    ...rest 
  } = attrs
  
  // 过滤掉不需要传递的属性
  const { class: _, style: __, ...filteredRest } = rest
  
  return {
    type,
    placeholder,
    disabled,
    ...filteredRest
  }
})
</script>

6.2 类型安全

<script setup lang="ts">
import { useSlots, useAttrs } from 'vue'

// 为插槽提供类型
interface ComponentSlots {
  header?: (props: { title: string }) => any
  default?: (props: { data: any }) => any
  footer?: () => any
}

// 为属性提供类型
interface ComponentAttrs {
  id?: string
  class?: string
  style?: string
  'data-test'?: string
  [key: string]: any
}

// 在实际使用中,Vue 会自动推断类型
const slots = useSlots()
const attrs = useAttrs()

// 类型安全的插槽检查
const hasTypedSlot = (slotName: keyof ComponentSlots): boolean => {
  return !!slots[slotName]
}

// 类型安全的属性访问
const getSafeAttr = (key: keyof ComponentAttrs) => {
  return attrs[key]
}
</script>

7. 总结

useSlots() 主要用途:

  • 检查插槽存在性:动态决定是否渲染某些内容
  • 条件渲染:基于插槽存在性进行条件渲染
  • 动态插槽处理:处理未知名称的插槽
  • 插槽组合:创建复杂的插槽组合逻辑

useAttrs() 主要用途:

  • 属性继承控制:精确控制属性的继承行为
  • 属性过滤:过滤和转换传入的属性
  • 属性传递:将属性传递给子元素或组件
  • 动态属性绑定:基于属性值进行条件渲染

最佳实践:

  1. 明确继承:使用 inheritAttrs: false 明确控制属性继承
  2. 性能优化:使用 computed 包装 slots 和 attrs 的访问
  3. 类型安全:为复杂的插槽和属性场景提供 TypeScript 类型
  4. 适度使用:不要过度使用,只在真正需要时访问 slots 和 attrs

通过合理使用 useSlots()useAttrs(),可以创建更加灵活和可复用的 Vue 组件。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐