五、Vue.js 组件的安全通信

在注意到现代 web 应用中的组件驱动体系结构之前,您不需要看太远。开发需求在短时间内发生了变化,web 从一个简单的文档查看器转变为托管具有大量代码库的复杂应用。因此,创建可重用组件的能力使我们作为前端开发人员的生活更加轻松,因为我们可以将核心功能封装到单个块中,从而降低总体复杂性,更好地分离关注点、协作和可伸缩性。

在本章中,我们将学习前面的概念,并将其应用到我们的 Vue 应用中。在本章结束时,您将实现:

  • 创建自己的 Vue 组件的能力
  • 更好地理解单个文件组件
  • 创建特定于每个组件的样式的能力
  • 能够在本地和全局注册组件,并理解为什么选择一个而不是另一个
  • 使用道具在父组件和子组件之间进行通信的能力
  • 使用全局事件总线跨应用进行通信的能力
  • 使用插槽使组件更加灵活的能力

让我们先看看您的第一个 Vue 组件。

您的第一个 Vue 组件

事实证明,我们一直在 Vue 应用内部使用组件!使用webpack-simple模板,我们支持单文件组件SFC),它本质上只是一个模板、脚本和带有.vue扩展名的样式标签:

# Create a new Vue project
$ vue init webpack-simple vue-component-1

# Navigate to directory
$ cd vue-component-1

# Install dependencies
$ npm install

# Run application
$ npm run dev

当我们为 VisualStudio 代码使用 Vetur 扩展时,我们可以键入scaffold并点击选项卡,然后创建一个可在项目内部使用的 SFC。如果我们用空组件覆盖App.vue,根据我们当前的定义,它将如下所示:

就这样!某种程度上。我们仍然需要向组件添加一些功能,如果我们正在创建一个新文件(即,不使用默认的App.vue组件),请在某个地方注册它以供使用。让我们通过在src/components/FancyButton.vue下创建一个新文件来了解这一点:

<template>
 <button>
  {{buttonText}}
 </button>
</template>

<script>
export default {
 data() {
  return {
   buttonText: 'Hello World!'
  }
 }
}
</script>

<style>
 button {
  border: 1px solid black;
  padding: 10px;
 }
</style>

我们的FancyButton组件只是一个按钮,上面写着'Hello World!'并且有一点样式。立即,我们需要考虑我们可以做些什么,使其更具可扩展性:

  • 允许在此组件上输入以更改按钮文本
  • 当我们设计button元素的样式时(或者即使我们添加了类),我们需要一种方法来阻止样式泄漏到应用的其余部分
  • 注册此组件,以便在整个应用中全局使用它
  • 注册此组件,以便它可以在组件中本地使用
  • 还有更多!

让我们从最简单的一个开始,注册组件以便在应用中使用。

全球注册组件

