插槽 Slot

插槽让组件的内容更加灵活。父组件可以向子组件传递模板内容,而不仅仅是数据。这是 Vue 组件化开发中非常强大的功能。

为什么需要插槽

假设你要创建一个按钮组件:

Vue.component('my-button', {
  template: '<button class="btn"><slot>默认按钮</slot></button>'
})

没有插槽,按钮内容是固定的。有了插槽,可以这样使用:

<my-button>提交</my-button>
<my-button>取消</my-button>
<my-button><icon name="save"></icon> 保存</my-button>

同一个组件,展示不同的内容,这就是插槽的价值。

默认插槽

基本用法

子组件使用 <slot> 标签定义插槽位置:

Vue.component('alert-box', {
  template: `
    <div class="alert">
      <strong>提示:</strong>
      <slot></slot>
    </div>
  `
})

父组件传入内容:

<alert-box>操作成功!</alert-box>
<alert-box>发生错误!</alert-box>

默认内容

插槽可以指定默认内容,当父组件没有传入内容时显示:

Vue.component('submit-button', {
  template: `
    <button type="submit">
      <slot>提交</slot>
    </button>
  `
})
<submit-button></submit-button>        <!-- 显示:提交 -->
<submit-button>保存</submit-button>    <!-- 显示:保存 -->

具名插槽

当组件需要多个插槽时,可以使用具名插槽。

定义具名插槽

Vue.component('base-layout', {
  template: `
    <div class="container">
      <header>
        <slot name="header"></slot>
      </header>
      <main>
        <slot></slot>
      </main>
      <footer>
        <slot name="footer"></slot>
      </footer>
    </div>
  `
})

使用具名插槽

<base-layout>
  <template v-slot:header>
    <h1>页面标题</h1>
  </template>
  
  <p>主要内容</p>
  <p>更多内容</p>
  
  <template v-slot:footer>
    <p>页脚信息</p>
  </template>
</base-layout>

简写语法

v-slot: 可以简写为 #

<base-layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <p>主要内容</p>
  
  <template #footer>
    <p>页脚信息</p>
  </template>
</base-layout>

旧版语法(已废弃)

Vue 2.6.0 之前使用 slot 属性:

<!-- 已废弃,不推荐使用 -->
<base-layout>
  <h1 slot="header">页面标题</h1>
  <p>主要内容</p>
  <p slot="footer">页脚信息</p>
</base-layout>

作用域插槽

作用域插槽让父组件可以访问子组件的数据。

基本用法

子组件通过 v-bind 传递数据:

Vue.component('user-list', {
  props: ['users'],
  template: `
    <ul>
      <li v-for="user in users" :key="user.id">
        <slot :user="user">
          {{ user.name }}
        </slot>
      </li>
    </ul>
  `
})

父组件通过 v-slot 接收数据:

<user-list :users="users">
  <template v-slot="{ user }">
    <span class="name">{{ user.name }}</span>
    <span class="email">{{ user.email }}</span>
  </template>
</user-list>

完整示例

Vue.component('data-table', {
  props: {
    items: Array,
    columns: Array
  },
  template: `
    <table>
      <thead>
        <tr>
          <th v-for="col in columns" :key="col.key">
            {{ col.title }}
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="item in items" :key="item.id">
          <td v-for="col in columns" :key="col.key">
            <slot :name="col.key" :item="item" :value="item[col.key]">
              {{ item[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  `
})
<data-table :items="users" :columns="columns">
  <template #name="{ item }">
    <strong>{{ item.name }}</strong>
  </template>
  
  <template #status="{ value }">
    <span :class="'status-' + value">{{ value }}</span>
  </template>
  
  <template #actions="{ item }">
    <button @click="edit(item)">编辑</button>
    <button @click="delete(item)">删除</button>
  </template>
</data-table>

实战示例

卡片组件

Vue.component('card', {
  props: {
    title: String,
    shadow: {
      type: Boolean,
      default: true
    }
  },
  template: `
    <div class="card" :class="{ 'card-shadow': shadow }">
      <div v-if="$slots.header" class="card-header">
        <slot name="header"></slot>
      </div>
      <div class="card-body">
        <slot></slot>
      </div>
      <div v-if="$slots.footer" class="card-footer">
        <slot name="footer"></slot>
      </div>
    </div>
  `
})
<card title="用户信息">
  <template #header>
    <h3>用户详情</h3>
  </template>
  
  <p>姓名:张三</p>
  <p>邮箱:zhangsan@example.com</p>
  
  <template #footer>
    <button>编辑</button>
  </template>
</card>

列表组件

Vue.component('list', {
  props: {
    items: Array,
    itemKey: {
      type: String,
      default: 'id'
    }
  },
  template: `
    <ul class="list">
      <li v-for="item in items" :key="item[itemKey]" class="list-item">
        <slot :item="item" :index="index">
          {{ item }}
        </slot>
      </li>
    </ul>
  `
})
<list :items="products">
  <template v-slot="{ item, index }">
    <span class="index">{{ index + 1 }}</span>
    <span class="name">{{ item.name }}</span>
    <span class="price">¥{{ item.price }}</span>
  </template>
</list>

标签页组件

Vue.component('tabs', {
  template: `
    <div class="tabs">
      <div class="tabs-header">
        <slot name="tab" v-for="tab in tabs" :tab="tab" :active="tab.active">
          <button 
            :key="tab.name"
            :class="{ active: tab.active }"
            @click="selectTab(tab)"
          >
            {{ tab.label }}
          </button>
        </slot>
      </div>
      <div class="tabs-content">
        <slot></slot>
      </div>
    </div>
  `,
  data() {
    return {
      tabs: []
    }
  },
  mounted() {
    this.tabs = this.$children
    this.tabs[0].active = true
  },
  methods: {
    selectTab(selectedTab) {
      this.tabs.forEach(tab => {
        tab.active = tab === selectedTab
      })
    }
  }
})

Vue.component('tab', {
  props: {
    name: String,
    label: String
  },
  data() {
    return {
      active: false
    }
  },
  template: `
    <div v-show="active" class="tab-pane">
      <slot></slot>
    </div>
  `
})
<tabs>
  <tab name="home" label="首页">
    <h2>首页内容</h2>
  </tab>
  <tab name="about" label="关于">
    <h2>关于我们</h2>
  </tab>
</tabs>

表单组件

Vue.component('form-group', {
  props: {
    label: String,
    error: String
  },
  template: `
    <div class="form-group">
      <label v-if="label">{{ label }}</label>
      <slot></slot>
      <span v-if="error" class="error">{{ error }}</span>
    </div>
  `
})
<form-group label="用户名" :error="errors.username">
  <input v-model="form.username">
</form-group>

<form-group label="头像">
  <file-upload @change="handleUpload"></file-upload>
</form-group>

对话框组件

Vue.component('dialog', {
  props: {
    visible: Boolean,
    title: String
  },
  template: `
    <div v-if="visible" class="dialog-overlay" @click.self="$emit('close')">
      <div class="dialog">
        <div class="dialog-header">
          <slot name="header">
            <h3>{{ title }}</h3>
          </slot>
          <button class="close" @click="$emit('close')">×</button>
        </div>
        <div class="dialog-body">
          <slot></slot>
        </div>
        <div class="dialog-footer">
          <slot name="footer">
            <button @click="$emit('close')">关闭</button>
          </slot>
        </div>
      </div>
    </div>
  `
})
<dialog :visible="showDialog" @close="showDialog = false" title="确认">
  <p>确定要删除吗?</p>
  
  <template #footer>
    <button @click="showDialog = false">取消</button>
    <button @click="confirm">确定</button>
  </template>
</dialog>

注意事项

插槽内容的编译作用域

插槽内容在父组件作用域编译,无法访问子组件的数据:

<!-- ❌ 错误:无法访问子组件的 user -->
<user-card>
  {{ user.name }}
</user-card>

<!-- ✅ 正确:使用作用域插槽 -->
<user-list :users="users" v-slot="{ user }">
  {{ user.name }}
</user-list>

检查插槽是否存在

使用 $slots 检查插槽是否有内容:

Vue.component('card', {
  template: `
    <div class="card">
      <div v-if="$slots.header" class="card-header">
        <slot name="header"></slot>
      </div>
      <div class="card-body">
        <slot></slot>
      </div>
    </div>
  `
})

小结

插槽要点:

  1. 默认插槽:使用 <slot> 定义,传入内容替换
  2. 具名插槽:使用 name 属性区分多个插槽
  3. 作用域插槽:子组件向父组件传递数据
  4. 编译作用域:插槽内容在父组件编译