Простое фото приложение на Vue.js, Axios и Flickr API — Часть 2

Spread the love

В предыдущей статье мы заложили основу для простого приложения для работы с фотографиями на основе Vue, которое использует API Flickr через библиотеку Axios. Мы успешно сделали наш первый запрос с помощью метода flickr.photos.search и получили изображения, которые затем отобразили на странице.

Прежде чем мы продолжим добавлять новые страниц и вызовы API, я подумал, что это хороший момент для рефакторинга. Было бы не плохо упростить процесс вызова методов Flickr. Мы так же улучшим взаимодействие с пользователем, добавив новую страницу SearchResults.vue, и улучшим визуальный эффект загрузки наших карточек с изображениями. Большое спасибо Кэмерон Ботнер за помощь в улучшениях!

Шаг 1: Рефакторинг вызова API Flickr

В любом вызова API, который мы делаем в этом проекте, есть много повторений. Все наши вызовы GET будут иметь одинаковый метод, url, api_key, format и т. д.

Чтобы уменьшить эту избыточность и сделать наш код DRY (don’t repeat yourself – не повторяйтесь), давайте создадим вспомогательную функцию с именем flickr в отдельном файле. Эта вспомогательная функция будет принимать два аргумента: method и params. method будет строкой типа ‘photos.search‘, params будет объектом любых дополнительных опций, которые мы передаем, для этого конкретного метода.

Добавьте новый файл flickr.js внутрь папки src со следующим содержимым:

import axios from 'axios'
import config from '../config'

export default function flickr(method, params) {
  return axios({
    method: 'get',
    url: 'https://api.flickr.com/services/rest',
    params: {
      api_key: config.api_key,
      format: 'json',
      nojsoncallback: 1,
      ...params,
      method: `flickr.${method}`,
    }
  })
}

Теперь мы можем импортировать функцию flickr() для любого запроса GET, который мы хотим сделать!

Обновим Home.vue:

  1. Импортируем flickr.js и удалим операторы импорта для axios и config.
  2. Упростим метод search(). Ранее в нем был API-код, распределенный между двумя функциями: search() и fetchImages(). Чтобы сделать код более читабельным, давайте объединим функции API и обработки данных в fetchImages(). Очистите метод search(), чтобы он выполнял только три вещи: изменял состояние loading на true, вызывал fetchImages(), а затем устанавливал loading обратно в false.
  3. Обновим fetchImages(), чтобы использовать экспортированный метод flickr из flickr.js. Помните, что он принимает два аргумента: оставшееся имя метода, которое мы хотим вызвать (в этом случае мы вызываем flickr.photos.search, поэтому мы добавляем ‘photos.search‘), и параметры, которые являются более уникальными для этого метода ( в этом случае tags, extras, page и per_page). Как и ранее метод axios(), метод flickr возвращает Promise, которое мы резолвим, назначая данные на this.images. Теперь вся обработка данных выполняется в одном месте.
<script>
import flickr from '../flickr.js';
import ImageCard from '@/components/ImageCard';

export default {
  name: 'home',
  components: {...},
  data() {...},
  computed: {...},
  methods: {
    search() {
      this.loading = true;
      this.fetchImages();
      this.loading = false;
    },
    fetchImages() {
      return flickr('photos.search', {
        tags: this.tag,
        extras: 'url_n, owner_name, description, date_taken, views',
        page: 1,
        per_page: 30,
      }).then((response) => {
        this.images = response.data.photos.photo
      });
    },
  }
};
</script>

Шаг 2: Создание нового views и компонентов

Сейчас у нас есть только одна страница, Home.vue, где пользователи ищут картинки по ключевым словам и видят результаты. Это на самом деле звучит как страница результатов поиска, и требует улучшения. Мы начнем с создания нового маршрута для страницы результатов поиска, затем создадим компонент NavBar.vue, который будет доступен на всех страницах, и завершим конвертированием Home.vue в SearchResults.vue.

Шаг 2.1: Добавление нового маршрута

Обновите router.js, и внесите в него следующее:

import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home.vue';
import SearchResults from './views/SearchResults.vue';

Vue.use(Router);

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home,
    },
    {
      path: '/search/:tag',
      name: 'searchResults',
      component: SearchResults,
      props: true,
    },
  ],
});

В соответствии с вышесказанным, наше приложение теперь будет иметь две страницы или как еще можно сказать два маршрута: Home и SearchResults. Сначала мы импортируем Home и SearchResults из соответствующих им .vue файлов из каталога views. Затем в массиве routes мы указываем путь, который мы хотим им дать и назначили имена для удобства ссылки на них из других частях приложения а так же указываем, какой компонент мы хотим использовать для каждого маршрута (component).

Объект маршрута searchResults содержит две дополнительные вещи: строка path имеет свойство :tag, которое представляет любое ключевое слово тега, (в нашем случае поисковый запрос пользователя). Затем строка 21 объявляет props как true, что означает, что мы передадим параметр tag компоненту SearchResults в качестве prop с этим именем. Таким образом наш компонент SearchResults будет знать, какое ключевое слово tag искать.

Шаг 2.2: Создание компонента NavBar

Мы так же хотим, чтобы пользователи могли выполнять поиск на любой странице нашего приложения, поэтому панель поиска/навигации должна быть отдельным компонентом, который находится в корне нашего проекта App.vue. Создайте файл с именем NavBar.vue в каталоге components со следующим содержимым:

<template>
  <nav class="navbar">
    <form class="searchbar">
      <label>
        <span class='screen-reader-only'>Search:</span>
        <input 
          v-model="tag" 
          placeholder="Search for photos"
          type="text" 
          class="searchbar-input">
      </label>
      <button 
        type="submit" 
        class="btn btn--green btn--go" 
        @click.prevent="search">
          Go
      </button>
    </form>
  </nav>
</template>

<script>
export default {
  name: 'NavBar',
  data() {
    return {
      tag: ''
    }
  },
  methods: {
    search() {
      this.$router.push({name: 'searchResults', params: {tag: this.tag}})
      this.tag = '';
    }
  }
}
</script>

Шаблон точно такой же, как мы использовали в Home.vue. Разница заключается в методе search(), который выполняется в NavBar.vue не будет обрабатывать фактический запрос API. Это будет сделано в SearchResults.vue. Таким образом, единственное, за что отвечает наш компонент NavBar, – это отправка пользователя на страницу результатов поиска и передача введенного им ключевого слова tag.

Вот что делается в строке 32:

this.$router.push({name: 'searchResults', params: {tag: this.tag}})

Мы обновляем маршрут, по которому перейдет пользователь, с помощью метода $router.push, который принимает объект с несколькими аргументами: имя маршрута name, по которому мы перейдем, и любые дополнительные параметры. В данном случае мы хотим перейти к странице searchResults. Этот маршрут также имеет :tag, поэтому мы передаем ему this.tag. Как только это будет сделано, мы сбрасываем свойство данных tag в пустую строку.

Последний шаг – добавить NavBar.vue в App.vue, чтобы он отображался на каждой странице:

<template>
  <div id="app">
    <nav-bar/>
    <router-view/>
  </div>
</template>

<script>
import NavBar from '@/components/NavBar';

export default {
  name: 'app',
  components: { NavBar }
}
</script>

<style>
...
</style>

Шаг 2.3: Конвертируем Home.vue в SearchResults.vue

Начните с переименования Home.vue в SearchResults.vue. Затем создайте новый файл Home.vue в каталоге views, который будет содержать следующее:

<template>
  <div class="wrapper">
    <h1>Welcome to Instaflickr</h1>
  </div>
</template>

<script>
export default {
  name: 'home',
};
</script>

SearchResults.vue требуем еще несколько обновлений. В router.js мы сказали, что представление SearchResults получит tag в качестве prop, поэтому добавим props с объявленным именем tag. Строка 16 также указывает, что она будет иметь тип String. В шаблоне строка 7 ниже добавляет тег h1 для отображения строки поиска, который ввел пользователь, поскольку она больше не отображается в области ввода поиска.

<template>
  <div class="wrapper">
    <p v-if="loading" class="text-centered">
      Loading...
    </p>
    <div v-else>
      <h1>Results for: "{{tag}}"</h1>
      <ul class="image-card-grid">
        <image-card
          v-for="image in cleanImages"
          :key="image.id"
          :image="image" />
      </ul>
    </div>
  </div>
</template>

<script>
import flickr from '../flickr.js';
import ImageCard from '@/components/ImageCard';

export default {
  name: 'searchResults',
  components: { ImageCard },
  props: {
    tag: String
  },
  data() {...},
  computed: {...},
  methods: {
    search() {...},
    fetchImages() {...},
  }
};
</script>

<style>
...
</style>

Теперь вы сможете ввести поисковый запрос на главной странице и в конечном итоге на оказаться на странице /search/:tag с отображаемым поисковым запросом:

Таким образом, мы находимся в правильном компоненте, и наш tag правильно передается… но мы не видим никаких результатов. Это потому, что нам нужно изменить способ вызова API. Раньше мы делали это, нажимая кнопку «Go», но теперь это служит другой цели (чтобы перенести нас на страницу результатов поиска и передать поисковый запрос).

