Архитектура фронтенд-приложений — миф или реальность
Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!
3 июля прошел первый ивент Uinno meetups. Мы решили локально организовать небольшой митапчик для разработчиков с опытом. В Запорожье в целом с IT-комьюнити проблемы. Точнее, было несколько попыток его создать, но они так ни к чему и не привели. Мы заметили (еще бы), что в сети слишком много ивентов на тему «войтивайти», но, к сожалению, практически нет мероприятий для ребят «повидавших» за свою карьеру.
Мы подобрали не простые, но очень важные, на наш взгляд, темы. Две темы по hard skills, одна тема по soft skills и анонсировали событие. Рассчитывали, что к нам максимум придет человек 30, были бы рады, если бы пришло
Ниже в статье вы можете подробно изучить конспект выступления Дмитрия Брагинца, тимлида Uinno, на тему архитектурных решений на фронтенде. Енджой ит.
Дисклеймер: хочу сразу предупредить, что если вы надеетесь, что, прочитав данную статью, познаете сакральные тайны бытия, то спешу вас огорчить. Это не так. Я далеко не Дядюшка Боб или Мартин Фаулер и не претендую на истину в первой инстанции. Всего лишь хочу поделиться своими мыслями. И надеюсь, что прочитанное заставит задуматься тех, кто ранее не задумывался о проблемах архитектурных решений, и придаст уверенности тем, кого этот вопрос уже гложет.
Так есть ли проблема
Базируясь на своем опыте, а также на исследовании «околодевелоперских» медиаресурсов, я могу предположить следующее. У фронтенд-инженеров (я все-таки хочу думать, что мы таки инженеры, а не фреймворк-программисты) всё-таки наблюдается перекос в сторону технологических решений, а не архитектурных.
Что я имею в виду? Например, то, что в большинстве обсуждений в интернете, будь то статьи на «Хабре», твиттер или «Медиум», в основном затрагиваются новые библиотеки, фреймворки, локальные решения. И очень редко когда можно увидеть обсуждение архитектурных проблем. Как мне кажется, данные обсуждения являются прямым отображением того кода, который пишут люди. Я много раз встречал проекты с действительно крутыми и навороченными локальными решениями, но расширять или модифицировать эти проекты приходилось не благодаря, а вопреки.
Корни проблемы
Как мне кажется, основной проблемой является то, что долгое время к фронтенд-части относились абсолютно несерьезно, а тех, кто занимался фронтом и за программистов то особо не считали. И пока бородатые и умудренные опытом бэкендеры обмазывались паттернами и решали архитектурные проблемы, верстаки-фронтендеры клепали формочки, приправленные jQuery.
Но времена меняются, и после того, как Google выпустил Gmail, который, по сути, является прообразом веб-приложений, у всех резко зачесалось и бизнес начал диктовать условия, мол, тоже так хочу. И понеслось — Angular, React, Vue (это я упоминаю только самые популярные в среде разработчиков решения). Сложность приложений возрастала с каждым годом, а кому приходилось всё это воплощать в жизнь? Правильно — вчерашним верстакам-фронтендерам или, прости господи, вебмастерам, у которых при слове «архитектура» максимум возникали ассоциации с древнегреческими храмами.
Будущие фронтенд-инженеры шли по тому пути, который уважаемые господа-бэкендеры протопали еще, наверное, в
Вторая, но не менее важная часть — это то, что в настоящее время фронтенд стал «воротами» в мир IT. Спрос на JS-разработчиков просто устрашающий, и это сильно помогает всем желающим прям с ноги открыть дверь в манящий мир смузи, макбуков и гироскутеров.
Это не плохо и не хорошо — это реалии текущего рынка. Плохо то, что обучить такое количество людей сложно, а некоторым компаниям и в целом не рентабельно, главное, чтобы весла ритмично погружались в пучину...
В итоге мы получаем миллионы строк кода, который худо-бедно работает, но истошно кряхтит под тяжестью технического долга. И данные подходы мигрируют из проекта в проект.
Что я подразумеваю под архитектурой
Конечно, легко взять какую-нибудь книгу вышеупомянутых уважаемых авторов и взять определение оттуда. Ну или, на худой конец, довольствоваться определением из Википедии, но, чтобы упростить наше общение и не прослыть снобом, я все же с вашего позволения до неприличия сокращу формулировку.
Архитектура — это то, как части/компоненты/модули вашей программы взаимодействуют между собой. Например, у вас самое настоящее одностраничное веб-приложение. Что я подразумеваю под «настоящим»? То, что оно всё записано в одном файле на
Простой пример
Примеры, которые я буду рассматривать в своей статье, будут в большей степени притянуты за уши. Это сделано для упрощения и для того, чтобы как-нибудь уместить это всё на пару-тройку сниппетов кода. Потому что если брать примеры из реальных проектов, то я замучаюсь писать статью, а вам скоро станет скучно от прочтения.
Ниже перед вами предстанет простой пример виджета списка задач (да, это очередной тудушник), который можно в похожих интерпретациях найти на просторах интернетов. Я позволил себе вольность использовать псевдокод, но лишь с целью уйти от деталей реализации, а сконцентрироваться на архитектуре.
Для описания примеров буду использовать React, так как я думаю, он будет достаточно понятен всем. Но подобные подходы с легкостью переносятся на Vue, особенно с использованием composition API.
const TodoListComponent = ({ todoListId }) => { const [todoList, setTodoList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const todoCreateHandler = useCallback((todo) => { axios .post("/todo", { // post body }) .then() .catch(); }); // handle API call to update todo by id const todoItemCheckHandler = useCallback(/** ... */); useEffect(() => { if (!isModal) { axios.get(`/todo-list/${todoListId}`).then((res) => { const data = lodash.get(res, "data"); setTodoList(data); }); } }, [todoListId]); return ( <div> <h2>{todoList.title}</h2> <ul> {todoList.todos.map(() => { /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={todoCreateHandler} /> </ul> </div> ); };
Как вы видите, тут всё в лучших традициях «обучающих статей» с medium/dev.to/etc. Мы получаем данные, используя axios прям компоненте, потом при помощи lodash достаем данные из тела ответа от API, сохраняем это дело в локальный стейт и мапим в JSX. И это всё будет работать.
А если это работает, то в чём же проблема, спросите вы? По моему мнению, проблема в том, что мы гвоздями приколотили все возможные зависимости к компоненту. Компонент знает о том, как, с помощью чего и откуда мы получаем данные, производим с ним манипуляции и потом полученную с бэкенда структуру напрямую мапим в наш компонент. А ещё нам нужно тут же обрабатывать состояние loading/fetched/error.
Давайте представим базовый случай. Бэкендеры изменили структуру ответа, и нам нужно менять компонент. Если они изменили названия полей, то нам придётся менять JSX/Template. Если мы решим использовать redux/vuex/state-manager, то нам опять же придётся переписывать компонент. Давайте пошагово рассмотрим то, что мы сможем сделать.
Делаем лучше
- Создадим APIClient, который будет использовать axios как транспорт.
- Так же выносим утилиты в отдельный модуль:
const TodoListComponent = ({todoListId}) => { const [todoList, setTodoList] = useState([]); const todoCreateHandler = useCallback((todo) => { apiClient.createTodo(todoListId, todo).then().catch() }) // handle API call to update todo by id const todoItemCheckHandler = useCallback(/** ... */); useEffect(() => { // Here we "hide" the axios and lodash into the separate modules apiClient.getTodoList(todoListId) .then(setTodoList); }, [todoListId]) return ( <div> <h2>{todoList.title}</h2> <ul> {todoList.todos.map(() =>{ /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={todoCreateHandler}/> </ul> </div> ) }
Теперь наш компонент не знает о том, что мы используем axios и lodash, и так же не знает о том, что мы ломимся на какой-то конкретный эндпоинт. Но мы по-прежнему напрямую используем в отображении данные, которые предоставляет нам бэкенд.
И еще...
- Применим что-то похожее на паттерн Repository, чтобы инкапсулировать получение данных о TodoList. Назовём его к примеру TodoListHttpRepository.
- Если мы используем React или Vue3, то с помощью хуков/composition API сделаем переиспользуемый модуль, который будет с помощью TodoListHttpRepository получать данные, создавать из них объект/экземпляр класса, который будет следовать контракту.
class TodoList { constructor(todoList) { this.title = todoList.listTitle; this.todos = todoList.listTodos; } } class TodoListHttpRepository { // Here we will pass the axios based api client constructor(transport) { this.transport = transport; } async getById(id) { const res = this.transport.get(`/todo-list/${id}`); return new TodoList(res); } async create(todoList) { /** */ } async updateById(id, todoList) { /** */ } async delete(id) { /** */ } }
const todoListRepo = new TodoListRepository(apiClient); const useTodoList = (todoListId) => { const [todoList, setTodoList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoading(true); todoListRepo .getById(todoListId) .then(setTodoList) .catch(setError) .finally(() => setLoading(false)); }, [todoListId]); return { data: todoList, error, loading }; };
const TodoListComponent = ({ todoListId }) => { const { data, loading, error } = useTodoList(todoListId); const todoItemCheckHandler = useTodoUpdate(todoListId); const todoCreateHandler = useTodoCreate(todoListId); return ( <div> <h2>{data.title}</h2> <ul> {data.todos.map(() => { /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={todoCreateHandler} /> </ul> </div> ); };
Теперь наш компонент не зависит напрямую от того, какой ответ нам пришлет API. Кто-то может увидеть в этом последнюю букву аббревиатуры SOLID — Devendency Inversion (инверсия зависимостей). Также наш компонент не знает, откуда он получает данные (ну практически не знает), так как на самом деле очень большая ценность состоит в том, чтобы компоненту было абсолютно всё равно, откуда к нему эти данные приходят — localstorage/http/state-manager/etc. Вы можете заметить, что useTodoList подозрительно напоминает заготовку react-query (swr, etc...), но это тема уже для отдельной статьи.
...и ещё...
Теперь физически (на уровне модулей) разделим представление (JSX/Template/etc) от логики.
const useTodoListComponentState = ({ todoListId }) => { const { data, loading, error } = useTodoList(todoListId); const updateHandler = useTodoUpdate(todoListId); const createHandler = useTodoCreate(todoListId); return { data, loading, error, createHandler, updateHandler }; }; const TodoListComponent = ({ todoListId }) => { const { data, loading, error, createHandler, updateHandler } = useTodoListComponentState(todoListId); return ( <div> <h2>{data.title}</h2> <ul> {data.todos.map(() => { /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={todoCreateHandler} /> </ul> </div> ); };
Вы спросите, зачем это — ответ будет чуть позже.
Внезапное новое требование
Вы, наверное, думаете: «Ага, и вместо одного файла/модуля, где я компактненько всё описал, теперь нужно вот это всё писать и на каждый чих создавать новый модуль и обмазываться абстракциями».
Во-первых, да. Для того чтобы иметь открытую к расширениям и стойкую к изменениям архитектуру, мы вынуждены разделять наш код и надеяться на контракты между слоями. И не важно, что мы пишем бэкенд или фронтенд.
А теперь самое интересное. Приходит к вам заказчик и говорит: «Приложуха огонь, выглядит суперски, но вчера меня посетила гениальная идея, пока я принимал ванну с шампанским...»
Для того чтобы сделать UX ещё круче, нужно позволить пользователю создавать TodoList, который сразу содержит задачи, и — барабанная дробь — это всё нужно делать в модалке (мы ведь обожаем модалки, не правда ли?)! Юзер нажимает кнопку, появляется модалка, в ней он вводит название списка и заполняет задачи, он может сколько угодно добавлять, менять уже добавленные или удалять задачи, а когда нажимает кнопку Create, всё это добро сохраняется в базе данных. У вас же уже всё работает и так, теперь это всё в модалочку запихнуть дело двух минут.
Как вы думаете, сколько нам бы всего пришлось переписать, если бы провели рефакторинг? Вероятно, наш компонент бы выглядел, как несколько conditional statements и некоей логикой выбора. Вам понадобится локальное состояние или некий state-manager для того, чтобы хранить промежуточное состояние данных, пока пользователь не нажал кнопку.
И если это всё совмещать в одном компоненте, то получится уже далеко не так красиво и просто, как было в самом начале. А любое изменение в существующем коде увеличивает шанс того, что что-то таки отпадет. Тем более тестами наш TodoList не покрыт, но тесты — это совсем отдельная тема для будущих митапов.
const TodoListComponent = ({ todoListId, isModal }) => { const [todoList, setTodoList] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const todoCreateHandler = useCallback((todo) => { axios .post("/todo", { // post body }) .then() .catch(); }); // handle API call to update todo by id const todoItemCheckHandler = useCallback(/** ... */); const todoListRedux = useSelector((state) => selectTodoListById(todoListId)); const dispatch = useDispatch(); useEffect(() => { if (!isModal) { axios.get(`/todo-list/${todoListId}`).then((res) => { const data = lodash.get(res, "data"); setTodoList(data); }); } }, [todoListId]); const data = isModal ? todoListRedux : todoList; const createHandler = isModal ? todoCreateHandler : (todo) => dispatch(createTodoAction(todo)); // Here could be the other conditions return ( <div> <h2>{data.title}</h2> <ul> {data.todos.map(() => { /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={createHandler} /> </ul> </div> ); };
Но после того, как мы провели рефакторинг, мы понимаем, что нам не нужно ничего изменять. Нам нужно лишь добавить.
Мы создаём отдельный модуль, который будет обрабатывать состояние нашего TodoList, но который находится в модалке. Теперь стоит лишь выбрать нужный модуль в зависимости от того, в каком месте находится наш компонент. Старый код не менялся, новый добавился легко и просто, так как нужно лишь соблюдать контракт между слоями.
const hooks = { "todo-list-page": useTodoListComponentState, }; const useHook = ({ context, ...rest }) => { return hooks[context](rest); }; const TodoListComponent = ({ todoListId, context }) => { const { data, loading, error, createHandler, updateHandler } = useHook({ todoListId, context, }); return ( <div> <h2>{data.title}</h2> <ul> {data.todos.map(() => { /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={todoCreateHandler} /> </ul> </div> ); };
const hooks = { "todo-list-page": useTodoListComponentState, "create-todo-list-modal": useTodoListCreateModalState, }; const useHook = ({ context, ...rest }) => { return hooks[context](rest); }; const TodoListComponent = ({ todoListId, context }) => { const { data, loading, error, createHandler, updateHandler } = useHook({ todoListId, context, }); return ( <div> <h2>{data.title}</h2> <ul> {data.todos.map(() => { /** * Here we implement the TodoItemComponent functionality * and handle todoItem check/uncheck */ })} <TodoItemCreateComponent onCreate={todoCreateHandler} /> </ul> </div> ); };
И вот как раз для этого мы и разделили отображение от логики. У одного и того же отображения может быть несколько источников данных и несколько контекстов исполнения. И это не выдумки моего воспаленного сознания. На одном из проектов, в котором я работал, один и тот же виджет использовался в четырех разных местах, при этом имея два разных источника данных. И это лишь вершина айсберга.
Выводы
Я не берусь утверждать, что я тут что-то изобрёл, или то, что мой подход — это серебряная пуля. Отнюдь нет, я лишь пытаюсь донести важность хорошей архитектуры, которая будет помогать нам добавлять новые фичи в приложения или изменять старые по желанию заказчика.
А это значит меньше время time-to-market, что, в свою очередь, уменьшает затраты заказчика и делает его более счастливым. Но это тоже тема для отдельного обсуждения.
Кто-то может сказать, что для того, чтобы уйти от контекстов, можно использовать HOC (aka Container Components), и будет прав. Кто-то может предложить кучу других решений и тоже будет прав. Я ещё раз хочу обратить внимание на то, что показанное мною в статье не нужно пытаться скопировать в проекты, это всё лишь для того, чтобы подумать хорошенько.
Всем добра!
116 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів