第 7 章 组件详解

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

组件与复用
  • 为什么使用组件

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



上面的图中是一个很常见的聊天界面,有一些标准的控件,比如右上角的关闭按钮、输入框、发送按钮等。你可能要问了,这有什么难的,不就是几个 div、 input 吗?好,那现在需求升级了,这几个控件还有别的地方要用到。没问题,复制粘贴呗。那如果输入框要带数据验证,按钮的图标支持自定义呢?这样用 JavaScript 封装后一起复制吧。那等到项目快完结时,产品经理说,所有使用输入框的地方,都要改成支持回车键提交。好吧,给我一天的时间,我一个一个加上去。
上面的需求虽然有点变态,但却是业务中很常见的,那就是一些控件、 JavaScript 能力的复用。 没错, Vue.js 的组件就是提高重用性的,让代码可复用,当学习完组件后,上面的问题就可以分分钟搞定了,再也不用害怕产品经理的奇葩需求。
我们先看一下图中的示例用组件来编写是怎样的,示例代码如下:

     <Card style="width:350px">
         <p slot="title">与XXX聊天</p>
         <a href="#" slot="extra">
             <Icon type="android-close" size="18"></Icon>
         </a>
         <div style="height:100px">
        
        </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>

是不是很奇怪,有很多我们从来都没有见过的标签,比如<Card>、<Row>、<i-col>、<i-input> 和<i-button>等,而且整段代码除了内联的几个样式外, 一句 css 代码也没有,但最终实现的 UI 就是上面图的效果。
这些没见过的自定义标签就是组件,每个标签代表一个组件,在任何使用 Vue 的地方都可以直接使用。接下来,我们就来看看组件的具体用法。

  • 组件用法

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

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

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

        Vue.component('my-component',{
           //选项
        });

my-component 就是注册的组件自定义标签名称,推荐使用小写加减号分割的形式命名。 要在父实例中使用这个组件,必须要在实例创建前注册,之后就可以用<my-component> </my-component>的形式来使用组件了,示例代码如下:

 <body>
    <div id="app">
      <my-component></my-component>
    </div>
     <script>
        Vue.component('my-component',{
            //选项
        });
        
         var app = new Vue({
             el: '#app'
         });       
     </script>
 </body>

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

 <body>
    <div id="app">
      <my-component></my-component>
    </div>
     <script>
        Vue.component('my-component',{
            template: '<div>这里是组件的内容</div>'
        });
        
         var app = new Vue({
             el: '#app'
         });       
     </script>
 </body>

渲染后的结果是:



template 的 DOM 结构必须被一个元素包含, 如果直接写成 “这里是组件的内容”, 不带 “<div></ div>”是无法渲染的。
在 Vue 实例中,使用 componens 选项可以局部注册组件,注册后的组件只有在该实例作用域下有效。组件中也可以使用 components 选项来注册组件,使组件可以嵌套。示例代码如下:

 <body>
    <div id="app">
      <my-component></my-component>
    </div>
     <script>
        var Child = {
            template: '<div>局部注册组件的内容</div>'
        };
        
         var app = new Vue({
             el: '#app',
            components: {
                'my-component': Child
            }
         });       
     </script>
 </body>

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

 <body>
    <div id="app">
      <table>
          <tbody is="my-component"></tbody>
      </table>
    </div>
     <script>
        Vue.component('my-component',{
            template: '<div>这里是组件的内容</div>'
        });
        
         var app = new Vue({
             el: '#app'
         });       
     </script>
 </body>

tbody 在渲染时, 会被替换为组件的内容。常见的限制元素还有<ul>、<ol>、<select> 。
提示:如果使用的是字符串模板,是不受限制的,比如后面章节中会介绍的 vue 单文件用法等。
除了 template 选项外,组件中还可以像 Vue 实例那样使用其他的选项,比如 data、 computed、 methods 等。但是在使用 data 时, 和实例稍有区别, data 必须是函数,然后将数据 return 出去, 例如:

<body>
   <div id="app">
     <my-component></my-component>
   </div>
    <script>
       Vue.component('my-component',{
           template: '<div>{{ message }}</div>',
          data: function(){
              return {
                  message: '组件内容'
              }
          }
       });
       
        var app = new Vue({
            el: '#app'
        });       
    </script>
</body>

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

<body>
   <div id="app">
     <my-component></my-component>
    <my-component></my-component>
    <my-component></my-component>
   </div>
    <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>
</body>

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

<body>
   <div id="app">
     <my-component></my-component>
    <my-component></my-component>
    <my-component></my-component>
   </div>
    <script>
       Vue.component('my-component',{
           template: '<button @click="counter++">{{ counter }}</button>',
          data: function(){
              return {
                  counter: 0
              };
          }
       });
       
       var app = new Vue({
           el: '#app'
       });       
    </script>
</body>

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

使用 props 传递数据
  • 基本用法

组件不仅仅是要把模板的内容进行复用,更重要的是组件间要进行通信。通常父组件的模板中包含子组件,父组件要正向地向子组件传递数据或参数,子组件接收到后根据参数的不同来渲染不同的内容或执行操作。这个正向传递数据的过程就是通过 props 来实现的。
在组件中,使用选项 props 来声明需要从父级接收的数据, props 的值可以是两种, 一种是字符串数组,一种是对象, 本小节先介绍数组的用法。比如我们构造一个数组,接收一个来自父级的数据 message,并把它在组件模板中渲染,示例代码如下:

<body>
   <div id="app">
     <my-component message="来自父组件的数据"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['message'],
          template: '<div>{{ message }}</div>'
       });
       
       var app = new Vue({
           el: '#app'
       });       
    </script>
</body>

渲染后的结果为:



props 中声明的数据与组件 data 函数 return 的数据主要区别就是 props 的来自父级,而 data 中的是组件自己的数据,作用域是组件本身,这两种数据都可以在模板 template 及计算属性 computed 和方法 methods 中使用。上例的数据 message 就是通过 props 从父级传递过来的,在组件的自定义标签上直接写该 props 的名称,如果要传递多个数据,在 props 数组中添加项即可。
由于 HTME 特性不区分大小写,当使用 DOM 模板时,驼峰命名 (camelCase)的 props 名称要转为短横分隔命名 (kebab-case),例如:

<body>
   <div id="app">
     <my-component warning-text="提示信息"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['warningText'],
          template: '<div>{{ warningText }}</div>'
       });
       
       var app = new Vue({
           el: '#app'
       });       
    </script>
</body>

提示:如果使用的是字符串模板,仍然可以忽略这些限制。
有时候,传递的数据并不是直接写死的,而是来自父级的动态数据,这时可以使用指令 v-bind 来动态绑定 props 的值,当父组件的数据变化时,也会传递给子组件。示例代码如下:

<body>
   <div id="app">
    <input type="text" v-model="parentMessage" />
     <my-component :message="parentMessage"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['message'],
          template: '<div>{{ message }}</div>'
       });
       
       var app = new Vue({
           el: '#app',
          data: {
              parentMessage: ''
          }
       });       
    </script>
</body>

这里用 v-model 绑定了父级的数据 parentMessage,当通过输入框任意输入时,子组件接收到的 props “message” 也会实时响应,并更新组件模板。
提示:注意,如果你要直接传递数字、布尔值、数组、对象,而且不使用 v-bind,传递的仅仅是字符串,尝试下面的示例来对比:

<body>
   <div id="app">
    <my-component message="[1,2,3]"></my-component>
     <my-component :message="[1,2,3]"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['message'],
          template: '<div>{{ message.length }}</div>'
       });
       
       var app = new Vue({
           el: '#app'
       });       
    </script>
</body>

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


  • 单向数据流
  • Vue 2.x 与 Vue l.x 比较大的一个改变就是, Vue2.x 通过 props 传递数据是单向的了, 也就是父组件数据变化时会传递给子组件,但是反过来不行。而在 Vue l.x 里提供了.sync 修饰符来支持双向绑定。之所以这样设计,是尽可能将父子组件解耦,避免子组件无意中修改了父组件的状态。
  • 业务中会经常遇到两种需要改变 prop 的情况,一种是父组件传递初始值进来,子组件将它作为初始值保存起来,在自己的作用域下可以随意使用和修改。这种情况可以在组件 data 内再声明一个数据,引用父组件的 prop,示例代码如下:
<body>
   <div id="app">
    <my-component :init-count="1"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['initCount'],
          template: '<div>{{ count }}</div>',
          data: function(){
              return {
                  count: this.initCount
              }
          }
       });
       
       var app = new Vue({
           el: '#app'
       });       
    </script>
</body>

组件中声明了数据 count, 它在组件初始化时会获取来自父组件的 initCount, 之后就与之无关 了,只用维护 count, 这样就可以避免直接操作 initCount。
另一种情况就是 prop 作为需要被转变的原始值传入。这种情况用计算属性就可以了, 示例代码如下:

<body>
   <div id="app">
    <my-component :width="100"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['width'],
          template: '<div :style="style">组件内容</div>',
          computed: {
              style: function(){
                  return {
                      width: this.width + 'px'
                  }
              }
          }
       });
       
       var app = new Vue({
           el: '#app'
       });       
    </script>
</body>

因为用 css 传递宽度要带单位(px),但是每次都写太麻烦,而且数值计算一般是不带单位的,所以统一在组件内使用计算属性就可以了。
提示:注意,在 JavaScript 中对象和数组是引用类型,指向同一个内存空间,所以 props 是对象和数组时,在子组件内改变是会影响父纽件的。

  • 数据验证
  • 我们上面所介绍的 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: function(){
                      return [];
                  }
              },
              //自定义一个验证函数
              propF: {
                  validator: function(value){
                      return value > 10;
                  }
              }
          }
       });

验证的 type 类型可以是:

序号 类型
1 String
2 Number
3 Boolean
4 Object
5 Function

type 也可以是一个自定义构造器,使用 instanceof 检测。 当 prop 验证失败时,在开发版本下会在控制台抛出一条警告。

组件通信

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



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

  • 自定义事件
  • 当子组件需要向父组件传递数据时,就要用到自定义事件。我们在介绍指令 v-on 时有提到, v-on 除了监听 DOM 事件外,还可以用于组件之间的自定义事件。
  • 如果你了解过 JavaScript 的设计模式一一观察者模式, 一定知道dispatchEvent 和 addEventListener 这两个方法。 Vue 组件也有与之类似的一套模式,子组件用 $emit() 来触发事件,父组件用 $on() 来监听子组件的事件。

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

<body>
   <div id="app">
      <p>总数:{{ total }}</p>
    <my-component @increase="handleGetTotal" @reduce="handleGetTotal"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          template: '\
          <div>\
              <button @click="handleIncrease">+1</button>\
              <button @click="handleReduce">-1</button>\
          </div>',
          data: function(){
              return {
                  counter: 0
              }
          },
          methods: {
              handleIncrease: function(){
                  this.counter++;
                  this.$emit('increase',this.counter);
              },
              handleReduce: function(){
                  this.counter--;
                  this.$emit('reduce',this.counter);
              },
          }
       });
       
       var app = new Vue({
           el: '#app',
          data: {
              total: 0
          },
          methods: {
              handleGetTotal: function(total){
                  this.total = total;
              }
          }
       });       
    </script>
</body>

上面示例中,子组件有两个按钮,分别实现加 1 和减 l 的效果, 在改变组件的 data “ counter" 后,通过 $emit() 再把它传递给父组件, 父组件用 v-on:increase 和 v-on:reduce(示例使用的是语法糖)。 $emit() 方法的第一个参数是自定义事件的名称, 例如示例的 increase 和 reduce 后面的参数都是要传递的数据,可以不填或填写多个。
除了用 v-on 在组件上监听自定义事件外,也可以监听 DOM 事件,这时可以用 .native 修饰符表示监听的是一个原生事件,监听的是该组件的根元素,示例代码如下:

  <my-component v-on:click.native="handleClick"></my-component>
  • 使用 v-model

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

<body>
   <div id="app">
    <p>总数:{{ total }}</p>
    <my-component v-model="total"></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          template: '<button @click="handleClick">+1</button>',
          data: function(){
              return {
                  counter: 0
              }
          },
          methods: {
              handleClick: function(){
                  this.counter++;
                  this.$emit('input',this.counter);
              }
          }
       });
       
       var app = new Vue({
           el: '#app',
          data: {
              total: 0
          }
       });       
    </script>
</body>

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

<body>
   <div id="app">
    <p>总数:{{ total }}</p>
    <my-component @input="handleGetTotal"></my-component>
   </div>
    <script>
       //...省略组件代码
       var app = new Vue({
           el: '#app',
          data: {
              total: 0
          },
          methods: {
              handleGetTotal: function(total){
                  this.total = total;
              }
          }
       });       
    </script>
</body>

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

<body>
   <div id="app">
    <p>总数:{{ total }}</p>
    <my-component v-model="total"></my-component>
    <button @click="handleReduce">-1</button>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['value'],
          template: '<input :value="value" @input="updateValue" />',
          methods: {
              updateValue: function (event){
                  this.$emit('input',event.target.value);
              }
          }
      });
       var app = new Vue({
           el: '#app',
          data: {
              total: 0
          },
          methods: {
              handleReduce: function(){
                  this.total--;
              }
          }
       });       
    </script>
</body>

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

  • 接收一个value 属性。
  • 在有新的 value 时触发 input 事件。
  • 非父子组件通信

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

<body>
   <!-- 注意:该示例使用 Vue.js 1.x 的版本 -->
   <div id="app">
    {{ message }}
    <my-component></my-component>
   </div>
    <script>
       Vue.component('my-component',{
          props: ['value'],
          template: '<button @click="handleDispatch">派发事件</button>',
          methods: {
              handleDispatch: function (event){
                  this.$dispatch('on-message','来自组件内部的数据');
              }
          }
      });
       var app = new Vue({
           el: '#app',
          data: {
              message: ''
          },
          events: {
              'on-message': function(msg){
                  this.message = msg;
              }
          }
       });       
    </script>
</body>
  • 同理, $broadcast() 是由上级向下级广播事件的,用法完全一致,只是方向相反。
  • 这两种方法一旦发出事件后,任何组件都是可以接收到的, 就近原则, 而且会在第一次接收到后停止冒泡,除非返回 true。
  • 这两个方法虽然看起来很好用,但是在 Vue.js 2.x 中都废弃了 , 因为基于组件树结构的事件流方式让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱,并且不能解决兄弟组件通信的问题。
  • 在 Vue.js 2.x 中 , 推荐使用一个空的 Vue 实例作为中央事件总线(bus),也就是一个中介。 为了更形象地了解它,我们举一个生活中的例子。
  • 比如你需要租房子, 你可能会找房产中介来登记你的需求, 然后中介把你的信息发给满足要求的出租者,出租者再把报价和看房时间告诉中介,由中介再转达给你,整个过程中, 买家和卖家并没有任何交流,都是通过中间人来传话的。
  • 或者你最近可能要换房了, 你会找房产中介登记你的信息, 订阅与你找房需求相关的资讯, 一旦有符合你的房子出现时,中介会通知你,并传达你房子的具体信息。
  • 这两个例子中 , 你和出租者担任的就是两个跨级的组件,而房产中介就是这个中央事件总线 (bus) 。
  • 比如下面的示例代码:
  <body>
      <div id="app">
          {{ message }}
          <component-a></component-a>
      </div>
      <script>
          var bus = new Vue({});
          Vue.component('component-a', {
              template: '<button @click="handleEvent">传递事件</button>',
              methods: {
                  handleEvent: function() {
                      bus.$emit('on-message', '来自组件component-a的内容');
                  }
              }
          });
          var app = new Vue({
              el: '#app',
              data: {
                  message: ''
              },
              mounted: function() {
                  var _this = this;
                  //在实例初始化时,监听来自bus实例的事件
                  bus.$on('on-message', function(msg) {
                      _this.message = msg;
                  });
              }
          })
      </script>
  </body>

首先创建了 一个名为 bus 的空 Vue 实例,里面没有任何内容;然后全局定义了组件 component-a;最后创建 Vue 实例 app,在 app 初始化时,也就是在生命周期 mounted 钩子函数里监听了来自 bus 的事件 on-message,而在组件 component-a 中,点击按钮会通过 bus 把事件 on-message 发出去,此时 app 就会接收到来自 bus 的事件,进而在回调里完成自己的业务逻辑。
这种方法巧妙而轻量地实现了任何组件间的通信,包括父子、兄弟、跨级,而且 Vue 1.x 和 Vue 2.x 都适用。如果深入使用,可以扩展 bus 实例,给它添加 data、 methods、 computed 等选项, 这些都是可以公用的,在业务中,尤其是协同开发时非常有用,因为经常需要共享一些通用的信息, 比如用户登录的昵称、性别、邮箱等,还有用户的授权 token 等。只需在初始化时让 bus 获取一次, 任何时间、任何组件就可以从中直接使用了,在单页面富应用( SPA)中会很实用,我们会在后面章节里逐步介绍这些内容。
当你的项目比较大,有更多的小伙伴参与开发时,也可以选择更好的状态管理解决方案vuex, 在在后面章节中会详细介绍关于它的用法。
除了中央事件总线 bus 外,还有两种方法可以实现组件间通信:父链和子组件索引。

  • 父链
    在子组件中,使用 this.$parent 可以直接访问该组件的父实例或组件,父组件也可以通过 this.$children 访问它所有的子组件,而且可以递归向上或向下无线访问, 直到根实例或最内层的组件。示例代码如下:
  <body>
      <div id="app">
          {{ message }}
          <component-a></component-a>
      </div>
      <script>
          Vue.component('component-a', {
              template: '<button @click="handleEvent">通过父链直接修改数据</button>',
              methods: {
                  handleEvent: function() {
                      //访问到父链后,可以做任何操作,比如直接修改数据
                      this.$parent.message = '来自组件 component-a 的内容'
                  }
              }
          });
          var app = new Vue({
              el: '#app',
              data: {
                  message: ''
              }
          })
      </script>
  </body>

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

  • 子组件索引
    当子组件较多时, 通过 this.$children 来一一遍历出我们需要的一个组件实例是比较困难的, 尤其是组件动态渲染时,它们的序列是不固定的。 Vue 提供了子组件索引的方法,用特殊的属性 ref 来为子组件指定一个索引名称,示例代码如下:
  <body>
      <div id="app">
          <button @click="handleRef">通过ref获取子组件实例</button>
          <component-a ref="comA"></component-a>
      </div>
      <script>
          Vue.component('component-a',{
              template:'<div>子组件</div>',
              data:function(){
                  return {
                      message:'子组件内容'
                  }  
              }
          });
          var app = new Vue({
              el:'#app',
              methods: {
                  handleRef: function() {
                      //通过$refs来访问指定的实例
                      var msg = this.$refs.comA.message;
                      console.log(msg);
                  }
              }
          });
      </script>
  </body>

在父组件模板中,子组件标签上使用 ref 指定一个名称,并在父组件内通过 this.$refs 来访问指定名称的子组件。
提示:$refs 只在渲染完成后才填元,并且它是非响应式的。 它仅仅作为一个直接访问子组件的应急方案,应当避免在模板或计算属性中使用础。
与 Vue 1.x 不同的是, Vue 2.x 将 v-el 和 v-ref 合并为了 ref, Vue 会自动去判断是普通标签还是组件。 可以尝试补全下面的代码,分别打印出两个 ref 看看都是什么:

      <div id="app">
          <p ref="p">内容</p>
          <child-component ref="child"></child-component>
      </div>
使用 slot 分发内容
  • 什么是 slot

我们先看一个比较常规的网站布局,如下图所示。



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

      <app>
          <menu-main></menu-main>
          <menu-sub></menu-sub>
          <div class="container">
              <menu-left></menu-left>
              <container></container>
          </div>
          <app-footer></app-footer>
      </app>

当需要让组件组合使用,混合父组件的内容与子组件的模板时,就会用到 slot, 这个过程叫作内容分发(transclusion)。以<app>为例,它有两个特点:

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

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

  • 作用域

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

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

这里的 message 就是一个 slot,但是它绑定的是父组件的数据,而不是组件<child-component> 的数据。
父组件模板的内容是在父组件作用域内编译,子组件模板的内容是在子组件作用域内编译。 例如下面的代码示例:

  <body>
      <div id="app">
          <child-component v-show="showChild"></child-component>
      </div>
      <script>
          Vue.component('child-component',{
              template:'<div>子组件</div>'
          });
          var app = new Vue({
              el:'#app',
              data: {
                  showChild: true
              }
          });
      </script>
  </body>

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

  <body>
      <div id="app">
          <child-component></child-component>
      </div>
      <script>
          Vue.component('child-component',{
              template:'<div v-show="showChild">子组件</div>',
              data: function(){
                  return {
                      showChild: true
                  }
              }
          });
          var app = new Vue({
              el:'#app'
          });
      </script>
  </body>

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

  • slot 用法
  • 单个 Slot
    在子组件内使用特殊的<slot>元素就可以为这个子组件开启一个 slot (插槽),在父组件模板里,插入在子组件标签内的所有内容将替代子组件的<slot> 标签及它的内容。示例代码如下:
  <body>
      <div id="app">
          <child-component>
              <p>分发的内容</p>
              <p>更多分发的内容</p>
          </child-component>
      </div>
      <script>
          Vue.component('child-component',{
              template:'\
              <div>\
                  <slot>\
                      <p>如果父组件没有插入内容,我将作为默认出现</p>\
                  </slot>\
              </div>'
          });
          var app = new Vue({
              el:'#app'
          });
      </script>
  </body>

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


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

  • 具名 Slot
    给<slot> 元素指定一个 name 后可以分发多个内容,具名 Slot 可以与单个 Slot 共存,例如下面的示例:
  <body>
      <div id="app">
          <child-component>
              <h2 slot="header">标题</h2>
              <p>正文内容</p>
              <p>更多的正文内容</p>
              <div slot="footer">底部信息</div>
          </child-component>
      </div>
      <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>
  </body>
  • 子组件内声明了 3 个<slot>元素,其中在<div class=”main"> 内的<slot> 没有使用 name 特性, 它将作为默认 slot 出现,父组件没有使用 slot 特性的元素与内容都将出现在这里。
  • 如果没有指定默认的匿名 slot,父组件内多余的内容片段都将被抛弃。
  • 上例最终渲染后的结果为:



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

  • 作用域插槽

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

  <body>
      <div id="app">
          <child-component>
              <template scope="props">
                  <p>来自父组件的内容</p>
                  <p>{{ props.msg}}</p>
              </template>
          </child-component>
      </div>
      <script>
          Vue.component('child-component', {
              template: '\
                  <div class="container">\
                      <slot msg="来自子组件的内容"></slot>\
                  </div>'
          });
          var app = new Vue({
              el: '#app'
          })
      </script>
  </body>

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



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

  <body>
      <div id="app">
          <my-list :books="books">
              <!--作用域的插槽也可以是具名的slot-->
              <template slot="book" scope="props">
                  <li>{{ props.bookName}}</li>
              </template>
          </my-list>
      </div>
      <script>
          Vue.component('my-list',{
              props:{
                  books:{
                      type:Array,
                      default:function(){
                          return [];
                      }
                  }
              },
              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>
  </body>

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

  • 访问 slot

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

  <body>
      <div id="app">
          <child-component>
              <h2 slot="header">标题</h2>
              <p>正文内容</p>
              <p>更多的正文内容</p>
              <div slot="footer">底部内容</div>
          </child-component>
      </div>
      <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(){
                  var header = this.$slots.header;
                  var main = this.$slots.default;
                  var footer = this.$slots.footer;
                  console.log(footer);
                  console.log(footer[0].elm.innerHTML);
              }
          });
          var app = new Vue({
              el:'#app'
          });
      </script>
  </body>