Нам нужно вызвать метод search(), когда пользователь попадет на эту страницу. Решением, которое мы будем использовать для этого, будет обработчик жизненного цикла create(). Мы будем использовать эту хук, потому что нам нужно сделать вызов API и заполнить наши данные (например, images) до отображения шаблона. created() позволяет нам запустить этот код в нужной точке. И причина, по которой мы хотим сделать все это до визуализации шаблона, заключается в том, что в противном случае Vue попытается создать страницу с данными, которых еще не существует, что приведет к ошибке.

Сразу после раздела props в SearchResults.vue добавьте хук created() в котором вызовите this.search():

<script>
export default {
...,
props: { 
  tag: String
},
created() {
  this.search();
},
data() {...}
...
}
</script>

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

Но если вы попытаетесь выполнить новый поиск, пока находитесь на странице поиска, поисковый запрос и URL, похоже, обновятся, но изображения будут старыми:

Теперь нам нужен способ отслеживать, когда tag измениться в SearchResults. Когда это произойдет, мы должны выполнить новый запрос API. Это работа для watchers, которые делают именно то, что мы хотим. Согласно документации Vue, «watchers наиболее полезны, когда вы хотите выполнять асинхронные или дорогостоящие операции в ответ на изменение данных». Бинго!

Код будет выглядит следующим образом:

<script>
  export default {
  ...,
  created() {...},
  watch: {
    tag(value) {
      this.search();
    }
  },
  ...
}
</script>

Внутри секции watch мы создаем функцию с именем переменной, за который мы хотим наблюдать. Эта функция принимает аргумент ( новое значение для этого свойства). С помощью этого вы можете делать с ним другие вещи, но нам это не нужно сейчас. Все, что нам нужно сделать, это снова вызвать this.search().

Теперь пользователи могут осуществлять поиск как с домашней страницы, так и со страницы результатов поиска!

Шаг 3: Рефакторинг компонента ImageCard

Не у всех есть отличная скорость интернета при использовании сети. Чтобы улучшить работу пользователей с более медленным подключением, давайте добавим состояние загрузки в наш компонент ImageCard.

Для этого нам нужно добавить свойство loading в ImageCard.vue, в котором будет хранится Boolean. Это значение будет передано из представления SearchResults в качестве prop. Когда оно true, оно применит некоторые дополнительные классы CSS к нашему html в CardImage.

Шаг 3.1: Обновление Template в ImageCard.vue

Обновите ImageCard.vue, чтобы иметь следующий шаблон:

<template>
  <li class="image-card">
    <img 
      class="image-card__image"
      :class="{ skeleton: loading}" 
      :src="imageUrl" 
      :alt="title">
    <div class="image-card__body">
      <p class="image-title" :class="{skeleton: loading}">{{ title }}</p>
      <p class="image-owner" :class="{skeleton: loading}">{{ byline }}</p>
      <section class="image-date-view-wrapper">
        <p class="image-date" :class="{skeleton: loading}">{{ timestamp }}</p>
        <p class="image-views" :class="{skeleton: loading}">Views: {{ viewCount }}</p>
      </section>
    </div>
  </li>
</template>

Во всем шаблоне мы применяем условный класса skeleton, при loading равном true:

:class="{ skeleton: loading }"

Внутри фигурных скобок skeleton – это строка класса, а loading – это переменная, которую мы используем, чтобы решить, будет ли класс добавлен или нет.

Обратите внимание, что в приведенном выше шаблоне мы также упрощаем строки шаблона для каждого фрагмента данных изображения: {{image.title}} теперь {{title}}. Это означает, что мы изменим некоторые свойства.

Шаг 3.2: Более подробный props

Затем обновим props в ImageCard:

props: { 
  image: {
    type: Object,
    default() {
      return {}
    }
  },
  loading: {
    type: Boolean,
    default: false,
  }
}

Чтобы добавить больше информации, props должен хранить объект, а не массив. Внутри props объекта каждый ключ – это имя переменной props. Этот ключ может хранить либо просто тип переменной, такой как loading: Boolean, либо мы можем пойти еще дальше, указав тип и значение по умолчанию. Несмотря на то, что вам не нужно ничего из этого, когда вы быстро создаете прототипы, в реальных приложениях эффективная практика заключается в том, чтобы делать именно так, поскольку это может предотвратить ошибки в будущем.

Выше мы говорим, что переменная image будет иметь тип Object и по умолчанию вернет пустой объект. loading будет Boolean с false по умолчанию.

Шаг 3.3: Новое свойство computed

