Vue.js
July 22, 2021

Организация работы с Vue: к чему я пришёл

Моя первая публикация в блоге…

…и сразу же про Vue.js. На самом деле, меня довольно многое связывает с этим фреймворком. Моё первое SPA-приложение было написано на jQuery: использовало AJAX для загрузки уже отрендеренного HTML и замены его в определённом блоке, который сейчас бы я назвал router-outlet.

Vue.js стал первым фреймворком в моей жизни и сразу показал мне, что SPA - это не больно. Круто было осознавать, что больше не нужно изобретать кучу велосипедов, чтобы “сайт менял страницы не перезагружаясь”.

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

Небольшой дисклеймер: я не претендую на первенство. Скорее всего, так до меня уже кто-то делал. Я всего лишь делюсь своими соображениями.

Что предполагает Vue

Основной способ разработки на Vue.js - использование однофайловых компонентов. На первый взгляд это безумно удобно: все составляющие вашего приложения под рукой, в пределах одного файла, главное лишь правильно “разделить” весь интерфейс на логические блоки.

И роутер работает с компонентами, и основной блок приложения - компонент. Это очень удобно, когда приложение небольшого размера и логики в нём не очень много. Но стоит подумать о чём-то более сложном, как сразу же возникают проблемы.

Если проблему с хранением данных решает Vuex, то от избытка кода никуда не уйти. И хотя Webpack успешно решает вынос javascript-части приложения в отдельный файл через script src, размер этого файла легко может выйти из-под контроля.

Можно перенести часть логики во Vuex, но это также может оказаться не самой лучшей идеей. Во-первых потому, что рано или поздно хранилище разрастётся (даже если выносить всё в отдельные модули), а во-вторых потому, что (и это моё субъективное мнение) работа с Vuex довольна громоздкая в плане написания кода (this.$store.commit( ‘NameOfMutation’, payload )).

В итоге, спустя два года активного написания приложений на Vue, я пришёл к простой истине: разделение - это хорошо.

Разделение - это хорошо

Мой подход заключается в простой вещи: мы должны поделить приложение на “зоны ответственности”:

  1. Шаблон. Файл с расширением .vue, который нужен исключительно для отрисовки данных. В нём не должно происходить ничего, кроме того, что нужно для отрисовки данных. Исключение составляют методы и computed-свойства, отвечающие за финальную подготовку данных к показу в шаблоне.
  2. Стили. Если компонент или страница имеют большое количество стилей, то их лучше вынести в отдельный css-файл (или файл других стилевых движков). Для себя я определил порог в 100 строк. Если стилей больше, они переезжают в свой файл.
  3. Логика. Я предпочитаю выносить всю работу с логикой компонента в отдельный сервис. Благо, новые стандарты javascript уже имеют синтаксический сахар для классов, поэтому я использую именно их. Если ваш проект пишется на typescript, то использовать сервисы-классы становится ещё на порядок приятнее.
  4. Данные. Здесь нам приходит на помощь Vuex и его именованные пространства имён. Важно, что в vuex-хранилище я не провожу никакие операции с данными, несмотря на наличие actions. Сторы я использую только для хранения, а все операции производятся в сервисе.

На практике

Давайте представим, что мы решили написать компонент поиска фильмов по названию, с автокомплитом и обращением к серверу. Мы начинаем вводить название фильма, часть введённой строки отправляется на сервер, который ищет все подходящие варианты и отправляет их в ответе. Далее компонент показывает поле со списком фильмов, удовлетворивших условию поиска.

Весь дальнейший код – не пример готового приложения. Моя цель просто показать, как я организую работу с файлами и разделением кода.

Итак, представим, что мы находимся в папке src приложения. В ней я создаю директорию components/FilmSearcher. Создадим в ней следующие файлы:

# ./src/components/FilmSearcher/

./FilmSearcher.vue
./FilmSearcher.service.js
./FilmSearcher.store.js
./FilmSearcher.style.scss

Думаю, не трудно догадаться, за что отвечает каждый из этих файлов.

Файл шаблона будет выглядеть примерно так:

<template>
  <div class="film-searcher-component">
    <input 
      class="film-searcher-input" 
      type="text" 
      @input="searchFilms" 
      v-model="filmSearcherText"
    >
    <div class="film-searcher-autocomplete" v-if="filmVariants.length">
      <template v-for="(variant, idx) in filmVariants">
        <div class="film-searcher-variant" :key="idx" v-html="variant.title"></div>
      </template>
    </div>
  </div>
</template>

<script>
import FilmSearcherService from './FilmSearcher.service';

export default {
  name: 'FilmSearcher',
  data() {
    return {
      service: null,
      filmSearcherText: ''
    } 
  },
  computed: {
    filmVariants () {
      return this.service.filmVariants;
    }
  },
  methods: {
    async searchFilms () {
      await this.service.search(this.filmSearcherText);
    }
  },
  created () {
    this.service = new FilmSearcherService({
      store: this.$store
    });
  },
  beforeDestroy () {
    // Удаляем хранилище компонента
    this.service.unregisterStorage()
  }
}
</script>

<style lang="scss">
@import "./FilmSearcher.style";
</style>

В хуке created важно инициализировать наш сервис. Ещё важнее передать в него ссылку на глобальное хранилище приложения.

Пример файла-сервиса:

import http from 'http';
import FilmSearcherModule from './FilmSearcher.store' // Импортируем стор

export default class FilmSearcherService {
    constructor(payload) {
        this.payload = payload;
        this.registerStorage(); // Вызов создания хранилища
    }

    // Динамически создаём хранилище для нашего компонента
    registerStorage () {
        this.payload.store.registerModule('filmSearcher', FilmSearcherModule);
    }   
    
    // Удаляем хранилище компонента
    unregisterStorage () {
        this.payload.store.unregisterModule('filmSearcher');
    }   
    
    async search (filmTitle) {
        const foundVariants = await this.http.get('https://server.com', {title: filmTitle});
        const result = // ...здесь обрабатываем результаты, если нужно
        // Записываем обработанные данные в хранилище
        this.payload.store.commit('filmSearcher/setFilms', result);
    }

    get filmVariants () {
        return this.payload.store.getters['filmSearcher/films'];
    }

}

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

Также здесь мы используем возможность динамической регистрации модулей Vuex. Вы можете использовать и глобальный модуль, если это нужно.

Вот так выглядит vuex-модуль компонента

export default FilmSearcherModule {
    namespaced: true,
    state: {
        _films: []
    },
    mutations: {
        setFilms(state, payload) {
            state._films = payload;
        } 
    },
    getters: {
        films: (state) => state.films;
    }
}

Здесь наглядно видно, что vuex-модуль не используется для обработки данных. Только хранение, запись и чтение. Всё.

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

Проговорим вслух

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

  • При создании компонента вызывается конструктор его сервиса. В нём же мы автоматически инициализируем хранилище компонента.
  • Вся работа с данными в компоненте никогда не происходит напрямую. Компонент отдаёт приказ сервису, который уже залезает в хранилище, обрабатывает всё должным образом и отдаёт своему “начальнику”.
  • Сервис не только забирает данные из хранилища, но и добавляет их туда, обращаясь к мутациям.
  • Все операции с данными внутри хранилища происходят исключительно посредством мутаций и геттеров.
  • При запуске ивента beforeDestroy внутри компонента хранилище компонента автоматически удаляется.

А не лишнее?

Кому-то такой подход может показаться чрезмерным усложнением, и такое мнение, безусловно, имеет право на существование. Однако, мой опыт vue-разработки показал, что очень часто приложение дорабатывается, переделывается и дополняется, и такое разделение очень хорошо помогает управлять имеющимся функционалом.

Поначалу применение такого подхода немного увеличивает время, потраченное на начальном этапе разработки, но это время с лихвой компенсируется впоследствии, когда вы возвращаетесь с новыми задачами в старый компонент. Все преимущества разделения становятся прозрачнее при разработке больших проектов (особенно если в разработке используется TypeScript с class-based синтаксисом).

Итого

Во всех проектах, которыми я сейчас занимаюсь на работе и в качестве хобби, я использую именно такую разбивку. И, пока такой подход не покажет свою несостоятельность, я вряд ли вернусь к однофайловым компонентам.

Интересно мнение сообщества на этот счёт!