В этой статье представлена архитектура фронтенда (для приложений, созданных с помощью Vue, React, Svelte и т. д.), в которой легко разобраться и которую легко поддерживать. Если вы создаете среднее/большое приложение и часто задаетесь вопросом, что и где должно быть, эта статья может быть вам полезна.
Преимущества хорошей архитектуры
Прежде чем углубляться в технические вопросы, давайте сначала решим небольшую задачу:
Вы можете с первого взгляда сказать, как заменить степлер на ленту? Некоторые из вас могут придумать интересный способ сделать это, но большинство из нас не сможет сразу понять, как решить эту задачу. Для наших глаз это - беспорядок, который сбивает с толку наш мозг.
Посмотрите теперь сюда:
А сейчас, можете сразу сказать, как заменить степлер на изображении выше? Нам просто нужно развязать привязанную к нему веревку и положить ленту на его место. Для этого вам не нужно почти никакого умственного усилия.
Представьте, что все предметы на изображениях выше являются модулями или частями вашего программного обеспечения. Хорошая архитектура должна больше походить на второй вариант расположения предметов. Преимущества такой архитектуры:
- Снижается когнитивная нагрузка/количество умственных усилий при работе над проектом.
- Ваш код становится более модульным, слабосвязанным, а значит, более тестируемым и поддерживаемым.
- Упрощается процесс замены той или иной детали в архитектуре.
Общая архитектура фронтенда
Самый простой и распространенный способ разделения фронтенда в настоящее время может выглядеть примерно так:
Поначалу в вышеупомянутой архитектуре нет ничего плохого. Но затем возникает общий паттерн, по которому вы жестко связываете части архитектуры вместе. Например, вот простое приложение счетчика, написанное на Vue 3 с Vuex 4:
<template>
<p>The count is {{ counterValue }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</template>
<script lang="ts">
import { computed } from 'vue';
import { useStore } from 'vuex';
export default {
name: 'Counter',
setup() {
const store = useStore();
const count = computed<number>(() => store.getters.count);
const increment = () => {
store.dispatch('increment');
};
const decrement = () => {
store.dispatch('decrement');
};
return {
count,
increment,
decrement
};
}
}
</script>
Вы увидите, что это довольно распространенный шаблон в приложениях, написанных на Vue 3 и Vuex, потому что он находится в руководстве по Vuex 4. Собственно, это также обычный шаблон для React с Redux или Svelte с Svelte Stores:
- Пример с React и Redux:
import React, { useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
export const CounterComponent = () => {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
const increment = () => {
dispatch({ type: 'increment' });
};
const decrement = () => {
dispatch({ type: 'decrement' });
};
return (
<div>
<p>The count is {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
};
- Пример со Svelte и Svelte Stores:
<script>
import { count } from './stores.js';
function increment() {
count.update(n => n + 1);
}
function decrement() {
count.update(n => n - 1);
}
</script>
<p>The count is {$count}</p>
<button on:click={increment}>+</button>
<button on:click={decrement}>-</button>
В этом нет ничего плохого. Фактически, большинство средних и крупных приложений, вероятно, написаны именно так. Это рекомендуемые способы в официальных руководствах / учебных пособиях.
Однако всегда приходится идти на компромисс. Итак, каковы преимущества и недостатки этого шаблона?
Самым очевидным преимуществом, вероятно, является простота.
Но чем приходится жертвовать ради этого?
Вы жестко связали сторы (stores) с компонентами. А что, если однажды ваша команда обнаружит, что Redux больше не подходит для приложения (возможно, потому, что он слишком сложен), и захочет переключиться на что-то другое? Вам придется не только переписать все свои stores, но и логику компонентов React, которые были жестко связаны с Redux.
Те же проблемы случаются со всеми остальными слоями вашего приложения. В конце концов, вы не сможете легко заменить часть вашего приложения чем-то другим, потому что все было тесно связано друг с другом. Лучше просто переписать все с нуля.
Но всё не обязательно должно быть именно так. По-настоящему модульная архитектура может позволить вам заменить приложение React + Redux на React + MobX (или Valtio) или ещё безумнее, React + Vuex или Vue + Redux (по любой причине), не затрагивая другие части приложения.
Итак, как нам заменить часть приложения, не влияя на остальные, или, другими словами, как нам отделить каждую часть нашего приложения друг от друга?
Представляем другой подход
Характеристики слоев следующие:
Presentation: этот уровень в основном состоит из компонентов пользовательского интерфейса. Для Vue это Vue SFcs. Для React это компоненты React. Для Svelte это SFC Svelte. И так далее. Уровень представления напрямую связан с уровнем приложения.
Application: этот уровень содержит логику приложения. Он знает об уровне домена и уровне инфраструктуры. В этой архитектуре уровень приложения реализован через React Hooks в React или Vue «Hooks» в Vue 3.
Domain: этот уровень предназначен для доменной/бизнес-логики. На уровне домена "живет" только бизнес-логика, поэтому здесь есть только чистый код JavaScript/TypeScript без каких-либо фреймворков/библиотек.
Infrastructure: этот уровень отвечает за связь с внешним миром (отправка запросов/ получение ответов) и хранение локальных данных. Вот пример библиотек, которые вы могли бы использовать в реальном приложении для этого уровня:
- HTTP-запросы/ответы: Axios, Fetch API, Apollo Client и т. д.
- Stores (управление состоянием): Vuex, Redux, MobX, Valtio и т. д.
Применение архитектуры
Если применить эту архитектуру к приложению, это будет выглядеть так:
Следующие характеристики взяты из приведенной выше схемы архитектуры:
- Когда вы заменяете библиотеку / фреймворк UI, это затрагивает только уровни презентации и приложения.
- На уровне инфраструктуры, когда вы заменяете детали реализации store (например, заменяете Redux на Vuex), это влияет только на само хранилище. То же самое касается замены Axios на Fetch API или наоборот. Уровень приложения не знает деталей реализации хранилища или HTTP-клиента. Другими словами, мы отделили React от Redux / Vuex / MobX. Логика сторов также достаточно универсальна, чтобы её можно было использовать не только с React, но и с Vue или Svelte.
- Если бизнес-логика изменится, уровень домена необходимо будет соответствующим образом изменить, и это повлияет на другие части архитектуры.
Что более интересно в этой архитектуре, так это то, что вы можете еще больше разбить ее на модули:
Предостережения
Несмотря на то, что архитектура может отделять части приложения друг от друга, за это приходится платить повышенной сложностью. Поэтому, если вы работаете над небольшим приложением, я бы не рекомендовал её использовать. Не стреляйте из пушки по воробьям.
В случае более сложного приложения эта архитектура, вероятно, может помочь вам добиться чего-то вроде этого:
Пример
Я создал простое приложение-счетчик, которое демонстрирует достоинства этой архитектуры. Вы можете посмотреть исходный код здесь: https://github.com/huy-ta/f flexible-counter-app.
В это приложение я включил Vue, React и Vue с Vuex, Redux, MobX, Valtio и даже localStorage. Все они могут быть заменены, не влияя друг на друга. Следуйте простым инструкциям из файла README и попробуйте заменить какую-нибудь часть приложения на другую имплементацию.
Я знаю, что в данном случае стреляю из пушки по воробьям, но о создании сложного приложения для меня сейчас не может быть и речи. Однако в Linagora мы пытаемся применить эту архитектуру к одному из наших проектов - Twake Console, исходный код которого скоро будет открыт. Пожалуйста, ожидайте с нетерпением.
Вопросы и обсуждения приветствуются😊.