Теперь пришло время добавить вычисленные свойства computed. Внутри ImageCard.vue добавьте раздел computed со следующими свойствами:

<script>
export default {
  ...,
  computed: {
    imageUrl() {
      if (this.loading) return TRANSPARENT_GIF
      return this.image.url_n
    },
    title() {
      return this.image.title || 'Untitled Image'
    },
    byline() {
      return `By ${this.image.ownername}`
    },
    timestamp() {
      return moment(this.image.datetaken).format("MMMM Do, YYYY")
    },
    viewCount() {
      const viewOrViews = this.image.views === 1 ? 'view' : 'views'
      return `${this.image.views} ${viewOrViews}`
    }
  }
}
</script>

Большинство из этих свойств довольно просты:

  • title()возвращает заголовок изображения, если он есть, или строку Untitled Image, если заголовка нет
  • byline() возвращает строку литерала шаблона, включающую атрибут имени владельца изображения
  • timestamp()использует библиотеку moment.js, которую мы уже импортировали, для форматирования даты. Теперь мы не используем filter.
  • viewCount() возвращает отформатированную строку с количеством просмотров фотографии. Если this.image.views равно 1, строка будет содержать 1 view. В противном случае в ней будет # views.

Немного сложнее – imageUrl(). Она возвращает внешнюю переменную, которая еще не существует, TRANSPARENT_GIF. Давайте добавим его сейчас, сразу после оператора импорта, до export default {…}:

<script>
import moment form 'moment';

const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
export default {...}
</script>

Значение TRANSPARENT_GIF – это картинка в base64, которая будет отображаться, пока loading имеет значение true. Мы используем это, потому что элемент img должен иметь что-то в качестве атрибута src, иначе его высота будет равна 0. Альтернативным подходом было бы использовать прозрачный PNG или заполнитель PNG из каталога ресурсов assets, но вышеупомянутый подход избавляет нас от дополнительного запроса и, следовательно, быстрее. Короче говоря, строка является ярлыком для отображения прозрачного изображения.

imageUrl() вернет прозрачный GIF, если loading равно true. Если loading будет false, он вернет URL изображения, которое будет отображаться.
i

Шаг 3.4: Стили Skeleton

Добавим необходимые стили skeleton и анимацию свечения в ImageCard.vue:

<style>
...
@keyframes skeleton-glow {
  from {
    border-color: rgba(206,217,224,.2);
    background: rgba(206,217,224,.2);
  }
  to {
    border-color: rgba(92,112,128,.2);
    background: rgba(92,112,128,.2);
  }
}
.skeleton {
  animation: skeleton-glow 1s linear infinite alternate;
  background-clip: border-box;
  background-clip: padding-box !important;
  background: rgba(206,217,224,.2) !important;
  border-color: rgba(206,217,224,.2) !important;
  border-radius: 2px;
  box-shadow: none !important;
  color: transparent !important;
  cursor: default;
  pointer-events: none;
  user-select: none;
}
</style>

Весь файл ImageCard.vue сейчас должен быть таким:

<template>
  <li class="image-card">
    <img 
      class="image-card__image"
      :class="{ skeleton: loading}" 
      :src="imageUrl" 
      :alt="title">
    <div class="image-card__body">
      <p class="image-title" :class="{skeleton: loading}">{{ title }}</p>
      <p class="image-owner" :class="{skeleton: loading}">{{ byline }}</p>
      <section class="image-date-view-wrapper">
        <p class="image-date" :class="{skeleton: loading}">{{ timestamp }}</p>
        <p class="image-views" :class="{skeleton: loading}">Views: {{ viewCount }}</p>
      </section>
    </div>
  </li>
</template>

<script>
import moment from 'moment';
const TRANSPARENT_GIF = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
export default {
  name: 'ImageCard',
  props: { 
    image: {
      type: Object,
      default() {
        return {}
      }
    },
    loading: {
      type: Boolean,
      default: false,
    }
  },
  computed: {
    imageUrl() {
      if (this.loading) return TRANSPARENT_GIF
      return this.image.url_n
    },
    title() {
      return this.image.title || 'Untitled Image'
    },
    byline() {
      return `By ${this.image.ownername}`
    },
    timestamp() {
      return moment(this.image.datetaken).format("MMMM Do, YYYY")
    },
    viewCount() {
      const viewOrViews = this.image.views === 1 ? 'view' : 'views'
      return `${this.image.views} ${viewOrViews}`
    }
  }
}
</script>

