# Vue 插槽 Slots,自定义弹窗、高级列表组件,依赖注入

TIP

从本节内容开始,我们学习 Vue 组件相关的核心内容,具体如下

  • 插槽(slots)
  • 祖先组件向孙组件传值 - 依赖与注入
  • 实战应用:自定义 Dialog 弹窗组件
  • 实战应用:高级列表组件
  • 祖先组件向孙组件传值 - 依赖与注入

# 一、插槽 Slots

TIP

插槽主要用来实现父组件向子组件传递模板内容,且使用起来非常方便。

我们会从以下几个点来展开插槽的学习

  • 为什么需要插槽
  • 默认插槽
  • 具名插槽
  • 实战应用:自定义Dialog弹窗组件
  • 作用域插槽

# 1、为什么需要插槽

TIP

在实际的开发中,父组件经常需要向子组件传递模板内容。

如下图所示的案例

image-20230513161942213

在父组件App中调用了同一个子组件<List>2 次,子组件的外观样式一样,但展示的主体内容模板和样式都不一样,这时候主体中需要展示的模板内容就需要通过父组件来传递。

也就是说子组件在定义时,并不知道使用该组件的父组件需要在子组件中显示什么模板内容,所以把需要显示的模板内容权限交给了使用他的父组件,父组件想要显示什么内容,直接传递给子组件就好。

父组件向子组件传递模板内容有以下两种方式:

方式 优缺点
通过 props 来传递模板内容 如果模板内容较复杂,使用起来会非常麻烦
通过插槽来传递模板内容 不管模板内容简单还是复杂,使用起来都非常简单和方便。

# 2、通过 props 来传递模板内容

  • 父组件中调用子组件,通过 Prop 传递模板内容
<!--通过prop传递模板内容-->
<List :content="<h3>新闻动态</h3> <div>新闻内容</div >" />
  • 子组件中使用传递的 prop 数据
<div class="list" v-html="content"></div>
  • 渲染后代码如下
<div class="list">
  <h3>新闻动态</h3>
  <div>新闻内容</div>
</div>

如果传递的模板内容比较简单还好,如果传递的模板数据较复杂,使用 props 来传递就会非常麻烦。

我们利用props来传递模板内容,实现如下效果

image-20230513161942213

  • App.vue组件
<script>
  import List from "./components/List.vue";
  export default {
    data() {
      return {
        courseTitle: "推荐课程",
        courseImg: `<img src="https://sce7a2b9c9d95a-sb-qn.qiqiuyun.net/files/course/2022/08-29/210311f40bcf290736.jpg" />`,
        newsTitle: "最新动态",
        newsList: ["动态1", "最新动态2", "最新动态3"],
      };
    },
    computed: {
      // 计算属性
      // 把数据拼接成最终要显示的HTML字符串
      myList() {
        let html = "<ul>";
        for (let i = 0; i < this.newsList.length; i++) {
          html += `<li>${this.newsList[i]}</li>`;
        }
        html += "</ul>";
        return html;
      },
    },
    components: {
      List,
    },
  };
</script>

<template>
  <div class="container">
    <List :title="courseTitle" :info="courseImg" />
    <List :title="newsTitle" :info="myList" />
  </div>
</template>

<style>
  .container {
    border: 2px solid skyblue;
    padding: 20px;
    margin: 20px auto;
    width: 440px;
    display: flex;
    justify-content: space-between;
  }

  ul {
    padding: 0px 10px;
  }

  ul li {
    line-height: 35px;
    border-bottom: 1px dotted #ddd;
  }
</style>
  • List.vue组件内容
<script>
  export default {
    // props接收
    props: ["title", "info"],
  };
</script>

<template>
  <div class="list">
    <h3>{{ title }}</h3>
    <!-- 因为接受的字符串最终要渲染成html,所以要用v-html指令-->
    <div v-html="info"></div>
  </div>
</template>

<style>
  html,
  body,
  ul,
  li,
  h3 {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .list {
    width: 200px;
    border: 1px solid skyblue;
  }

  .list h3 {
    text-align: center;
    background-color: skyblue;
  }

  .list img {
    width: 80%;
    display: block;
    margin: 20px auto;
  }
</style>

注:

通过上面代码,我们分析得出,如果使用Prop来传递模板内容,所有html标签等内容都要写到字符串中,又回到操作 DOM 和子符串拼接的时代,这肯定不是 Vue 框架设计的初衷。

所以 Vue 为传递模板内容提供了更加简单的方式,即使用插槽来传递模板内容。

# 3、插槽的分类

TIP

Vue 为我们提供了以下三种插槽

  • 默认插槽
  • 具名插槽
  • 作用域插槽

# 4、默认插槽

默认插槽的使用分为以下两步:

  • ①、定义插槽内容(slot content): 在父组件中,把需要传入给默认插槽的模板内容,直接写在子组件标签中间,如下:
<!--App 父组件-->
<List>
  <!-- 插槽内容,以下内容会替换子组件中的<solt></slot>元素 -->
  <h3>新闻动态</h3>
  <div>新闻内容</div>
</List>
  • ②、定义插槽出口(slot outlet): 在子组件中,我们想把传入的模板内容插入到模板的哪个位置,我们就可以在对应位置插入<slot></slot>
<!--List子组件-->
<!--在子组件使用插槽传递的模板内容-->
<div class="list">
  <!-- slot为插槽出口,上面插槽内容最终会替换这里的slot标签 -->
  <slot></slot>
</div>

注:

<slot> 元素是一个插槽出口(slot outlet),标示了父元素提供的插槽内容(slot content) 将在哪里被渲染。

以上代码最终渲染后效果如下:

<!--渲染后模板代码如下-->
<div class="list">
  <h3>新闻动态</h3>
  <div>新闻内容</div>
</div>

# 4.1、插槽的渲染作用域

TIP

  • 渲染作用域 :插槽内容可以访问到父组件的数据作用域,而无法访问子组件的数据,因为插槽内容本身是在父组件模板中定义的
  • 插槽内容:可以是任意合法的模板内容,例如:模板内容可以是多个 HTML 元素,也可以是组件

代码演示

App.vue 根组件

<script>
  import Person from "./components/Person.vue";
  import Button from "./components/Button.vue";
  export default {
    data() {
      return {
        userName: "艾编程",
        age: 33,
        sex: "男",
      };
    },
    components: {
      Person,
      Button,
    },
  };
</script>

<template>
  <Person>
    <h3>用户:{{ userName }}</h3>
    <div class="info">年龄:{{ age }} -- 性别: {{ sex }}</div>
    <!--插槽内容中可以使用组件-->
    <button>
      <button>提交</button>
    </button>
  </Person>
</template>

以上代码插槽内容中访问的userNameagesex 均为当前父组件中的数据

Person.vue 子组件

<template>
  <div class="container">
    <slot></slot>
  </div>
</template>

Button.vue子组件

<template>
  <slot></slot>
</template>

以上案例最终渲染后代码如下:

<div class="container">
  <h3>用户:艾编程</h3>
  <div class="info">年龄:33 -- 性别: 男</div>
  <button>提交</button>
</div>

# 4.2、插槽默认内容

TIP

我们可以为插槽指定默认内容,当父组件中没有提供任何插槽内容时,显示插槽的默认内容。如果指定了,就显示指定的内容。

  • Person组件中定义插槽出口,并为插槽指定默认内容
<!--Person组件-->
<div class="container">
  <slot>
    <!--以下内容为默认内容,如果父组件中没有指定插槽内容,则显示以下内容-->
    <button>登录</button>
    <button>注册</button>
  </slot>
</div>
  • 当我们在父组件中使用 <Person>组件 且没有提供任何插槽内容时
<Person></Person>

最终渲染出来的效果如下:

<div class="container">
  <button>登录</button>
  <button>注册</button>
</div>

# 4.3 、插槽内容的 CSS 样式

TIP

在子组件中不会渲染插槽出口的内容,其内容由父组件的插槽内容渲染后提供。

这一点决定了插槽内容的 CSS 样式应该写在父组件中,而不能写在子组件中。

代码示例

  • App.vue文件内容
<script>
  import List from "./components/List.vue";
  export default {
    components: {
      List,
    },
  };
</script>
<template>
  <List>
    <div class="title">插槽内容</div>
  </List>
</template>
  • List.vue 文件内容
<template>
  <div class="list">
    <slot></slot>
  </div>
</template>
<style scoped>
  .list {
    width: 300px;
    height: 200px;
    border: 1px solid skyblue;
  }

  .list .title {
    background-color: skyblue;
  }
</style>

最终渲染后的效果如下图,CSS 样式并没有生效

image-20230630155514945

我们查看下最终渲染后的 HTML 与 CSS 代码,如下:

<div data-v-b7ac1dbf="" class="list">
  <div class="title">插槽内容</div>
</div>

<style>
  .list[data-v-b7ac1dbf] {
    width: 300px;
    height: 200px;
    border: 1px solid skyblue;
  }
  .list .title[data-v-b7ac1dbf] {
    background-color: skyblue;
  }
</style>

注:

因为插槽内容是由父组件渲染后提供给子组件的,所以插槽内容的标签上并不会添加子组件的data-v-xx属性。

所以子组件scoped中的样式并不会应用到插槽内容上。

如果我们把控制插槽内容的 CSS 样式写在父组件中,如下:

<script>
  import List from "./components/List.vue";
  export default {
    components: {
      List,
    },
  };
</script>
<template>
  <List>
    <div class="title">插槽内容</div>
  </List>
</template>
<style scoped>
  .title {
    background-color: skyblue;
  }
</style>

最终渲染效果如下,CSS 样式生效了:

image-20230630160016921

我们查看下最终渲染后的 HTML 与 CSS 代码,如下:

<div data-v-b7ac1dbf="" data-v-7a7a37b1="" class="list">
  <div data-v-7a7a37b1="" class="title">插槽内容</div>
</div>
<style>
  .list[data-v-b7ac1dbf] {
    width: 300px;
    height: 200px;
    border: 1px solid skyblue;
  }

  .title[data-v-7a7a37b1] {
    background-color: skyblue;
  }
</style>

注:

以上代码也证明了,插槽内容是在父组件中渲染后再放入子组件,即插槽内容中的 html 元素会加上父组件的data-v-xx属性。

总结

插槽内容对应的 CSS 样式和数据都要写在父组件中,不能写在子组件,因为插槽内容是在父组件中渲染后提供给子组件的。

# 4.4、插槽选择器

TIP

如果我们想在子组件中通过 css 选择器选择插槽内容,可以借助:slotted 伪类。

:slotted(.list .title) {
  background-color: skyblue;
}

修改前面的案例

App.vue中的 css 样式去掉,然后将 css 样式写入<List>组件中,并添加:slotted伪类,具体如下

<template>
  <div class="list">
    <slot></slot>
  </div>
</template>
<style scoped>
  .list {
    width: 300px;
    height: 200px;
    border: 1px solid skyblue;
  }

  :slotted(.list .title) {
    background-color: skyblue;
  }
</style>

最终渲染后效果如下:

image-20230630160016921

我们查看下最终渲染后的 HTML 与 CSS 代码,如下:

<div data-v-b7ac1dbf="" class="list">
  <!--加上了唯一data-v-xx-s属性-->
  <div data-v-b7ac1dbf-s="" class="title">插槽内容</div>
</div>
<style>
  .list[data-v-b7ac1dbf] {
    width: 300px;
    height: 200px;
    border: 1px solid skyblue;
  }
  /* 通过唯一属性来选择 */
  .list .title[data-v-b7ac1dbf-s] {
    background-color: skyblue;
  }
</style>

注:

观察以上代码发现,.title元素添加上了唯一data-v-b7ac1dbf-s属性,同时 css 选择器也添加上了属性选择,即:.list .title[data-v-b7ac1dbf-s]

# 4.5、案例演示

接下来,我们用插槽来实现下图所示的案例

image-20230513161942213

  • App.vue根组件
<script>
  import List from "./components/List.vue";
  export default {
    data() {
      return {
        imgUrl:
          "https://sce7a2b9c9d95a-sb-qn.qiqiuyun.net/files/course/2022/08-29/210311f40bcf290736.jpg",
        courseTitle: "推荐课程",
        newsTitle: "最新动态",
        newsList: ["动态1", "最新动态2", "最新动态3"],
      };
    },
    components: {
      List,
    },
  };
</script>

<template>
  <div class="container">
    <List :title="courseTitle">
      <!--插槽内容-->
      <img id="img" :src="imgUrl" />
    </List>
    <List :title="newsTitle">
      <!--插槽内容-->
      <ul>
        <li v-for="item in newsList">{{ item }}</li>
      </ul>
    </List>
  </div>
</template>
<style>
  html,
  body,
  ul,
  li,
  h3 {
    margin: 0;
    padding: 0;
    list-style: none;
  }
</style>
<style scoped>
  .container {
    border: 2px solid skyblue;
    padding: 20px;
    margin: 20px auto;
    width: 440px;
    display: flex;
    justify-content: space-between;
  }

  ul {
    padding: 0px 10px;
  }

  ul li {
    line-height: 35px;
    border-bottom: 1px dotted #ddd;
  }

  #img {
    width: 80%;
    display: block;
    margin: 20px auto;
  }
</style>
  • List.vue 子组件
<script>
  export default {
    // props接收
    props: ["title"],
  };
</script>

<template>
  <div class="list">
    <h3>{{ title }}</h3>
    <!--插槽出口-->
    <slot></slot>
  </div>
</template>

<style scoped>
  .list {
    width: 200px;
    border: 1px solid skyblue;
  }

  .list h3 {
    text-align: center;
    background-color: skyblue;
  }
</style>

# 5、具名插槽

TIP

有时候我们需要在一个组件中包含多个插槽出口,同时为每个插槽出口指定对应的插槽内容,如下图所示的弹窗效果。

image-20230513205906663

image-20230513210131239

注:

上图两个效果采用的是同一个组件实现的。

  • 标题部分利用的是Prop传值来实现的
  • 中间内容和底部按扭分别为两个插槽,每个插槽中显示的内容不一样。只是第二个图中,底部的插槽中没有传入任何内容,所以啥也没显示。

课程最后,我们会带大家来开发这个弹窗效果

# 5.1、具名插槽与默认插槽区别

TIP

相对于前面讲到的默认插槽而言,具名插槽的<slot>元素上有一个特殊的name属性,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容。

<!-- 具名插槽 -->
<slot name="header"> </slot>
<!--默认插槽-->
<slot> </slot>

实际上默认插槽<slot>元素也有name属性,name属性为default,通常情况下该name属性可以省略不写

<!--默认插槽-->
<slot></slot>

<!--默认插槽,和上面写法表示的是一个意思-->
<slot name="default"></slot>

# 5.2、具名插槽的使用

使用具名插槽,需要分以下两步:

  • ①、指定插槽出口:在子组件模板中,在需要显示插槽内容的位置添加<slot>标签指定插槽出口,同时为具名插槽添加name属性,如下
<!-- Layout组件 -->
<div class="container">
  <header>
    <!-- 具名插槽 -->
    <slot name="header"> </slot>
  </header>
  <main>
    <!--默认插槽 -->
    <slot></slot>
  </main>
  <footer>
    <!-- 具名插槽-->
    <slot name="footer"> </slot>
  </footer>
</div>
  • ②、指定插槽内容:要为具名插槽传入对应的内容,我们需要使用一个含v-slot指令的<template>元素,并将目标插槽的名字传给该指令。
<Layout>
  <template v-slot:default>
    <!--默认插槽内容放这里-->
  </template>
  <template v-slot:header>
    <!--header插槽内容放这里-->
  </template>
  <template v-slot:footer>
    <!--footer插槽内容放这里-->
  </template>
</Layout>

以下为对应的插槽出口指定插槽内容

<Layout>
  <!--默认插槽内容-->
  <template v-slot:default>
    <div class="main">我是主体内容</div>
  </template>
  <template v-slot:header>
    <!--header插槽内容-->
    <div class="header">我是头部内容</div>
  </template>
  <template v-slot:footer>
    <!--footer插槽内容-->
    <div class="footer">我是底部内容</div>
  </template>
</Layout>

最终所有插槽内容都被传递到了相应的插槽出口,渲染后的效果如下:

<div class="container">
  <header>
    <div class="header">我是头部内容</div>
  </header>
  <main>
    <div class="main">我是主体内容</div>
  </main>
  <footer>
    <div class="footer">我是底部内容</div>
  </footer>
</div>

# 5.3、注意事项

  • 当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容,所以上面内容也可以写成
<Layout>
  <!--Layout标签中,所有没有写在template模板中的内容,都为默认插槽内容-->
  <div class="main">我是主体内容</div>
  <template v-slot:header>
    <!--header插槽内容-->
    <div class="header">我是头部内容</div>
  </template>
  <template v-slot:footer>
    <!--footer插槽内容-->
    <div class="footer">我是底部内容</div>
  </template>
</Layout>
  • v-slot指令可以简写成#,因此<template v-slot:header>可以简写成<template #header> ,所以上面内容也可以简写成
<Layout>
  <!--Layout标签中,所有没有写在template模板中的内容,都为默认插槽内容-->
  <div class="main">我是主体内容</div>
  <template #header>
    <!--header插槽内容-->
    <div class="header">我是头部内容</div>
  </template>
  <template #footer>
    <!--footer插槽内容-->
    <div class="footer">我是底部内容</div>
  </template>
</Layout>
  • 可以为插槽指定动态的插槽名
<template v-slot:[header]> .... </template>
<!--上面简写形式-->
<template #[header]> ..... </template>

以上代码可以改写成如下:

<script>
  import exp from "constants";
  import Layout from "./components/Layout.vue";
  export default {
    data() {
      return {
        header: "header",
        footer: "footer",
      };
    },
    components: {
      Layout,
    },
  };
</script>
<template>
  <Layout>
    <!--只要没有写在对应template模板中的内容,都会放到默认插槽中-->

    <template v-slot:default>
      <div class="main">我是主体内容</div>
    </template>
    <template v-slot:[header]>
      <!--header插槽内容放这里-->
      <div class="header">我是头部内容</div>
    </template>
    <template #[footer]>
      <!--footer插槽内容放这里-->
      <div class="footer">我是底部内容</div>
    </template>
  </Layout>
</template>

# 二、实战应用:自定义 Dialog 弹窗组件

TIP

  • 点击显示弹窗按扭,会弹出对应的弹窗
  • 点击弹窗中的取消与确认按扭,都会关闭弹窗。
  • 点击右上角的关闭按扭会弹出一个提示框,询问是确定关闭弹窗吗?点击确定就会关才,点击取消,就不关闭。

GIF2023-5-1322-27-58

GIF2023-7-419-31-41

# 1、实现步骤

第一步: 首先定义一个子组件Dialog,实现如下布局效果

image-20230630170324029

<template>
  <!--黑色半透明遮罩层-->
  <div class="dialog-mask"></div>
  <!--弹出层-->
  <div class="dialog">
    <!--弹窗头部标题-->
    <div class="dialog-header">
      <div class="title">弹窗头部</div>
      <div class="close">X</div>
    </div>
    <!--弹窗主体内容-->
    <div class="dialog-main">弹窗主体内容</div>
    <!--弹窗底部-->
    <div class="dialog-footer">弹窗底部</div>
  </div>
</template>
<style scoped>
  .dialog-mask {
    width: 100%;
    height: 100%;
    position: fixed;
    top: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 998;
  }

  .dialog {
    width: 600px;
    min-height: 100px;
    background-color: #fff;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 999;
  }

  .dialog .dialog-header {
    position: relative;
    height: 45px;
    overflow: hidden;
  }

  .dialog .dialog-header .title {
    line-height: 55px;
    font-size: 24px;
    text-indent: 1em;
  }

  .dialog .dialog-header .close {
    width: 20px;
    height: 20px;
    background-color: #ddd;
    position: absolute;
    right: 0px;
    top: 0px;
    text-align: center;
    line-height: 20px;
    z-index: 1;
    cursor: pointer;
  }

  .dialog .dialog-main {
    margin: 20px;
  }
</style>

第二步: 在子组件Dialog中添加一个默认插槽出口与具名插槽出口

<template>
  <!--黑色半透明遮罩层-->
  <div class="dialog-mask"></div>
  <!--弹出层-->
  <div class="dialog">
    <!--弹窗头部标题-->
    <div class="dialog-header">
      <div class="title">弹窗头部</div>
      <div class="close">X</div>
    </div>
    <!--弹窗主体内容-->
    <div class="dialog-main">
      <slot>弹窗主体内容</slot>
    </div>
    <!--弹窗底部-->
    <div class="dialog-footer">
      <slot name="footer">弹窗底部</slot>
    </div>
  </div>
</template>

第三步: 在父组件中使用Dialog子组件,最终实现如下效果

image-20230704193038581

  • 在组件上添加title属性,将内容的标题传入子组件
<dialog :title="title"></dialog>
  • 在子组件中接受传过来的title属性,并在模板中使用
<script>
  export default {
    props: ["title"],
  };
</script>
<template>
  ...........
  <!--弹出层-->
  <div class="dialog">
    <!--弹窗头部标题-->
    <div class="dialog-header">
      <div class="title">{{ title }}</div>
      <div class="close">X</div>
    </div>
    .........
  </div>
</template>
  • 为默认插槽和具名插槽传入内容,同时添加为插槽内容设置 CSS 样式
<script>
    import Dialog from "./components/Dialog.vue"
    export default {
        data() {
            return {
                title: "收货地址",
                isVisible: false,   // 弹窗显示
                // 弹窗主体显示数据
                gridData: [{
                    date: '2027-07-02',
                    name: '王小花',
                    address: 'xxx普陀区金沙江路 12345 '
                }, {
                    date: '2027-07-04',
                    name: '刘晓冉',
                    address: 'xxx普陀区金沙江路 12345'
                }, {
                    date: '2027-07-01',
                    name: '小清心',
                    address: 'xxx普陀区金沙江路 12345'
                }, {
                    date: '2027-07-03',
                    name: '王小虎',
                    address: 'xxx普陀区金沙江路 12345'
                }],
            }
        },
        components: {
            Dialog
        }
    }
</script>

<template>
    <Dialog :title="title">
        <template v-slot:default>
            <!--默认插槽内容-->
            <table>
                <tr>
                    <th>日期</th>
                    <th>姓名</th>
                    <th>收货地址</th>
                </tr>
                <tr v-for="{ date, name, address } in gridData">
                    <td>{{ date }}</td>
                    <td>{{ name }}</td>
                    <td>{{ address }}</td>
                </tr>
            </table>
        </template>
        <!--具名插槽内容-->
        <template v-slot:footer>
            <div class="button">
                <button class="cancle">取消</button>
                <button class="confirm">确认</button>
            </div>
        </template>
    </Dialog>
</template>

<style>
    .button {
        display: flex;
        justify-content: flex-end;
        margin-bottom: 20px;
    }

    .button button {
        width: 80px;
        height: 30px;
        margin-right: 20px;
        border: none;
    }

    .button .confirm {
        background-color: rgb(96, 183, 217);
        color: #fff;
    }

    table {
        width: 100%;
        border-collapse: collapse;
    }

    table tr td,
    table tr th {
        height: 35px;
        text-align: center;
        border: 1px solid #ddd
    }
</style>

第四步:点击显示弹窗按扭,显示弹窗,然后弹窗右上角的关闭按扭,会弹出确认提示框询问是否关闭弹窗,如果是,则关闭,否则不关闭。

  • 在父组件中定义变量isVisible来控制弹窗的显示与关闭,一开始弹窗是关闭的,则默认值为 fasle。
  • 在组件上添加visible属性,将isVisible的值作为visible属性的值传递给到子组件,子组件拿到该值来控制弹窗的显示与隐藏
  • 在父组件中添加一个显示弹窗按扭,点击后将isVisible=true,则显示弹窗
  • 同时监听before-close事件,在子组件中触发before-close事件时会调用handleClose方法关闭弹窗。
<!--App 父组件--->
<script>
  import Dialog from "./components/Dialog.vue";
  export default {
    data() {
      return {
        // ......
        isVisible: false, // 弹窗显示
        // ......
      };
    },
    components: {
      Dialog,
    },
    methods: {
      // 点击弹窗右上角关闭按扭,会触发这个方法,弹出一个新弹窗询问是否确定关闭弹窗
      handleClose(done) {
        const bool = confirm("确认关闭吗?");
        if (bool) {
          this.isVisible = false;
        }
      },
    },
  };
</script>

<template>
  <button @click="isVisible = true">显示弹窗</button>
  <dialog :title="title" :visible="isVisible" @before-close="handleClose">
    <!-- ......-->
  </dialog>
</template>
<!--Dialog 子组件-->
<script>
  export default {
    props: ["title", "visible"],
  };
</script>
<template>
  <!--黑色半透明遮罩层-->
  <div class="dialog-mask" v-if="visible"></div>
  <!--弹出层-->
  <div class="dialog" v-if="visible">
    <!--弹窗头部标题-->
    <div class="dialog-header">
      <div class="title">{{ title }}</div>
      <div class="close" @click="$emit('beforeClose')">X</div>
    </div>
    <!--.......-->
  </div>
</template>

第五步:点击弹窗中的确认取消按扭,则关闭弹窗

<template v-slot:footer>
  <div class="button">
    <button @click="isVisible = false" class="cancle">取消</button>
    <button @click="isVisible = false" class="confirm">确认</button>
  </div>
</template>

# 2、完整版代码

  • App.vue 根组件
<script>
    import Dialog from "./components/Dialog.vue"
    export default {
        data() {
            return {
                title: "收货地址",
                isVisible: false,   // 弹窗显示
                // 弹窗主体显示数据
                gridData: [{
                    date: '2027-07-02',
                    name: '王小花',
                    address: 'xxx普陀区金沙江路 12345 '
                }, {
                    date: '2027-07-04',
                    name: '刘晓冉',
                    address: 'xxx普陀区金沙江路 12345'
                }, {
                    date: '2027-07-01',
                    name: '小清心',
                    address: 'xxx普陀区金沙江路 12345'
                }, {
                    date: '2027-07-03',
                    name: '王小虎',
                    address: 'xxx普陀区金沙江路 12345'
                }],
            }
        },
        components: {
            Dialog
        },
        methods: {
            // 点击弹窗右上角关闭按扭,会触发这个方法,弹出一个新弹窗询问是否确定关闭弹窗
            handleClose(done) {
                const bool = confirm("确认关闭吗?");
                if (bool) {
                    this.isVisible = false
                }
            }
        }
    }
</script>

<template>
    <button @click="isVisible = true">显示弹窗</button>
    <Dialog :title="title" :visible="isVisible" @before-close="handleClose">
        <template v-slot:default>
            <!--默认插槽内容-->
            <table>
                <tr>
                    <th>日期</th>
                    <th>姓名</th>
                    <th>收货地址</th>
                </tr>
                <tr v-for="{ date, name, address } in gridData">
                    <td>{{ date }}</td>
                    <td>{{ name }}</td>
                    <td>{{ address }}</td>
                </tr>
            </table>
        </template>
        <!--具名插槽内容-->
        <template v-slot:footer>
            <div class="button">
                <button @click="isVisible = false" class="cancle">取消</button>
                <button @click="isVisible = false" class="confirm">确认</button>
            </div>
        </template>
    </Dialog>
</template>

<style>
    .button {
        display: flex;
        justify-content: flex-end;
        margin-bottom: 20px;
    }

    .button button {
        width: 80px;
        height: 30px;
        margin-right: 20px;
        border: none;
    }

    .button .confirm {
        background-color: rgb(96, 183, 217);
        color: #fff;
    }

    table {
        width: 100%;
        border-collapse: collapse;
    }

    table tr td,
    table tr th {
        height: 35px;
        text-align: center;
        border: 1px solid #ddd
    }
</style>

  • Dialog.vue 弹窗组件
<script>
  export default {
    props: ["title", "visible"],
  };
</script>
<template>
  <!--黑色半透明遮罩层-->
  <div class="dialog-mask" v-if="visible"></div>
  <!--弹出层-->
  <div class="dialog" v-if="visible">
    <!--弹窗头部标题-->
    <div class="dialog-header">
      <div class="title">{{ title }}</div>
      <div class="close" @click="$emit('beforeClose')">X</div>
    </div>
    <!--弹窗主体内容-->
    <div class="dialog-main">
      <slot>弹窗主体内容</slot>
    </div>
    <!--弹窗底部-->
    <div class="dialog-footer">
      <slot name="footer">弹窗底部</slot>
    </div>
  </div>
</template>
<style scoped>
  .dialog-mask {
    width: 100%;
    height: 100%;
    position: fixed;
    top: 0;
    left: 0;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 998;
  }

  .dialog {
    width: 600px;
    min-height: 100px;
    background-color: #fff;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 999;
  }

  .dialog .dialog-header {
    position: relative;
    height: 45px;
    overflow: hidden;
  }

  .dialog .dialog-header .title {
    line-height: 55px;
    font-size: 24px;
    text-indent: 1em;
  }

  .dialog .dialog-header .close {
    width: 20px;
    height: 20px;
    background-color: #ddd;
    position: absolute;
    right: 0px;
    top: 0px;
    text-align: center;
    line-height: 20px;
    z-index: 1;
    cursor: pointer;
  }

  .dialog .dialog-main {
    margin: 20px;
  }
</style>

# 3、作用域插槽

TIP

在上面插槽的渲染中我们提到,插槽的内容可以访问父组件数据作用域(即父组件状态),但无法访问到子组件的状态

然而在某些场景下,插槽的内容可能想要同时使用父组件域内和子组件域内的数据。要做到这一点,我们需要一种方法来让子组件在渲染时将一部分数据提供给插槽。

Vue 框架也为我们考虑到了这一点,我们可以像对组件传递 props 那样,向一个插槽的出口上传递属性

<!-- <MyComponent> 的模板 -->
<div>
  <slot :msg="message" :count="1"></slot>
</div>

当需要接受插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别,我们分开来讲解。

# 3.1、默认作用域插槽

TIP

在默认插槽中,我们需要通过子组件标签(如:<MyComponent>)上的v-slot指令,直接接受一个插槽 props 对象。

这个对象可以直接在插槽内容的表达式中访问。

<!-- slotProps 名字可自定义 -->
<MyComponent v-slot="slotProps">
  {{ slotProps.msg }} {{ slotProps.count }}
</MyComponent>

代码演示

  • App.vue
<script>
  import MyComponent from "./components/MyComponent.vue";
  export default {
    components: {
      MyComponent,
    },
  };
</script>
<template>
  <MyComponent v-slot="slotProps">
    {{ slotProps.msg }} ---{{ slotProps.count }}
  </MyComponent>
</template>
  • MyComponent.vue
<script>
  export default {
    data() {
      return {
        message: "Hello Vue!!",
        count: 1,
      };
    },
  };
</script>

<template>
  <slot :msg="message" :count="count"></slot>
</template>

以上代码最终渲染后效果如下:

image-20230513230428102

# 3.2、具名作用域插槽

TIP

  • 我们要在父组件中接受对应具名插槽传过来的 props,可以在插槽内容对应的<template>标签上的v-slot指令的值中被访问到。
  • 如果一个组件中同时使用了默认插槽和具名插槽,则默认插槽内容一定要写在<template #default>标签中,然后能过 #default="defaultProps"方式来接受值。如果直接在子组件的v-slot中来接受,会导致编译错误。这是为了避免因默认插槽的 props 的作用域而困惑
<MyComponent>
  <template #header="headerProps"> {{ headerProps }} </template>

  <!--这里是用来接受默认插槽传过来的props-->
  <template #default="defaultProps"> {{ defaultProps }} </template>

  <template #footer="footerProps"> {{ footerProps }} </template>
</MyComponent>

以下写法是错的

<MyComponent v-slot="defaultProps">
  <template #header="headerProps"> {{ headerProps }} </template>

  <template #footer="footerProps"> {{ footerProps }} </template>
</MyComponent>
  • 以下方式向具名插槽出口中传入 props
<slot name="header" msg="hello" count="1"></slot>

插槽上的name属性是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。

因此,最终 headerProps 的结果是 { msg: 'hello' ,count:1}

  • 每个插槽出口对外传递的 props,只能在当前插槽对应的插槽内容中使用
<MyComponent>
  <template #header="headerProps">
    <div>{{ headerProps.hMsg }}</div>
    <!--此插槽内容中,没有办法访问其它插槽传过来的内容,会在控台报错-->
    <div>{{ defaultProps.mMsg }}</div>
  </template>

  <!-- 这里是用来接受默认插槽传过来的props -->
  <template #default="defaultProps">
    <div>{{ defaultProps.mMsg }}</div>
  </template>

  <template #footer="footerProps"> {{ footerProps }} </template>
</MyComponent>

代码演示

  • App.vue
<script>
  import MyComponent from "./components/MyComponent.vue";
  export default {
    components: {
      MyComponent,
    },
  };
</script>
<template>
  <MyComponent>
    <template #header="headerProps">
      <div>{{ headerProps.hMsg }}</div>
    </template>

    <!-- 这里是用来接受默认插槽传过来的props -->
    <template #default="defaultProps">
      <div>{{ defaultProps.mMsg }}</div>
    </template>

    <!--采用了对象的解构赋值-->
    <template #footer="{ fMsg }">
      <div>{{ fMsg }}</div>
    </template>
  </MyComponent>
</template>
  • MyComponent.vue
<script></script>
<template>
  <slot name="header" h-msg="header内容"></slot>
  <slot m-msg="main内容"></slot>
  <slot name="footer" f-msg="footer内容"></slot>
</template>

最终渲染后效果如下:

<div id="app" data-v-app="">
  <div>header内容</div>
  <div>main内容</div>
  <div>footer内容</div>
</div>

# 三、实战应用:高级列表组件

我们来实现如下图所示的无限滚动加载更多内容的案例

GIF2023-7-115-20-15

我们的需求

在实际的开发中,我们经常需要远程请求数据,并对请求过来的数据进行列表渲染、实现分页、无限滚动加载这样的功能。但最终渲染出来的内容和样式都不一样。

所以我们期望封装一个<FancyList>组件,这个组件封装了远程加载请求数据的逻辑,同时对请求过来的数据进行列表渲染、实现分页、无限滚动加载这样的功能。

为了保证渲染出来的内容和样式都不一样,我们把单个列表元素的内容和样式的控制权留给使用它的父母的组件。

我们期望<FancyList>组件的用法可能是如下这样

<!--
	api-url:为请求数据的地址
 	page-num:请求加载数据的条目
	#item="{ title }":中接受的 title为请求加载回来每一条数据中的新闻标题,还可以有更多信息
-->
<FancyList :api-url="url" page-num="10">
  <!--{title} 采用了解构赋值-->
  <template #item="{ title }">
    <h3 class="title">{{ title }}</h3>
    <!---更多内容....-->
  </template>
</FancyList>

<FancyList> 之中,我们可以多次渲染 <slot> 并每次都提供不同的数据 (注意我们这里使用了 v-bind 来传递插槽的 props):

<div class="container">
  <ul class="list">
    <!--对请求回来的数据进行列表渲染-->
    <li v-for="item in list">
      <!--插槽出口,接收父组件传递的模板内容-->
      <slot name="item" v-bind="item" />
    </li>
  </ul>
</div>

# 1、实现思路

第一步:

  • 创建 FancyList 组件,并在父组件中使用<FancyList>组件
  • <FancyList>组件中通过 axios 发送请求获取数据
  • 请求的地址和请求加载数据的条目都由父组件通过 prop 传递给到<FancyList>组件
<!--App.vue-->
<script>
  import FancyList from "./components/FancyList.vue";
  export default {
    data() {
      return {
        // 请求地址
        url: "https://www.fastmock.site/mock/6ec78e345df340241e1f5043f0167833/icode/news",
        // 首次请求的数据条目
        pageNum: 8,
      };
    },
    components: {
      FancyList,
    },
  };
</script>
<template>
  <FancyList :api-url="url" :page-num="pageNum"> </FancyList>
</template>
<!--FancyList.vue-->
<script>
  // 这里要记得执行npm i axios 下载axios包
  import axios from "axios";
  export default {
    // 接受父组件传递的props
    props: ["apiUrl", "pageNum"],
    data() {
      return {
        items: [], // 存放请求回来的数据
      };
    },
    created() {
      // 发请求获取数据
      axios.get(this.apiUrl + "/" + this.pageNum).then((res) => {
        this.items = res.data.data;
      });
    },
  };
</script>

<template>
  <ul class="list">
    <li v-for="item in items">
      <!--   {{ item.title }} -->
      <!-- 具体要显示的模板内容由父组件提供 -->
    </li>
  </ul>
</template>
<style>
  ul {
    margin: 0;
    padding: 0;
    list-style: none;
  }
</style>

第二步:

在子组件中提供插槽出口,并且将请求回来的数据提供给到插槽

<template>
  <ul class="list">
    <li v-for="item in items">
      <!--v-bind=item 将返回的每一项数据的所有信息都以props提供给插槽-->
      <slot name="item" v-bind="item"></slot>
    </li>
  </ul>
</template>

第三步:

在父组件中提供插槽内容,并编写对应的 CSS 样式

<template>
  <FancyList :api-url="url" :page-num="pageNum">
    <template #item="{ title, image }">
      <div class="item">
        <div class="img"><img :src="image" /></div>
        <div class="title">{{ title }}</div>
      </div>
    </template>
  </FancyList>
</template>

<style scoped>
  .item {
    width: 500px;
    display: flex;
    padding: 20px 0px;
    border-bottom: 1px solid #ddd;
  }

  .item .img {
    width: 140px;
    height: 88px;
    margin-right: 20px;
    font-size: 0;
  }
</style>

第四步:

  • 利用IntersectionObserver类实现无限滚动加载更多功能
  • <FancyList>组件模板的最下添中一个.loading元素,然后监听该元素是否进入浏览器可视区,如果进入,则说明滚动到了浏览器底部,则加载更多内容,否则不做任何处理。
<script>
  import axios from "axios";
  export default {
    // ......
    mounted() {
      this.createObserver(this.$refs.loading);
    },
    methods: {
      // 根据n来加载对应的数据条目
      loadMore(n) {
        // 模拟远程请求
        setTimeout(() => {
          for (let i = 0; i < n; i++) {
            this.items.push({
              title: `新加载标题` + i,
              image:
                "http://cms-bucket.ws.126.net/2019/03/08/d41c98c5380647d498d7750a252d6d50.png?imageView&thumbnail=140y88&quality=85",
            });
          }
        }, 1000);
      },

      // 创建观察器实例,观察目标元素
      createObserver(el) {
        // 创建观察器实例
        const io = new IntersectionObserver((entries) => {
          // 如果进入可视区,表示加载的元素加载完成,则再加载10条
          if (entries[0].isIntersecting) {
            this.loadMore(10);
          }
        });
        // 添加被观察者
        io.observe(el);
      },
    },
  };
</script>

<template>
  <ul class="list">
    <li v-for="item in items">
      <slot name="item" v-bind="item"></slot>
    </li>
  </ul>
  <div class="loading" ref="loading">加载更多.......</div>
</template>
<style>
  ul {
    margin: 0;
    padding: 0;
    list-style: none;
  }
</style>

# 2、完整版代码

  • App.vue文件
<script>
  import FancyList from "./components/FancyList.vue";
  export default {
    data() {
      return {
        // 请求地址
        url: "https://www.fastmock.site/mock/6ec78e345df340241e1f5043f0167833/icode/news",
        // 首次请求的数据条目
        pageNum: 8,
      };
    },
    components: {
      FancyList,
    },
  };
</script>
<template>
  <FancyList :api-url="url" :page-num="pageNum">
    <template #item="{ title, image }">
      <div class="item">
        <div class="img"><img :src="image" /></div>
        <div class="title">{{ title }}</div>
      </div>
    </template>
  </FancyList>
</template>
<style scoped>
  .item {
    width: 500px;
    display: flex;
    padding: 20px 0px;
    border-bottom: 1px solid #ddd;
  }

  .item .img {
    width: 140px;
    height: 88px;
    margin-right: 20px;
    font-size: 0;
  }
</style>
  • FancyList.vue文件内容
<script>
  import axios from "axios";
  export default {
    props: ["apiUrl", "pageNum"],
    data() {
      return {
        items: [],
      };
    },
    created() {
      // 发请求获取数据
      axios.get(this.apiUrl + "/" + this.pageNum).then((res) => {
        this.items = res.data.data;
        console.log(this.items);
      });
    },
    mounted() {
      this.createObserver(this.$refs.loading);
    },
    methods: {
      // 根据n来加载对应的数据条目
      loadMore(n) {
        // 模拟远程请求
        setTimeout(() => {
          for (let i = 0; i < n; i++) {
            this.items.push({
              title: `新加载标题` + i,
              image:
                "http://cms-bucket.ws.126.net/2019/03/08/d41c98c5380647d498d7750a252d6d50.png?imageView&thumbnail=140y88&quality=85",
            });
          }
        }, 1000);
      },

      // 创建观察器实例,观察目标元素
      createObserver(el) {
        // 创建观察器实例
        const io = new IntersectionObserver((entries) => {
          // 如果进入可视区,表示加载的元素加载完成,则再加载10条
          if (entries[0].isIntersecting) {
            this.loadMore(10);
          }
        });
        // 添加被观察者
        io.observe(el);
      },
    },
  };
</script>

<template>
  <ul class="list">
    <li v-for="(item,index) in items" :key="index">
      <slot name="item" v-bind="item"></slot>
    </li>
  </ul>
  <div class="loading" ref="loading">加载更多.......</div>
</template>
<style>
  ul {
    margin: 0;
    padding: 0;
    list-style: none;
  }
</style>

# 3、获取鼠标坐标

GIF 2023-7-115-40-15

我们期望实现一个组件,这个组件内部主要封装了获取鼠标坐标的功能。当我们在使用这些组件时,我们希望拿在父组件中拿到当前鼠标的坐标,然后显示在页面中。

我们期望的可能是下面这样

<MouseTracker v-slot="{ x, y }"> Mouse is at: {{ x }}, {{ y }} </MouseTracker>

<MouseTracker>组件代码实现逻辑

<script>
  export default {
    data() {
      return {
        x: 0, // 保存x坐标
        y: 0, // 保存y坐标
      };
    },
    methods: {
      // 更新x与y标坐
      update(e) {
        this.x = e.pageX;
        this.y = e.pageY;
      },
    },
    mounted() {
      window.addEventListener("mousemove", this.update);
    },
    // 组件卸载,取消事件监听
    unmounted() {
      window.removeEventListener("mousemove", this.update);
    },
  };
</script>
<template>
  <slot :x="x" :y="y"></slot>
</template>

# 4、作用域插槽的应用场景

TIP

如果某个组件同时封装了逻辑和视图,然而又希望把一部视图的输出交给父组件来实现,这时子组件需要将一部分数据供给给到插槽让父组件来使用。就好比上面讲到高级列表组件。

当然还有一部分组件只封装了逻辑,没有视图,我们在使用这部分组件时,子组件也需把数据供给到插槽供父组件来使用。就好比上面讲到的获取鼠标坐标案例。

# 5、总结

TIP

本小节我们重点需要掌握以下内容

插槽的定义

插槽主要用来实现父组件向子组件传递模板内容,且使用起来非常方便。

插槽的分类

插槽分为:默认插槽、具名插槽、作用域插槽

默认插槽

默认插槽的使用分以下两步:

  • ①、在子组件中通过定义插槽的出口
  • ②、在父组件中使用子组件时,直接写在子组件标签中间的内容就是插槽内容。该内容会替换插槽出口<slot>标签

image-20230701160304665

具名插槽

  • 如果<slot>标签上有一个特殊的name属性,则该插槽为具名插槽,如:<slot name="main" ></slot>name属性用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容。
  • 默认插槽本质上也是有name属性的,name 属性的值为default,即<slot name="default"></slot>,不过可以省略不写。

在指定具名插槽的使用分两步:

  • ①、在子组件中定义插槽出口
  • ②、在父组件中使用子组件时,指定插槽内容

image-20230701162302078

插槽注意事项

  • 插槽的渲染作用域:插槽内容可以访问到父组件的数据作用域,而无法访问子组件的数据,因为插槽内容本身是在父组件模板中定义的

  • 插槽默认内容:写在<slot></slot>标签中间的内容为插槽的默认内容

  • 插槽内容的 CSS 样式:在子组件中不会渲染插槽出口的内容,其内容由父组件的插槽内容渲染后提供。这一点决定了插槽内容的 CSS 样式应该写在父组件中,而不能写在子组件中。

  • 插槽选择器:如果我们想在子组件中通过 CSS 选择器选择插槽内容,可以借助:slotted 伪类。

:slotted(.box) {
  color: red;
}

作用域插槽

  • 当我们父组件中的插槽内容需要访问子组件中的数据时,就需要子组件在插槽出口上像传递 props 一样将数据传递给到父组件。
<!-- 具名插槽 -->
<slot name="main" :msg="msg" :count="count"></slot>
<!-- 默认插槽 -->
<slot :msg="msg" :count="count"></slot>
  • 当需要接受插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别

如果只有默认插槽,则可以在子组件标签上来通过v-slot指令的值来接受插槽 props

<!-- slotProps 名字可自定义 -->
<MyComponent v-slot="slotProps">
  {{ slotProps.msg }} {{ slotProps.count }}
</MyComponent>

如果同时有默认插槽与具名插槽,则都需要在通过<template>标签上的v-slot指令的值来接受插槽的 props

<MyComponent>
  <template #header="headerProps"> {{ headerProps }} </template>

  <!--这里是用来接受默认插槽传过来的props-->
  <template #default="defaultProps"> {{ defaultProps }} </template>

  <template #footer="footerProps"> {{ footerProps }} </template>
</MyComponent>

# 四、祖先组件向孙组件传值 - 依赖与注入

TIP

本小节我们来学习祖先组件如何向孙组件及更远的后代祖件提供数据,这就需要用到依赖(Provide)与注入(Inject)。

本小节涉及内容如下:

  • Prop 逐级透传问题
  • 依赖与注入定义
  • 依赖注入的基本使用
  • provide 选项
  • 应用层 Provide
  • 注入(Inject)
  • 使用 Symbol 作为注入名

# 1、Prop 逐级透传问题

TIP

通常情况下,当我们需要从父组件向子组件传递数据时,会使用props

但如果出现如下图所示情况,我们需要将组件<Root>中的数据传递给到孙组件<Item>或后代组件<ListChild>时要如何传呢 ?

image-20230516153434804

如果用之前讲到的props来实现,传递方式如下图:

  • 数据从<Root>组件传到<Item>组件:需要先将数据传递给到<Aside>组件,再通过<Aside>组件传递给到<Item>组件。
  • 数据从<Root>组件传到<ListChild>组件:需要先将数据传递给到<Main>组件,再通过<Main>组件传递给到<List>组件,最后通过<List>组件传递给到<ListChild>组件。

image-20230516150039593

代码演示

  • App.vue 根组件,相当于 Root
<script>
  import Aside from "./components/Aside.vue";
  export default {
    data() {
      return {
        message: "Hello props", // 将该数据传递给到 <Item>孙组件
      };
    },
    components: {
      Aside,
    },
  };
</script>
<template>
  <!--props方式传递数据-->
  <aside :msg="message" />
</template>
  • Aside.vue 子组件
<script>
  import Item from "./Item.vue";
  export default {
    // props选项声明
    props: ["msg"],
    components: {
      Item,
    },
  };
</script>
<template>
  <div class="aside">
    <!--props方式接着向下传递数据-->
    <Item :msg="msg"></Item>
  </div>
</template>
  • Item.vue 孙组件
<script>
  export default {
    props: ["msg"],
  };
</script>
<template>
  <div class="item">Item组件中显示:{{ msg }}</div>
</template>

最终在孙组件Item中访问到了App组件中传递的数据,显示结果如下

image-20230516165526979

Prop 逐级透传问题

利用props<Root>中的数据传递给到<Item>组件时,需要先把数据传递给到<Aside>组件。

<Aside>组件可能根本不关心这些props,但为了使<Item>能访问到它们,他需要接受并继续向下传递,造成当前组件数据管理的混乱。

如果组件链路非常长,一层一层传递非常麻烦,同时会影响到这条链路上的其它组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。

针对上面提到的问题,依赖与注入要以帮助我们解决。

# 2、依赖与注入定义

TIP

  • 一个父组件相对于其所有的后代组件而言,他是数据提供者,我们称为依赖提供(Provide)者
  • 所有该组件的后代组件,无论层级有多深,都可以**注入(Inject)**由祖先组件提供给整条链路的数据(依赖)

image-20230516151734357

# 3、依赖注入的基本使用

TIP

祖先组件向后代组件提供数据,后代组件能接受到数据。

需要经过以下两步:

①、Provider (提供)

祖先组件要为后代组件提供数据,需要用到provide选项

export default {
  provide: {
    message: "Hello props",
  },
};

provide对象上的每一个属性,后代组件会用其key为注入名查找期望注入的值,属性的值就是要提供的数据。

②、Inject (注入)

要注入上层组件提供的数据,需使用 inject选项来声明:

export default {
  inject: ["message"],
};

代码演示

  • App.vue
<script>
  import Aside from "./components/Aside.vue";
  export default {
    // 提供数据
    provide: {
      message: "Hello props",
    },
    components: {
      Aside,
    },
  };
</script>
<template>
  <aside />
</template>
  • Aside.vue 子组件
<script>
  import Item from "./Item.vue";
  export default {
    components: {
      Item,
    },
  };
</script>

<template>
  <div class="aside">
    <Item></Item>
  </div>
</template>
  • Item.vue 孙组件
<script>
  export default {
    // 注入 App组件中提供的数据
    inject: ["message"],
  };
</script>

<template>
  <div class="item">Item组件中显示:{{ message }}</div>
</template>

# 4、provide 选项

TIP

关于provide选项在提供数据时,有以下几个需要特别注意的点

  • 如何提供组件实例的状态(如:data() 定义的数据属性)
  • 如何保证inject注入数据时保持响应性
  • 如何修改inject注入的数据

接下来,我们就针对这三个点分别展开讲解。

# 4.1、提供组件实例的状态

TIP

如果我们需要提供依赖当前组件实例的状态(比如:那些由 data() 定义的数据属性),provide选项的值必需改写成函数形式。

export default {
  data() {
    return {
      message: "Hello props",
    };
  },
  provide() {
    return {
      msg: this.message, // 访问data中的属性
    };
  },
};

这种方式提供的数据,在注入时不会保持响应性,如在子组件中通过this.msg='Hello update更新msg的值,页面并不会同步更新。

# 4.2、inject 注入数据时保持响应性

TIP

如果需要使提供的数据在注入时保持响应性,我们需要使用computed()函数提供一个计算属性

import { computed } from "vue";
export default {
  data() {
    return {
      message: "Hello props",
    };
  },
  provide() {
    return {
      // msg的值是一个计算属性
      msg: computed(() => this.message),
    };
  },
};

临时配置要求

上面的用例需要设置 app.config.unwrapInjectedRef = true 以保证注入会自动解包这个计算属性。

这将会在 Vue 3.3 后成为一个默认行为,而我们暂时在此告知此项配置以避免后续升级对代码的破坏性。在 3.3 后就不需要这样做了。

// main.js中添加以下配置
app.config.unwrapInjectedRef = true;

# 4.3、如何操作 inject 注入的数据

TIP

如果我们需要在后代组件中操作inject注入的数据,推荐在上层组件提供数据时,顺带提供操作此数据的方法。

import { computed } from "vue";
export default {
  data() {
    return {
      message: "Hello props",
    };
  },
  // 提供了message数据和更新该数据的update方法
  provide() {
    return {
      message: computed(() => this.message),
      update: this.update,
    };
  },
  methods: {
    update() {
      console.log("更新");
      this.message = "Hello update!!";
    },
  },
};

# 4.4、代码演示

TIP

以下代码演示了App组件向后代组件Item提供了msg属性和更新数据的update方法。

