Думаю, все уже знают о том, как написать поиск по пользователям GitHub на React, Svelte, Angular, или вообще без них. Ну и как же тут обойтись без Vue? Самое время заполнить этот пробел.

Итак, сегодня мы создадим то самое приложение с использованием Vue, напишем для него тесты на Cypress и немного затронем Vue CLI 3.
В посте есть гифки
Подготовка
Для начала установим Vue CLI последней версии:
npm i -g @vue/cli
И запустим создание проекта:
vue create vue-github-search
Следуем по шагам генератора. Для нашего проекта я выбрал Manual mode и следующую конфигурацию:

Дополнительные модули
В качестве стилей будем использовать Stylus, поэтому нам понадобятся stylus и stylus-loader. Ещё нам понадобится Axios для запросов в сеть и Lodash, из которого мы возьмем функцию debounce.
Перейдем в папку проекта и установим необходимые пакеты:
cd vue-github-search npm i stylus stylus-loader axios lodash
Проверяем
Запускаем проект и убеждаемся что все работает:
npm run serve
Все изменения в коде будут мгновенно применяться в браузере без перезагрузки страницы.
Store
Начнём с того, что напишем vuex store, где будут все данные приложения. Хранить нам нужно всего-то ничего: поисковый запрос, данные пользователя и флаг процесса загрузки.
Откроем store.js и опишем изначальное состояние приложения и необходимые мутации:
... const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; const SET_LOADING = 'SET_LOADING'; const SET_USER = 'SET_USER'; const RESET_USER = 'RESET_USER'; export default new Vuex.Store({ state: { searchQuery: '', loading: false, user: null }, mutations: { [SET_SEARCH_QUERY]: (state, searchQuery) => state.searchQuery = searchQuery, [SET_LOADING]: (state, loading) => state.loading = loading, [SET_USER]: (state, user) => state.user = user, [RESET_USER]: state => state.user = null } });
Добавим action'ы для загрузки данных с GitHub API и для изменения поискового запроса (понадобиться нам для поисковой строки). В итоге наш store примет такой вид:
store.js
import Vue from 'vue'; import Vuex from 'vuex'; import axios from 'axios'; Vue.use(Vuex); const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; const SET_LOADING = 'SET_LOADING'; const SET_USER = 'SET_USER'; const RESET_USER = 'RESET_USER'; export default new Vuex.Store({ state: { searchQuery: '', loading: false, user: null }, mutations: { [SET_SEARCH_QUERY]: (state, searchQuery) => state.searchQuery = searchQuery, [SET_LOADING]: (state, loading) => state.loading = loading, [SET_USER]: (state, user) => state.user = user, [RESET_USER]: state => state.user = null }, actions: { setSearchQuery({commit}, searchQuery) { commit(SET_SEARCH_QUERY, searchQuery); }, async search({commit, state}) { commit(SET_LOADING, true); try { const {data} = await axios.get(`https://api.github.com/users/${state.searchQuery}`); commit(SET_USER, data); } catch (e) { commit(RESET_USER); } commit(SET_LOADING, false); } } });
Строка поиска
Создадим новый компонент Search.vue в папке components. Добавим computed-свойство, чтобы связать компонент со store. При изменениях поискового запроса будем вызывать поиск с debounce.
Search.vue
<template> <input v-model="query" @input="debouncedSearch" placeholder="Enter username" /> </template> <script> import {mapActions, mapState} from 'vuex'; import debounce from 'lodash/debounce'; export default { name: 'search', computed: { ...mapState(['searchQuery']), query: { get() { return this.searchQuery; }, set(val) { return this.setSearchQuery(val); } } }, methods: { ...mapActions(['setSearchQuery', 'search']), debouncedSearch: debounce(function () { this.search(); }, 500) } }; </script> <style lang="stylus" scoped> input width 100% font-size 16px text-align center </style>
Теперь подключим нашу строку поиска в главный компонент App.vue и попутно удалим лишние строки, созданные генератором.
App.vue
<template> <div id="app"> <Search /> </div> </template> <script> import Search from './components/Search'; export default { name: 'app', components: { Search } }; </script> <style lang="stylus"> #app font-family 'Avenir', Helvetica, Arial, sans-serif font-smoothing antialiased margin 10px </style>
Посмотрим результат в браузере, убедившись что всё работает с помощью vue-devtools:

Как видим, у нас уже готова вся логика приложения! Мы вводим имя пользователя, выполняется запрос и данные профиля сохраняются в store.
Профиль пользователя
Создадим компонент User.vue и добавим логику для индикации загрузки, отображения профиля и ошибку, когда пользователь не найден. Также добавим анимацию переходов.
<template> <div class="github-card"> <transition name="fade" mode="out-in"> <div v-if="loading" key="loading"> Loading </div> <div v-else-if="user" key="user"> <div class="background" :style="{backgroundImage: `url(${user.avatar_url})`}" /> <div class="content"> <a class="avatar" :href="`https://github.com/${user.login}`" target="_blank"> <img :src="user.avatar_url" :alt="user.login" /> </a> <h1>{{user.name || user.login}}</h1> <ul class="status"> <li> <a :href="`https://github.com/${user.login}?tab=repositories`" target="_blank"> <strong>{{user.public_repos}}</strong> <span>Repos</span> </a> </li> <li> <a :href="`https://gist.github.com/${user.login}`" target="_blank"> <strong>{{user.public_gists}}</strong> <span>Gists</span> </a> </li> <li> <a :href="`https://github.com/${user.login}/followers`" target="_blank"> <strong>{{user.followers}}</strong> <span>Followers</span> </a> </li> </ul> </div> </div> <div v-else key="not-found"> User not found </div> </transition> </div> </template> <script> import {mapState} from 'vuex'; export default { name: 'User', computed: mapState(['loading', 'user']) }; </script> <style lang="stylus" scoped> .github-card margin-top 50px padding 20px text-align center background #fff color #000 position relative h1 margin 16px 0 20px line-height 1 font-size 24px font-weight 500 .background filter blur(10px) opacity(50%) z-index 1 position absolute top 0 left 0 right 0 bottom 0 background-size cover background-position center background-color #fff .content position relative z-index 2 .avatar display inline-block overflow hidden background #fff border-radius 100% text-decoration none img display block width 80px height 80px .status background white ul text-transform uppercase font-size 12px color gray list-style-type none margin 0 padding 0 border-top 1px solid lightgray border-bottom 1px solid lightgray zoom 1 &:after display block content '' clear both li width 33% float left padding 8px 0 box-shadow 1px 0 0 #eee &:last-of-type box-shadow none strong display block color #292f33 font-size 16px line-height 1.6 a color #707070 text-decoration none &:hover color #4183c4 .fade-enter-active, .fade-leave-active transition opacity .5s .fade-enter, .fade-leave-to opacity 0 </style>
Подключим наш компонент в App.vue и насладимся результатом:
<template> <div id="app"> <Search /> <User /> </div> </template> <script> import Search from './components/Search'; import User from './components/User'; export default { name: 'app', components: { User, Search } }; </script> <style lang="stylus"> #app font-family 'Avenir', Helvetica, Arial, sans-serif font-smoothing antialiased margin 10px </style>

Тесты
Напишем простые тесты для нашего приложения.
tests/e2e/specs/test.js
describe('Github User Search', () => { it('has input for username', () => { cy.visit('/'); cy.get('input'); }); it('has "User not found" caption', () => { cy.visit('/'); cy.contains('User not found'); }); it("finds Linus Torvalds' GitHub page", () => { cy.visit('/'); cy.get('input').type('torvalds'); cy.contains('Linus Torvalds'); cy.get('img'); cy.contains('span', 'Repos'); cy.contains('span', 'Gists'); cy.contains('span', 'Followers'); }); it("doesn't find nonexistent page", () => { cy.visit('/'); cy.get('input').type('_some_random_name_6m92msz23_2'); cy.contains('User not found'); }); });
Запустим тесты командой
npm run test:e2e
В открывшемся окне нажимаем кнопку Run all specs и видим, что тесты проходят:

Сборка
Vue CLI 3 поддерживает новый режим сборки приложения, modern mode. Он создает 2 версии скриптов: облегченную для современных браузеров, которые поддерживают последние фичи JavaScript, и полную версию со всеми необходимыми полифилами для более старых. Главная прелесть заключается в том, что нам абсолютно не нужно заморачиваться с деплоем такого приложения. Это просто работает. Если браузер поддерживает <script type="module">, он сам подтянет облегченную сборку. Как это работaет, можно подробнее почитать в этой статье.
Добавим в package.json флаг modern к команде сборки:
"build": "vue-cli-service build --modern"
Собираем проект:
npm run build
Посмотрим на размеры итоговых скриптов:
8.0K ./app-legacy.cb7436d4.js 8.0K ./app.b16ff4f7.js 116K ./chunk-vendors-legacy.1f6dfb2a.js 96K ./chunk-vendors.a98036c9.js
Как видим, новый метод действительно уменьшает размер сборки. Разница будет еще более заметна на крупных проектах, поэтому фича однозначно заслуживает внимания.
Код
На этом все, спасибо за внимание!
