6 простых принципов написания приложения на Vue, которое легко поддерживать

Kate

Administrator
Команда форума
Привет! Меня зовут Наташа Калачева. Я Frontend-разработчик в компании AGIMA. Vue — один из самых популярных фреймворков JS, его используют для разработки SPA и PWA. А его главные плюсы — это понятная, четкая документация, готовая структура и низкий порог входа.

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

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

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

1. Делать простую масштабируемую структуру​

Базовая структура папок​

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

После инициализации приложения с Vue CLI мы уже видим предложенную структуру.

d8757c3cb58a0b52007efeef93bac429.png

Assets: здесь организуем хранение файлов CSS, шрифтов и изображений.

Components: это автономные компоненты Vue, которые одновременно инкапсулируют структуру шаблона, логику JavaScript и представление CSS.

Router: хранит все настройки роутинга и маршруты.

Store: содержит конфиг и данные хранилища (Vuex, Pinia).

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

Расширение базовой структуры​

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

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

На какие папки можно разделить компоненты?

Components (ui). Здесь хранятся компоненты дизайн-системы. Это самые простые элементы интерфейса, которые часто переиспользуются. Обычно эти компоненты взаимодействуют с «внешним миром» через пропсы и события. Обращение из них к стору и роутингу будет лишним. Чаще всего они не отправляют запросов к серверу и не содержат сложной бизнес-логики.

Примеры таких компонентов: инпуты, кнопки, алерты и другие UI-элементы.

5fa5dce8e733d478827dc7b6e8d6f868.png

Blocks. Это компоненты блоков. Блок — небольшой кусок интерфейса, который состоит из компонентов и уже имеет бизнес-логику. Примером блока может служить карточка продукта. Важно также хранить блоки простыми, не обращаться из них к стору, к роуту, не делать лишних запросов. Чаще всего таким компонентам достаточно информации из пропсов. Это позволит переиспользовать один блок для нескольких страниц.

Views/pages. Страницы собираются из блоков и компонентов, но сами по себе являются более сложными компонентами, из которых мы обращаемся к стору, роутингу и т. д.

Layouts. Хранит компоненты-макеты с данными, которые используются для нескольких страниц. На нем обычно присутствует Footer, Header, глобальный прелоадер и др. Например, может быть один макет для авторизованных пользователей, другой — для страницы авторизации.

Помимо компонентов, важно организовать хранение дополнительного JS-кода.

Plugins. В этой папке храним все сторонние библиотеки, там же их инициализируем и настраиваем.

Hooks. Можно выделить отдельную папку для хранения кода, использующегося в Setup-компонентах (composition API).

Helpers. Помощники будут содержать вспомогательные функции, которые вы хотели бы использовать в своем приложении. Например, функции форматирования, конвертирования данных или валидаторы.

API/services. Папка содержит все функции вызова API.

Constants. Всё, на что в приложении будут ссылаться глобально, но не хранится в .env, можно хранить здесь. Это могут быть статические данные или, например, список типов глобальных окон, которые можно вызвать глобально (через эмиттеры).

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

Эти папки описаны для примера и общего представления о том, как можно разделять кодовую базу. Конечно, можно видоизменять это под потребности проекта. Например, кто-то предпочитает хранить в /pages не только сам компонент страницы, но и папку этой страницы вместе со всеми используемыми блоками. Может быть удобно в папке /views хранить pages, store, blocks для каждого сервиса приложения.

Можно выбрать любой подходящий вариант, важно понимать, что несмотря на то, что компонент vue хранит в себе и template, и js, и css это не повод нагружать его слишком сильно.

2. Выделять все запросы к API​

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

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

44577ae2fa48a3381222954d723c99de.png

Каждый файл хранит и экспортирует нужные функции по категориям. Например, products.js может содержать следующее:

export function getProduct (id) {
return axios.get(`${API_URL}/products/${id}`)
}


export function postProduct (data) {
return axios.post(`${API_URL}/products`, data)
}


export function patchProduct (data) {
return axios.patch(`${API_URL}/products`, data)
}

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

Далее мы можем использовать расширенный вариант функции в сторе или методах компонента, нанизывая бизнес-логику, хранение данных, отлов ошибок. Например, в сторе:

import { getProduct } from '@/api/products'
import { IProduct } from '@/interfaces'
const actions = {
async fetchProduct ({ commit }, id: string): Promise<IProduct> {
try {
commit('setIsLoading', true)
const response = await getProduct(id)
commit('setProduct', response.data)
return response.data
} catch (err) {
// отлавливаем ошибки
} finally {
commit('setIsLoading', false)
}
}
}

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

methods: {
getProductById (id) {
return axios.get(`https://example.com/api/products/${id}`)
}
}

Когда новому разработчику понадобится этот запрос в другом компоненте, ему придется копипастить или писать заново. А если поменяется урл запроса, то менять его в нескольких местах.

Основной путь к серверу лежит в .env, например VUE_APP_API_URL. Так что доступ к этой переменной возможно получить в любом месте приложения, и он может динамически изменяться в зависимости от окружения.

const API_URL = process.env.VUE_APP_API_URL
Основные плюсы этого подхода в том, что он достаточно прост и гибок, обеспечивает хорошую читабельность, сразу видно, какие запросы возможны в любой части приложения.

Это отлично работает для небольших и средних приложений, в которых не хочется усложнять структуру. Хотя для больших систем можно посмотреть в сторону разделения данных ORM. Подробнее об этом в документации для Vuex ORM. Это плагин Vuex, который позволяет разделять состояние приложения с точки зрения объектов данных (продуктов, пользователей и т. д.) и операций CRUD (создания, обновления, удаления).

3. Использовать стор, когда это действительно необходимо, и разделять на модули​

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

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

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

Но всегда ли стор необходим?​

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

Например, при переходе с одного продукта на другой данные не обновляются сразу, и пользователь видит какое-то время «старые» данные. Нужно дополнительно заботиться не только о получении и хранении данных, но и об их обновлении/удалении.

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

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

  • Будут ли данные повторно использоваться где-то еще?
  • Могу ли я вместо этого использовать здесь локальное состояние?
  • Способствует ли использование стора улучшению архитектуры приложения?
Если ответы на эти вопросы вызывают сомнения, то, возможно, стоит использовать props или provide/inject.

47d4423558b23a8bd405f5978ac917b9.png

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

Примеры разделения на модули:

  • index.js — основной файл стора, который импортирует и хранит все модули;
  • auth.js — хранит состояние авторизации, логин, логаут, рефреш токена и т.д.;
  • user.js — хранит данные юзера и методы, связанные с ними;
  • config.js — хранит настройки приложения.
Создание модуля ничем не отличается от обычного центрального хранилища Vuex. Синтаксис почти такой же, за исключением того, что мы не создаем новый экземпляр хранилища в модуле. Вместо этого мы экспортируем объект, содержащий все его свойства, в экземпляр центрального хранилища:

const defaultState = () => {
return {
exampleData: {
prop1: '1',
prop2: '2'
}
}
}


const getters = {
exampleGetter: (state) => state.exampleData.prop1
}


const mutations = {
setExampleData (state, data) {
state.exampleData = data
}
}


const actions = {
async fetchData ({ commit }) {
try {
const response = await getData()
commit('setExampleData', response.data)
} catch (err) {
console.error(err)
}
},


}


export default {
namespaced: true,
state: defaultState,
getters,
actions,
mutations
}

В то же время в основном хранилище мы импортируем модули:

import example from '@/store/example'



export default {
state: defaultState,
getters,
actions,
mutations,
modules: { example }
}

Хорошая практика использовать атрибут namespaced:true у модуля, чтобы обеспечить его автономность. В этом случае вы сможете повторно использовать одно и то же имя для своего состояния, изменения и действий, не вызывая ошибок. В компонентах мы можем обращаться напрямую к модулю по его названию:

computed: {
...mapGetters('example', ['exampleGetter'])
}

4. Оборачивать библиотеки при инициализации​

Лучше всего создавать отдельный файл для инициализации широко используемых плагинов. Например, вместо того чтобы тянуть Axios в компонентах непосредственно из зависимостей, вы можете создать класс с более общим именем — такой, как HTTP, чьи методы вызывают Axios под капотом. Например:

import axios from 'axios';
class $Http {
constructor(options) {
this.instance = axios.create({
baseURL: options?.baseURL ?? '',
headers: options?.headers ?? {},
params: options?.params ?? {},
});
}
get = async (resource, params) => this.instance.get(resource, { params });
// другие методы класса, интерцепторы и т.д.
}
export default $Http;
На первый взгляд, это бесполезный модуль, ведь он делает то же самое, что делал бы модуль из зависимости. Только вы вместо этого пишете импорт HTTP из «http.js». Однако это облегчает переиспользование кода и расширение общей функциональности. Мы можем расширять наш класс и добавлять новые функции/конфиги глобально.

Например, с Axios это может быть использование интерсепторов. Мы можем всегда предупреждать пользователя, когда запрос ajax терпит неудачу, а не использовать catch везде, где делаем запрос.

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

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

  • Вы не уверены в том, что реализация библиотеки на 100% удовлетворяет будущие задачи?
  • Эта зависимость требует настройки и расширения функционала?
  • Будете ли это переиспользовать в разных частях приложения?
  • Вы будете использовать только часть функционала?
Если я могу с уверенностью ответить «да» на часть этих вопросов, значит, дополнительная работа над оберткой оправдана.

5. Валидировать пропсы, использовать типы​

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


export default {
name: 'BlockName',
props: {
title: {
type: String,
default: ''
}
}
}

Если вы используете Typescript, то сложные типы можно описывать с помощью интерфейсов:

import { PropType, defineComponent } from 'vue'


interface IItem {
id: string
title: string
}


export default defineComponent({
name: 'BaseList',
props: {
list: {
type: Array as PropType<IItem[]>,
default: () => []
}
}
})

Лучший способ ограничить пропсы определенным набором значений — использовать параметр валидатора. Это функция проверки, которая принимает значение и возвращает один из двух результатов — true или false.

export default {
name: 'BaseIcon',
props: {
position: {
type: String,
validator: s => ['top', 'left'].includes(s)
}
}
};

Всё вышеперечисленное справедливо при использовании Vue 3 либо с options, либо с composition API. Разница только в setup: пропсы должны быть объявлены с помощью функции defineProps(). Например:

<script setup lang="ts">
interface IProps {
title: string
list: Array<IItem>
}


const { title, list } = defineProps<IProps>()
</script>
В сочетании с TypeScript встроенные механизмы валидации и типизирования пропсов могут обеспечить правильное использование компонента, уменьшить количество ошибок и упростить общее понимание кода.

6. Организовать стили​

Даже если логика нашего приложения написана идеально, CSS может значительно подпортить внешний вид и создать множество проблем при дебаге и расширении/изменении дизайна. Важно уделять стилям повышенное внимание, чтобы избежать конфликтов и непредсказуемого поведения.

Есть множество методологий написания CSS, например БЭМ, OOCSS, SMACSS, Atomic CSS и т. п. Среди этих подходов нет идеального — у всех свои плюсы и минусы. Поэтому можно выбрать один, а можно совместить несколько. Важно договориться с командой о едином подходе. Тогда масштабирование проекта будет даваться легче.

Нужно прописать глобальные стили, которые распространяются на всё приложение. Есть несколько стилей, которые мы хотим применить ко всем компонентам. Например, normalize.css или reset.css. Или общие стили, такие как шрифты, размеры заголовка, цвета и CSS-переменные. Добавим их в тег стиля App.vue

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


<style>
body {
margin: 0;
}
</style>
Стили компонента всегда изолируются при помощи директивы scoped. Это гарантирует, что любой стиль CSS, определенный в компоненте, будет применяться только к этому шаблону и не создаст конфликта стилей за его пределами.

<style lang="scss" scoped>
.Item {
&__text {
margin-bottom: 30px;
}
}
</style>

Если стилей одного компонента много или одни и те же стили переиспользуются в нескольких компонентах, в assets я создаю отдельную папку styles/components и добавляю туда стили, которые в дальнейшем импортирую в компонент.

<style lang="scss" scoped>
@import "@/assets/styles/components/table.scss";
</style>
Однако использование scoped не отменяет использование классов для стилизации элементов. Это наиболее производительный вариант селектора по сравнению с названием тегов. Подробнее об этом в документации.

Как альтернативу можно использовать CSS Module. Они также решают проблему конфликта стилей. Но модули предполагают динамический рендеринг и отображение имен классов (например, можно настроить классы в зависимости от среды разработки или props).

<template>
<section>
<div>
<h1 :class="$style.heading">Заголовок</h1>
</div>
</section>
</template>




<style module>
.heading {
font-size: 50px;
font-weight: 900;
text-align: center;
}
</style>


Данный модуль генерирует следующий CSS:

.ComponentName__heading__2Kxy {
font-size: 50px;
font-weight: 900;
text-align: center;
}

Заключение​

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

 
Сверху