# 组件

对于前端开发而言, 如何去做代码复用一直是一个热门主题. 开发者都希望能够高效, 简洁地复用之前写好的代码. 同时不希望这种复用会带来任何副作用. 各个主流框架对于如何实现代码复用, 如何进行组件化都有自己的实现. 虽然实现方法不同, 但它们的核心思想都是: 把 UI 结构映射到恰当的组件树.

1647496-a07a17c3c9655ded

Vue 框架也有自己的组件系统,支持自定义 Tag 和原生 HTML 元素的扩展。

# 组件注册

在 Vue 中, 组件是可复用的 Vue 实例. 在注册组件时, 可分为 "全局注册" 和 "局部注册"

# 全局注册

通过 Vue.component 方法可以注册一个全局组件. 也就是说它们在注册之后可以用在任何新创建的 Vue 根实例的模板中。

在注册一个组件的时候,我们需要给它一个唯一的名字. 这个名字作为 Vue.component 方法的第一个参数传入. 第二个参数为一个选项对象. 和创建 Vue 实例时传入的选项对象一样.

例如:

Vue.component('my-component', {
  // ... 选项 ...
})
<div id="app">
  <my-component></my-component>
</div>

在上面代码中, 我们全局注册了一个名为 my-component 的组件, 并在模板中使用了它.

需要注意的是, 全局注册要在 Vue 根实例初始化之前注册.

# 组件名大小写

定义组件名的方式有两种:

  • 连字符命名: 字母全小写且必须包含一个连字符. 例如: my-component
  • 首字母大写命名: 每个单词的首字母都大写. 例如: MyComponent

注册完之后, 当你在引用这个自定义元素时两种命名法都可以使用。但如果在 DOM 模板中使用组件时, 只有连字符形式是有效的. 因为 DOM 原生不分大小写. Vue 解析模板时仍然会将标签转为小写形式。

例如, 如果一个自定义组件在定义时, 名字为 MyComponent.

<div id="app">
  <!-- 有效 -->
  <my-component></my-component>
  <!-- 无效 -->
  <MyComponent></MyComponent>
</div>
new Vue({
    el: '#app',
    // 有效
    template: `<MyComponent></MyComponent>`
})

# 局部注册

很多时候我们并不需要让自定义组件在全局都可用. 全局注册所有的组件意味着即便你已经不再使用一个组件了,它仍然会被包含在你最终的构建结果中. 很多时候我们想让某些组件只能在特定的 Vue 实例的作用域下可使用.

在 Vue 实例中, 可以通过 components 对象来局部注册组件. 组件中也可以使用 components 对象来局部注册组件, 依次来实现组件嵌套.

例如:

var ComponentA = { /* .选项. */ }
var ComponentB = { /* .选项. */ }


new Vue({
  el: '#app',
  components: {
    'component-a': ComponentA,
    'component-b': ComponentB
  }
})

对于 components 对象中的每个属性来说,其属性名就是自定义元素的名字,其属性值就是这个组件的选项对象。

# 组件选项对象

前面说了注册组件除了命名, 还需要传入一个选项对象. 那组件选项对象和创建 Vue 实例的时候传入的选项对象有什么区别呢?

# data 属性

首先, 因为组件是可复用的, 那也就是说我们注册的组件可能会被实例化多次. 我们都知道对象 (Object) 在 JavaScript 中是引用类型. 传递的是指针, 而不是一份独立拷贝. 那么组件的 data 选项不同于创建 Vue 实例, 是一个返回对象的函数

Vue.component('my-component', {
    data: function() {
        count: 0
    }
})

这样的话每一个组件实例都维护一份被返回的独立对象拷贝.

# template 属性

在前面我们并没怎么用过 template 属性.

template 属性的值被作为渲染 Vue 实例的模板. 如果一个 Vue 实例已经挂载到了一个 HTML 元素上了. template 的值将会替换挂载的元素。

它的值可以是字符串形式的 HTML 代码; 也可以以 # 开始, 被用作选择符, 来匹配定义在 <template> 元素的 innerHTML 作为模板.

例如:

直接在 template 属性里写模板代码:

Vue.component('my-component-a', {
  template: `
    <ul>
      <li>首页</li>
      <li>项目介绍</li>
      <li>关于我</li>
    </ul>
`
});

通过选择符, 获取匹配到的 <template> 元素里的内容作为模板:

<div id="app">
    <my-component-a></my-component-a>
</div>

<template id="myTemplate">
  <ul>
    <li>首页</li>
    <li>项目介绍</li>
    <li>关于我</li>
  <ul>
</template>
Vue.component('my-component-a', {
  template: '#my-template'
});

同理, 在局部注册组件的时候, 上述规则同样适用:

var MyComponentA = {
    template: '#myTemplate'
};
  
new Vue({
  el: '#app',
  components: {
    MyComponentA: MyComponentA
  }
})

最后上述的所有代码渲染出来结果都为如下:

Screen Shot 2019-03-05 at 10.24.38 AM

需要注意的是, 组件的模板内容的最外层只能有一个根元素. 意思就是:

这样写是有效的.

<div class="blog-post">
  <h3>{{ title }}</h3>
  <div v-html="content"></div>
</div>

这样是无效的.

<h3>{{ title }}</h3>
<div v-html="content"></div>

# props 属性

通常, 一个应用会以一棵嵌套的组件树的形式来组织. 你可能会有页头、侧边栏、内容区等组件,每个组件又包含了其它的像导航链接、博文之类的组件。

子组件经常需要从父组件获取数据. 例如, 我们有一个展示博文的组件. 它需要从父组件获得文章标题, 和文章内容. 如果没有这些内容那这个组件也就没法用.

但需要明确的是,组件实例的作用域是孤立的,也就是说子组件的模板和模块中是无法直接调用父组件的数据. 那这个时候 props 就起到了父子组件数据传递的桥梁作用. 我们通过 props 将父组件的数据传递给子组件.

在组件的选项对象中, 我们需要显示声明组件可以接收的数据. 最简单的做法是, 把组件可以接收的数据名称以字符串形式写到一个数组里.

例如:

Vue.component('blog-post', {
  props: ['title', 'content'],
  template: `
    <div>
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    </div>
  `
})

上面代码中, props 属性里声明了两个希望接收的数据: titlecontent. 这两个数据会从组件的父作用域传进来.

<div id="app">
    <blog-post 
      title="我的文章"
      content="这是我的文章内容..."
    ></blog-post>
</div>

父组件可以向子组件传递静态数据外, 也可以通过 v-bind 的方式将父组件实例的数据和方法传递给子组件.

例如:

new Vue({
    el: '#app',
    data: {
        title: "我的文章",
        content="这是我的文章内容..."
    }
})
<div id="app">
    <blog-post 
      :title="title"
      :content="content"
    ></blog-post>
</div>

# 类型验证, 设置默认值, 必须传入

除了以字符串数组形式列出的所有的 Prop.

但是,通常你希望每个 Prop 都有指定的值类型。这时,你可以以对象形式列出 prop. 每个 Prop 可以通过一个对象配置高级选项,如类型检测、设置默认值, 要求数据必须被传入.

拿上面的代码举例:

Vue.component('blog-post', {
  props: {
      title: {
          // 数据类型为 String
          type: String,
          // 默认值
          default: '未设置标题',
          // 必须被传入
          required: true,
      },
      content: {
          type: String,
          default: '未设置内容',
          required: true,
      }
  },
  template: `
    <div>
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    </div>
  `
})

当 prop 验证失败的时候,开发环境构建版本的 Vue 将会产生一个控制台的警告。

例如假如我们给组件的 title Prop 一个 Number 类型的值, 控制台就会出现如下警告:

[Vue warn]: Invalid prop: type check failed for prop "title". Expected String, got Number. (found in component )

# 子组件内别改 Prop 值

通过 Prop 我们把父组件的数据传到了子组件. 但要注意的是, 每次父级组件发生更新时,子组件中所有的 Prop 都将会更新为最新值。这意味着你不应该在一个子组件内部改变 Prop 的值。如果你这样做了,Vue 会在浏览器的控制台中发出警告。如果你希望传入的 Prop 可以作为组件内部的数据使用, 你可以把它的值赋给本地的 data 属性, 或 computed 属性.

例如:

props: ['initialCounter'],
data: function () {
  return {
    counter: this.initialCounter
  }
}

# 组件间通信

前面已经看到了用 props 可以实现从父组件向子组件传递数据. 但是 Vue 组件间通信的场景不知有这一种.

归纳起来可以分为如下三类:

  • 父子组件通信
  • 兄弟组件通信
  • 跨级组件通信

Screen Shot 2019-03-05 at 4.18.01 PM

通过 Prop 进行通信, 只能形成一个从父级到子级的 "单向数据流". 但是, 子级组件不可以向父级组件传数据. 这样做是为了防止从子组件意外改变父级组件的状态.

那下面就讲讲其他组件间通信方法.

# Prop 传递引用类型

虽然子组件不能向父组件传递数据, 但是父组件通过 Prop 可以向子组件传递引用类型的数据. 我们都知道引用类型数据传递的是指针而不是值. 子组件接收到的, 和父组件中的引用类型数据都指向同一块内存. 所以修改了子组件中 Prop 的数据即修改了父组件的数据。

那先看看在传递一个 "对象" (Object) 数据的情况下是如何进行的:

例如:

<div id="app">
    {{ dataObj.data }}
    <change-data :data-obj="dataObj"></change-data>
</div>
Vue.component('change-data', {
    props: ['dataObj'],
    methods: {
      changeData: function() {
        this.dataObj.data = 'O(∩_∩)O哈哈~'
      }
    },
    template: `<button @click="changeData">改变父组件数据</button>`
})
  
var example1 = new Vue({
  el: '#app',
  data: {
    dataObj: {
      data: '谁能改变我?'
    }
  }
})

效果如下:

2019-03-05 16-42-19.2019-03-05 16_42_50

我们还可以传递一个 "函数" 来实现子组件向父组件传递数据的目的:

例如:

<div id="app">
    {{ data }}
    <change-data :change-data="changeData"></change-data>
</div>
Vue.component('change-data', {
    props: ['changeData'],
    template: `
      <button @click="changeData('哈哈')">
        改变父组件数据为 '哈哈'
      </button>`
})
  
var example1 = new Vue({
  el: '#app',
  data: {
    data: '你好...'
  },
  methods: {
    changeData: function(newValue) {
      this.data = newValue;
    }
  }
})

定义在父组件的函数 changeData 内部通过 this 实现了对父组件作用域的引用. 这个函数传入到子组件后, 通过点击事件触发, 并向函数内传入了一个参数. 这个参数沿着作用域链改变了父组件作用域中的 data 属性的值.

虽然上面都实现了子组件向父组件传递数据的目的. 但请注意, 通过 props 传递引用类型值来修改父组件数据是不被推荐的. 因为这样做破坏了组件的独立性. 组件之间应当保持解耦的状态. 应当只通过特定的接口传递数据来达到修改数据的目的, 组件内部数据状态由本地的 data 负责管理. 如果在组件内部作用域去改变另一个组件内部的数据, 这种行为会使数据流变得混乱.

这一节只是希望大家能够对这种数据传递方法有个了解. 并不推荐在开发中使用. 

# 自定义事件

子组件向父组件传递数据推荐的做法是用 "自定义事件".

子组件内可以通过调用内建的 $emit 方法, 并传入事件名来触发一个自定义的事件. 之后在父组件内的子组件标签上用 v-on 来监听事件.

<div id="app" :style="{color: textColor}">
    {{ text }}
    <change-data
      @change-text-color="textColor = 'red'"
    ></change-data>
</div>
Vue.component('change-data', {
    template: `
      <button @click="$emit('change-text-color')">
        改变文本颜色
      </button>`
})
  
var example1 = new Vue({
  el: '#app',
  data: {
    text: '嘻嘻',
    textColor: 'green'
  }
})

上面代码, 我在 <change-data> 标签上绑定了一个针对于 change-text-color 事件的监听器. 当 <change-data> 组件中的按钮被点击的时候, 就会触发一个叫 change-text-color 的自定义事件. 事件触发监听器, Vue 根节点内的 textColor 属性值被改变.