<style lang="scss">
.image-card {
  width: calc(33% - 1rem);
  margin: .5rem;
  border-radius: 5px;
  box-shadow: 0 5px 15px rgba(0, 0, 0, .15);
  background: white;
  @media only screen and (max-width: 799px) {
    width: calc(50% - 1rem);
  }
  @media only screen and (max-width: 549px) {
    width: 100%;
    margin: .5rem 0;
  }
}
.image-card__image {
  border-radius: 5px 5px 0 0;
  width: 100%;
  height: 200px;
  object-fit: cover;
}
.image-card__body {
  padding: .5rem 1rem 1rem;
}
.image-title {
  font-weight: bold;
  margin: 0;
}
.image-owner {
  margin-top: 0;
  font-size: .8rem;
}
.image-title,
.image-owner {
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
}
.image-date-view-wrapper {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.image-date,
.image-views {
  margin-bottom: 0;
  font-size: .8rem;
}
@keyframes skeleton-glow {
  from {
    border-color: rgba(206,217,224,.2);
    background: rgba(206,217,224,.2);
  }
  to {
    border-color: rgba(92,112,128,.2);
    background: rgba(92,112,128,.2);
  }
}
.skeleton {
  animation: skeleton-glow 1s linear infinite alternate;
  background-clip: border-box;
  background-clip: padding-box !important;
  background: rgba(206,217,224,.2) !important;
  border-color: rgba(206,217,224,.2) !important;
  border-radius: 2px;
  box-shadow: none !important;
  color: transparent !important;
  cursor: default;
  pointer-events: none;
  user-select: none;
}
</style>

Шаг 3.5: Изменения в SearchResults.vue

Мы почти завершили! Нам просто нужно обновить SearchResults.vue, чтобы он отображал наше шикарное новое состояние загрузки. Мы сделаем это, создавая два разных ul: один для загрузочных карт, а другой для реальных карт изображений. Если загрузка имеет значение false в SearchResults.vue, мы отобразим фактические карточки с изображениями. В противном случае мы покажем массив из 6 imageCard изображений с loading, установленном в значение true.

<template>
  <div class="wrapper">
    <h1>Results for: "{{tag}}"</h1>

    <ul v-if="!loading" class="image-card-grid">
      <image-card v-for="image in cleanImages" :key="image.id" :image="image" />
    </ul>

    <ul v-else class="image-card-grid">
      <image-card v-for="n in 6" :key="n" :loading="true" />
    </ul>
  </div>
</template>

<script>
import flickr from '../flickr.js';
import ImageCard from '@/components/ImageCard';
export default {
  name: 'searchResults',
  ...,
  computed: {
    isTagEmpty() {
        return !this.tag || this.tag.length === 0;
    },
    cleanImages() {
      return this.images.filter(image => image.url_n)
    }
  },
  methods: {
    search() {
      if (!this.isTagEmpty) {
        this.loading = true;
        this.fetchImages();
      }
    },
    fetchImages() {
      return flickr('photos.search', {
        tags: this.tag,
        extras: 'url_n, owner_name, description, date_taken, views',
        page: 1,
        per_page: 30,
      }).then((response) => {
        this.images = response.data.photos.photo;
        this.loading = false;
      });
    },
  }
};
</script>

<style>
...
</style>

“N in 6” в строке 10 – это просто более короткий способ создания цикла, который будет повторяться 6 раз:

<image-card v-for="n in 6" :key="n" :loading="true" />

В строках 24–26 также добавлено одно новое вычисляемое свойство isTagEmpty, которое проверяет, установлено ли для this.tag значение null или пустая строка. Мы вставим это в наш метод search(), чтобы пользователи не могли отправлять пустой поиск, что приведет к неправильному запросу API. Поместим оператор if для проверки isTagEmpty прямо внутри метода search. Если tag не пустой, мы сделаем запрос API. Более подробная обработка ошибок может быть сделана здесь в будущем, но этого пока достаточно:

search() {      
  if (!this.isTagEmpty) {        
    this.loading = true;        
    this.fetchImages();      
  }    
},

Подводя итоги и следующие шаги

Вот и все для этой части! Может показаться, что мы не сделали что-то «новое», но на самом деле мы сделали много работы. Наше приложение стало более гибким и DRY, плюс мы сделали:

  • добавили отдельную страницу с результатами поиска
  • компонент NavBar, который можно использовать на всех страницах
  • добавили новое состояние загрузки карт изображений

Еще раз спасибо Кэмерон Ботнер за помощь во многих из этих улучшений, особенно в эффекте состояния загрузки ImageCard!

В следующий третьей части, мы добавим больше функциональности на домашнюю страницу и начнем вызывать больше методов из API Flickr. ✌️

Оригинальная статья: Simple Photo App with Vue.js, Axios and Flickr API — Part 2


Spread the love

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *