Vue.js для начинающих, урок 11: вкладки, глобальная шина событий +24


Сегодня, в 11 уроке, который завершает этот учебный курс по основам Vue, мы поговорим о том, как организовать содержимое страницы приложения с помощью вкладок. Здесь же мы обсудим глобальную шину событий — простой механизм по передаче данных внутри приложения.



> Vue.js для начинающих, урок 1: экземпляр Vue
> Vue.js для начинающих, урок 2: привязка атрибутов
> Vue.js для начинающих, урок 3: условный рендеринг
> Vue.js для начинающих, урок 4: рендеринг списков
> Vue.js для начинающих, урок 5: обработка событий
> Vue.js для начинающих, урок 6: привязка классов и стилей
> Vue.js для начинающих, урок 7: вычисляемые свойства
> Vue.js для начинающих, урок 8: компоненты
> Vue.js для начинающих, урок 9: пользовательские события
> Vue.js для начинающих, урок 10: формы

Цель урока


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

Начальный вариант кода


Вот как на данном этапе работы выглядит содержимое файла index.html:

<div id="app">
  <div class="cart">
    <p>Cart({{ cart.length }})</p>
  </div>

  <product :premium="premium" @add-to-cart="updateCart"></product>
</div>

В main.js имеется следующий код:

Vue.component('product', {
  props: {
    premium: {
      type: Boolean,
      required: true
    }
  },
  template: `
  <div class="product">

    <div class="product-image">
      <img :src="image" />
    </div>

    <div class="product-info">
      <h1>{{ title }}</h1>
      <p v-if="inStock">In stock</p>
      <p v-else>Out of Stock</p>
      <p>Shipping: {{ shipping }}</p>

      <ul>
        <li v-for="(detail, index) in details" :key="index">{{ detail }}</li>
      </ul>
      <div
        class="color-box"
        v-for="(variant, index) in variants"
        :key="variant.variantId"
        :style="{ backgroundColor: variant.variantColor }"
        @mouseover="updateProduct(index)"
      ></div>

      <button
        @click="addToCart"
        :disabled="!inStock"
        :class="{ disabledButton: !inStock }"
      >
        Add to cart
      </button>

    </div>

    <div>
      <h2><font color="#3AC1EF">Reviews</font></h2>
      <p v-if="!reviews.length">There are no reviews yet.</p>
      <ul>
        <li v-for="review in reviews">
        <p>{{ review.name }}</p>
        <p>Rating: {{ review.rating }}</p>
        <p>{{ review.review }}</p>
        </li>
      </ul>
    </div>

    <product-review @review-submitted="addReview"></product-review>   
  
    </div>
  `,
  data() {
    return {
      product: 'Socks',
      brand: 'Vue Mastery',
      selectedVariant: 0,
      details: ['80% cotton', '20% polyester', 'Gender-neutral'],
      variants: [
        {
          variantId: 2234,
          variantColor: 'green',
          variantImage: './assets/vmSocks-green.jpg',
          variantQuantity: 10
        },
        {
          variantId: 2235,
          variantColor: 'blue',
          variantImage: './assets/vmSocks-blue.jpg',
          variantQuantity: 0
        }
      ],
      reviews: []
    }
  },
    methods: {
      addToCart() {
        this.$emit('add-to-cart', this.variants[this.selectedVariant].variantId);
      },
      updateProduct(index) {
        this.selectedVariant = index;
      },
      addReview(productReview) {
        this.reviews.push(productReview)
      }
    },
    computed: {
      title() {
        return this.brand + ' ' + this.product;
      },
      image() {
        return this.variants[this.selectedVariant].variantImage;
      },
      inStock() {
        return this.variants[this.selectedVariant].variantQuantity;
      },
      shipping() {
        if (this.premium) {
          return "Free";
        } else {
          return 2.99
        }
      }
    }
})

Vue.component('product-review', {
  template: `
    <form class="review-form" @submit.prevent="onSubmit">

      <p v-if="errors.length">
        <b>Please correct the following error(s):</b>
        <ul>
          <li v-for="error in errors">{{ error }}</li>
        </ul>
      </p>

      <p>
        <label for="name">Name:</label>
        <input id="name" v-model="name">
      </p>
      
      <p>
        <label for="review">Review:</label>      
        <textarea id="review" v-model="review"></textarea>
      </p>
      
      <p>
        <label for="rating">Rating:</label>
        <select id="rating" v-model.number="rating">
          <option>5</option>
          <option>4</option>
          <option>3</option>
          <option>2</option>
          <option>1</option>
        </select>
      </p>
          
      <p>
        <input type="submit" value="Submit">  
      </p>    

    </form>

  `,
  data() {
    return {
      name: null,
      review: null,
      rating: null,
      errors: []
    }
  },
  methods: {
    onSubmit() {
      if(this.name && this.review && this.rating) {
        let productReview = {
          name: this.name,
          review: this.review,
          rating: this.rating
        }
        this.$emit('review-submitted', productReview)
        this.name = null
        this.review = null
        this.rating = null
      } else {
        if(!this.name) this.errors.push("Name required.")
        if(!this.review) this.errors.push("Review required.")
        if(!this.rating) this.errors.push("Rating required.")
      }
    }
  }
})

var app = new Vue({
  el: '#app',
  data: {
    premium: true,
    cart: []
  },
  methods: {
    updateCart(id) {
      this.cart.push(id);
    }
  }
})

Вот как сейчас выглядит приложение.


Страница приложения

Задача


Сейчас отзывы и форма, которая используется для отправки отзывов, выводятся на странице рядом друг с другом. Это — вполне рабочая структура. Но ожидается, что со временем на странице будет появляться всё больше и больше отзывов. Это значит, что пользователям будет удобнее взаимодействовать со страницей, на которой, по их выбору, выводится либо форма, либо список отзывов.

Решение задачи


Для того чтобы решить нашу задачу, мы можем добавить на страницу систему вкладок. Одна из них, с заголовком Reviews, будет выводить отзывы. Вторая, с заголовком Make a Review, будет выводить форму для отправки отзывов.

Создание компонента, реализующего систему вкладок


Начнём работу с создания компонента product-tabs. Он будет выводиться в нижней части визуального представления компонента product. Со временем он заменит собой тот код, который сейчас используется для вывода на странице списка отзывов и формы.

Vue.component('product-tabs', {
  template: `
    <div>
      <span class="tab" v-for="(tab, index) in tabs" :key="index">{{ tab }}</span>
    </div>
  `,
  data() {
    return {
      tabs: ['Reviews', 'Make a Review']      
    }
  }
})

Сейчас это — лишь заготовка компонента, которую мы скоро доработаем. Пока же в двух словах обсудим то, что представлено в этом коде.

В данных компонента имеется массив tabs, содержащий строки, которые мы используем в качестве заголовков вкладок. В шаблоне компонента применяется конструкция v-for, с помощью которой для каждого элемента массива tabs создаётся элемент <span>, содержащий соответствующую строку. То, что формирует этот компонент на данном этапе работы над ним, будет выглядеть так, как показано ниже.


Компонент product-tabs на начальном этапе работы над ним

Нам, для достижения наших целей, нужно знать о том, какая из вкладок является активной. Поэтому добавим в данные компонента свойство selectedTab. Будем динамически задавать значение этого свойства, пользуясь обработчиком событий, реагирующим на щелчки по заголовкам вкладок:

@click="selectedTab = tab"

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

То есть, если пользователь щёлкнет по вкладке Reviews, то в selectedTab будет записана строка Reviews. Если будет сделан щелчок по вкладке Make a Review, то в selectedTab попадёт строка Make a Review.

Вот как теперь будет выглядеть полный код компонента.

Vue.component('product-tabs', {
  template: `
    <div>    
      <ul>
        <span class="tab" 
              v-for="(tab, index) in tabs" 
              @click="selectedTab = tab"
        >{{ tab }}</span>
      </ul> 
    </div>
  `,
  data() {
    return {
      tabs: ['Reviews', 'Make a Review'],
      selectedTab: 'Reviews'  // устанавливается с помощью @click
    }
  }
})

Привязка класса к активной вкладке


Пользователь, работая с интерфейсом, в котором используются вкладки, должен знать о том, какая вкладка является активной. Реализовать подобный механизм можно, воспользовавшись привязкой классов к элементам <span>, использующимся для вывода названий вкладок:

:class="{ activeTab: selectedTab === tab }"

Вот CSS-файл, в котором определён стиль использованного здесь класса activeTab. Вот как выглядит этот стиль:

.activeTab {
  color: #16C0B0;
  text-decoration: underline;
}

А вот — стиль класса tab:

.tab {
  margin-left: 20px;
  cursor: pointer;
}

Если объяснить вышеприведённую конструкцию простым языком, то оказывается, что к вкладке применяется стиль, заданный для класса activeTab, в том случае, когда selectedTab равняется tab. Так как в selectedTab записывается название вкладки, по которой только что щёлкнул пользователь, стиль .activeTab будет применяться именно к активной вкладке.

Другими словами, когда пользователь щёлкнет по первой вкладке, в tab будет находиться Reviews, то же самое будет записано и в selectedTab. В результате к первой вкладке будет применён стиль .activeTab.

Теперь заголовки вкладок на странице будут выглядеть так, как показано ниже.


Выделенный заголовок активной вкладки

Судя по всему, на данном этапе всё работает так, как ожидается, поэтому мы можем идти дальше.

Работа над шаблоном компонента


Теперь, когда мы можем сообщить пользователю о том, какая именно вкладка является активной, можно продолжить работу над компонентом. А именно, речь идёт о доработке его шаблона, об описании того, что именно будет выводиться на странице при активации каждой из вкладок.

Подумаем о том, что надо показать пользователю в том случае, если он щёлкнет по вкладке Reviews. Это, понятно, отзывы о товаре. Поэтому переместим код вывода отзывов из шаблона компонента product в шаблон компонента product-tabs, разместив этот код ниже конструкции, используемой для вывода заголовков вкладок. Вот как теперь будет выглядеть шаблон компонента product-tabs:

template: `
  <div>    
    <ul>
      <span class="tab"
            :class="{ activeTab: selectedTab === tab }" 
            v-for="(tab, index) in tabs" 
            @click="selectedTab = tab"
      >{{ tab }}</span>
    </ul> 
    <div>
      <p v-if="!reviews.length">There are no reviews yet.</p>
      <ul>
        <li v-for="review in reviews">
        <p>{{ review.name }}</p>
        <p>Rating: {{ review.rating }}</p>
        <p>{{ review.review }}</p>
        </li>
      </ul>
    </div>
  </div>
`

Обратите внимание на то, что мы избавились от тега <h2><font color="#3AC1EF">, так как нам больше не нужно выводить заголовок Reviews над списком отзывов. Вместо этого заголовка будет выводиться заголовок соответствующей вкладки.

Но одного только переноса кода шаблона недостаточно для того чтобы обеспечить вывод отзывов. Массив reviews, данные которого используются для вывода отзывов, хранится в составе данных компонента product. Нам нужно передать этот массив в компонент product-tabs, используя механизм входных параметров компонента. Добавим в объект с опциями, используемый при создании product-tabs, следующее:

props: {
  reviews: {
    type: Array,
    required: false
  }
}

Передадим массив reviews из компонента product в компонент product-tabs, воспользовавшись, в шаблоне product, следующей конструкцией:

<product-tabs :reviews="reviews"></product-tabs>

А теперь поразмыслим о том, что нужно вывести на странице в том случае, если пользователь щёлкнет по заголовку вкладки Make a Review. Это, конечно, форма для отправки отзывов. Для того чтобы подготовить проект к дальнейшей работе над ним — перенесём код подключения компонента product-review из шаблона компонента product в шаблон product-tabs. Разместим следующий код ниже элемента <div>, используемого для вывода списка отзывов:

<div>
  <product-review @review-submitted="addReview"></product-review>
</div>

Если взглянуть сейчас на страницу приложения, то окажется, что список отзывов и форма выводятся на ней ниже заголовков вкладок.


Промежуточный этап работы над страницей

При этом щелчки по заголовкам, хотя и приводят к их выделению, никак не влияют на другие элементы страницы. Далее, если попытаться воспользоваться формой, окажется, что она перестала нормально работать. Всё это — вполне ожидаемые следствия изменений, внесённых нами в приложение. Продолжим работу и приведём наш проект в работоспособное состояние.

Вывод элементов страницы по условию


Теперь, когда мы подготовили основные элементы шаблона компонента product-tabs, пришло время создать систему, которая позволит выводить разные элементы страницы основываясь на том, по заголовку какой именно вкладки щёлкнул пользователь.

В данных компонента уже есть свойство selectedTab. Мы можем воспользоваться им в директиве v-show для организации условного рендеринга того, что относится к каждой из вкладок.

Так, к тегу <div>, содержащему код формирования списка отзывов, мы можем добавить такую конструкцию:

v-show="selectedTab === 'Reviews'"

Благодаря ей список отзывов будет выводиться тогда, когда будет активной вкладка Reviews.

Аналогично, к тегу <div>, в котором содержится код подключения компонента product-review, мы добавим следующее:

v-show="selectedTab === 'Make a Review'"

Это приведёт к тому, что форма будет выводиться только тогда, когда активна вкладка Make a Review.

Вот как теперь будет выглядеть шаблон компонента product-tabs:

template: `
  <div>    
    <ul>
      <span class="tab"
            :class="{ activeTab: selectedTab === tab }" 
            v-for="(tab, index) in tabs" 
            @click="selectedTab = tab"
      >{{ tab }}</span>
    </ul> 
    <div v-show="selectedTab === 'Reviews'">
      <p v-if="!reviews.length">There are no reviews yet.</p>
      <ul>
        <li v-for="review in reviews">
        <p>{{ review.name }}</p>
        <p>Rating: {{ review.rating }}</p>
        <p>{{ review.review }}</p>
        </li>
      </ul>
    </div>
    <div v-show="selectedTab === 'Make a Review'">
      <product-review @review-submitted="addReview"></product-review>
    </div>
  </div>
`

Если взглянуть на страницу и пощёлкать по вкладкам — можно убедиться в том, что созданный нами механизм работает правильно.


Щелчки по вкладкам приводят к скрытию одних элементов и к отображению других

Но отправка отзывов с помощью формы всё так же не работает. Исследуем проблему и исправим её.

Решение проблемы с отправкой отзывов


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


Предупреждение в консоли

Очевидно, система не может обнаружить метод addReview. Что с ним случилось?

Для ответа на этот вопрос вспомним о том, что addReview — это метод, который объявлен в компоненте product. Он должен вызываться в том случае, если компонент product-review (а это — дочерний компонент компонента product) генерирует событие review-submitted:

<product-review @review-submitted="addReview"></product-review>

Именно так всё и работало до переноса вышеприведённого фрагмента кода в компонент product-tabs. А теперь дочерним компонентом product является компонент product-tabs, а product-review — это теперь не «ребёнок», компонента product, а его «внук».

Наш код сейчас рассчитан на взаимодействие компонента product-review с родительским компонентом. Но теперь это — уже не компонент product. В результате оказывается, что нам, чтобы форма заработала бы правильно, нужно подвергнуть код проекта рефакторингу.

Рефакторинг кода проекта


Для того чтобы обеспечить связь внучатых компонентов с их «бабушками» и «дедушками», или для того, чтобы наладить связь между компонентами одного уровня, нередко используют механизм, называемый глобальной шиной событий (global event bus).

Глобальная шина событий — это канал связи, который можно использовать для передачи информации между компонентами. И это, на самом деле, просто экземпляр Vue, который создают, не передавая ему объект с опциями. Создадим шину событий:

var eventBus = new Vue()

Этот код попадёт на верхний уровень файла main.js.

Возможно, вам будет легче освоить эту концепцию, если вы представите шину событий в виде автобуса. Его пассажирами являются данные, которые одни компоненты отправляют другим. В нашем случае речь идёт о передаче одним компонентам сведений о событиях, сгенерированных другими компонентами. То есть, наш «автобус» будет ездить от компонента product-review к компоненту product, перевозя сведения о том, что форма был отправлена, и доставляя данные формы из product-review в product.

Сейчас в компоненте product-review, в методе onSubmit, есть такая строчка:

this.$emit('review-submitted', productReview)

Заменим её на следующую, воспользовавшись eventBus вместо this:

eventBus.$emit('review-submitted', productReview)

После этого больше не нужно прослушивать событие review-submitted компонента product-review. Поэтому изменим код этого компонента в шаблоне компонента product-tabs на такой:

<product-review></product-review>

Из компонента product теперь можно удалить метод addReview. Вместо него мы воспользуемся следующей конструкцией:

eventBus.$on('review-submitted', productReview => {
  this.reviews.push(productReview)
})

О том, как именно применить её в компоненте, мы поговорим ниже, а пока в двух словах опишем то, что в ней происходит. Эта конструкция указывает на то, что когда eventBus генерирует событие review-submitted, нужно взять данные, передаваемые в этом событии (то есть — productReview) и поместить их в массив reviews компонента product. Собственно говоря, это очень похоже на то, что до сих пор делалось в методе addReview, который нам больше не нужен. Обратите внимание на то, что в вышеприведённом фрагменте кода используется стрелочная функция. Этот момент достоин более подробного освещения.

Причины использования стрелочной функции


Здесь мы используем синтаксис стрелочных функций, который появился в ES6. Дело в том, что контекст стрелочной функции привязан к родительскому контексту. То есть — когда мы, внутри этой функции, пользуемся ключевым словом this, оно равнозначно тому ключевому слову this, которое соответствует сущности, содержащей стрелочную функцию.

Этот код можно переписать и без использования стрелочных функций, но тогда понадобится организовать привязку this:

eventBus.$on('review-submitted', function (productReview) {
  this.reviews.push(productReview)
}.bind(this))

Завершение работы над проектом


Мы почти достигли цели. Всё, что осталось сделать — найти место для фрагмента кода, обеспечивающего реакцию на событие review-submitted. Таким местом в компоненте product может стать функция mounted:

mounted() {
  eventBus.$on('review-submitted', productReview => {
    this.reviews.push(productReview)
  })
}

Что это за функция? Это — хук жизненного цикла, который вызывается один раз после того, как компонент будет смонтирован в DOM. Теперь, после того, как компонент product будет смонтирован, он будет ожидать появления событий review-submitted. После того, как такое событие будет сгенерировано, в данные компонента будет добавлено то, что передано в этом событии, то есть — productReview.

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


Форма работает так, как нужно

Шина событий — это не лучшее решение для обеспечения связи компонентов


Хотя шина событий используется часто, и хотя вы можете встретить её в различных проектах, учитывайте то, что это — далеко не самое лучшее решение задачи обеспечения связи компонентов приложений.

По мере того, как приложение растёт, в нём может очень пригодиться система управления состоянием, основанная на Vuex. Это — паттерн и библиотека управления состоянием приложений.

Практикум


Добавьте в проект вкладки Shipping и Details, на которых, соответственно, выводится стоимость доставки покупок и сведения о товарах.

  • Вот заготовка, которую вы можете использовать для решения этой задачи.
  • Вот решение задачи.

Итоги


Вот что вы узнали, изучив этот урок:

  • Для организации механизма вкладок можно воспользоваться средствами условного рендеринга.
  • Для передачи данных между компонентами приложений часто пользуются глобальной шиной событий, представленной отдельным экземпляром Vue, создаваемым без использования объекта с опциями.
  • Шина событий — это не самое лучшее решение задачи организации обмена данными между компонентами. Для решения подобной задачи лучше всего воспользоваться специализированной библиотекой. Например — Vuex.

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

Если вы только что завершили этот курс — просим поделиться впечатлениями.

> Vue.js для начинающих, урок 1: экземпляр Vue
> Vue.js для начинающих, урок 2: привязка атрибутов
> Vue.js для начинающих, урок 3: условный рендеринг
> Vue.js для начинающих, урок 4: рендеринг списков
> Vue.js для начинающих, урок 5: обработка событий
> Vue.js для начинающих, урок 6: привязка классов и стилей
> Vue.js для начинающих, урок 7: вычисляемые свойства
> Vue.js для начинающих, урок 8: компоненты
> Vue.js для начинающих, урок 9: пользовательские события
> Vue.js для начинающих, урок 10: формы




К сожалению, не доступен сервер mySQL