# 第4章 Vue 组件的使用

上一章我们讲到,一个 Vue 应用由根实例以及组件树(可复用的 Vue 实例)组成。我们讲了单个的 Vue 实例,本章我们来介绍多个 Vue 实例(组件)的组成——组件系统。

# 4.1 组件系统

关于组件的划分,不同的人有不同的方式。而组件系统是一种抽象,允许我们使用小型、独立和通常可复用的组件构建大型应用。

# 4.1.1 什么是组件

简单来说,组件可以扩展 HTML 元素,封装可重用的代码

<!--长这样-->
<my-component></my-component>

别看它什么都没有,这只是我们最后使用的样子,逻辑都封装在组件里面了。一个组件,它的呈现可能会千奇百怪,因为不管怎么说,组件虽然可视为个体或是实例,但也是一种抽象。

image
图 4-1 一只猫也可以是一个组件

正如图 4-1 中的这只猫,它可以拖动,也可以鼠标悬浮弹出一个提示框,还可以双击或长按让它消失,这些逻辑也都封装在组件里,最终我们使用的时候,只需要引入组件,然后在页面里插入<Kitty></Kitty>这样一个组件就可以了。

# 合理的抽象

几乎任意类型的应用界面都可以抽象为一个组件树,例如 Github 上 Vue 主页,我们能看到页面能划分成一块块的内容块,其中有些也可以看作组件:
image
图 4-2 Vue Github 主页

一般来说,这样的一个管理页面,我们可以抽象成这样的组件树:
image
图 4-3 Vue Github 主页组件树

以代码的方式来表达这样的组件树,通常的代码呈现是:

<div id="app">
  <app-header>
    <header-search></header-search>
    <header-nav></header-nav>
    <header-aside></header-aside>
  </app-header>
  <app-view>
    <group-info></group-info>
    <app-tab></app-tab>
    <app-tab-container>
      <project-card></project-card>
      <card-list></card-list>
    </app-tab-container>
  </app-view>
</div>

其实这个页面抽象的方法很多,在不同的开发者眼里,可以完全抽象成不同的树状。我们在实际开发中,具体要以怎样的逻辑或者思维去进行抽象,这些内容会在后面详述。这里我们就直接讲一些和组件相关的基础内容。

# 树状的组件管理

上面我们看到,几乎所有的页面都可以抽象成组件树,但为什么是树状呢?因为我们的页面组成、DOM 节点、HTML 元素也都是树状结构的,组件说白了也就是将一部分的 HTML+CSS+Javascript 代码组合成一个可复用、更简洁表达的代码片段,因此最终呈现也是树状结构的。

树状结构是组织和管理中十分快捷、方便又清晰的结构,我们的文件管理、思维导图等也都是基于树状结构来进行的。第1章中有讲,AngularJS 最初的 watch 机制是环状的,导致了一些性能问题,在升级成 Angular 之后,也调整为树状结构,性能也上去了。组件系统使用这样的方式,可以由上到下的方式来管理,不管是事件的通知、数据的检测、父子组件的通信等,都会更高效。

讲了这么多,我们来看一下组件的定义和使用方式。

# 4.2 组件使用

组件是可复用的 Vue 实例,所以它们与new Vue()接收相同的选项,例如datacomputedwatchmethods以及生命周期钩子等(生命周期一致,可以参考第3章中 Vue 实例的生命周期)。除了el这种是根实例特有的选项。

# 4.2.1 注册方式

为了能在模板中使用,这些组件必须先注册以便 Vue 能够识别。Vue 中有两种组件的注册类型:全局注册和局部注册。

# 组件名

HTML 中的特性名是大小写不敏感的,所以浏览器会把所有大写字符解释为小写字符。在 Vue 中,组件命名有两种方式:
(1) 使用短横线分隔(kebab-case)命名。当使用 kebab-case 定义一个组件时,你也必须在引用这个自定义元素时使用 kebab-case,如<my-component></my-component>
(2) 使用大驼峰(PascalCase)。当使用 PascalCase 定义一个组件时,既可以使用 PascalCase 也可以使用 kebab-case 来引用,像<my-component></my-component><MyComponent></MyComponent>

# 全局注册

全局注册可以通过Vue.component()的方式进行,该方法第一个参数要传入组件的名称,第二个参数传入该组件的选项:

Vue.component("my-button", {
  // 选项
  // 除了 el 以外,组件的选项与 Vue 实例相同
});

# 局部注册

局部注册可通过在实例中的components选项进行配置:

// 获取组件
import MyButton from "../components/my-button";

new Vue({
  components: { MyButton }
});

而以这种方式使用组件的时候,则需要在组件里通过name选项进行命名:

// my-button.vue
new Vue({
  name: "my-button"
});

这种方式定义的组件,如果也进行了全局注册,其命名会以全局注册的名字为准,也就是全局注册的命名优先级更高。

# 4.2.2 单文件组件

前面讲到,一个组件是一些逻辑和功能完整的代码片段组成的,同时也包括了 HTML、CSS 和 Javascript 的代码。在 Vue 里,我们常常使用单文件组件,使用.vue 后缀命名的文件,一般也包括这三部分:

<template>
  <!-- 组件模板 -->
</template>

<script>
  // 组件逻辑
  // 在.vue文件中,需要默认export一个Vue实例
  export default {
    name: "MyComponent"
  };
</script>

<style>
  /* 组件样式 */
</style>

通过这种方式,我们可以更方便地在项目中管理组件和文件。这里的样式,我们还可以通过添加scoped属性的方式<style scoped>,来增加组件的样式作用域。具体的实现是,通过使用 PostCSS 来实现以下转换:

<template>
  <div class="example">hi</div>
</template>

<style scoped>
  .example {
    color: red;
  }
</style>

<!-- 会转换成以下 -->
<!-- 通过增加局部随机属性,来匹配到具体的组件 -->
<template>
  <div class="example" data-v-f3f3eg9>hi</div>
</template>

<style>
  .example[data-v-f3f3eg9] {
    color: red;
  }
</style>

除了scoped之外,前面我们也讲到官方的脚手架 Vue CLI 中内置了 CSS Loader 一大堆,包括 extract-css-loader、vue-style-loader、css-loader、cssnano、postcss-loader、sass-loader、less-loader 等。所以我们在使用官方脚手架的时候,也可以尽情地使用这些 CSS 预处理器,使用 lang 属性就可以:<style lang="scss"></style>

单文件组件是 Vue 里推荐的使用方式,如果开发者不习惯在一个页面中同时维护着 HTML、CSS 和 Javascript 的话,也可以通过 src 属性等方式来引入:

<!-- my-component.vue -->
<template>
  <div>This will be pre-compiled</div>
</template>
<script src="./my-component.js"></script>
<style src="./my-component.css"></style>

# 4.3 组件间通信

第3章中我们介绍了 Vue 实例常用的选项和模板语法,而 Vue 组件其实也是 Vue 实例,但组件系统由于树状结构而构成的父子组件等关系,则是单个 Vue 实例中没有的。因此,这里我们重点介绍一下 Vue 组件间的数据传递和通信等。

# 4.3.1 Prop 数据传递

大多数情况下,我们会将部分的代码抽象成组件,是因为该部分的内容在别处也有使用到,而抽象成组件,可以提供更简单的复用方式。例如常用的按钮,我们可以对它进行封装。在 Vue 实例的选项中,我们可以用一个props选项将其包含在该组件可接受的 prop 列表中:

<template>
  <button
    class="my-button"
    @click="handleClick"
    :disabled="disabled || loading"
    :type="type"
    :class="[
      type ? 'my-button--' + type : '',
      buttonSize ? 'my-button--' + buttonSize : '',
      {
        'is-disabled': disabled,
        'is-loading': loading
      }
    ]"
  >
    <i class="el-icon-loading" v-if="loading"></i>
    <i :class="icon" v-if="icon && !loading"></i>
    <span v-if="$slots.default"><slot></slot></span>
  </button>
</template>
<script>
  export default {
    name: "MyButton",
    props: {
      // 按钮类型,如info、warn、error等
      type: {
        type: String,
        default: "default"
      },
      // icon类型,匹配样式
      icon: {
        type: String,
        default: ""
      },
      loading: Boolean, // 是否在加载中
      disabled: Boolean // 是否不可用
    },

    methods: {
      // 点击触发click事件
      handleClick(evt) {
        this.$emit("click", evt);
      }
    }
  };
</script>

可以看到,该组件封装了按钮类型、图标和状态,通过 prop 提供给外部配置使用。Prop 是我们可以在组件上注册的一些自定义特性,常常用于接收来自父组件的数据/属性值,我们可以直接在需要的地方使用:

<my-button>原按钮</my-button>
<!-- 可以像这样给 prop 传入一个静态的值 -->
<my-button type="info" icon="config">提示样式按钮(带管理图标)</my-button>
<!-- 也可以通过 v-bind 动态赋值 -->
<my-button type="info" :loading="true">提示样式按钮(加载中)</my-button>
<my-button type="error" :disabled="true">错误样式按钮(不可用)</my-button>

一个组件默认可以拥有任意数量的 prop,任何值都可以传递给任何 prop。

# Prop 命名

前面我们讲过在 Vue 中组件的命名,包括短横线分隔(kebab-case)和大驼峰(PascalCase)。我们知道,HTML 中的特性名是大小写不敏感的,因此 PascalCase 的 prop 名需要使用其等价的 kebab-case 来使用:

Vue.component("my-component", {
  // 在 JavaScript 中是 camelCase 的
  props: ["myMessage"],
  template: "<p>{{ myMessage }}</p>"
});
<!-- 在 HTML 中是 kebab-case 的 -->
<my-component my-message="test"></my-component>

# Prop 类型

props可以是简单的数组,或者使用对象作为替代,对象允许配置高级选项,如类型检测、自定义验证和设置默认值等等:

表 4-1 props高级选项

props高级选项 作用 说明
type 会检查该 prop 是否是给定的类型,否则抛出警告 可以是下列原生构造函数中的一种:StringNumberBooleanArrayObjectDateFunctionSymbol、任何自定义构造函数、或上述内容组成的数组
default 为该 prop 指定一个默认值 如果该 prop 没有被传入,则换做用这个值,对象或数组的默认值必须从一个工厂函数返回
required 定义该 prop 是否是必填项,默认为false 在非生产环境中,如果这个值为true且该prop没有被传入的,则一个控制台警告将会被抛出
validator 自定义验证函数会将该 prop 的值作为唯一的参数代入 在非生产环境下,如果该函数返回一个false的值 (也就是验证失败),一个控制台警告将会被抛出

我们来直接看看代码更直观:

Vue.component("my-component", {
  // 简单的数组
  props: ["propA", "propB", "propC", "propD", "propE", "propF"],
  // 高级配置
  props: {
    // 基础的类型检查 (`null` 和 `undefined` 会通过任何类型验证)
    propA: Number,
    // 多个可能的类型
    propB: [String, Number],
    // 必填的字符串
    propC: {
      type: String,
      required: true
    },
    // 带有默认值的数字
    propD: {
      type: Number,
      default: 100
    },
    // 带有默认值的对象
    propE: {
      type: Object,
      // 对象或数组默认值必须从一个工厂函数获取
      default: function() {
        return { message: "hello" };
      }
    },
    // 自定义验证函数
    propF: {
      validator: function(value) {
        // 这个值必须匹配下列字符串中的一个
        // 当 prop 验证失败的时候,(开发环境构建版本的) Vue 将会产生一个控制台的警告。
        return ["success", "warning", "danger"].indexOf(value) !== -1;
      }
    }
  }
});

# 4.3.2 父子组件通信

所有的 prop 都使得其父子 prop 之间形成了一个单向下行绑定:父级 prop 的更新会向下流动到子组件中,但是反过来则不行。除了父组件给子组件传递数据之外,有时候我们也需要在子组件中和父级组件进行沟通。在 Vue 中,父级组件可以像处理原生的 DOM 事件一样通过v-on监听子组件实例的任意事件:

<my-button @click="handleClick">自定义按钮</my-button>

# 自定义事件

上一章我们在介绍 Vue 常用的模板语法时,也讲到了事件绑定。除了原生的 DOM 事件之外,Vue 里还提供了自定义事件系统。子组件可以通过调用内建的$emit方法,并传入事件名称来触发一个事件:

<button v-on:click="$emit('test')">test</button>
<!-- $emit 可以通过参数传值 -->
<button v-on:click="$emit('plus', 1)">Plus One</button>

这时候,父组件可以通过v-on监听(参数值将会作为参数传入这个方法):

<my-button @plus="handlePlus">自定义按钮</my-button>

<script>
  export default {
    methods: {
      handlePlus(num) {
        this.menberNum += num;
      }
    }
  };
</script>

通过 prop 和自定义事件,已经可以实现了父子组件间的双向通信了。而其实在 Vue 中还可以有更多的组件间、全局的通信方式,会在后面章节中阐述。

# 4.3.3 slot 插槽

前面我们在自定义<my-button>组件中,有使用到 slot 这个 DOM 元素。Vue 实现了一套内容分发的 API,将<slot>元素作为承载分发内容的出口。这个自定义的<slot>元素,可以让我们向一个组件传递自定义的内容。

还是回到<my-button>组件:

<template>
  <button class="my-button">
    <i class="el-icon-loading" v-if="loading"></i>
    <i :class="icon" v-if="icon && !loading"></i>
    <span>
      <slot></slot>
    </span>
  </button>
</template>

我们可以给该组件添加内容,添加的内容会替换<slot></slot>

<my-button>按钮</my-button>
<!-- 最终会变成这样 -->
<button><span>按钮</span></button>

<my-button><a>test</a>按钮</my-button>
<!-- 最终会变成这样 -->
<button><span><a>test</a>按钮<span></button>

<my-button loading><a>test</a>按钮</my-button>
<!-- 最终会变成这样 -->
<button><i class="el-icon-loading"></i><span><a>test</a>按钮</span></button>

# 默认内容

我们可以在<slot></slot>元素里,为一个插槽设置具体的后备 (也就是默认的) 内容:

<template>
  <button class="my-button">
    <slot>button</slot>
  </button>
</template>

我们在使用的时候,会这样渲染:

<my-button>按钮</my-button>
<!-- 最终会变成这样 -->
<button>按钮</button>

<my-button></my-button>
<!-- 在没有提供内容的时候被渲染 -->
<!-- 最终会变成这样 -->
<button>button</button>

# 具名插槽

有时我们需要多个插槽,<slot>元素可以通过 name 特性来定义额外的插槽。例如我们封装了一个弹窗组件:

<!-- my-dialog.vue -->
<div class="dialog-container">
  <header>
    <!-- 弹窗头部内容 -->
    <slot name="header">提示</slot>
  </header>
  <main>
    <!-- 弹窗主要内容 -->
    <!-- 一个不带 name 的 <slot> 出口会带有隐含的名字“default” -->
    <!-- 相当于 <slot name="default"></slot> -->
    <slot></slot>
  </main>
  <footer>
    <!-- 弹窗底部内容,通常是按钮 -->
    <slot name="footer">
      <!-- 设置了默认内容 -->
      <button @click="$emit('confirm')">确定</button>
    </slot>
  </footer>
</div>

使用的时候,我们可以在一个<template>元素上使用v-slot指令(注意v-slot只能添加在一个<template>上,只有默认插槽时,组件的标签才可以被当作插槽的模板来使用):

<my-dialog>确认要关闭吗?</my-dialog>
<!-- 任何没有被包裹在带有 v-slot 的 <template> 中的内容都会被视为默认插槽的内容 -->
<!-- 最终会变成这样 -->
<div class="dialog-container">
  <header>提示</header>
  <main>确认要关闭吗?</main>
  <footer>
    <button @click="$emit('confirm')">确定</button>
  </footer>
</div>

<my-dialog>
  <template v-slot:header>请确认</template>
  <template>确认要关闭吗?</template>
  <template v-slot:footer>
    <button>确定</button>
    <button>取消</button>
  </template>
</my-dialog>
<!-- 最终会变成这样 -->
<div class="dialog-container">
  <header>请确认</header>
  <main>确认要关闭吗?</main>
  <footer>
    <button>确定</button>
    <button>取消</button>
  </footer>
</div>

v-slot也有缩写,即把参数之前的所有内容 (v-slot:) 替换为字符#

<my-dialog>
  <template v-slot:header>请确认</template>
  <template>确认要关闭吗?</template>
  <template v-slot:footer>
    <button>确定</button>
    <button>取消</button>
  </template>
</my-dialog>
<!-- 可以这么进行缩写 -->
<my-dialog>
  <template #header>请确认</template>
  <template>确认要关闭吗?</template>
  <template #footer>
    <button>确定</button>
    <button>取消</button>
  </template>
</my-dialog>

以上内容是 Vue 中组件的基础内容,而在真实工作中,通常烦扰我们的不是一些基本语法的使用,而是如何进行组件的划分。关于组件的划分,我们来介绍一些好用的经验吧。

# 4.4 组件的自我修养

组件的划分通常分成两个方式:
(1) 【设计上】视觉和交互上是一个完整的组件。
(2) 【实现上】写代码的时候,可重复的内容即可视为一个组件。

# 4.4.1 通过视觉和交互划分

第一个角度,(设计上)视觉和交互上是一个完整的组件

通常来说,组件的划分,与视觉、交互等密切相关,我们可通过功能、独立性来判断是否适合作为一个组件。这里挑了个视频网站的样式,如图 4-4:

image
图 4-4 某视频网站首页组件划分方式一

我们第一眼看上去可区分出独立的视图和功能的,则可以列为这种组件的划分。或许也可以成为区块,是为视觉和交互上完整的组件。其实这样的划分,也是需要不断地在实现过程中思考和总结。而通常情况下,设计、交互以及各种开发,都能将这样的一块内容划分出来,我们可以作为一张带图片、标题和描述的卡片来看待。

如果用代码表示,这样一张卡片的使用应该是:

<card url="xxx" title="喵喵喵" text="喵喵喵喵喵" info="58:43"></card>

# 4.4.2 通过代码复用划分

第二个角度,(实现上)写代码的时候,可重复的内容即可视为一个组件。还是这个页面,我们看:

image
图 4-5 某视频网站首页组件划分方式二

这里,我们能看到这种卡片形式的内容,存在页面中的各个地方。而框住的这一整块内容,在代码实现上其实是一致的,所以我们也可以同样将他们划分为一个组件。这样的话,用代码表示应该是:

<card-container title="你喜欢的类型,这里都有">
  <card
    v-for="card in cardList1"
    :url="card.url"
    :title="card.title"
    :text="card.text"
    :info="card.info"
  ></card>
</card-container>
<card-container title="你不喜欢的类型,这里也有">
  <card
    v-for="card in cardList2"
    :url="card.url"
    :title="card.title"
    :text="card.text"
    :info="card.info"
  ></card>
</card-container>

这种情况下,我们将卡片封装成一个简单的组件,包括图片+文字描述。同时,也将有重复的部分代码,我们将它抽象成一个<card-container>的组件。这种方式的组件划分,在理解上或许没有从业务上划分的直观,会有人觉得这里就一行字,为什么我还得封装成一个组件呢?我完全可用这么写:

<p>你喜欢的类型,这里都有</p>
<card
  v-for="card in cardList1"
  :url="card.url"
  :title="card.title"
  :text="card.text"
  :info="card.info"
></card>

<p>你不喜欢的类型,这里也有</p>
<card
  v-for="card in cardList2"
  :url="card.url"
  :title="card.title"
  :text="card.text"
  :info="card.info"
></card>

的确这样写也是很简单的,而且还减少了一个组件的维护。至于到哪种程度才需要单独封装一个组件呢,这需要你不断地探索和实践,同时也需要和合作伙伴一起商量讨论。很多时候,我们要正确地做好抽象和组件划分,也少不了设计同学的配合。

怎样才能算是一个合格的组件呢?我们在设计的时候,经常要考虑解耦,但很多时候,过度的解耦反而会导致项目复杂度变高,维护性降低。总的来看,使用设计角度或者实现角度来进行封装,哪种方式效率高一些也都不好说。(温馨提示:在一个团队内,最好是使用一种方式来进行划分。因为对于成员的相互配合和项目的维护来说,统一的规范是比较重要的。)

# 4.4.3 组件的封装

个人认为,一个称职的组件,是以下形式的:

表 4-2 怎样才算一个称职的组件

能力描述 Vue 中对应属性
组件内维护自身的数据和状态 data
组件内维护自身的事件 methods
通过提供配置的方式,来控制展示,或者控制执行逻辑 props
通过一定的方式(事件触发/监听、API 提供),提供与外界(如父组件)通信的方式 $emit$onref

笼统地概括下,就是我们尽量需要把组件进行隔离,拥有独立的个体空间,同时保持与外界适当的联系。

# 组件内维护自身的数据和状态

这个比较好理解,以这样一个视频的小卡片为例子:

image
图 4-6 视频小卡片

这个小卡片,它维护着自己的数据:封面图、描述、头像、作者。还有一个初始的状态,就是目前我们看到的样子。这些内容保存在组件自己的 scope 里,每个卡片组件都拥有自己的数据和状态。

# 组件内维护自身的事件

我们在把鼠标放在卡片上,随着鼠标的位置,顶部会有个小小的进度条,同时封面图会改变,如图:

image
图 4-7 视频小卡片进度条改变

每个小卡片都有自己 mousemove 事件。当然,这里面维护了一个鼠标位置的状态,同时根据鼠标位置来控制图片的展示。

# 通过初始化事件,来初始化组件状态,激活组件

组件的数据,大多数需要外界提供并传入,故可通过初始化事件来激活。

# 对外提供配置项,来控制展示以及具体功能

可以通过配置的方式,来控制组件的展示和功能。

我们看这个小卡片:
image
图 4-8 另一种视频小卡片

和上面的不一样,左下角展示的是视频时长,而不是头像和名字,这种我们可以通过配置的方式来控制。

# 通过对外提供查询接口,可获取组件状态

很多时候,组件独立维护着自身的数据和状态,但有些场景下,父组件或者应用需要知道组件当前的状态,故我们需要对外提供接口,以供查询。像 Vue 中,便可以通过vm.$refs来获取子组件的实例。

# 4.4.4 独立的组件

组件的独立性,可以包括以下几个方面:

  • 维护自身的数据和状态、作用域
  • 维护自身的事件

拿一个常见的内容卡片来看:

image
图 4-9 内容小卡片

这是个独立的卡片,它拥有以下的数据、状态和事件:
(1) 内容数据,包括标题、文字、图片、点赞数、评论数、日期等。
(2) 状态数据,是否已点赞、是否已收藏、是否详细展示内容、是否展示评论等。
(3) 事件,包括点击分享、收藏、点赞、回复等等。

<template>
  <div>
    <h2>{{cardInfo.question}}</h2>
    <div :class="isContextShown ? 'content-detail' : 'content-brief'">
      <div v-if="cardInfo.withImage"><img :url="cardInfo.imageUrl" /></div>
      <div>{{cardInfo.content}}</div>
    </div>
    <div>
      <span @click="likeIt()">点赞</span>
      <span @click="keepIt()">收藏</span>
    </div>
  </div>
</template>
<script>
  export default {
    name: "my-card",
    props: {
      // 传入卡片信息
      cardInfo: {
        type: Object,
        default: () => {}
      }
    },
    data() {
      return {
        isContextShown: false // 是否省略
      };
    },
    methods: {
      likeIt() {}, // 点赞
      keepIt() {} // 收藏
    },
    mounted() {}
  };