通过$slots 可以访问某个具名 slot, this.$slots.default 包括了所有没有被包含在具名 slot 中的节点。尝试编写代码,查看两个 console 打印的内容。
$slots 在业务中几乎用不到, 在用 render 函数(后面章节中将介绍)创建组件时会比较有用,但主要还是用于独立组件开发中。

组件的高级用法

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

  • 递归组件

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

  <body>
      <div id="app">
          <child-component :count="1"></child-component>
      </div>
      <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>
  </body>

设置 name 后,在组件模板内就可以递归使用了,不过需要注意的是,必须给一个条件来限制递归数量,否则会抛出错误: max stack size exceeded。
组件递归使用可以用来开发一些具有未知层级关系的独立组件,比如级联选择器和树形控件等,如下面图中所示所示。




在实战章节里,我们会详细介绍级联选择器的实现。

  • 内联模板

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

  <body>
      <div id="app">
          <child-component inline-template>
              <div>
                  <h2>在父组件中定义子组件的模板</h2>
                  <p>{{ message }}</p>
                  <p>{{ msg }}</p>
              </div>
          </child-component>
      </div>
      <script>
          Vue.component('child-component', {
              data: function() {
                  return {
                      msg: '在子组件声明的数据'
                  }
              }
          });
          var app = new Vue({
              el: '#app',
              data: {
                  message: '在父组件中声明的数据'
              }
          });
      </script>
  </body>

渲染后的结果为:



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

  • 动态组件

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

  <body>
      <div id="app">
          <component :is="currentView"></component>
          <button @click="handleChangeView('A')">切换到A</button>
          <button @click="handleChangeView('B')">切换到B</button>
          <button @click="handleChangeView('C')">切换到C</button>
      </div>
      <script>
          var app = new Vue({
              el: '#app',
              components: {
                  comA: {
                      template: '<div>组件A</div>'
                  },
                  comB: {
                      template: '<div>组件B</div>'
                  },
                  comC: {
                      template: '<div>组件C</div>'
                  }
              },
              data: {
                  currentView: 'comA'
              },
              methods: {
                  handleChangeView: function(component) {
                      this.currentView = 'com' + component;
                  }
              }
          });
      </script>
  </body>

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

  <body>
      <div id="app">
          <component :is="currentView"></component>
      </div>
      <script>
          var Home = {
              template: '<p>Welcome home!</p>'
          };
          var app = new Vue({
              el:'#app',
              data:{
              currentView:Home
              }
          });
      </script>
  </body>
  • 异步组件

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

  <body>
      <div is="app">
          <child-component></child-component>
      </div>
      <script>
          Vue.component('child-component', function (resolve, reject) {
              window.setTimeout(function() {
                  resolve({
                      template: '<div>我是异步渲染的</div>'
                  });
              }, 2000);
          });
          var app = new Vue({
              el: '#app'
          });
      </script>
  </body>

工厂函数接受一个 resolve 回调,在收到从服务器下载的组件定义时调用。也可以调用 reject(reason) 指示加载失败。这里 setTimeout 只是为了演示异步,具体的下载逻辑可以自己决定,比如把组件配置写成一个对象配置,通过Ajax来请求,然后调用 resolve 传入配置选项。
在后面的章节里,我们还会介绍主流的打包编译工具 webpack 和.vue 单文件的用法,更优雅地实现异步组件(路由)。

其他
  • nextTick

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

  <body>
      <div id="app">
          <div id="div" v-if="showDiv">这是一段文本</div>
          <button @click="getText">获取div内容</button>
      </div>
      <script>
          var app = new Vue({
              el:'#app',
              data:{
                  showDiv: false
              },
              methods:{
                  getText: function(){
                      this.showDiv = true;
                      var text = document.getElementById('div').innerHTML;
                      console.log(text);
                  }
              }
          });
      </script>
  </body>