我们可以创建组件,并通过以下接口进行全局注册:Vue.component(name: string, options: Object<VueInstance>)。虽然这不是必需的,但在命名组件时,遵守 W3C 自定义元素规范(中的命名约定非常重要 https://www.w3.org/TR/custom-elements/#valid-自定义元素名称,即所有小写字母,且必须包含连字符。

在我们的main.js文件中,我们先从适当的路径导入FancyButton组件,以注册它:

import FancyButton from './components/FancyButton.vue';

之后,我们可以使用Vue.component注册组件,可以用粗体显示,在main.js中生成的代码如下:

import Vue from 'vue';
import App from './App.vue';
import FancyButton from './components/FancyButton.vue';

Vue.component('fancy-button', FancyButton);

new Vue({
  el: '#app',
  render: h => h(App)
});

塔达!我们的组件现已在全球注册。现在我们如何在App.vue组件内部使用此功能?还记得我们指定的标签吗?我们只是将其添加到template中,如下所示:

<template>
 <fancy-button/>
</template>

以下是我们努力工作的结果(放大到 500%):

范围样式

伟大的如果我们添加另一个按钮元素会发生什么?由于我们直接使用 CSS 设计了button元素的样式:

<template>
  <div>
    <fancy-button></fancy-button>
    <button>I'm another button!</button>
  </div>
</template>

如果我们转到浏览器,我们可以看到我们创建的每个按钮:

哦!另一个按钮不是fancy-button,为什么它会得到样式?谢天谢地,阻止样式泄漏到组件之外很简单,我们只需将scoped属性添加到style标记中:

<style scoped>
 button {
 border: 1px solid black;
 padding: 10px;
 }
</style>

默认情况下,作用域属性不是 Vue 的一部分,它来自我们的网页vue-loader。您会注意到,添加此按钮后,按钮样式仅针对我们的fancy-button组件。如果我们在下面的屏幕截图中查看这两个按钮之间的差异,我们可以看到一个按钮只是一个按钮,另一个是使用随机生成的数据属性设计按钮的样式。这将阻止浏览器在此场景中将样式应用于两个按钮元素。

在 Vue 中使用作用域 CSS 时,请记住,在组件中创建的规则不会在整个应用中全局访问:

在本地注册组件

我们还可以在应用中本地注册组件。这可以通过将其专门添加到我们的 Vue 实例中来实现,例如,让我们在main.js中注释掉全局注册,然后导航到App.vue

// Vue.component('fancy-button', FancyButton);

在将任何代码添加到我们的应用组件之前,请注意我们的按钮已经消失,因为我们不再在全球注册它。要在本地注册该组件,我们需要首先导入与之前类似的组件,然后将其添加到实例中的component对象:

<template>
 <div>
 <fancy-button></fancy-button>
 <button>I'm another button!</button>
 </div>
</template>

<script>
import FancyButton from './components/FancyButton.vue';

export default {
 components: {
 FancyButton
 }
}
</script>

<style>

</style>

我们的按钮现在再次出现在屏幕上。在决定在何处注册组件时,请考虑在整个项目中使用组件的频率。

组件通信

我们现在能够创建可重用组件,使我们能够在项目中封装功能。为了使这些组件可用,我们需要让它们能够相互通信。我们要看的第一件事是与组件属性(称为“道具”)的单向通信。

组件通信的要点是保持我们的特性分布、松散耦合,从而使我们的应用更易于扩展。若要强制松耦合,您不应尝试引用子组件中的父组件数据,而应仅使用props传递该数据。让我们来看看在我们的 To1 T1 上制作一个属性,它改变了 Ty2 T2 文本:

<template>
 <button>
  {{buttonText}}
 </button>
</template>

<script>
export default {
 props: ['buttonText'],
}
</script>

<style scoped>
 button {
 border: 1px solid black;
 padding: 10px;
 }
</style>

注意,当我们自己创建了一个包含每个组件属性的字符串或对象值的props数组时,我们如何能够绑定到模板中的buttonText值。可以将 kebab case 设置为组件本身的属性,这是必需的,因为 HTML 不区分大小写:

<template>
 <fancy-button button-text="I'm set using props!"></fancy-button>
</template>

这给了我们以下结果:

配置属性值

我们可以通过将属性值设置为对象来进一步配置属性值。这允许我们定义默认值、类型、验证器等。让我们用我们的buttonText财产来做这件事:

export default {
 props: {
  buttonText: {
   type: String,
   default: "Fancy Button!",
   required: true,
   validator: value => value.length > 3
  }
 },
}

首先,我们要确保只能将字符串类型传递到此属性。我们还可以对照其他类型进行检查,例如:

  • 大堆
  • 布尔值
  • 作用
  • 数字
  • 对象
  • 一串
  • 象征

根据 web 组件良好实践,向道具发送原始值是一种良好实践。

在引擎盖下,这是针对属性运行instanceof操作符,因此它还可以针对构造函数类型运行检查,如以下屏幕截图所示:

同时,我们还可以使用数组语法检查多种类型:

export default {
 props: {
  buttonText: {
   type: [String, Number, Cat],
  }
 },
}

接下来,我们将默认文本设置为FancyButton!,这意味着默认情况下,如果未设置此属性,它将具有该值。我们还将 required 设置为 true,这意味着无论何时创建一个FancyButton都必须包含buttonText属性。

这在术语(即默认值和必需值)上是矛盾的,但有时您希望在不需要属性的情况下使用默认值。最后,我们将向其添加一个验证函数,以指定任何时候设置此属性时,其字符串长度必须大于 3。

我们如何知道属性验证是否失败?在开发模式下,我们可以检查我们的开发控制台,我们应该有一个相应的错误。例如,如果我们忘记在组件上添加buttonText属性:

自定义事件

我们正在取得巨大的进步。我们现在有了一个组件,它可以接受输入、在全局或本地注册、具有范围样式、验证等等。现在我们需要让它能够在点击FancyButton按钮时将事件发回其父组件进行通信,这是通过编辑$emit事件的代码来完成的:

<template>
 <button 
  @click.prevent="clicked">
  {{buttonText}}
 </button>
</template>

<script>
export default {
 props: {
  buttonText: {
   type: String,
   default: () => {
     return "Fancy Button!" 
   },
   required: true,
   validator: value => value.length > 3
  }
 },
 methods: {
  clicked() {
   this.$emit('buttonClicked');
  }
 }
}
</script>

在我们的示例中,我们将clicked函数附加到按钮的点击事件,这意味着无论何时选择它,我们都会发出buttonClicked事件。然后,我们可以在App.vue文件中侦听此事件,在该文件中,我们将元素添加到 DOM 中:

<template>
  <fancy-button 
   @buttonClicked="eventListener()" 
   button-text="Click 
   me!">
  </fancy-button>
</template>

<script>
import FancyButton from './components/FancyButton.vue';

export default {
  components: {
    'fancy-button': FancyButton
  },
  methods: {
    eventListener() {
      console.log("The button was clicked from the child component!");
    }
  }
}
</script>

<style>

</style>

注意我们现在是如何使用@buttonClicked="eventListener()"。它使用v-on事件在任何时候发出事件时调用eventListener()函数,随后将消息记录到控制台。我们现在已经演示了在两个组件之间发送和接收事件的能力。

发送事件值

为了使事件系统更加强大,我们还可以将值传递给其他组件。让我们在FancyButton组件中添加一个输入框(也许我们需要重命名它,或者考虑将输入分离到它自己的组件中!):

<template>
 <div>
  <input type="text" v-model="message">
  <button 
  @click.prevent="clicked()">
   {{buttonText}}
  </button>
 </div>
</template>

<script>
export default {
 data() {
  return {
   message: ''
  };
 },
 // Omitted
}

接下来要做的事情是通过$emit调用传递消息值。我们可以在clicked方法中这样做:

 methods: {
  clicked() {
   this.$emit('buttonClicked', this.message);
  }
 }

此时,我们可以将事件捕获为eventListener函数的参数,如下所示:

<template>
 <fancy-button @buttonClicked="eventListener($event)" button-text="Click me!"></fancy-button>
</template>

此时要做的最后一件事是匹配函数的预期参数:

 eventListener(message) {
  console.log(`The button was clicked from the child component with this message: ${message}`);
 }

然后,我们应该在控制台中获得以下内容:

我们现在能够在父组件和子组件之间真正发送事件,以及我们可能希望随它一起发送的任何数据。

事件总线

当我们想要创建一个应用范围的事件系统(也就是说,没有严格意义上的父子组件)时,我们可以创建一个事件总线。这允许我们通过单个 Vue 实例“管道”所有事件,本质上只允许通过父组件和子组件进行通信。除此之外,对于那些不希望使用第三方库(如Vuex)或不处理许多操作的较小项目的人来说,这也是非常有用的。让我们制作一个新的游乐场项目来演示:

# Create a new Vue project
$ vue init webpack-simple vue-event-bus

# Navigate to directory
$ cd vue-event-bus

# Install dependencies
$ npm install

# Run application
$ npm run dev

首先在src文件夹中创建一个EventsBus.js。从这里,我们可以导出一个新的 Vue 实例,我们可以使用它来发出类似于前面使用$emit发出的事件:

import Vue from 'vue';

export default new Vue();

接下来,我们可以创建两个组件,ShoppingInputShoppingList。这将允许我们输入一个新项目,并在我们的购物清单上显示一个输入项目列表,从我们的ShoppingInput组件开始:

<template>
 <div>
  <input v-model="itemName">
  <button @click="addShoppingItem()">Add Shopping Item</button>
 </div>
</template>

<script>
import EventBus from '../EventBus';

export default {
 data() {
  return {
   itemName: ''
  }
 },
 methods: {
  addShoppingItem() {
   if(this.itemName.length > 0) {
    EventBus.$emit('addShoppingItem', this.itemName)
    this.itemName = "";
   }
  }
 },
}
</script>

这个组件的关键之处在于,我们现在正在导入EventBus并使用$emit而不是使用它,将应用的事件系统从基于组件更改为基于应用。然后,我们可以使用$on观察任何组件的变化(以及后续值)。让我们来看看下一个组件ShoppingList

<template>
 <div>
  <ul>
   <li v-for="item in shoppingList" :key="item">
    {{item}}
   </li>
  </ul>
 </div>
</template>

<script>
import EventBus from '../EventBus';
export default {
 props: ['shoppingList'],
 created() {
  EventBus.$on('addShoppingItem', (item) => {
   console.log(`There was an item added! ${item}`);
  })
 }
}
</script>

查看我们的ShoppingList组件,我们可以看到$on的用法,这允许我们侦听名为addShoppingItem的事件(与我们发出的事件名称相同,或者您要侦听的任何其他事件)。这将返回该项,然后我们可以将其注销到控制台或执行其他操作。

我们可以把这些都放在我们的App.vue中:

<template>
 <div>
  <shopping-input/>
  <shopping-list :shoppingList="shoppingList"/>
 </div>
</template>

<script>
import ShoppingInput from './components/ShoppingInput';
import ShoppingList from './components/ShoppingList';
import EventBus from './EventBus';

export default {
 components: {
  ShoppingInput,
  ShoppingList
 },
 data() {
  return {
   shoppingList: []
  }
 },
 created() {
  EventBus.$on('addShoppingItem', (itemName) => {
   this.shoppingList.push(itemName);
  })
 },
}

我们正在定义这两个组件,并在创建的生命周期钩子中侦听addShoppingItem事件。和前面一样,我们得到了itemName,然后我们可以将它添加到我们的数组中。我们可以将数组作为道具传递给另一个组件,例如要在屏幕上渲染的ShoppingList

最后,如果我们想停止收听事件(全部或每个事件),我们可以使用$off。在App.vue的内部,我们制作一个新按钮,进一步显示:

<button @click="stopListening()">Stop listening</button>

然后我们可以创建如下的stopListening方法:

methods: {
 stopListening() {
  EventBus.$off('addShoppingItem')
 }
},

如果我们想停止收听所有事件,我们可以简单地使用:

EventBus.$off();

现在,我们已经创建了一个事件系统,它允许我们与任何组件进行通信,而不管父/子关系如何。我们可以通过EventBus发送事件并收听它们,从而使我们的组件数据具有更大的灵活性。

当我们组成我们的组件时,我们应该考虑我们自己和我们的团队将如何使用它们。使用插槽允许我们以不同的行为向组件动态添加元素。让我们通过制作一个新的游乐场项目来了解这一点:

# Create a new Vue project
$ vue init webpack-simple vue-slots

# Navigate to directory
$ cd vue-slots

# Install dependencies
$ npm install

# Run application
$ npm run dev

然后我们可以继续创建一个名为Messagesrc/components/Message.vue的新组件。然后,我们可以为该组件添加特定的内容(例如下面的h1以及slot标记,我们可以使用该标记从其他地方注入内容:

<template>
 <div>
   <h1>I'm part of the Message component!</h1>
   <slot></slot>
 </div>
</template>

<script>
export default {}
</script>

如果我们在App.vue中注册我们的组件并将其放入模板中,我们就可以在component标记中添加内容,如下所示:

<template>
 <div id="app">
   <message>
     <h2>What are you doing today?</h2>
   </message>
   <message>
     <h2>Learning about Slots in Vue.</h2>
   </message>
 </div>
</template>

<script>
import Message from './components/Message';

export default {
 components: {
  Message
 }
}
</script>

此时,message标签内的所有内容都被放置在Message组件内的slot内:

注意我们看到我是消息组件的一部分!对于Message组件的每个声明,这表明即使我们将内容注入这个空间,我们仍然可以每次显示特定于该组件的模板信息。

默认值

虽然我们可以将内容添加到插槽中,但我们可能希望添加默认内容,以显示我们自己没有添加任何内容。这意味着我们不必每次都添加内容,如果我们愿意,我们可以在这种情况下覆盖它。

如何将默认行为添加到插槽中?那很简单!我们需要做的就是在slot标记之间添加元素,如下所示:

<template>
 <div>
  <h1>I'm part of the Message component!</h1>
  <slot>
   <h2>I'm a default heading that appears <em>only</em> when no slots 
   have been passed into this component</h2>
   </slot>
 </div>
</template>

因此,如果我们添加另一个message元素,但这次没有任何标记,我们将得到以下结果:

<template>
 <div id="app">
  <message>
   <h2>What are you doing today?</h2>
  </message>
  <message>
   <h2>Learning about Slots in Vue.</h2>
  </message>
  <message></message>
 </div>
</template>

现在,如果我们转向浏览器,我们可以看到我们的消息按预期显示如下:

命名槽

我们还可以通过命名插槽进一步实现这一点。假设我们的message组件需要datemessageText输入,其中一个是插槽,另一个是组件的属性。我们的用例是,也许我们希望以不同的方式显示日期,添加不同的信息位,或者甚至不显示它。

我们的消息组件变成:

<template>
 <div>
  <slot name="date"></slot>
  <h1>{{messageText}}</h1>
 </div>
</template>

<script>
export default {
 props: ['messageText']
}
</script>

注意我们的slot标签上的name="date"属性。这使我们能够在运行时将内容动态地放置在正确的位置。然后,我们可以构建一个小型聊天系统来展示这一点,在继续之前,让我们确保在我们的项目中安装了moment

$ npm install moment --save

您可能还记得在第 4 章Vue.js 指令中使用了moment,我们还将重用之前创建的Date管道。让我们升级我们的App.vue以包含以下内容:

<template>
 <div id="app">

  <input type="text" v-model="message">
  <button @click="sendMessage()">+</button>

  <message v-for="message in messageList" :message-text="message.text" :key="message">
   <h2 slot="date">{{ message.date | date }}</h2>
  </message>
 </div>
</template>

<script>
import moment from 'moment';
import Message from './components/Message';

const convertDateToString = value => moment(String(value)).format('MM/DD/YYYY');

export default {
 data() {
  return {
   message: '',
   messageList: []
  }
 },
 methods: {
  sendMessage() {
   if ( this.message.length > 0 ) {
    this.messageList.push({ date: new Date(), text: this.message });
    this.message = ""
   }
  }
 },
 components: {
  Message
 },
 filters: {
  date: convertDateToString
 }
}
</script>

这里发生了什么事?在我们的模板中,我们在messageList上迭代,并在每次添加新消息时创建一个新的消息组件。在组件标记内部,我们希望出现messageText(我们将其作为道具传递,标记在消息组件内部定义),但我们也使用slot动态添加日期:

如果我们从 h2 中删除slot="date",会发生什么?日期还显示吗?不。这是因为当我们只使用命名插槽时,没有其他位置可以添加插槽。只有当我们将Message组件更改为接受一个未命名的插槽时,才会出现如下情况:

<template>
 <div>
  <slot name="date"></slot>
  <slot></slot>
  <h1>{{messageText}}</h1>
 </div>
</template>

总结

本章为我们提供了创建可相互通信的可重用组件的能力。我们已经研究了如何在整个项目中全局注册组件,或者在特定实例中本地注册组件,从而为我们提供灵活性和适当的关注点分离。从添加简单属性到复杂的验证和默认值,我们已经看到了它的强大功能。

在下一章中,我们将研究如何创建更好的 UI。我们将在表单、动画和验证的上下文中更多地研究指令,如v-model