</script>

简化后大概就是这样一个组件。我们来看看数据这部分,正如前面说过组件和数据抽离,那么我们可以获取到两部分的内容:
(1) 通过props从外部注入的数据,例如这个卡片的问题、回答、是否带图片、是否已收藏、是否已点赞等等。
(2) 组件自己维护的数据data,例如这个卡片是被展开还是被省略。

两部分的数据划分,区别常常在于这个状态是否依赖外界,或是与外界有联系。如果我们一整个页面里有很多的卡片,但同时只允许展开一个,这种情况下我们可以选择把isContextShown也放到props里,通过外部控制。

# 4.4.5 组件与外界

我们在保持组件独立性的时候,当然还需要考虑它与外界的交互,主要包括两个方面。

(1) 对外提供配置项,来控制展示以及具体功能。

这里最简单的,我们每个卡片都需要传入内容,我们一次性拉取列表数据,并传入每个卡片,在 Vue 中可以使用 props。

(2) 对外提供查询接口,可从外界获取组件事件和状态。

这个的话,更多时候我们是通过事件等方式来告诉外界一些事情。我们前面也做了个假设,一个页面只允许一个卡片内容处于详细展开状态,除了把isContextShown放到外部控制的方法之外,我们还需要通过事件通知外部该状态的变更:

<template>
  <div>
    <h2>{{cardInfo.question}}</h2>
    <div
      @click="toggleContext()"
      :class="cardInfo.isContextShown ? 'content-detail' : 'content-brief'"
    >
      <div v-if="cardInfo.withImage"><img :url="cardInfo.imageUrl" /></div>
      <div>{{cardInfo.content}}</div>
    </div>
    <div>
      <span @click="likeIt()">点赞</span>
      <span @click="keepIt()">收藏</span>
    </div>
  </div>
</template>
<script>
  export default {
    name: "my-card",
    props: {
      // (1) 对外提供配置项,来控制展示以及具体功能
      cardInfo: {
        type: Object,
        default: () => {}
      }
    },
    methods: {
      likeIt() {},
      keepIt() {},
      toggleContext() {
        // (2) 通过事件通知,通知外界状态变更,以及最新的状态
        this.$emit("toggle", this.cardInfo);
      }
    },
    mounted() {}
  };
</script>

简单调整之后,我们会这样使用:

<template>
  <my-card
    v-for="card in cardList"
    :cardInfo="card"
    @toggle="toggleCard"
  ></my-card>
</template>
<script>
  export default {
    data() {
      return {
        cardList: [
          // ...此处省略
        ]
      };
    },
    methods: {
      toggleCard($event) {
        const cardInfo = $event.target.value;
        if (cardInfo.isContextShown) {
          // 如果该卡片原先未省略状态,展开后把其他卡片也省略
          this.cardList = this.cardList.map(card => {
            return {
              ...card,
              isContextShown: cardInfo.id === card.id
            };
          });
        } else {
          // 如果该卡片原先展开状态,则把该卡片省略就可以
          const cardIndex = this.cardList.findIndex(x => x.id === cardInfo.id);
          // Vue不能检测以下数组的变动,故需要使用Vue.set
          Vue.set(this.cardList, cardIndex, {
            ...cardInfo,
            isContextShown: false
          });
        }
      }
    }
  };
</script>

这是最简单的对内和对外的联系,对一个组件来说,它也有 in 和 out 两个方向的流动。在 Vue 里,如果父组件需要获取子组件的实例,也可以通过vm.$refs来获取。

以上,结束了本章 Vue 组件的内容,包括基本语法、API,还有常见的组件封装原则和划分方式。当然,这些内容并不是一定的,书中的内容永远是死的,而我们面临的问题往往却是奇形怪状的,但是思考依然是一刻不能少。所以,本章内容更重要的是给大家一个参考的逻辑思维,思考和总结才是不断进步的方式。