В этой статье я хотел бы познакомить вас с компонентной моделью Vue.js и показать преимущества повторного использования и инкапсуляции компонентов.

Эта статья изначально была опубликована в моем блоге.

Если вы еще не разбираетесь в Vue.js, я настоятельно рекомендую официальное Руководство по Vue.js. Но, пожалуйста, не бойтесь сразу погрузиться в эту статью вместе со мной. Я укажу вам на соответствующую официальную документацию, чтобы дать вам дополнительную информацию и возможность прочитать дополнительные справочные материалы.

Мы начнем с простого примера компонента и постепенно улучшим его функциональность.

HTML-код этого компонента карточки состоит из большой области изображения и тела с некоторым текстом:

<div id="demo">
  <div class="image-card">
    <img class="image-card__image" src="cat.jpg" />
    <div class="image-card__body">
      <h3 class="image-card__title">Striped Tiger Cat</h3>
      <div class="image-card__author">Image by @lemepe</div>
    </div>
  </div>
</div>

Мы используем корневой HTML-элемент с demo id в качестве нашего элемента для запуска Vue:

new Vue({ el: '#demo' })

Чего мы добились? Мы использовали Vue.js для рендеринга этой графической карты. Но мы не можем повторно использовать этот код как есть, и мы не хотим копировать и вставлять и тем самым дублировать код.

Решение нашей проблемы - превратить это в компонент.

Компоненты можно использовать повторно

Итак, давайте отделим карточку изображения от оставшегося приложения Vue.js.

Сначала мы вводим элемент шаблона со всем содержимым карточки изображения:

<template id="template-image-card">
  <div class="image-card">
    <img class="image-card__image" src="cat.jpg" />
    <div class="image-card__body">
      <h3>Striped Tiger Cat</h3>
      <div class="image-card__author">Image by @lemepe</div>
    </div>
  </div>
</template>

И мы определяем компонент с помощью Vue.component и ссылаемся на наш идентификатор шаблона template-image-card:

Vue.component('image-card', {
  template: "#template-image-card"
})

Это снова завернуто в корневой элемент HTML:

<div id="demo">
  <image-card></image-card>
  <image-card></image-card>
</div>

И вуаля! У нас есть две кошки :-)

Очевидно, что две кошки лучше, чем одна кошка, и мы показали, что у нас может быть несколько экземпляров нашего image-card компонента на одной странице.

Теперь у нас есть средства для повторного использования этого компонента в нашем приложении. И если задуматься, то на самом деле весьма примечательно, что он включает в себя наш код HTML, CSS и Javascript, заключенный в один компонент.

Но все же этот компонент не очень полезен, не правда ли? Это просто недостаточно гибко! Было бы здорово, если бы мы могли изменить изображение и текст для каждого компонента.

Передача данных дочерним компонентам в качестве свойств

Чтобы настроить поведение компонента, мы будем использовать props.

Начнем с того, как мы хотим использовать наш компонент:

<div id="demo">
  <image-card image-src="cat1.jpg" heading="Striped Tiger Cat" text="Image by @lemepe"></image-card>
  <image-card image-src="cat2.jpg" heading="Alternative Text" text="alternative subtitle"></image-card>
</div>

Мы представляем три новых реквизита image-src, heading и text. При использовании компонента они будут переданы как атрибуты HTML.

Далее следует prop определение нашего компонента:

Vue.component('image-card', {
  template: "#template-image-card",
  props: {
    heading: String,
    text: String,
    imageSrc: String
  }
});

Обратите внимание, как опора imageSrc написана в camelCase, тогда как в атрибутах HTML используется тире image-src. Вы можете узнать больше о props в официальном Руководстве по Vue.js.

И сопутствующий шаблон снова использует этот реквизит в формате camelCase:

<template id="template-image-card">
  <div class="image-card">
    <img class="image-card__image" :src="imageSrc" />
    <div class="image-card__body">
      <h3>{{heading}}</h3>
      <div class="image-card__author">{{text}}</div>
    </div>
  </div>
</template>

Посмотрим на результат!

Это сработало! Мы использовали два экземпляра нашего image-card компонента с разными реквизитами.

Разве не приятно, что мы можем визуализировать компонент по-другому, используя реквизиты в качестве входных данных?

Компоненты имеют состояние

На моей обычной повседневной работе менеджер по продукту, скорее всего, заметил бы, что image-card сам по себе выглядит довольно хорошо с кошками и прочими вещами. Но пока это не очень интересно. Как насчет того, чтобы позволить пользователям лайкать нашу кошку и затем подсчитывать, какая кошка понравилась больше всего?

Компоненты могут иметь состояние с помощью атрибута data:

Vue.component('image-card', {
    template: "#template-image-card",
    props: {
      heading: String,
      text: String,
      imageSrc: String
    },
    data: function () {
      return {
        count: 0
      }
    }
  });

Обратите внимание, что data возвращает функцию, а не только объект Javascript data: { count: 0 }. Это необходимо, чтобы каждый экземпляр компонента мог поддерживать независимую копию возвращаемых данных. Подробнее об этом читайте в Руководстве по Vue.js.

В нашем шаблоне используется этот счетчик:

<template id="template-image-card">
  <div class="image-card">
      <img class="image-card__image" :src="imageSrc" />
    <div class="image-card__body">
        <h3 class="image-card__heading">{{heading}}</h3>
      <div class="image-card__author">{{text}}</div>
      <button class="image-card__heart" @click="count++">
        <svg viewBox="0 0 32 29.6">
          <path d="M16,28.261c0,0-14-7.926-14-17.046c0-9.356,13.159-10.399,14-0.454c1.011-9.938,14-8.903,14,0.454 C30,20.335,16,28.261,16,28.261z"/>            
        </svg>
      </button>
      <div class="image-card__count" v-if="count > 0">{{count}}</div>
    </div>
  </div>
</template>

Мы используем элемент SVG для рендеринга сердечка, а в событии click мы увеличиваем счетчик на 1. Рядом с сердечком отображается небольшое количество с текущим значением count.

Если вас больше интересует работа с SVG, загляните в Поваренную книгу Vue.js для получения дополнительной информации.

Обратите внимание, что каждый экземпляр компонента имеет собственное локальное состояние count, которое может быть изменено независимо от count другого компонента.

В то время как в предыдущем примере мы только инкапсулировали HTML-код и сделали его более гибким с помощью props. Теперь мы также инкапсулируем некоторую бизнес-логику, чтобы вести счет.

В то время как props являются входными параметрами нашего компонента, состояние является чем-то внутренним по отношению к компоненту и скрыто от пользователя кода нашего компонента. Мы могли бы изменить имя нашей переменной с count на clickCount, и пользователю нашего компонента даже не нужно было бы знать. Это замечательно, потому что мы можем продолжать улучшать наш компонент, не нарушая пользовательский код.

Отправка сообщений родителям с событиями

Теперь, когда мы знаем, как передавать данные потомкам и как инкапсулировать состояние. Не хватает одного: как мы можем получить данные от ребенка?

В Vue.js мы можем отправить настраиваемое событие от компонента к его родительскому элементу, который прослушивает это конкретное событие. Это событие может дополнительно передавать данные.

В нашем примере мы можем использовать $emit для отправки события с именем change с данными родителю:

Vue.component('image-card', {
  template: "#template-image-card",
  props: {
    heading: String,
    text: String,
    imageSrc: String
  },
  data: function () {
    return {
      count: 0
    }
  },
  methods: {
    handleClick() {
      this.count++;
      this.$emit("change", this.count);
    }
  }
});

Мы определили метод handleClick, который не только увеличивает наше count состояние, но дополнительно использует $emit для отправки сообщения нашему родителю. handleClick вызывается в click событии нашего сердца:

<template id="template-image-card">
  <div class="image-card">
    <img class="image-card__image" :src="imageSrc" />
    <div class="image-card__body">
        <h3 class="image-card__heading">{{heading}}</h3>
      <div class="image-card__author">{{text}}</div>
      <button class="image-card__heart" @click="handleClick">
        <svg viewBox="0 0 32 29.6">
          <path d="M16,28.261c0,0-14-7.926-14-17.046c0-9.356,13.159-10.399,14-0.454c1.011-9.938,14-8.903,14,0.454 C30,20.335,16,28.261,16,28.261z"/>            
        </svg>
      </button>
      <div class="image-card__count" v-if="count > 0">{{count}}</div>
    </div>
  </div>
</template>

Теперь родительский шаблон может использовать это для прослушивания события change для увеличения totalCount:

<div id="demo">
  <image-card image-src="cat.jpg" heading="Striped Tiger Cat" text="Image by @lemepe" @change="handleChange"></image-card>
  <image-card image-src="cat.jpg" heading="Alternative Text" text="alternative subtitle" @change="handleChange"></image-card>
  <p>Total Count: {{totalCount}}</p>
</div>

Вместе с экземпляром Vue.js для отслеживания totalCount:

new Vue({
  el: '#demo',
  data: {
    totalCount: 0
  },
  methods: {
    handleChange(count) {
      console.log("count changed", count);
      this.totalCount++;
    }
  }
});

Обратите внимание, что родитель ничего не знает о внутреннем устройстве компонента. Он просто знает, что доступно событие изменения и что сообщение отправляет count компонента.

Событие, генерируемое через this.$emit("event"), отправляется только родительскому компоненту. Он не будет всплывать в иерархии компонентов, как это происходит с собственными событиями DOM.

Резюме

В этой статье мы исследовали базовые концепции компонентной модели. Мы обсудили повторное использование и инкапсуляцию компонентов, как использовать реквизиты для передачи данных потомкам и как генерировать события для передачи сообщений родителю.

Если вам понравился этот пост, также ознакомьтесь с моим новым курсом Vue.js Component Patterns Course.