Vue.js组件详解


文章标题,强烈建议填写此选项

title: “7. 组件详解”

发布时间,强烈建议填写此选项,且最好保证全局唯一

date: 2022-06-09

分组名

categories:

  • E-BOOKS

Tag标签

tags:

  • E-BOOKS
  • VueActualCombat
  • Article_1-Based_Vue.js

::: tip
组件(Component)Vue.js最核心的功能,也是整个框架设计最精彩的地方,当然也是最难掌握的,本章将带领你由浅入深地学习组件的全部内容,并通过几个实战项目熟练使用 Vue 组件。
:::

7.1 组件与复用

7.1.1 为什么使用组件

在正式介绍组件前,我们先来看一个简单的场景。如图所示:

Forever丿顾北

图中是个很常见的聊天界面,虽然有点简陋,但有有些标准的控件,比如右上角的关闭按钮输入框发送按钮等,你可能要问了,这有什么难的,不就是几个divinput吗?,好,那现在需求升级了,这几个控件还有别的地方要用到。没问题,复制粘贴呗,那如果输入框要带数据验证,按钮的图标支持自定义呢?这样用JavaScript封装后一起复制吧,那等到项目快完结时,产品经理说,所有使用输入框的地方都要改成支持回车键提交, 好吧,给我一天的时间,我一个个加上去。

上面的需求虽然有点变态,但却是业务中很常见的,那就是一些控件JavaScript能力的复用。没错,Vue.js的组件就是提高重用性的让代码可复用,当学习完组件后,上面的问题就可以分分钟搞定了,再也不用害怕产品经理的奇随需求。

我们先看一下上图,的示例用组件来编写是怎样的,示例代码如下:

  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>示例</title>
    </head>
    <body>
      <Card style="width:350px;">
        <p slot="title">与xxx聊天中</p>
        <a href="#" slot="extra" >
            <Icon type="android-close"size="18"></Icon>
        </a>
        <div style="height:lOOpx;">
        </div>
        <div>
            <Row :gutter="16">
                <i-col span="17">
                    <i-input v-model="value" placeholder="请输入...."></i-input>
                </i-col>
                <i-col span="4">
                    <i-button type=”primary” icon="paper-airplane">发送<i-button>
                </i-col>
            </Row>
        </div>
      </Card>
    </body>
  </html>

是不是很奇怪,有很多我们从来都没有见过的标签,比如<Card><Row><i-col><i-input><i-button>等,而且整段代码除了内联的几个样式外,一句 CSS 代码也没有,但最终实现的 UI 就是图的效果

这些没见过的自定义标签就是组件,每个标签代表一个组件,在任何使用 Vue 的地方都可以直接使用。我们就来看看组件的具体用法

7.1.2 组件用法

回顾一下我们创建 Vue 实例的方法:

var app = new Vue({
  el: "#app",
});

组件与之类似,需要注册后才可以使用。注册有全局注册局部注册两种方式。全局注册后,任何Vue实例都可以使用。全局注册示例代码如下:

Vue.component("my-component", {
  // 组件选项
});

my-component就是注册的组件自定义标签名称,推荐使用小写加减号分割的形式命名。

要在父实例中使用这个组件,必须要在实例创建前注册,之后就可以用<my-component>的形式来使用组件了,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        // 组件选项
      });
      var app = new Vue({
        el: "#app",
        data: {
          text: "",
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

此时打开页面还是空白的,因为我们注册的组件没有任何内容,在组件选项中添加template就可以显示组件内容了,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: "<div>我的第一个全局组件</div>",
      });
      var app = new Vue({
        el: "#app",
        data: {
          text: "",
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

<!-- 此时会渲染成这样的 -->
<div id="app" v-cloak>
  <div>我的第一个全局组件</div>
</div>

Forever丿顾北

template的 DOM 结构必须被一个元素包含,如果直接写成这里是组件的内容,<div></div>是无法渲染的。

Vue.js实例中,使用components选项可以局部注册组件,注册后的组件只有在该实例作用域下有效,组件中也可以使用components选项来注册组件,使组件可以嵌套。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var Child = {
        template: "<div>局部注册组件的内容</div>",
      };
      var app = new Vue({
        el: "#app",
        components: {
          "my-component": Child,
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

Vue组件的模板在某些情况下会受到HTML的限制,比如<table>内规定只允许是<tr><td><th>等这些表格元素,所以在<table>内直接使用组件是无效的。这种情况下,可以使用特殊的is属性来挂载组件,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <table>
        <tbody is="my-component"></tbody>
      </table>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: "<div>我的第一个全局组件</div>",
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

tbody在渲染时 会被替换为组件的内容。常见的限制元素还有<ul><ol><select>

提示:如果使用的是字符串模板,是不受限制的,比如后面的章节介绍.vue 单文件用法等

除了template选项外,组件中还可以像Vue实例那样使用其他的选项,比如datacomputedmethods等,但是在使用data时,和实例稍有区别,data必须是函数,然后将数据return出去, 例如:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: "<div>{{ message }}</div>",
        data: function() {
          return {
            message: "组件内容",
          };
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

JavaScript对象引用关系,所以return出的对象引用了外部的一个对象,那这个对象就是共享的,任何一方修改都会同步, 比如下面的示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component></my-component>
      <my-component></my-component>
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var data = {
        counter: 0,
      };
      Vue.component("my-component", {
        template: '<button @click="counter ++ ">{{ counter }}</button>',
        data: function() {
          return data;
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

组件使用了 3 次,但是点击任意一个<button>,3 个数字都会加 1,那时因为data引用的是外部的对象,这肯定不是我们期望的效果,所以给组件返回一个新的data的对象来独立,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component></my-component>
      <my-component></my-component>
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: '<button @click="counter ++ ">{{ counter }}</button>',
        data: function() {
          return {
            counter: 0,
          };
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

这样,点击这 3 个按钮就互不影响了,完全达到复用的目的。

7.2 使用 props 传递数据

7.2.1 基本用法

组件不仅仅是要把模板的内容进行复用,更重要的是组件间要进行通信。通常父组件的模板中包含子组件,父组件要正向地向子组件传递数据或参数,子组件接收到后根据参数的不同来渲染不同的内容或执行操作。这个正向传递数据的过程就是通过props来实现的

在组件中,使用选项props来声明需要从父级接收的数据,props的值可以是两种,一种是字符串数组,一种是对象。本小节先介绍数组的用法。比如我们构造一个数组,接收一个来自父级的数据message,并把它在组件模板中渲染,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component message="来自父组件的数据"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["message"],
        template: "<div>{{ message }}</div>",
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
  <!-- 渲染后的结果为:-->
  <div id="app" v-cloak>
    <div>来自父组件的数据</div>
  </div>
</html>

Forever丿顾北

props中声明的数据与组件data函数return的数据主要区别就是props的来自父级,而data中的是组件自己的数据,作用域是组件本身,这两种数据都可以在模板template及计算属性computed和方法methods中使用。上例的数据message就是通过props从父级传递过来的,在组件的自定义标签上直接写该props名称,如果要传递多个数据,在props数组中添加项即可。

由于HTML特性不区分大小写,当使用DOM模板时,驼峰命名(CcamelCase)props的名称要转为短横分隔命名(kebab-case),例如:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component warning-text="提示信息"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["warningText"],
        template: "<div>{{ warningText }}</div>",
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
  <!-- 渲染后的结果为:-->
  <div id="app" v-cloak>
    <div>来自父组件的数据</div>
  </div>
</html>

提示:如果使用的是字符串模板,仍然可以忽略这些限制。

有时候,传递的数据并不是直接写死的,而是来自父级的动态数据,这时可以使用指令V-bind来动态绑定props的值,当父组件的数据变化时,也会传递给子组件。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <input type="text" v-model="parentMessage" />
      <my-component :message="parentMessage"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["message"],
        template: "<div>{{ message }}</div>",
      });
      var app = new Vue({
        el: "#app",
        data: {
          parentMessage: "",
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

这里用v-model绑定了父级的数据parentMessage,当通过输入框任意输入时,子组件接收到的props,message也会实时响应,并更新组件模板。

注意:如果你要直接传递数字、布尔值、数组、对象,而且不使用 v-bind,传递的仅仅是字符串,尝试下面的示例来对比:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component message="[1,2,3]"></my-component>
      <my-component :message="[1,2,3]"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["message"],
        template: "<div>{{ message.length }}</div>",
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

同一个组件使用了两次,区别仅仅是第二个使用的是v-bind,渲染后的结果,第一个是 7,第二个才是数组的长度 3。

7.2.2 单向数据流

Vue2.XVue1.X比较大的一个改变就是,Vue2.X通过props传递数据是单向的了,也就是父组件数据变化时会传递给子组件,但是反过来不行。 ,而在Vue1.X里提供了.sync修饰符来支持双向绑定。之所以这样设计,是尽可能将父子组件解耦,避免子组件无意中修改了父组件的状态。

业务中会经常遇到两种需要改变prop情况,一种是父组件传递初始值进来,子组件将它作为初始值保存起来,在自己的作用域下可以随意使用和修改。这种情况可以在组件data内再声明一个数据,引用父组件的 prop,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component :init-count="1"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["initCount"],
        template: "<div>{{ count }}</div>",
        data() {
          return {
            count: this.initCount,
          };
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

组件中声明了数据count, 它在组件初始化时会获取来自父组件的initCount,之后就与之无关了,只用维护count, 这样就可以避免直接操作initCount

另一种情况就是prop作为需要被转变的原始值传入。这种情况用计算属性就可以了,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-component :width="100"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["width"],
        template: '<div :style="style">组件内容</div>',
        computed: {
          style() {
            return {
              width: this.width + "px",
            };
          },
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

因为用CSS传递宽度要带单位(px),但是每次都写太麻烦,而且数值计算一般是不带单位的,所以统一在组件内使用计算属性就可以了

注意:在 JavaScript 中对象和数组是引用类型,指向同一个内存,所以 props 是对象和数组时,在子组件内改变是会影响父纽件的。

7.2.3 数据验证

我们上面所介绍的props选项的值都是一个数组,一开始也介绍过,除了数组外,还可以是对象,当prop需要验证时,需要对象写法。

一般当你的组件需要提供给别人使用时,推荐都进行数据验证,比如某个数据必须是数字类型,如果传入字符串,就会在控制台弹出警告。

以下是几个prop示例:

Vue.component("my-component", {
  props: {
    // 必须是数字类型
    propA: Number,
    // 必须是字符串或数字类型
    propB: [String, Number],
    // 布尔值,如果没有定义,默认值就是true
    propC: {
      type: Boolean,
      default: true,
    },
    // 数字,而且是必传
    propD: {
      type: Number,
      required: true,
    },
    // 如果是数组或对象,默认值必须是一个函数来返回
    propE: {
      type: Array,
      default: () => [],
    },
    // 自定义一个验证函数
    propF: {
      validator: function(value) {
        return value > 10;
      },
    },
  },
});

验证的type类型可以是:

  • String
  • Number
  • Boolean
  • Object
  • Array
  • Function

type也可以是一个自定义构造器,使用instanceof检测

prop验证失败时,在开发版本下会在控制台抛出一条警告。

7.3 组件通信

我们已经知道,从父组件向子组件通信,通过props传递数据就可以了, 但是Vue组件通信的场景不止有这一种,归纳起来,组件之间通信可以用如图所示:

Forever丿顾北

组件关系可分为父子组件通信兄弟组件通信跨级组件通信,本节将介绍各种组件之间通信的方法。

7.3.1 自定义事件

子组件需要向父组件传递数据时,就要用到自定义事件,我们在介绍指令v-on时有提到,v-on除了监昕DOM事件外,还可以用于组件之间的自定义事件。

如果你了解过JavaScript的设计模式一一观察者模式,一定知道dispatchEventaddEventListener,这两个方法。Vue组件也有与之类似的一套模式,子组件用$emit()来触发事件,父组件用$on()来监昕子组件的事件

父组件也可以直萨在子组件的自定义标签上使用v-on来监昕子组件触发的自定义事件,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <p>总数: {{ total }}</p>
      <my-component
        @increase="handleGetTotal"
        @reduce="handleGetTotal"
      ></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: `\
            <div>\
              <button  @click="handleIncrease">+1</button>\
              <button  @click="handleReduce">-1</button>\
            <div>`,
        data() {
          return {
            counter: 0,
          };
        },
        methods: {
          handleincrease() {
            this.counter++;
            this.$emit("increase", this.counter);
          },
          handleReduce() {
            this.counter--;
            this.$emit("reduce", this.counter);
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          total: 0,
        },
        methods: {
          handleGetTotal(total) {
            this.total = total;
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

上面示例中,子组件有两个按钮,分别时加 1 和减 1 的效果,在改变组件的data中counter后,通过$emit()再把它传递给父组件,父组件用v-on:increase和v-on:reduce(示例使用的是语法糖)。 $emit()方法的第一个参数是自定义事件的名称,例如示例的increasereduce后面的参数都是要传递的数据,可以不填或填写多个

除了用v-on在组件上监听自定义事件外,也可以监听DOM事件,这时可以用.native修饰符表示监听的是一个原生事件,监听的是该组件的根元素,示例代码如下:

<my-component v-on:click.native="handleClick"></my-component>

7.3.2 使用 v-model

Vue2.x可以在自定义组件上使用v-model指令,我们先来看一看示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <p>总数: {{ total }}</p>
      <my-component v-model="total"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: `<button @click="handleClick">+1</button>`,
        data() {
          return {
            counter: 0,
          };
        },
        methods: {
          handleClick() {
            this.counter++;
            this.$emit("input", this.counter);
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          total: 0,
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

仍然是点击按钮加 1 的效果,不过这次组件$emit()的事件名称是特殊的input,在使用组件的父级,井没有在<my-component>上使用@input="handler"而是直接用了v-model绑定的一个数据total,这也可以称作是一个语法糖,因为上面的示例可以间接地用自定义事件来实现:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <p>总数: {{ total }}</p>
      <my-component @input="handleGetTotal"></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        template: `<button @click="handleClick">+1</button>`,
        data() {
          return {
            counter: 0,
          };
        },
        methods: {
          handleClick() {
            this.counter++;
            this.$emit("input", this.counter);
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          total: 0,
        },
        methods: {
          handleGetTotal(total) {
            this.total = total;
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

v-model还可以用来创建自定义的表单输入组件,进行数据双向绑定,例如:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <p>总数: {{ total }}</p>
      <my-component v-model="total"></my-component>
      <button @click="handleReduce">-1</button>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["value"],
        template: `<input :value="value" @input="updateValue">+1</input>`,
        methods: {
          updateValue(event) {
            this.$emit("input", event.target.value);
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          total: 0,
        },
        methods: {
          handleReduce(total) {
            this.total--;
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

实现这样一个具有双向绑定的v-model组件要满足下面两个要求:

  • 接收一个value属性
  • 在有新的value时触发input事件

7.3.3 非父子组件通信

在实际业务中,除了父子组件通信,还有很多非父子组件通信的场景,非父子组件一般有两种,兄弟组件跨多级组件。为了更加彻底地了解Vue2.X中的通信方法。我们先来看一下在Vue1.X中是如何实现的,这样便于我们了解Vue的设计思想。
Vue1.X中,除了$emit()方法外,还提供了$dispatch()$broadcast()这两个方法。$dispatch()用于向上级派发事件,只要是它的父级(一级或多级以上),都可以在Vue实列的events选项内接收,示例代码如下:

<!-- 该示例代码使用的是1.X版本 -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      {{ message }}
      <my-component></my-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/1.0.0/vue.min.js"></script>
    <script>
      Vue.component("my-component", {
        props: ["value"],
        template: `<button @click="handleDispatch">派发事件</button>`,
        methods: {
          handleDispatch() {
            this.$dispatch("on-message", "来自内部组件的数据");
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          message: "",
        },
        events: {
          "on-message": function(msg) {
            this.message = msg;
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

同理,$broadcast()是由上级向下级广播事件的,用法完全一致,只是方向相反。

两种方法一旦发出事件后,任何组件都是可以接收到的,就近原则,而且会在第一次接收到后停止冒泡,除非返回true

这两个方法虽然看起来很好用,但是在Vue2.X中都废弃了,因为基于组件树结构的事件流方式让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱,并且不能解决兄弟组件通信的问题

Vue2.X中,推荐使用一个空的Vue实例作为中央事件总线(bus),,也就是一个中介,为了更形象地了解它,我们举一个生活中的例子。

比如你需要租房子,你可能会找房产中介来登记你的需求,然后中介把你的信息发给满足要求的出租者,出租者再把报价和看房时间告诉中介,由中介再转达给你,整个过程中,买家和卖家并没有任何交流,都是通过中间人来传话的。

或者你最近可能要换房了,你会找房产中介登记你的信息,订阅与你找房需求相关的资讯,一旦有符合你的房子出现时,中介会通知你,并传达你房子的具体信息。

这两个例子中,你和出租者担任的就是两个跨级的组件,而房产中介就是这个中央事件总线(bus),比如下面的示例代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      {{ message }}
      <component-a></component-a>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var bus = new Vue();
      Vue.component("component-a", {
        template: `<button @click="handleEvent">传递事件</button>`,
        methods: {
          handleEvent() {
            bus.$emit("on-message", "来自component-a组件的数据");
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          message: "",
        },
        mounted: function() {
          let that = this;
          // 在实例初始化时,监听来自 bus 实例的事件
          bus.$on("on-message", function(msg) {
            that.message = msg;
          });
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

首先创建了一个名为bus的空Vue实例,里面没有任何内容;然后全局定义了组件component-a;最后创建Vue实例app,在app初始化时,也就是在生命周期mounted钩子函数里监听了来自bus的事件on-message,而在组件component-a中,点击按钮会通过bus把事件on-message发出去,此时app就会接收到来自bus的事件,进而在回调里完成自己的业务逻辑。

这种方法巧妙而轻量地实现了任何组件间的通信,包括父子兄弟跨级,而且Vue1.XVue2.X都适用;如果深入使用,可以扩展bus实例,给它添加datamethodscomputed等选项, 这些都是可以公用的,在业务中,尤其是协同开发时非常有用,因为经常需要共享一些通用的信息,比如用户登录的昵称性别邮箱等,还有用户的授权token 等。只需在初始化时让bus获取一次,任何时间、任何组件就可以从中直接使用了,在单页面富应用(SPA)中会很实用,我们会在进阶篇里逐步介绍这些内容。

当你的项目比较大,有更多的小伙伴参与开发时,也可以选择更好的状态管理解决方案Vuex,进阶篇里会详细介绍关于它的用法。

除了中央事件总线(bus)外,还有两种方法可以实现组件间通信:父链和子组件索引

  • 父链

子组件中,使用this.$parent可以直接访问该组件的父实例或组件,父组件也可以通过this.$children访问它所有的子组件,而且可以递归向上或向下无线访问,直到根实例或最内层的组件。例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      {{ message }}
      <component-a></component-a>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("component-a", {
        template: `<button @click="handleEvent">通过父链直接修改数据</button>`,
        methods: {
          handleEvent() {
            this.$parent.message = "来自组件component-a的内容";
          },
        },
      });
      var app = new Vue({
        el: "#app",
        data: {
          message: "",
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

尽管 vue 允许这样操作,但在业务中,子组件应该尽可能地避免依赖父组件的数据,更不应该去主动修改它的数据,因为这样使得父子组件紧藕合,只看父组件,很难理解父组件的状态,因为它可能被任意组件修改,理想情况下,只有组件自己能修改它的状态。父子组件最好还是通过props$emit来通信

  • 子组件索引

当子组件较多时,通过this.$children来一一遍历出我们需要的一个组件实例是比较困难的,尤其是组件动态渲染时,它们的序列是不固定的。Vue 提供了子组件索引的方法,用特殊的属性ref来为子组件指定一个索引名称,示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <button @click="handleRef">〉通过ref获取子组件实例</button>
      <component-a ref="comA"></component-a>
      <p>此时console.log的值为:{{ newMessage }}</p>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("component-a", {
        template: `<div>子组件</div>`,
        data() {
          return {
            message: "子组件内容",
          };
        },
      });
      var app = new Vue({
        el: "#app",
        data() {
          return {
            newMessage: "",
          };
        },
        methods: {
          handleRef() {
            // 通过$refs来访问指定的实例
            this.newMessage = this.$refs.comA.message;
            console.log("通过$refs来访问指定的实例", this.newMessage);
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

父组件模板中,子组件标签上使用ref指定一个名称,井在父组件内通过this.$refs来访问指定名称的子组件

注意: r e f s 只在渲染完成后才填充 , 并且它是非响应式的 , 它仅仅作为一个直接访问子组件的应急方案 , 应当避免在模板或计算属性中使用 refs只在渲染完成后才填充,并且它是非响应式的,它仅仅作为一个直接访问子组件的应急方案,应当避免在模板或计算属性中使用 refs只在渲染完成后才填充,并且它是非响应式的,它仅仅作为一个直接访问子组件的应急方案,应当避免在模板或计算属性中使用refs

Vue1.X不同的是, Vue2.X将v-el和v-ref合并为了ref,Vue会自动去判断是普通标签还组件

7.4 使用 slot 分发内容

7.4.1 什么是 slot

我们先看一个比较常规的网站布局,如图:

Forever丿顾北

这个网站由一级导航二级导航左侧列表正文以及底部版权信息5 个模块组成,如果要将它们都组件化,这个结构可能会是:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <app>
      <menu-main></menu-main>
      <menu-sub></menu-sub>
      <div class="container">
        <menu-left></menu-left>
        <container>></container>
      </div>
      <app-footer></app-footer>
    </app>
  </body>
</html>

当需要让组件组合使用,混合父组件的内容与子组件的模板时,就会用到slot,这个过程叫作内容分发(transclusion)。

<app>为例,它有两个特点:

  • <app>组件不知道它的挂载点会有什么内容。挂载点的内容是由<app>的父组件决定的。
  • <app>组件很可能有它自己的模板。

props传递数据、events发事件和slott内容分发就构成了Vue组件的 3 个 API 来源,再复杂的组件也是由这3部分构成的。

7.4.2 作用域

正式介绍slot前,需要先知道一个概念: 编译的作用域。比如父组件中有如下模板:

<child-component>
  {{message }}
</child-component>

这里的message就是一个slot,但是它绑定的是父组件的数据而不是组件<child-component>的数据

父组件模板的内容是在父组件作用域内编译,子组件模板的内容是在子组件作用域内编译。例如下面的代码示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component v-show="showChild"></child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        template: `<div>子组件</div>`,
      });
      var app = new Vue({
        el: "#app",
        data() {
          return {
            showChild: true,
          };
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

这里的状态showChild绑定的是父组件的数据,如果想在子组件上绑定,那应该是:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component></child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        template: `<div v-show="showChild">子组件</div>`,
        data() {
          return {
            showChild: true,
          };
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

因此,slot分发的内容,作用域是在父组件上的

7.4.3 slot 用法

  • 单个 slot

子组件内使用特殊的<slot>元素就可以为这个子组件开启一个slot(插槽),父组件模板,插入在子组件标签内的所有内容将替代子组件的<solt>标签及它内容。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component>
        <p>分发的内容</p>
        <p>更多分发的内容</p>
      </child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        template: `
            <div>
              <slot>
                <p>如果父组件没有插入内容,我将作为默认出现</p>
              </slot>
            </div>
          `,
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

子组件<child-componentt>模板内定义了一个<slot>元素,并且用一个<p>标签作为默认的内容,在父组件没有使用slot时,会渲染这段默认的文本:如果写入了solt,那就会替换整个<slot>, 所以上例渲染后的结果为:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <div>
        <p>分发的内容</p>
        <p>更多分发的内容</p>
      </div>
    </div>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

注意:子组件<slot>内的备用内容,它的作用域是子组件本身。

  • 具名 slot

给<slot>元素指定一个name后可以分发多个内容,具名slot可以与单个slot共存,例如下面的的示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component>
        <h2 slot="header">标题</h2>
        <p>正文的内容</p>
        <p>更多的正文内容</p>
        <div slot="footer">底部信息</div>
      </child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        template: `
            <div class="container">
              <div class="header">
                <slot name="header"></slot>
              </div>
              <div class="main">
                <slot></slot>
              </div>
              <div class="footer">
                <slot name="footer"></slot>
              </div>
            </div>
          `,
      });
      var app = new Vue({
        el: "#app",
      });
    </script>

    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

子组件内声明了3个<slot>元素,其中在<div class="main">内的<slot>没有使用name特性,它将作为默认slot出现,父组件没有使用slot特性的元素与内容都将出现在这里

如果没有指定默认的匿名slot,父组件内多余的内容片段都将被抛弃。

上例最终渲染后的结果为:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <div class="container">
        <div class="header">
          <h2>标题</h2>
        </div>
        <div class="main">
          <p>正文的内容</p>
          <p>更多的正文内容</p>
        </div>
        <div class="footer">
          <div>底部信息</div>
        </div>
      </div>
    </div>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

在组合使用组件时,内容分发API至关重要。

7.4.4 作用域插槽

作用域插槽是一种特殊的slot,使用一个可以复用的模板替换己渲染元素。概念比较难理解,我们先看一个简单的示例来了解它的基本用法。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component>
        <template scope="props">
          <p>来组父组件的内容</p>
          <p></p>
          <p>{{ props.msg }}</p>
        </template>
      </child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        template: `
            <div class="container">
              <solt msg="来自子组件的内容"></solt>
            </div>
          `,
      });
      var app = new Vue({
        el: "#app",
      });
    </script>

    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

观察子组件的模板,<slot>元素上有一个类似props传递数据给组件的写法msg="xxx",将数据传到了插槽。父组件中使用了<template>元素,而且拥有一个scope="props"的特性,这里的props只是一个临时变量,就像v-for="item in items"里的item一样。template内可以通过临时变量props访问来自子组件插槽的数据msg

将上面的示例渲染后的最终结果为:

  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>示例</title>
    </head>
    <body>
      <div id="app" v-cloak>
        <div class="container">
          <p>来组父组件的内容</p>
          <p>来自子组件的内容</p>
        </div>
      </div>
      </script>

      <style>
        /* 解决屏幕闪动 */
        [v-cloak]{
            display:none
        }
      </style>
    </body>
  </html>

作用域插槽更具代表性的用例是列表组件,允许组件自定义应该如何渲染列表每一项。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <my-list :books="books">
        <!-- 作用域插槽也可以是具名的slot -->
        <template slot="book" scope="props">
          <li>{{ props.bookName }}</li>
        </template>
      </my-list>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("my-list", {
        props: {
          books: {
            type: Array,
            default: () => [],
          },
        },
        template: `
            <ul>
              <slot name="book" v-for="book in books" :book-name="book.name"></slot>
            </ul>
          `,
      });
      var app = new Vue({
        el: "#app",
        data: {
          books: [
            { name: "《Vue.js实战》" },
            { name: "《JavaScript 语言精粹》" },
            { name: "《JavaScript 高级程序设计》" },
          ],
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

子组件my-list接收一个来自父级的prop数组books,并且将它在namebookslot上使用v-for指令循环,同时暴露一个变量bookName

如果你仔细揣摩上面的用法,你可能会产生这样的疑问:我直接在父组件用v-for不就好了吗,为什么还要绕一步在子组件里面循环呢?,的确:如果只是针对上面的示例,这样写是多此一举的。此例的用意主要是介绍作用域插槽的用法,并没有加入使用场景,而作用域插槽的使用场景就是既可以复用子组件的slot,又可以使slot内容不一致。如果上例还在其它组件内使用,<li>的内容渲染权是由使用者掌握的,而数据却可以通过临时变量(比如props)从子组件内获取。

7.4.5 访问 slot

Vue1.x中,想要获取某个slot是比较麻烦的,需要用v-el间接获取,Vue2.x提供了用来访问被slot分发的内容的方法$slots, 请看下面的示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component>
        <h2 slot="header">标题</h2>
        <p>正文内容</p>
        <p>更多的正文内容</p>
        <div slot="footer">底部信息</div>
      </child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        template: `
            <div class="container">
              <div class="header">
                <slot name="header"></slot>
              </div>
              <div class="main">
                <slot></slot>
              </div>
              <div class="footer">
                <slot name="footer"></slot>
              </div>
            </div>
          `,
        mounted: function() {
          let header = this.$slots.header;
          let main = this.$slots.default;
          let footer = this.$slots.footer;
          console.log("footer", footer); // [mo]
          console.log("footer2", footer[0].elm.innerHTML); // 底部信息
        },
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

通过$slots可以访问某个具名slot,this.$slots.default包括了所有没有被包含在具名slot中的节点。尝试编写代码,查看两个console打印的内容。

$slots在业务中几乎用不到,在用render数(进阶篇中将介绍)创建组件时会比较有用,但主要还是用于独立组件开发中

7.5 组件高级用法

本节会介绍组件的一些高级用法,这些用法在实际业务中不是很常用,在独立组件开发时可能会用到。如果你感觉以上内容已经足够完成你的业务开发了,可以跳过本节;如果你想继续探索Vue组件的奥秘,读完本节会对你有很大的启发。

7.5.1 递归组件

组件在它的模板内可以递归地调用自己,只要给组件设置name选项就可以了。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component :count="1"></child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        name: "child-component",
        props: {
          count: {
            type: Number,
            default: 1,
          },
        },
        template: `
            <div class="child">
              <child-component :count="count + 1" v-if="count < 3"></child-component>
            </div>
          `,
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

设置name后,在组件模板内就可以递归使用了,不过需要注意的是,必须给一个条件来限制递归数量,否则会抛出错误Maximum call stack size exceeded

组件递归使用可以用来开发一些具有未知层级关系的独立组件,比如级联选择器树形控件等,在实战篇里,我们会详细介绍级联选择器的实现。

7.5.2 内联模板

组件的模板一般都是在<template>选项内定义的, Vue 提供了一个内联模板的功能,在使用组件时,给组件标签使用inline-template特性,组件就会把它的内容当作模板,而不是把它当内容分发,这让模板更灵活。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component inline-template>
        <div>
          <h2>在父组件中定义子组件的模板</h2>
          <p>{{ message }}</p>
          <p>{{ msg }}</p>
        </div>
      </child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", {
        data() {
          return {
            msg: "在子组件声明的数据",
          };
        },
      });
      var app = new Vue({
        el: "#app",
        data() {
          return {
            message: "在父组件声明的数据",
          };
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

渲染后的结果为:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app">
      <div>
        <h2>在父组件中定义子组件的模板</h2>
        <p>在子组件声明的数据</p>
        <p>在父组件声明的数据</p>
      </div>
    </div>
  </body>
</html>

在父组件中声明的数据message子组件中声明的数据msg,两个都可以渲染(如果同名,优先使用子组件的数据),这反而是内联模板的缺点,就是作用域比较难理解,如果不是非常特殊的场景,建议不要轻易使用内联模板。

7.5.2 动态组件

Vue.js提供了一个特殊的元素<component>用来动态地挂载不同的组件,使用is特性来选择要挂载的组件, 示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <component :is="currentView"></component>
      <button @click="handleChangeView('A')">切换到A</button>
      <button @click="handleChangeView('B')">切换到B</button>
      <button @click="handleChangeView('C')">切换到C</button>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var app = new Vue({
        el: "#app",
        components: {
          comA: {
            template: `<div>组件A</div>`,
          },
          comB: {
            template: `<div>组件B</div>`,
          },
          comC: {
            template: `<div>组件C</div>`,
          },
        },
        data() {
          return {
            currentView: "comA",
          };
        },
        methods: {
          handleChangeView(component) {
            this.currentView = "com" + component;
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

动态地改变currentView值就可以动态挂载组件了。也可以直接绑定在组件对象上:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <component :is="currentView"></component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var Home = {
        template: `<p>Welcome home!</p>`,
      };
      var app = new Vue({
        el: "#app",
        data() {
          return {
            currentView: Home,
          };
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

7.5.4 异步组件

当你的工程足够大,使用的组件足够多时,是时候考虑下性能问题了,因为一开始把所有的组件都加载是没必要的一笔开销,好在Vue.js允许将组件定义为一个工厂函数,动态地解析组件。Vue.js只在组件需要渲染时触发工厂函数,并且把结果缓存起来,用于后面的再次渲染。例如下面的示例:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <child-component></child-component>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      Vue.component("child-component", function(resolve, reject) {
        window.setTimeout(function() {
          resolve({
            template: `<div>我是异步渲染的</div>`,
          });
        }, 2000);
      });
      var app = new Vue({
        el: "#app",
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

工厂函数接收resolve回调,在收到从服务器下载的组件定义时调用。也可以调用reject(reason)指示加载失败。这里setTimeout只是为了演示异步,具体的下载逻辑可以自己决定,比如把组件配置写成一个对象配置,通过Ajax来请求,然后调用resolve传入配置选项。

在进阶篇里,我们还会介绍主流的打包编译工具webpack.vue单文件的用法。更优雅地实现异步组件(路由)

7.6 其他

7.6.1 $nextTick

我们先来看这样一个场景:有一个div,默认用v-if将它隐藏,点击一个按钮后,改变v-if的值,让它显示出来,同时拿到这个div的文本内容。如果v-if的值是false,直接去获取div的内容是获取不到的,因为此时div还没有被创建出来,那么应该在点击按钮后,改变v-if值为true,div才会被创建,此时再去获取。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <div id="div" v-if="showDiv">这是一段文本</div>
      <button @click="getText">获取div内容</button>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var app = new Vue({
        el: "#app",
        data() {
          return {
            showDiv: false,
          };
        },
        methods: {
          getText() {
            this.showDiv = true;
            let text = document.getElementById("div").innerHTML;
            console.log(text);
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

这段代码并不难理解,但是运行后在控制台会抛出一个错误:Cannot read properties of null (reading 'innerHTML'),意思就是获取不到div元素。这里就涉及Vue一个重要的概念:异步更新队列

Vue在观察到数据变化时并不是直接更新DOM,而是开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。在缓冲时会去除重复数据,从而避免不必要的计算和 DOM 操作。然后,在下一个事件循环tick中,Vue 刷新队列井执行实际(己去重的)工作。所以如果你用一个 for 循环来动态改变数据 100 次,其实它只会应用最后一次改变,如果没有这种机制,DOM 就要重绘 100 次,这固然是一个很大的开销。

Vue会根据当前浏览器环境优先使用原生的Promise.thenMutationObserver,如果都不支持,就会采用setTimeout代替。

知道了Vue异步更新DOM原理,上面示例的报错也就不难理解了,事实上,在执行this.showDiv = true; 时,div 仍然还是没有被创建出来,直到下一个Vue事件循环时,才开始创建。$nextTick就是用来知道什么时候 DOM 更新完成的。所以上面的示例代码需要修改为:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app" v-cloak>
      <div id="div" v-if="showDiv">这是一段文本</div>
      <button @click="getText">获取div内容</button>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var app = new Vue({
        el: "#app",
        data() {
          return {
            showDiv: false,
          };
        },
        methods: {
          getText() {
            this.showDiv = true;
            this.$nextTick(function() {
              let text = document.getElementById("div").innerHTML;
              console.log(text);
            });
          },
        },
      });
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

这时再点击按钮,控制台就打印出 div 的内容这是一段文本了。

理论上,我们应该不用去主动操作DOM,因为Vue的核心思想就是数据驱动DOM,但在很多业务里,我们避免不了会使用一些第三方库,比如popper.jsswi per等,这些基于原生 JavaScript 库都有创建更新及销毁的完整生命周, 与Vue配合使用时,就要利用好$nextTick

7.6.2 X-Templates

如果你没有使用webpackgulp等工具,试想一下你的组件template的内容很冗长、复杂,如果都在 JavaScript 里拼接字符串,效率是很低的,因为不能像写 HTML 那样舒服。Vue提供了另一种定义模板的方式,在<script>标签里使用 text/x-template 类型,井且指定一个 id,将这个 id 赋给 template 示例代码如下:

  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>示例</title>
    </head>
    <body>
      <div id="app" v-cloak>
        <my-component><my-component>
        <script type="text/x-template" id="my-component">
          <div>这是组件的内容</div>
        </script>
      </div>
      <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
      <script>
        Vue.component('my-component',{
         template: '#my-component'
        })
        var app = new Vue ({
          el :"#app",
        })
      </script>
      <style>
        /* 解决屏幕闪动 */
        [v-cloak]{
            display:none
        }
      </style>
    </body>
  </html>

<script>标签里,你可以愉快地写HTML代码,不用考虑换行等问题。

很多刚接触Vue开发的新手会非常喜欢这个功能,因为用它,再加上组件知识,就可以很轻松地完成交互相对复杂的页面和应用了。如果再配合一些构建工具(gulp)组织好代码结构,开发一些中小型产品是没有问题的。不过,Vue的初衷并不是滥用它,因为它将模板和组件的其他定义隔离了。在进阶篇里,我们会介绍如何使用webpack来编译.vue从而优雅地解决 HTML 书写的问题。

7.6.3 手动挂载实例

我们现在所创建的实例都是通过new Vue()的形式创建出来的。在一些非常特殊的情况下,我们需要动态地去创建Vue实例,Vue提供了Vue.extend$mount两个方法来手动挂载一个实例。

Vue.extend基础Vue构造器,创建一个子类,参数是一个包含组件选项的对象。

如果Vue实例在实例化时没有收到el选项,它就处于未挂载状态,没有关联的DOM元素。可以使用$mount()手动地挂载一个未挂载的实例。这个方法返回实例自身,因而可以链式调用其他实例方法。示例代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="mount-div" v-cloak></div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var MyComponent = Vue.extend({
        template: `<div>Hello: {{ name }}</div>`,
        data() {
          return {
            name: "Forever",
          };
        },
      });
      new MyComponent().$mount("#mount-div");
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

运行后,id为mount-div的div元素会被替换为组件MyComponent的template内容

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="mount-div" v-cloak>
      <div>Hello: Forever</div>
    </div>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

除了这种写法外,以下两种写法也是可以的:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="mount-div" v-cloak></div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script>
      var MyComponent = Vue.extend({
        template: `<div>Hello: {{ name }}</div>`,
        data() {
          return {
            name: "Forever",
          };
        },
      });
      new MyComponent({
        el: "#mount-div",
      });
      // 或者,在文档之外渲染并且随后挂载
      var component = new MyComponent().$mount();
      document.getElemetByid("mount-div").appendChild(component.$el);
    </script>
    <style>
      /* 解决屏幕闪动 */
      [v-cloak] {
        display: none;
      }
    </style>
  </body>
</html>

手动挂载实例(组件)是一种比较极端的高级用法,在业务中几乎用不到,只有在开发一些复杂的独立组件时可能会使用,所以只做了解就好。

7.7 实战:两个常用组件的开发

本节以组件知识为基础,整合指令事件等前面章节的内容,开发两个业务中常用的组件,即数字输入框标签页

7.7.1 开发一个数字输入框组件

数字输入框是对普通输入框的扩展,用来快捷输入一个标准的数字,如图

Forever丿顾北

数字输入框只能输入数字,而且有两个快捷按钮,可以直接减1或加1 。除此之外,还可以设置初始值、最大值、最小值,在数值改变时,触发一个自定义事件来通知父组件

了解了基本需求后,我们先定义目录文件:

  • index.html--------入口文件
  • input-number.js—数字输入框组件
  • index.js----------根实例

因为该示例是以交互功能为主,所以就不写CSS美化样式了

首先写入基本的结构代码,初始化项目。

  • index.html--------入口文件
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script src="input-number.js"></script>
    <script src="index.js"></script>
  </body>
</html>
  • index.js----------根实例
var app = new Vue({
  el: "#app",
});
  • input-number.js—数字输入框组件
Vue.component("input-number", {
  template: `
      <div class="input-number">

      </div>
    `,
  props: {
    max: {
      type: Number,
      default: Infinity,
    },
    min: {
      type: Number,
      default: -Infinity,
    },
    value: {
      type: Number,
      default: 0,
    },
  },
});

该示例的主角是input-number.js,所有的组件配置都在这里面定义,先在tmplate里定义了组件的根节点,因为是独立组件,所以应该对每个prop进行校验,这里根据需求有最大值最小值默认值(也就是绑定值)3 个prop,maxmin都是数字类型,默认值是正无限大负无限大,value也是数字类型默认值是0

接下来,我们先在父组件引入input-number组件,并给它一个默认值5 ,最大值10,最小值0

  • index.js----------根实例
var app = new Vue({
  el: "#app",
  data() {
    return {
      value: 5,
    };
  },
});
  • index.html--------入口文件
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>示例</title>
    </head>
    <body>
      <div id="app">
        <input-number v-model="value" :max="10" :min="0"><input-number>
      </div>
      <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
      <script src="input-number.js"></script>
      <script src="index.js"></script>
    </body>
  </html>

value是一个关键的绑定值,所以用了v-model,这样既优雅地实现了双向绑定,也让API看起来很合理。大多数的表单类组件都应该有v-model比如输入框单选框多选框下拉选择器等。

剩余的代码量就都聚焦到了input-number.js

我们之前介绍过,Vue组件是单向数据流,所以无法从组件内部直接修改prop、value的值。解决办法也介绍过:就是给组件声明一个data,默认引用value的值,然后在组件内部维护这个data

  • input-number.js—数字输入框组件
Vue.component("input-number", {
  template: `
      <div class="input-number">

      </div>
    `,
  data() {
    return {
      currentValue: this.value,
    };
  },
  props: {
    max: {
      type: Number,
      default: Infinity,
    },
    min: {
      type: Number,
      default: -Infinity,
    },
    value: {
      type: Number,
      default: 0,
    },
  },
});

这样只解决了初始化时引用父组件value的问题,但是如果从父组件修改了value, input-number.js组件的currentValue也要一起更新。为了实现这个功能,我们需要用到一个新的概念:监听(watch)

监听(watch)选项用来监昕某个propdata的改变,当它们发生变化时, 就会触发watch配置的函数,从而完成我们的业务逻辑。在本例中,我们要监听两个量:valuecurrentValue,监听value是要知晓从父组件修改了value,监听currentValue是为了当currentValue改变时,更新value,相关代码如下:

  • input-number.js—数字输入框组件
Vue.component("input-number", {
  template: `
      <div class="input-number">

      </div>
    `,
  data() {
    return {
      currentValue: this.value,
    };
  },
  watch: {
    currentValue(value) {
      this.$emit("input", value);
      this.$emit("on-change", value);
    },
    value(val) {
      this.updateValue(val);
    },
  },
  props: {
    max: {
      type: Number,
      default: Infinity,
    },
    min: {
      type: Number,
      default: -Infinity,
    },
    value: {
      type: Number,
      default: 0,
    },
  },
  methods: {
    updateValue(val) {
      if (val > this.max) val = this.max;
      if (val < this.min) val = this.min;
      this.currentValue = val;
    },
  },
  mounted() {
    this.updateValue(this.value);
  },
});

从父组件传递过来value有可能是不符合当前条件的(大于 max,或小于 min),所以在选项methods里写了一个方法updateValue用来过滤一个正确currentValue

watch监听的数据回调函数有2个参数可用,第一个是新的值,第二个是旧的值,这里没有太复杂的逻辑,就只用了第一个参数。 在回调函数里,this指向当前组件实例的,所以可以直接调用this.currentValue(),因为Vue代理propsdatacomputedmethods

监听updateValue的回调里,this.$emit('input', value)是在使用v-model时改变value的;this.$emit ('on-change', value)是触发自定义事件on-change,用于告诉父组件数字输入的值有所改变(示例中没有使用该事),

在生命周期mounted钩子里也调用了updateValue()方法,是因为第一次初始化时,也对value进行了过滤。这里也有另一种写法,在data选项返回对象前进行过滤

  • input-number.js—数字输入框组件
Vue.component("input-number", {
  template: `
      <div class="input-number">

      </div>
    `,
  data() {
    let val = this.value;
    if (val > this.max) val = this.max;
    if (val < this.min) val = this.min;
    return {
      currentValue: val,
    };
  },
  watch: {
    currentValue(value) {
      this.$emit("input", value);
      this.$emit("on-change", value);
    },
    value(val) {
      this.updateValue(val);
    },
  },
  props: {
    max: {
      type: Number,
      default: Infinity,
    },
    min: {
      type: Number,
      default: -Infinity,
    },
    value: {
      type: Number,
      default: 0,
    },
  },
  methods: {},
  mounted() {
    this.updateValue(this.value);
  },
});

实现的效果是一样的。

最后剩余的就是补全模板template,内容是一个输入框两个按钮,相关代码如下

  • input-number.js—数字输入框组件
function isValueNurnber(value) {
  return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test(
    value + ""
  );
}
Vue.component("input-number", {
  template: `
      <div class="input-number">
        <input type="text" :value="currentValue" @change="handleChange">
        <button @click="handleDown" :disabled="currentValue <= min"> - </button>
        <button @click="handleUp" :disabled="currentValue >= max"> + </button>
      </div>
    `,
  props: {
    max: {
      type: Number,
      default: Infinity,
    },
    min: {
      type: Number,
      default: -Infinity,
    },
    value: {
      type: Number,
      default: 0,
    },
  },
  data() {
    return {
      currentValue: this.value,
    };
  },
  watch: {
    currentValue(value) {
      this.$emit("input", value);
      this.$emit("on-change", value);
    },
    value(val) {
      this.updateValue(val);
    },
  },
  methods: {
    handleDown() {
      if (this.currentValue <= this.min) return;
      this.currentValue -= 1;
    },
    handleUp() {
      if (this.currentValue >= this.max) return;
      this.currentValue += 1;
    },
    updateValue(val) {
      if (val > this.max) val = this.max;
      if (val < this.min) val = this.min;
      this.currentValue = val;
    },
    handleChange(event) {
      let val = event.target.value.trim();
      let max = this.max;
      let min = this.min;
      if (isValueNumber(val)) {
        val = Number(val);
        this.currentValue = val;
        if (val > max) {
          this.currentValue = max;
        } else if (val < min) {
          this.currentValue = min;
        }
      } else {
        event.target.value = this.currentValue;
      }
    },
  },
  mounted() {
    this.updateValue(this.value);
  },
});

input绑定了数据currentValue和原生的change事件,在句柄handleChange函数中,判断了当前输入的是否是数字。注意,这里绑定的currentValue是单向数据流,并没有用 v-model,所以在输入时,currentValue的值并没有实时改变。如果输入的不是数字(比如英文和汉字等),就将输入的内容重置为之前的currentValue,果输入的是符合要求的数字,就把输入的值赋给currentValue,

数字输入框组件的核心逻辑就是这些。回顾一下我们设计一个通用组件的思路,首先,在写代码前一定要明确需求,然后规划好 API。一个组件的 API 只来自propseventsslots,确定好这 3 部分的命名规则,剩下的逻辑即使第一版没有做好,后续也可以迭代完善,但是 API 如果没有设计好,后续再改对使用者成本就很大了。

完整的示例代码如下:

  • index.html--------入口文件
  <!DOCTYPE html>
  <html>
    <head>
      <meta charset="utf-8">
      <title>示例</title>
    </head>
    <body>
      <div id="app">
        <input-number v-model="value" :max="10" :min="0"><input-number>
      </div>
      <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
      <script src="input-number.js"></script>
      <script src="index.js"></script>
    </body>
  </html>
  • input-number.js—数字输入框组件
function isValueNurnber(value) {
  return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test(
    value + ""
  );
}
Vue.component("input-number", {
  template: `
      <div class="input-number">
        <input type="text" :value="currentValue" @change="handleChange">
        <button @click="handleDown" :disabled="currentValue <= min"> - </button>
        <button @click="handleUp" :disabled="currentValue >= max"> + </button>
      </div>
    `,
  props: {
    max: {
      type: Number,
      default: Infinity,
    },
    min: {
      type: Number,
      default: -Infinity,
    },
    value: {
      type: Number,
      default: 0,
    },
  },
  data() {
    return {
      currentValue: this.value,
    };
  },
  watch: {
    currentValue(value) {
      this.$emit("input", value);
      this.$emit("on-change", value);
    },
    value(val) {
      this.updateValue(val);
    },
  },
  methods: {
    handleDown() {
      if (this.currentValue <= this.min) return;
      this.currentValue -= 1;
    },
    handleUp() {
      if (this.currentValue >= this.max) return;
      this.currentValue += 1;
    },
    updateValue(val) {
      if (val > this.max) val = this.max;
      if (val < this.min) val = this.min;
      this.currentValue = val;
    },
    handleChange(event) {
      let val = event.target.value.trim();
      let max = this.max;
      let min = this.min;
      if (isValueNumber(val)) {
        val = Number(val);
        this.currentValue = val;
        if (val > max) {
          this.currentValue = max;
        } else if (val < min) {
          this.currentValue = min;
        }
      } else {
        event.target.value = this.currentValue;
      }
    },
  },
  mounted() {
    this.updateValue(this.value);
  },
});
  • index.js----------根实例
var app = new Vue({
  el: "#app",
  data() {
    return {
      value: 5,
    };
  },
});

衍生拓展练习:

  1. 练习 1:在输入框聚焦时,增加对键盘上下按键的支持,相当于加1减1

    这个比较简单,只需要在input-number.js组件input里加两个事件(@keyup.down 键盘下和@keyup.up 键盘上),然后调用我们写的handleDownhandleUp方法就可以了,完整代码如下:

    • input-number.js—数字输入框组件
    function isValueNurnber(value) {
      return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test(
        value + ""
      );
    }
    Vue.component("input-number", {
      template: `
        <div class="input-number">
          <input type="text" :value="currentValue" @change="handleChange" @keyup.down="handleDown" @keyup.up="handleUp">
          <button @click="handleDown" :disabled="currentValue <= min"> - </button>
          <button @click="handleUp" :disabled="currentValue >= max"> + </button>
        </div>
      `,
      props: {
        max: {
          type: Number,
          default: Infinity,
        },
        min: {
          type: Number,
          default: -Infinity,
        },
        value: {
          type: Number,
          default: 0,
        },
      },
      data() {
        return {
          currentValue: this.value,
        };
      },
      watch: {
        currentValue(value) {
          this.$emit("input", value);
          this.$emit("on-change", value);
        },
        value(val) {
          this.updateValue(val);
        },
      },
      methods: {
        handleDown() {
          if (this.currentValue <= this.min) return;
          this.currentValue -= 1;
        },
        handleUp() {
          if (this.currentValue >= this.max) return;
          this.currentValue += 1;
        },
        updateValue(val) {
          if (val > this.max) val = this.max;
          if (val < this.min) val = this.min;
          this.currentValue = val;
        },
        handleChange(event) {
          let val = event.target.value.trim();
          let max = this.max;
          let min = this.min;
          if (isValueNumber(val)) {
            val = Number(val);
            this.currentValue = val;
            if (val > max) {
              this.currentValue = max;
            } else if (val < min) {
              this.currentValue = min;
            }
          } else {
            event.target.value = this.currentValue;
          }
        },
      },
      mounted() {
        this.updateValue(this.value);
      },
    });
    
  2. 练习 2:增加一个步进器(step),比如设置10,点击加号按钮,一次增加10,点击减号按钮,一次减10

    在这我将 index.html 中做了修改,将最大值 max 去掉了,使其最大数为正无穷,在input-number.js组件中加了一个输入框,并用stepChange来监听步进器(step)改变,并将handleDownhandleUp做了修改。完整代码如下:

    • input-number.js—数字输入框组件
    function isValueNurnber(value) {
      return /^(([0-9]+\.[0-9]*[1-9][0-9]*)|([0-9]*[1-9][0-9]*\.[0-9]+)|([0-9]*[1-9][0-9]*))$/.test(
        value + ""
      );
    }
    Vue.component("input-number", {
      template: `
        <div class="input-number">
          <input type="text" :value="stepValue" @change="stepChange">
          <input type="text" :value="currentValue" @change="handleChange" @keyup.down="handleDown" @keyup.up="handleUp">
          <button @click="handleDown" :disabled="currentValue <= min"> - </button>
          <button @click="handleUp" :disabled="currentValue >= max"> + </button>
        </div>
      `,
      props: {
        max: {
          type: Number,
          default: Infinity,
        },
        min: {
          type: Number,
          default: -Infinity,
        },
        value: {
          type: Number,
          default: 0,
        },
      },
      data() {
        return {
          currentValue: this.value,
          stepValue: 1,
        };
      },
      watch: {
        currentValue(value) {
          this.$emit("input", value);
          this.$emit("on-change", value);
        },
        value(val) {
          this.updateValue(val);
        },
      },
      methods: {
        handleDown() {
          if (this.currentValue <= this.min) return;
          this.currentValue -= this.stepValue;
        },
        handleUp() {
          if (this.currentValue >= this.max) return;
          this.currentValue += this.stepValue;
        },
        updateValue(val) {
          if (val > this.max) val = this.max;
          if (val < this.min) val = this.min;
          this.currentValue = val;
        },
        stepChange(event) {
          let val = event.target.value.trim();
          let min = this.min;
          if (isValueNurnber(val)) {
            val = Number(val);
            this.stepValue = val;
            if (val < min) {
              this.stepValue = 1;
            }
          } else {
            this.stepValue = event.target.value = 1;
          }
        },
        handleChange(event) {
          let val = event.target.value.trim();
          let min = this.min;
          if (isValueNurnber(val)) {
            val = Number(val);
            this.currentValue = val;
            if (val < min) {
              this.currentValue = min;
            }
          } else {
            event.target.value = this.currentValue;
          }
        },
      },
      mounted() {
        this.updateValue(this.value);
      },
    });
    

7.7.2 开发一个标签页组件

本小节将开发一个比较有挑战的组件:标签页组件。标签页(即选项卡切换组件)是网页布局中经常用到的元素,用于平级区域大块内容的收纳和展现,如图

Forever丿顾北

根据上个示例的经验,我们先分析业务需求,制定出 API,这样不至于一上来就无从下手。

每个标签页的主体内容肯定是由使用组件的父级控制的,所以这部分是一个slot,而且slot的数量决定了标签切换按钮的数量。假设我们有 3 个标签页,点击每个标签按钮时,另外的两个标签对应的slot应该被隐藏掉。一般这个时候,比较容易想到的解决办法是,在slot里写3个div,在接收到切换通知时,显示和隐藏相关的div。这样设计没有问题,只不过体现不出组件的价值来,因为我们还是写了一些与业务无关的交互逻辑,而这部分逻辑最好组件本身帮忙处理了,我们只用聚焦在 slot 内容本身,这才是与我们业务最相关的。这种情况下,我们再定义一个子组件pane,聚焦在slot内容本身,这才是与我们业务最相关的。这种情况下,我们再定义一一个子组件pane,嵌套在标签页组件 tabs 里,我们的业务代码都放在 pane 的 slot 内,而 3 个 pane 组件作为整体成为tabsslot

由于tabspane两个组件是分离的,但是tabs组件上的标题应该由pane组件来定义,因为slot是写在pane里,因此在组件初始化(及标签标题动态改变)时,tabs要从pane里获取标题,并保存起来,自己使用。

确定好了结构,我们先创建所需的文件:

  • index.html--------入口文件
  • style.css---------样式表
  • tabs.js-----------标签页外层的组件 tabs
  • pane.js-----------标签页嵌套的组件 pane

首先写入基本的结构代码,初始化项目。

  • index.html--------入口文件
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>
  <body>
    <div id="app" v-cloak></div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script src="pane.js"></script>
    <script src="tabs.js"></script>
    <script type="text/javascript">
      var app = new Vue({
        el: "#app",
      });
    </script>
  </body>
</html>
  • tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
  template: `
    <div class="tabs">
      <div class="tabs-bar">
        <!--标签页标题,这里要用v-for-->
      </div>
      <div class="tabs-content">
        <!--这里的slot就是嵌套的pane -->
        <slot></slot>
      </div>
    </div>
  `,
});
  • pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
  name: "pane",
  template: `
    <div class="pane">
      <slot></slot>
    </div>
  `,
});

pane需要控制标签页内容的显示与隐藏, 设置data:show,井且用v-show指令来控制元素

  • pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
  name: "pane",
  template: `
    <div class="pane" v-show="show">
      <slot></slot>
    </div>
  `,
  data() {
    return {
      show: true,
    };
  },
});

当点击到这个pane对应的标签页标题按钮时,此paneshow值设置为true,否则应该是false,这步操作是在tabs组件完成的,我们稍后再介绍。

既然要点击对应的标签页标题按钮,那应该有一个唯一的值来标识这个pane,我们可以设置一个prop:name让用户来设置,但它不是必需的。如果使用者不设置,可以默认从0开始自动设置,这步操作仍然是tabs执行的,因为pane本身并不知道自己是第几个。除了name,还需要标签页标题prop:label, tabs组件需要将它显示在标签页标题里,。这部分代码如下:

  • pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
  name: "pane",
  template: `
    <div class="pane" v-show="show">
      <slot></slot>
    </div>
  `,
  data() {
    return {
      show: true,
    };
  },
  props: {
    name: {
      type: String,
    },
    label: {
      type: String,
      default: "",
    },
  },
});

上面的prop:label用户是可以动态调整的,所以在pane初始化及label更新时,都要通知父组件也更新,因为是独立组件,所以不能依赖像bus.jsvuex这样的状态管理办法,我们可以直接通过this.$parent访问tabs组件的实例来调用它的方法更新标题,该方法名暂定为updateNav。注意,在业务中尽可能不要使用$parent 来操作父链,这种方法适合于标签页这样的独立组件。分代码如下:

  • pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
  name: "pane",
  template: `
    <div class="pane" v-show="show">
      <slot></slot>
    </div>
  `,
  data() {
    return {
      show: true,
    };
  },
  props: {
    name: {
      type: String,
    },
    label: {
      type: String,
      default: "",
    },
  },
  methods: {
    updateNav() {
      this.$parent.updateNav();
    },
  },
  watch: {
    label() {
      this.updateNav();
    },
  },
  mounted() {
    this.updateNav();
  },
});

生命周期mounted,也就是pane初始化时,调用一遍tabsupdateNav方法,同时监听了prop:label, 在label更新时,同样调用。

剩余任务就是完成tabs.js组件。

首先需要把pane组件设置的标题动态渲染出来,也就是当pane触发tabs的updateNav方法时,更新标题内容。我们先看一下这部分的代码:

  • tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
  template: `
    <div class="tabs">
      <div class="tabs-bar">
          <!--标签页标题,这里要用v-for-->
      </div>
      <div class="tabs-content">
          <!--这里的slot就是嵌套的pane -->
          <slot></slot>
      </div>
    </div>
  `,
  data() {
    return {
      // 用于渲染tabs的标题
      navList: [],
    };
  },
  methods: {
    getTabs() {
      return this.$children.filter((item) => {
        return item.$options.name === "pane";
      });
    },
    updateNav() {
      this.navList = [];
      // 设置对this的引用,在function回调里,this指向的并不是Vue实例
      let that = this;
      this.getTabs().forEach((pane, index) => {
        that.navList.push({
          label: pane.label,
          name: pane.name || index,
        });
        // 如果没有给pane设置name默认设置它的索引
        if (!pane.name) pane.name = index;
        // 设置当前选中的 ab 的索引,在后面介绍
        if (index === 0) {
          if (!that.currentValue) {
            that.currentValue = pane.name || index;
          }
        }
      });
      this.updateStatus();
    },
    updateStatus() {
      let tabs = this.getTabs();
      let that = this;
      // 显示当前选中的tab对应的pane组件,隐藏没有选中的
      tabs.forEach((tab) => {
        return (tab.show = tab.name === that.currentValue);
      });
    },
  },
});

getTabs一个公用的方法,使用this.$children来拿到所有的pane组件实例

需要注意的是,在methods使用了有function回调的方法时(例如遍历数组的方法forEach),在回调内的this不再执行当前的Vue实例,也就是tabs组件本身,所以要在外层设置一个that = this局部变量来间接使用this。如果你熟悉ES2015, 也可以直接使用箭头函数=>,我们会在实战篇里介绍相关的用法。

遍历了每一个pane组件后,把它的labelname提取出来,构成一一个Object并添加到数据navList数组里,后面我们会在template里用到它。

设置完navList数组后,我们调用了updateStatus方法,又将pane组件遍历了一遍,不过这时是为了将当前选中的tab对应的pane组件内容显示出来,把没有选中的隐藏掉。因为在上一步操作里,我们有可能需要设置currentValue来标识当前选中项name(在用户没有设置value时,才会自动设置),所以必须要遍历2次才可以

拿到navList后,就需要对它用v-for指令把tab的标题渲染出来,并且判断每个tab当前的状态。这部分代码如下:

Vue.component("tabs", {
  template: `
    <div class="tabs">
      <div class="tabs-bar">
        <div
          :class="tabCls(item)"
          v-for="(item, index) in navList"
          @click="handleChange(index)"
        >
          {{ item.label }}
        </div>
      </div>
      <div class="tabs-content">
          <slot></slot>
      </div>
    </div>
    `,
  props: {
    // 这里的 value 是为了可以使用 model
    value: [String, Number],
  },
  data() {
    return {
      // 因为不能修改飞ralue ,所以复制一份自己维护
      currentValue: this.value,
      navList: [],
    };
  },
  methods: {
    tabCls(item) {
      return [
        "tabs-tab",
        {
          // 给当前选中的tab加一个class
          "tabs-tab-active": item.name === this.currentValue,
        },
      ];
    },
    // 点击 tab 标题时触发
    handleChange(index) {
      let nav = this.navList[index];
      let name = nav.name;
      // 改变当前选中的 tab ,并触发下面的 watch
      this.currentValue = name;
      // 更新 value
      this.$emit("on-click", name);
    },
    updateStatus() {
      let tabs = this.getTabs();
      let that = this;
      // 显示当前选中的tab对应的pane组件,隐藏没有选中的
      tabs.forEach((tab) => {
        return (tab.show = tab.name === that.currentValue);
      });
    },
  },
  watch: {
    value(val) {
      this.currentValue = val;
    },
    currentValue() {
      // 在当前选中的tab发生变化时,更新pane的显示状态
      this.updateStatus();
    },
  },
});

在使用v-for指令循环显示tab标题时,使用v-bind:class指向了一个名为tabCls的methods动态设置class名称。因为计算属性不能接收参数,无法知道当前tab是否是选中的,所以这里我们才用到methods,不过要知道,methods是不缓存的,可以回顾关于计算属性的章节

点击每个tab标题时,会触发handleChange方法来改变当前选中tab的索引,也就是pane组件的name。在watch选项里,我们监听了currentValue, 当其发生变化时,触发updateStatus方法来更新pane组件的显示状态

以上就是标签页组件的核心代码分解。总结一下该示例的技术难点:使用了组件嵌套的方式,将一系列pane组件作为tabs组件的slot; tabs 组件和pane组件通信上,使用了$parent和$children的方法访问父链和子链;定义了prop: value和data: currentValue, 使用$emit(input)来实现v-model的用法

以下是标签页组件的完整代码:

  • index.html--------入口文件
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>

  <body>
    <div id="app" v-cloak>
      <tabs v-model="activeKey">
        <pane label="标签一" name="1">
          标签一内容
        </pane>
        <pane label="标签二" name="2">
          标签二内容
        </pane>
        <pane label="标签三" name="3">
          标签三内容
        </pane>
      </tabs>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script src="pane.js"></script>
    <script src="tabs.js"></script>
    <script type="text/javascript">
      var app = new Vue({
        el: "#app",
        data() {
          return {
            activeKey: "1",
          };
        },
      });
    </script>
  </body>
</html>
  • style.css---------样式表
[v-cloak] {
  display: none;
}
.tabs {
  font-size: 14px;
  color: #657180;
}
.tabs-bar:after {
  content: "";
  display: block;
  width: 100%;
  height: 1px;
  background: #d7dde4;
  margn-top: -1px;
}
.tabs-tab {
  display: inline-block;
  padding: 4px 16px;
  margin-right: 6px;
  background: #fff;
  border: 1px solid #d7dde4;
  cursor: pointer;
  position: relative;
}
.tabs-tab-active {
  color: #3399ff;
  border-top: 1px solid #3399ff;
  border-bottom: 1px solid #fff;
}
.tabs-tab-active:before {
  content: "";
  display: block;
  height: 1px;
  background: #3399ff;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}
.tabs-content {
  padding: 8px 0;
}
  • tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
  template: `
    <div class="tabs">
      <div class="tabs-bar">
        <div
          :class="tabCls(item)"
          v-for="(item, index) in navList"
          @click="handleChange(index)"
        >
          {{ item.label }}
        </div>
      </div>
      <div class="tabs-content">
          <slot></slot>
      </div>
    </div>
    `,
  props: {
    // 这里的 value 是为了可以使用 model
    value: [String, Number],
  },
  data() {
    return {
      // 因为不能修改飞ralue ,所以复制一份自己维护
      currentValue: this.value,
      navList: [],
    };
  },
  methods: {
    tabCls(item) {
      return [
        "tabs-tab",
        {
          // 给当前选中的tab加一个class
          "tabs-tab-active": item.name === this.currentValue,
        },
      ];
    },
    getTabs() {
      return this.$children.filter((item) => {
        return item.$options.name === "pane";
      });
    },
    updateNav() {
      this.navList = [];
      // 设置对this的引用,在function回调里,this指向的并不是Vue实例
      let that = this;
      this.getTabs().forEach((pane, index) => {
        that.navList.push({
          label: pane.label,
          name: pane.name || index,
        });
        // 如果没有给pane设置name默认设置它的索引
        if (!pane.name) pane.name = index;
        // 设置当前选中的 ab 的索引,在后面介绍
        if (index === 0) {
          if (!that.currentValue) {
            that.currentValue = pane.name || index;
          }
        }
      });
      this.updateStatus();
    },
    updateStatus() {
      let tabs = this.getTabs();
      let that = this;
      // 显示当前选中的tab对应的pane组件,隐藏没有选中的
      tabs.forEach((tab) => {
        return (tab.show = tab.name === that.currentValue);
      });
    },
    // 点击 tab 标题时触发
    handleChange(index) {
      let nav = this.navList[index];
      let name = nav.name;
      // 改变当前选中的 tab ,并触发下面的 watch
      this.currentValue = name;
      // 更新 value
      this.$emit("on-click", name);
    },
  },
  watch: {
    value(val) {
      this.currentValue = val;
    },
    currentValue() {
      // 在当前选中的tab发生变化时,更新pane的显示状态
      this.updateStatus();
    },
  },
});
  • pane.js-----------标签页嵌套的组件 pane
Vue.component("pane", {
  name: "pane",
  template: `
      <div class="pane" v-show="show">
        <slot></slot>
      </div>
    `,
  data() {
    return {
      show: true,
    };
  },
  props: {
    name: {
      type: String,
    },
    label: {
      type: String,
      default: "",
    },
  },
  methods: {
    updateNav() {
      this.$parent.updateNav();
    },
  },
  watch: {
    label() {
      this.updateNav();
    },
  },
  mounted() {
    this.updateNav();
  },
});

衍生拓展练习:

  1. 练习 1:给pane组件新增一个prop:closable的布尔值,来支持是否可以关闭这个pane, 如果开启,在tabs的标签标题上会有一个关闭的按钮。注意:在初始化 pane 时, 我们是在 mounted 里通知的,关闭时,你会用到 beforeDestroy

修改代码如下:

  • index.html--------入口文件
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>示例</title>
    <link rel="stylesheet" type="text/css" href="style.css" />
  </head>

  <body>
    <div id="app" v-cloak>
      <tabs
        v-model="activeKey"
        @on-click="handleOnclick"
        @on-close="handleOnclose"
      >
        <pane
          v-for="item in pans"
          :key="item.mes"
          :label="item.label"
          :name="item.name"
          :closable="item.closable"
        >
          {{item.mes}}
        </pane>
      </tabs>
    </div>
    <script src="https://cdn.staticfile.org/vue/2.2.2/vue.min.js"></script>
    <script src="pane.js"></script>
    <script src="tabs.js"></script>
    <script type="text/javascript">
      var app = new Vue({
        el: "#app",
        data() {
          return {
            activeKey: "1",
            pans: [
              {
                label: "标签一",
                name: "1",
                closable: true,
                mes: "标签一的内容",
              },
              {
                label: "标签二",
                name: "2",
                closable: true,
                mes: "标签二的内容",
              },
              {
                label: "标签三",
                name: "3",
                closable: true,
                mes: "标签三的内容",
              },
            ],
          };
        },
        methods: {
          handleOnclick(name) {
            this.activeKey = name;
          },
          handleOnclose(name) {
            var index = 0;
            for (var i = 0; i < this.pans.length; i++) {
              if (this.pans[i].name === name) {
                index = i;
                break;
              }
            }
            this.pans.splice(index, 1);
          },
        },
      });
    </script>
  </body>
</html>
  • tabs.js-----------标签页外层的组件 tabs
Vue.component("tabs", {
  template: `
    <div class="tabs">
      <div class="tabs-bar">
        <div
          :class="tabCls(item)"
          v-for="(item, index) in navList"
          @click="handleChange(index)"
         >
           {{ item.label }}
           <span class="del" v-show="item.name === currentValue" @click="handleClose(item,index)">x</span>
         </div>
       </div>
       <div class="tabs-content">
        <slot></slot>
       </div>
    </div>
    `,
  props: {
    // 这里的 value 是为了可以使用 model
    value: [String, Number],
  },
  data() {
    return {
      // 因为不能修改飞ralue ,所以复制一份自己维护
      currentValue: this.value,
      navList: [],
    };
  },
  methods: {
    tabCls(item) {
      return [
        "tabs-tab",
        {
          // 给当前选中的tab加一个class
          "tabs-tab-active": item.name === this.currentValue,
        },
      ];
    },
    getTabs() {
      return this.$children.filter((item) => {
        return item.$options.name === "pane";
      });
    },
    handleClose(item, index) {
      this.navList.splice(index, 1);
      //console.log(this.navList.length);
      var name = item.name;
      //console.log(name)
      this.handleChange(0);
      this.$emit("on-close", name);
    },
    updateNav() {
      this.navList = [];
      // 设置对this的引用,在function回调里,this指向的并不是Vue实例
      let that = this;
      this.getTabs().forEach((pane, index) => {
        that.navList.push({
          label: pane.label,
          name: pane.name || index,
          closable: pane.closable,
        });
        // 如果没有给pane设置name默认设置它的索引
        if (!pane.name) pane.name = index;
        // 设置当前选中的 ab 的索引,在后面介绍
        if (index === 0) {
          if (!that.currentValue) {
            that.currentValue = pane.name || index;
          }
        }
      });
      this.updateStatus();
    },
    updateStatus() {
      let tabs = this.getTabs();
      let that = this;
      // 显示当前选中的tab对应的pane组件,隐藏没有选中的
      tabs.forEach((tab) => {
        return (tab.show = tab.name === that.currentValue);
      });
    },
    // 点击 tab 标题时触发
    handleChange(index) {
      let nav = this.navList[index];
      let name = nav.name;
      // 改变当前选中的 tab ,并触发下面的 watch
      this.currentValue = name;
      // 更新 value
      this.$emit("on-click", name);
    },
  },
  watch: {
    value(val) {
      this.currentValue = val;
    },
    currentValue() {
      // 在当前选中的tab发生变化时,更新pane的显示状态
      this.updateStatus();
    },
  },
});
  1. 练习 2:尝试在切换pane显示与隐藏时,使用滑动的动画。提示:可以使用CSS3的transform:translateX
  • 在template中,用transition组件包裹住 div 元素,设置 name 和mode 特性。
  • 在style中,添加 .pane等 class。其实 类 pane在书中已经有设置,只是未填写具体内容,算是作者给的一个提示线索吧。

修改代码如下:

  • pane.js-----------标签页嵌套的组件 pane
 Vue.component('pane', {
    name: 'pane', 
    template: `
      <transition name="fade" mode="out-in">
        <div class="pane" v-show="show">
          <slot></slot>
        </div>
      </transition>
    `,
    data() {
      return {
        show: true
      }
    },
    props: {
      name: {
        type: String,
      },
      label: {
        type: String,
        default: ''
      }
    },
    methods: {
      updateNav() {
        this.$parent.updateNav()
      }
    },
    watch: {
      label() {
        this.updateNav()
      }
    },
    mounted() {
      this.updateNav()
    }
  })

style.css---------样式表


[v-cloak] {
  display: none;
}
.tabs {
  font-size: 14px;
  color: #657180;
 }
.tabs-bar:after {
  content: "";
  display: block;
  width: 100%;
  height: 1px;
  background: #d7dde4;
  margn-top: -1px;
}
.tabs-tab {
  display: inline-block;
  padding: 4px 16px;
  margin-right: 6px;
  background: #fff;
  border: 1px solid #d7dde4;
  cursor: pointer;
  position: relative;
}
.tabs-tab-active {
  color: #3399ff;
  border-top: 1px solid #3399ff;
  border-bottom: 1px solid #fff;
}
.tabs-tab-active:before {
  content: "";
  display: block;
  height: 1px;
  background: #3399ff;
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
 }
 .tabs-content {
  padding: 8px 0;
 }

.pane {
  display: inline-block;
}
.fade-enter-active,
.fade-leave-active {
  position: absolute;
  transition: all 0.8s ease;
}
.fade-enter,
.fade-leave-to {
  transform: translateX(100px);
  opacity: 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值