Item组件中点击更新按扭,就会调用update方法更新message属性的值,message属性的值一变化,msg的值也更新为最新,最后页面同步更新

  • App.vue 根组件
<script>
  import Aside from "./components/Aside.vue";
  import { computed } from "vue";
  export default {
    data() {
      return {
        message: "Hello props",
      };
    },
    provide() {
      return {
        msg: computed(() => this.message),
        update: this.update,
      };
    },
    methods: {
      update() {
        console.log("更新");
        this.message = "Hello update!!";
      },
    },
    components: {
      Aside,
    },
  };
</script>
<template>
  <aside />
</template>
  • Aside.vue子组件
<script>
  import Item from "./Item.vue";
  export default {
    components: {
      Item,
    },
  };
</script>
<template>
  <div class="aside">
    <Item></Item>
  </div>
</template>
  • Item.vue孙组件
<script>
  export default {
    inject: ["msg", "update"],
  };
</script>
<template>
  <button @click="update">更新</button>
  <div class="item">Item组件中显示:{{ msg }}</div>
</template>

以上代码最终渲染结果如下:

GIF2023-5-1618-07-33

# 5、应用层 Provide

TIP

除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖

import { createApp } from "vue";

const app = createApp({});

app.provide("msg", "hello!"); // msg 为注入名   hello 为值

在应用级别提供(provide)的数据在该应用内的所有组件中都可以注入(inject)。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值。

# 6、注入(Inject)

TIP

在子组件中注入上层组件提供的数据时,有以下几个需要注意的点

  • 注入被解析时机
  • 注入别名
  • 注入默认值

接下来,我们就针对这三个点分别展开讲解。

# 6.1、注入被解析时机

TIP

inject(注入)会在组件自身的状态之前被解析,你可以在

  • data()methodcomputed等中访问到注入的属性
  • created生命周期函数中访问到。

beforeCreate生命周期函数中是访问不到的

export default {
  inject: ["msg"],
  data() {
    return {
      text: this.msg,
    };
  },
  methods: {
    print() {
      console.log("打印msg", this.msg);
    },
  },
  computed: {
    newMsg() {
      return this.msg + " computed";
    },
  },
};

# 6.2、注入别名

TIP

如果我们想在子组件中用一个不同的本地属性名注入上层组件提供的属性时,inject选项需要采用以下对象写法

<script>
  export default {
    // 必须使用对象形式
    inject: {
      // text 为本地属性名
      text: {
        from: "msg", // 注入来源名,msg为上层组件提供的属性(注入名)
      },
    },
  };
</script>
<template>
  <!--使手注入的属性-->
  <div>{{ text }}</div>
</template>

# 6.3、注入默认值

TIP

如果inject选项中声明的注入名没有被提供,则会抛出一个运行时警告。所以在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值。

这样在注入名没有被提供时,会采用默认值,如果有被提供,则采用提供的值。

不过此时inject选项必须使用对象形式来书写

export default {
  // 当声明注入的默认值时
  // 必须使用对象形式
  inject: {
    text: {
      from: "msg", // 注入来源,如果本地属性名与原注入名相同,这个属性是可选的
      default: "default value", // 默认值
    },
    count: {
      from: "count", // 当与原注入名同名时,这个属性是可选的
      // 如果默认值需要经过复杂的计算,则可以写成函数
      default() {
        //....
        return 0;
      },
    },
  },
};

# 6.4、代码演示

  • App.vue 根组件
<script>
  import Aside from "./components/Aside.vue";
  import { computed } from "vue";
  export default {
    data() {
      return {
        count: 10,
        message: "Hello props",
      };
    },
    // 提供数据
    provide() {
      return {
        msg: computed(() => this.message),
        count: computed(() => this.count),
        countAdd: this.add,
      };
    },
    methods: {
      // 更新count的方法
      add() {
        this.count++;
      },
    },
    components: {
      Aside,
    },
  };
</script>
<template>
  <aside />
</template>
  • Aside.vue子组件
<script>
  import Item from "./Item.vue";
  export default {
    components: {
      Item,
    },
  };
</script>
<template>
  <div class="aside">
    <Item></Item>
  </div>
</template>
  • Item.vue 孙组件
<script>
  export default {
    // 注入数据
    inject: {
      text: {
        from: "msg",
        default: "default value",
      },
      count: {
        // 如果默认值需要经过复杂的计算,则可以写成函数
        default() {
          //....
          return 0;
        },
      },
      countAdd: {
        from: "countAdd",
      },
    },
    data() {
      return {
        // 基本数据类型,count的值更新了,newCount的值不会更新
        newCount: this.count,
      };
    },
  };
</script>
<template>
  <button @click="countAdd">count++</button>
  <div>text的值:{{ text }}</div>
  <div>count的值:{{ count }}</div>
  <div>newCount的值 {{ newCount }}</div>
</template>

最终渲染效果如下:

GIF2023-5-1620-11-54

# 7、使用 Symbol 作为注入名

TIP

但如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库。

建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

我们通常推荐在一个单独的文件中导出这些注入名 Symbol:

// keys.js
export const count = Symbol("count");
export const msg = Symbol("msg");
// 在供给方组件
import { computed } from "vue";
import { count, msg } from "./keys.js";
export default {
  data() {
    return {
      count: 10,
      message: "Hello props",
    };
  },
  provide() {
    return {
      /* msgt 和 count 为Symbol类型,则需要采用[]方括号写法*/
      [msg]: computed(() => this.message),
      [count]: computed(() => this.count),
    };
  },
};
// 注入方组件
// 导入keys
import { count, msg } from "../keys.js";
export default {
  // 注入数据
  inject: {
    text: {
      from: msg,
      default: "default value",
    },
    count: {
      from: count,
      // 如果默认值需要经过复杂的计算,则可以写成函数
      default() {
        //....
        return 0;
      },
    },
  },
};

# 8、总结

TIP

本小节我们重点需要掌握以下内容:

为什么需要依赖注入

因为Prop在实现祖先组件向后代组件传递数据里,需要沿着组件链一层一层向下传递,非常的麻烦。在传递过层过程,很多组件其实并不需要关注上层组件传递的props,但为了能把数据向下传递,则需要接受props并继续向下传递,造成组件数据管理混乱问题。

而依赖注入可以帮助解决这个问题。

依赖与注入

  • 依赖提供者:向下层提供数据的组件称为依赖提供者。
  • 注入者:需要接受上层组件传递的数据组件,称为注入者

依赖注入的基本使用

  • 依赖提供者通过provide选项对外提供数据
  • 在后代组件中通过inject选项来注入父或(上层)组件提供(provide)的数据

provide 选项注意事项

  • 如何提供组件实例的状态(如:data() 定义的数据属性)
  • 如何保证inject注入数据时保持响应性
  • 如何修改inject注入的数据

inject 注入选项注意事项

  • 注入被解析时机
  • 注入别名
  • 注入默认值

应用层 Provide 的基本用法

app.provide(/* 注入名 */ "message", /* 值 */ "hello!");

使用Symbol作为注入名

  • 通常把需要注入的名写在一个单独的 JS 文件中
  • 在供给方组件中(依赖提供者),通过import导入上面提到的 JS 文件,将导入的 Symbol 作为提供的属性名
  • 在注方组件中,同样通过import导入上面提到的 JS 文件,将导入的 Symbol 作为注入的注入来源名

注意事项

依赖注入主要用来实现上层组件向后代组件传递数据,所以父子间传递数据也是可行的。

但是父子组件间传递数据,更推荐使用props来实现。

上次更新时间: 7/4/2023, 8:06:33 PM

大厂最新技术学习分享群

大厂最新技术学习分享群

微信扫一扫进群,获取资料

X