这段代码并不难理解,但是运行后在控制台会抛出一个错误:Cannot read property 'innerHTML' of null,意思就是获取不到div元素。这里就涉及 Vue 一个重要概念:异步更新队列。
Vue 在观察到数据变化时并不是直接去除重复数据,从而避免不必要的计算和 DOM 操作。然后,在下一个事件循环 tick 中,Vue 刷新队列并执行实际(已去重的)工作 。所以如果你用一个 for 循环来动态改变数据 100 次,其实它只会应用最后一次改变,如果没有这种机制,DOM 就要重绘 100 次,这固然是一个很大的开销。
Vue 会根据当前浏览器环境优先使用原生的 Promise.then 和 MutationObsever,如果都不支持,就会采用 setTimeout 代替。
知道了 Vue 异步更新 DOM 的原理,上面示例的报错也就不难理解了。事实上,在执行 this.showDiv=true 时,div 仍然还是没有被创建出来,直到下一个 Vue 事件循环时,才开始创建。$nextTick 就是用来知道什么时候 DOM 更新完成的,所以上面的示例代码修改为:

  <body>
      <div id="app">
          <div id="div" v-if="showDiv">这是一段文本</div>
          <button @click="getText">获取div内容</button>
      </div>
      <script>
          var app = new Vue({
              el:'#app',
              data:{
                  showDiv: false
              },
              methods:{
                  getText: function(){
                      this.showDiv = true;
                      this.$nextTick(function(){
                          var text = document.getElementById('div').innerHTML;
                          console.log(text);
                      });
                  }
              }
          });
      </script>
  </body>

这时再点击按钮,控制台就打印出 div 的内容“这是一段文本”了。
理论上,我们不应该去主动操作 DOM,因为Vue的核心思想就是数据驱动 DOM ,但在很多业务里,我们避免不了会用一些第三方库,比如 popper.js、swiper 等,这些基于原生 JavaScript 的库都有创建和更新及销毁的完整生命周期,与 Vue 配合使用时,就要利用好 $nextTick 。

  • X-Templates

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

  <body>
      <div id="app">
          <my-component></my-component>
          <script type="text/x-template" id="my-component">
              <div>这是组件的内容</div>
          </script>
      </div>
      <script>
          Vue.component('my-component',{
              template: '#my-component'
          });
          var app = new Vue({
              el:'#app'
          });
      </script>
  </body>

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

  • 手动挂载实例

我们现在所创建的实例都是通过 new Vue() 的形式创建出来的。在一些非常特殊的情况下,我们需要动态地去创建 Vue 实例, Vue 提供了 Vue.extend 和 $mount 两个方法来手动挂载一个实例。
Vue.extend 是基础 Vue 构造器,创建一个“子类”,参数是一个包含组件选项的对象。
如果 Vue 实例在实例化时没有收到 el 选项,它就处于“未挂载”状态,没有关联的 DOM 元 素。可以使用 $mount() 手动地挂载一个未挂载的实例。这个方法返回实例自身,因而可以链式调用其他实例方法。示例代码如下:

  <body>
      <div id="mount-div">
          
      </div>
      <script>
          var MyComponent = Vue.extend({
              template: '<div>Hello : {{ name }}</div>',
              data: function (){
                  return {
                      name: '清水三千尺'
                  }
              }
          });
          new MyComponent().$mount('#mount-div');
      </script>
  </body>

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



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

  new MyComponent().$mount('#mount-div');
  //同上
  new MyComponent({
      el: '#mount-div'
  });
  //或者,在文档之外渲染并且随后挂载
  var component = new MyComponent().$mount();
  document.getElementById('mount-div').appendChild(component.$el);

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,784评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,745评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,702评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,229评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,245评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,376评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,798评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,471评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,655评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,485评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,535评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,235评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,793评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,863评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,096评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,654评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,233评论 2 341

推荐阅读更多精彩内容