# 事件抛出数据

在开发中, 很多时候我们需要让事件抛出一个特定的值. 我们可以使用 $emit 的第二个参数来提供这个值.

比如, 我们想让 change-text-color 这个事件触发的同时, 抛出一个指定颜色的字符串. 我们的代码可以这样写:

<button v-on:click="$emit('change-text-color', 'red')">
  改变字体颜色
</button>

当在父级组件监听这个事件的时候,我们可以通过 $event 访问到被抛出的这个值:

<div id="app" :style="{color: textColor}">
    {{ text }}
    <change-data
      @change-text-color="textColor = $event"
    ></change-data>
</div>

如果这个事件处理函数是一个方法, 那么这个值将会作为第一个参数传入这个方法.

<div id="app" :style="{color: textColor}">
    {{ text }}
    <change-data
      @change-text-color="changeColor"
    ></change-data>
</div>
methods: {
  changeColor: function (newColor) {
    this.color = newColor;
  }
}

# v-model

# 中央事件总线 (bus)

上述的通信方式都只适合父子组件之间通信. 而实际业务中, 兄弟组件, 跨多级组件之间通信的需求也是很常见的. 虽说用上述所讲的方法, 也是可以实现这些需求的. 但是会让数据流变得混乱, 不易管理, 代码逻辑也会变得非常复杂.

Vue 推荐使用一个空的 Vue 实例作为 "中央事件总线 (bus)". 各个组件之间可以通过这个 Vue 实例来触发事件和监听事件.

你可以把它理解成一个 "房产中介". "买房者" 找中介去登记他想要的房屋信息, "卖房者" 找中介去登记要卖的房屋信息. 中介在这些信息中去进行匹配, 当合适的房屋出现后, 中介就会通知 "买房者", 并发给他房子的具体信息. 在这个过程中, "买房者" 和 "卖房者" 并不做接触. "买房者" 和 "卖房者" 就是两个组件, "房产中介" 就是这个中央事件总线 (bus).

具体使用实例如下:

<div id="app">
    <component-a></component-a>
    <component-b></component-b>
</div>

// 创建 bus 实例
const bus = new Vue();

Vue.component('component-a', {
    data: function() {
      return {
          text: '等待输入中...'
      }
    },
    template: `
        <div>{{ text }}</div>
    `,
    mounted: function() {
        const that = this;
        // 监听事件
        bus.$on('changeText', function(text) {
            that.text = text;
        })
    }
})

Vue.component('component-b', {
    data: function() {
      return {
          inputText: ""
      }
    },
    methods: {
        changeText: function() {
            // 触发事件
            bus.$emit('changeText', this.inputText);
        }
    },
    template: `
        <div>
            <input type="text" v-model="inputText" @input="changeText" />
        </div>
    `,
});

最终效果如下:

2019-03-05 23-26-22.2019-03-05 23_26_35

上面示例中, <component-a><component-b> 是兄弟组件. 在 <component-a> 组件的选项对象中定义了 mounted 钩子函数, 使其在组件初始化阶段监听来自 bus 的 changeText 事件. <component-b> 会在 input 事件触发时通过 bus 触发 changeText 事件, 并把组件内的 inputText 属性的值一同发送出去. 此时, <component-a> 接收到了事件, 和随同的数据, 并将数据赋值给组件内的 text 属性.

# 内容分发

前面的例子中我们都是单个使用地组件. 我们希望组件能像 HTML 元素一样,可以插入其他内容, 可以互相嵌套. 就像这样:

<component-a>
  <component-b>
    哈哈...
  </component-b>
</component-a>

这可以通过 Vue 自定义的 <slot> 元素实现, 也叫做 "插槽". 当组件渲染的时候, 子组件内的 <slot> 元素会被替换成外部插入的内容. 这使得我们在使用组件时, 可以即使用它的结构, 也能向其内部填充不同的内容.

# 基础用法

例如:

假如一个叫 <alert-box> 的组件模板如下:

<div style="{color: red}">
    警告: <slot></slot> !!!
</div>

在使用这个组件的时候, 我们可以向其中插入内容:

<alert-box>
    你的账号在异地登录
<alert-box>

最后渲染的时候, <slot> 就会被替换成 "你的账号在异地登录". 同样插槽内也可以包含任何模板代码, 或其他组件. 例如:

<alert-box>
    <span>你的账号在异地登录</span>
<alert-box>
<alert-box>
    <alert-message>你的账号在异地登录</alert-message>
<alert-box>

如果你想在 <slot> 中使用数据的话, 请注意作用域问题. 请记住, 父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的

例如:

<navigation-link url="/profile">
  Logged in as {{ user.name }}
</navigation-link>

当在模板中使用 <navigation-link> 组件的时候, 可以向内插入的数据只能是当前作用域内的数据. <navigation-link> 组件内定义的数据, 是不可以访问的.

# 默认内容

如果一个插槽没有接受到任何内容时, 我们希望它自己能够有一个默认的后备内容. 那我们可以直接把它这个内容写在 <slot> 标签内.

例如:

<button type="submit">
  <slot>Submit</slot>
</button>

这个组件内的 <button> 在没有接收到外部插入的任何内容时, 会将 "Submit" 做为默认内容. 那如果有内容插入的话, 后备内容就会被替代了.

# 具名插槽

在前面的例子中, 我们只在组件内使用了一个 <slot> 元素. 但根据开发需要, 有时候我们会需要多个插槽, 以来将外部传入的多个内容, 安插到组件内不同的地方. 那对于这样的情况, 我们可以为 <slot> 元素定义 name 属性. 这被称为 "具名插槽".

例如对于一个带有如下模板的 <base-layout> 组件:

<div class="container">
  <header>
    <!-- 我们希望把页头放这里 -->
    <slot name="header"></slot>
  </header>
  <main>
    <!-- 我们希望把主要内容放这里 -->
    <slot></slot>
  </main>
  <footer>
    <!-- 我们希望把页脚放这里 -->
    <slot name="footer"></slot>
  </footer>
</div>

在向具名插槽提供内容的时候,我们可以在一个 <template> 元素上使用 v-slot 指令,并以 v-slot 的参数的形式提供其名称. 没有指定 name 属性的 <slot> 元素被作为默认插槽, 接收所有没有被包裹在带有 v-slot<template> 中的内容.

<base-layout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>

  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template v-slot:footer>
    <p>Here's some contact info</p>
  </template>
</base-layout>

现在 <template> 元素中的所有内容都将会被传入相应的插槽。任何没有被包裹在带有 v-slot<template> 中的内容都会被视为默认插槽的内容。

# 动态组件

"动态组件" 指的是多个组件同时使用一个挂载点, 然后根据指令动态切换.

我们可以通过使用 <component> 元素,动态地将想要的组件名绑定到它的 is 特性,可以实现动态组件.

例如:

<div id="app">
    <template v-for="(name, index) in componentList">
        <button @click="changeIndex(index)">{{ name }}</button>
    </template>
    // componet 组件会被渲染成 is 属性上对应的组件名
    <component :is="componentList[currentIndex]"></component>
</div>
Vue.component('component-a', {
    template: `
        <div>
            我是组件 A
        </div>
    `
})

Vue.component('component-b', {
    template: `
        <div>
            我是组件 B
        </div>
    `,
});

new Vue({
  el: "#app",
  data: {
      currentIndex: 0,
      componentList: ['component-a', 'component-b'],
  },
  methods: {
     changeIndex: function(index) {
         this.currentIndex = index;
     }
  }
})

效果如下:

2019-03-06 10-04-48.2019-03-06 10_04_55

# keep-alive

当这些组件之间切换的时候, 有时候你会希望被切换出去的组件不要被销毁, 希望组件实例能够被在它们第一次被创建的时候缓存下来. 以避免反复重渲染导致的性能问题。

为了解决这个问题,我们可以用一个 <keep-alive> 元素将其动态组件包裹起来。

例如:

<!-- 失活的组件将会被缓存!-->
<keep-alive>
  <component v-bind:is="currentTabComponent"></component>
</keep-alive>
上次更新: 7/4/2020, 4:14:54 AM