Метафора: легкотестируемый и легкоизменяемый UI
Метафора: легкотестируемый и легкоизменяемый UI

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

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

Проблемы с архитектурой и шаблонами проектирования

React и подобные библиотеки

React и многие другие похожие библиотеки для фронтенда часто попадают в одну и ту же ловушку. Они тесно связывают бизнес-логику, IO и управление состоянием.

Эти библиотеки позволяют разрабатываеть компоненты с HMR, добавлять логику запросов к серверу (ввод-вывод) в тело компонента, а ещё — redux или подобные хуки. Это не только очень удобно, но и дает возможность делать работающие вещи довольно быстро.

Это хорошо работает для небольших проектов. Но это не работает для разработки в масштабе.

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

Удивительно, но React позиционирует себя как библиотека чисто для представления, но в реальности только с представлением она работает крайне редко (в том числе потому что хуки захватили мир).

Обзор архитектурных подходов

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

Model-View-Controller (MVC)

Связанные фреймворки: Ruby on Rails (Ruby), Django (Python), Laravel (PHP), Spring MVC (Java), ASP.NET MVC (C#)

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

  • Модель — управляет данными, логикой и правилами приложения.

  • Представление —  абстрагирует детали используемой технологии для представления.

  • Контроллер — принимает входные данные и преобразует их в команды для модели или представления.

Проблема с MVC заключается в том, что при масштабировании приложения этот паттерн может привести к серьезной запутанности. По мере того как растёт число зависимостей между разными частями приложения (например контроллер-контроллер), тестировать и менять что-то становится сложно.

// == EXAMPLE OF MVC ==

// MODEL
class ButtonModel {
    constructor() {
        this.click_count = 0;
    }

    incrementCount() {
        this.click_count++;
    }

    getCount() {
        return this.click_count;
    }
}

// VIEW
class ButtonView {
    constructor() {
        this.button = document.getElementById('myButton');
        this.label = document.getElementById('clickCounter');
    }

    updateLabel(count) {
        this.label.innerText = `Clicked: ${count} times`;
    }
}

// CONTROLLER
class ButtonController {
    constructor(model, view) {
        this.model = model;
        this.view = view;
        this.view.button.addEventListener('click', () => this.buttonClicked());
    }

    buttonClicked() {
        this.model.incrementCount();
        const count = this.model.getCount();
        this.view.updateLabel(count);
    }
}

// Main
const buttonModel = new ButtonModel();
const buttonView = new ButtonView();
const buttonController = new ButtonController(buttonModel, buttonView);

Model-View-Presenter (MVP):

Связанные фреймворки: GWT (Java), Vaadin (Java)

MVP является производным от архитектуры MVC:

  • Модель — это слой данных.

  • Представление — такое же, как в MVC, но теперь также обрабатывает подписку на события (уже не в контроллере), а также сохраняет ссылку на представителя.

  • Представитель — это по сути контроллер, но без подписок, поэтому его немного проще тестировать отдельно.

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

// == EXAMPLE OF MVP ==

// MODEL
class ButtonModel {
    constructor() {
        this.click_count = 0;
    }

    incrementCount() {
        this.click_count++;
    }

    getCount() {
        return this.click_count;
    }
}

// VIEW
class ButtonView {
    constructor() {
        this.button = document.getElementById('myButton');
        this.label = document.getElementById('clickCounter');
        this.button.addEventListener('click', () => this.onClick());
    }

    setPresenter(presenter) {
        this.presenter = presenter;
    }

    onClick() {
        this.presenter.handleButtonClick();
    }

    updateLabel(count) {
        this.label.innerText = `Clicked: ${count} times`;
    }
}

// PRESENTER
class ButtonPresenter {
    constructor(view, model) {
        this.view = view;
        this.model = model;
        this.view.setPresenter(this);
    }

    handleButtonClick() {
        this.model.incrementCount();
        const count = this.model.getCount();
        this.view.updateLabel(count);
    }
}

// Main
const buttonModel = new ButtonModel();
const buttonView = new ButtonView();
const buttonPresenter = new ButtonPresenter(buttonView, buttonModel);

Model-View-ViewModel (MVVM)

Связанные фреймворки: Knockout.js (JavaScript), Vue.js (JavaScript), Angular (JavaScript/TypeScript), WPF (Windows Presentation Foundation) с C#

MVVM — это еще одна производная от MVC, где контроллер в добавок обрабатывает pub-sub data binding для слоя представления. Он тоже полагается на декларативную разметку. Однако тестирование представления в отдельности усложнено, так как нет явного состояния, которое можно было бы задать и гарантировать, что все будет работать из раза в раз одинаково.

// == EXAMPLE OF MVVM ==

// MODEL
class ButtonModel {
    constructor() {
        this.click_count = 0;
    }

    incrementCount() {
        this.click_count++;
    }

    getCount() {
        return this.click_count;
    }
}

// VIEWMODEL
class ButtonViewModel {
    constructor() {
        this.model = new ButtonModel();
        this.button = document.getElementById('myButton');
        this.label = document.getElementById('clickCounter');

        // Binding the ViewModel's method to the button's click event
        this.button.addEventListener('click', () => this.handleButtonClick());
    }

    handleButtonClick() {
        this.model.incrementCount();
        this.updateView();
    }

    updateView() {
        this.label.innerText = `Clicked: ${this.model.getCount()} times`;
    }
}

// VIEW
<div>
  <button id="myButton">Click me!</button>
  <p id="clickCounter">Clicked: 0 times</p>
</div>

// Initialize ViewModel
const buttonViewModel = new ButtonViewModel();

Model-View-Update (MVU)

Популярные фреймворки: Elm (Elm Language), Fabulous (F#), SwiftUI (Swift)

Шаблон MVU, популяризированный архитектурой Elm, является относительно новым подходом к разработке фронтенда. В MVU модель определяет состояние приложения, представление отображает пользовательский интерфейс на основе состояния (модели), а функция обновления изменяет состояние на основе сообщений (таких как действия пользователя или ответы сервера). Этот однонаправленный поток данных обеспечивает предсказуемость пользовательского интерфейса и существенно упрощает отладку.

// == EXAMPLE OF MVU ==

// MODEL
const initialModel = {
    clickCount: 0
};

// VIEW
function view(model) {
    const counterLabel = document.getElementById('clickCounter');
    counterLabel.innerText = `Clicked: ${model.clickCount} times`;
}

// UPDATE
function update(model, action) {
    switch (action.type) {
        case 'INCREMENT':
            return { ...model, clickCount: model.clickCount + 1 };
        default:
            return model;
    }
}

Flux

Популярные фреймворки: Оригинальный Flux, Redux, Alt.js, RefluxJS, Marty.js, McFly, Флаксибл, Делореан, NuclearJS

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

Компоненты архитектуры Flux

  • Action: Это полезная информационная нагрузка, которая отправляет данные из приложения в dispatcher.

  • Dispatcher: Центральный хаб, который управляет всем потоком данных в приложении. Принимает action и апдейтит необходимые store.

  • Store: Содержит состояние и логику приложения. Они в некоторой степени похожи на модели в традиционной модели MVC.

  • View: Конечный результат работы приложения на основе текущего состояния store.

В Flux пользовательские взаимодействия, ответы сервера и отправка формы — это все примеры action. Dispatcher обрабатывает эти action и обновляет store. View извлекает новое состояние из store и соответствующим образом обновляет пользовательский интерфейс. Этот однонаправленный поток (Action -> Dispatcher -> Store -> View) похож на поток данных MVU (Model-View-Update), где ввод пользователя генерирует сообщение, Модель обновляется на основе сообщения, а Представление является функцией Модели.

Redux основан на архитектуре Flux, но упрощает ее, соблюдая несколько правил:

  • Единый источник истины: Состояние всего приложения хранится в одном объектном дереве в одном store.

  • Состояние только для чтения: Единственный способ изменить состояние - это создать action, которое является объектом, описывающим действие.

  • Изменения производятся с помощью чистых функций: Чтобы указать, как дерево состояния изменяется с помощью действий, вы пишете чистые редукторы.

Эти правила помогают поддерживать согласованность и предсказуемость в приложении, что упрощает отслеживание изменений состояния и отладку приложения.

Redux ближе к MVU, чем к традиционной модели MVC. Как и MVU, он использует однонаправленный поток данных, где Действие (аналогично "сообщению" MVU) вызывает изменение состояния приложения (единый источник правды Redux подобен "Модели" MVU), и Представление обновляется на основе этого нового состояния.

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

// == EXAMPLE OF FLUX ==

// DISPATCHER
const Dispatcher = function() {
    this._lastID = 0;
    this._callbacks = {};
}

Dispatcher.prototype.register = function(callback) {
    const id = 'CID_' + this._lastID++;
    this._callbacks[id] = callback;
    return id;
}

Dispatcher.prototype.dispatch = function(action) {
    for (const id in this._callbacks) {
        this._callbacks[id](action);
    }
}

const AppDispatcher = new Dispatcher();

// STORE
const ButtonStore = (function() {
    let clickCount = 0;

    function incrementCount() {
        clickCount++;
    }

    function getCount() {
        return clickCount;
    }

    AppDispatcher.register(function(action) {
        if (action.type === 'INCREMENT') {
            incrementCount();
            updateView();
        }
    });

    return {
        getCount: getCount
    }
})();

// ACTIONS
const ButtonActions = {
    increment: function() {
        AppDispatcher.dispatch({
            type: 'INCREMENT'
        });
    }
};

// VIEW
function updateView() {
    const counterLabel = document.getElementById('clickCounter');
    counterLabel.innerText = `Clicked: ${ButtonStore.getCount()} times`;
}

document.getElementById('myButton').addEventListener('click', function() {
    ButtonActions.increment();
});

Model-View-Intent (MVI) с Cycle.js

Фреймворки: Cycle.js (JavaScript)

Еще один отличный фреймворк, о котором я недавно узнал, - это Cycle.js.

Как и Elm, он также позволяет полностью отделить слой представления от остальной части приложения.

Cycle.js — это функциональный и реактивный JavaScript-фреймворк для написания более чистого кода. Он представляет вариант паттерна MVU, называемый Model-View-Intent (MVI). В Cycle.js каждый из этих компонентов имеет уникальную и четко определенную роль:

  • Intent: Обрабатывает все пользовательские вводы или события, такие как клики кнопок и отправка форм. Намерение, стоящее за взаимодействием пользователя, обрабатывается и преобразуется в объект или "сообщение", которое будет использоваться моделью.

  • Model: Модель получает сообщение от Intent и использует его для обновления состояния приложения. Это делается предсказуемым и детерминированным способом, что обеспечивает согласованность состояния во всем приложении.

  • View: Представление в Cycle.js — это чистая функция, которая преобразует состояние приложения.

Cycle.js и паттерн MVI предлагают другой подход к архитектуре пользовательского интерфейса. Вместо распространения действий по всему приложению, как в традиционной модели MVC, Cycle.js обеспечивает однонаправленный поток данных и отделяет состояние приложения от пользовательского интерфейса. Он также использует Observables для управления асинхронным потоком данных, что дополнительно способствует разделению ответственностей.

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

// == EXAMPLE OF MVI ==

// INTENT
function intent() {
  const button = document.getElementById('myButton');
  button.addEventListener('click', () => {
    model({ type: 'INCREMENT' });
  });
}

// MODEL
let clickCount = 0;
function model(action) {
  switch (action.type) {
    case 'INCREMENT':
      clickCount++;
      break;
    default:
      break;
  }
  view(clickCount);
}

// VIEW
function view(count) {
  const counterLabel = document.getElementById('clickCounter');
  counterLabel.innerText = "Clicked: ${ count } times";
}

// Initialize
intent();

Предварительный итог

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

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

Elm MVU

Elm — намного лучшее решение для разработки фронтенда с точки зрения архитектурных паттернов, особенно благодаря его способности полностью отделить представление как чистую функцию состояния с помощью паттерна Model-View-Update (MVU).

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

Если бы Elm не был тесно связанной комбинацией ML языка, фреймворка и паттерна MVU, предлагающей только всё или ничего, я бы просто рассматривал Elm в этой статье.

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

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

В Elm применяется паттерн Model-View-Update. Полная модель передается в функцию представления каждый раз при обновлении, что означает, что полное состояние приложения доступно при рендеринге представления.

Redux, с другой стороны, более гибкий и менее прескриптивный в отношении того, как связывать состояние с представлениями. В Redux используется функция connect (при использовании React-Redux), чтобы привязать только часть состояния, которая нужна конкретному компоненту, а не всё состояние.

Однако в Redux это делает представление тесно связанным с остальной частью цикла, поэтому вы не можете легко заменить представление без сложного распутывания. Еще один способ, которым и Redux, и Elm тесно связывают представление и остальную часть цикла — это отправка действий из элементов управления (элементов пользовательского интерфейса), связывающая их с логикой.

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

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

Но представление на самом деле не должно знать о бизнес-логике, так же как и бизнес-логика не должна знать о представлении.

Поэтому действия должны быть отделены от слоя представления.

-- == EXAMPLE OF ELM MVU ==

-- Import necessary modules
import Browser
import Html exposing (..)
import Html.Events exposing (onClick)

-- Main function to start the application
main =
    Browser.sandbox { init = init, update = update, view = view }

-- Define the Model (the state of our application)
type alias Model =
    { clickCount : Int }

-- Initialize the model with a default state
init : Model
init =
    { clickCount = 0 }

-- Define possible actions that can change the state
type Msg
    = Increment

-- The update function describes how to handle each action and update the state
update : Msg -> Model -> Model
update msg model =
    case msg of
        Increment ->
            { model | clickCount = model.clickCount + 1 }

-- The view function displays the state
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Increment ] [ text "Click Me!" ]
        , p [] [ text ("Clicked: " ++ String.fromInt model.clickCount ++ " times") ]
        ]

Шаблоны проектирования

У каждого известного архитектурного подхода есть множество дополнительных рекомендуемых шаблонов проектирования. Я не буду углубляться в них, но постараюсь предоставить некоторые полезные ссылки в конце поста.

Проблемы с тестированием

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

React (и многие другие фреймворки) имеют различные решения для тестирования, однако есть и очень важные недостатки.

Для библиотек, которые полагаются на выполнение React под капотом, как RTL, тесты становятся интеграционными тестами.

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

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

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

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

Как мы можем улучшить ситуацию?

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

Такой подход позволит нам поместить всё представление в Storybook, где мы сможем легко видеть его в разных состояниях. А ещё мы сможем делать визуальные снимки, которые помогут нам при рефакторинге.

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

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

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

Кроме того, мы сможем поместить все это в Storybook, чтобы увидеть, как все это взаимодействует.

В целом, подход будет очень Storybook-friendly, что, в свою очередь, означает, что мы решили главную проблему— получать быструю обратную связь.

Заключение

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

При соблюдении этих двух условий код будет проще менять.

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

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

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

В следующей статье мы попытаемся решить эту проблему.

Полезные ссылки по теме

  • Cycle.js Official Website: Это основной ресурс для всего, что связано с Cycle.js. Здесь есть подробные руководства и ссылки на API.

  • Problem with MVP: Статья, которая исследует недостатки паттерна MVP.

  • Design patterns — Подробное руководство по паттернам проектирования программного обеспечения. Каждый паттерн подробно объясняется с примерами, чтобы помочь понять, когда и где их использовать.

  • SOLID Principles: Explanation and examples — Эта статья FreeCodeCamp разбирает принципы SOLID легким способом с большим количеством примеров.

  • React Documentation: Официальная документация React - это необходимый ресурс для всех, кто работает с React.

  • Redux Documentation: Официальная документация Redux предоставляет подробное руководство по началу работы с Redux, а также более продвинутые темы.

  • Elm Language Guide: Это официальное руководство по языку Elm, предоставляющее подробный обзор синтаксиса и возможностей языка.

  • React-Redux connect function Documentation: Эта часть документации Redux предоставляет конкретную информацию о функции "connect", которая является ключевой для понимания связи между состоянием и представлениями в Redux.

  • Testing React Applications: Документация React Testing Library (RTL) предлагает руководство по тестированию компонентов React.

  • Storybook for React: Storybook - это инструмент с открытым исходным кодом для разработки компонентов пользовательского интерфейса в изоляции. Эта ссылка ведет к конкретной документации по использованию Storybook с React.

  • Model-View-Update (MVU) pattern: Эта страница руководства Elm объясняет паттерн Model-View-Update (MVU), который является ключевым концептом в языке Elm.

  • Understanding Unidirectional Data Flow in React: Эта статья объясняет концепцию однонаправленного потока данных в React и Redux.

  • Clean Architecture for SwiftUI

  • Model-View-Update

  • SwiftUI TEA

Комментарии (2)


  1. alex_k777
    30.09.2023 12:31
    +1

    Что насчет FSD архитектуры?


    1. kino6052 Автор
      30.09.2023 12:31

      Хороший вопрос.

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

      FSD хорошо удовлетворяет условию упорядочивания кода.

      Но не решает проблему с быстрой обратной связью и полным разделением бизнес-логики и презентации.

      Кажется, что FSD это не альтернатива ни одной из архитектурных паттернов, главной целью которых разделить ответственность логики. FSD - это про правильное упорядочивание кода по фичам. Поэтому я могу себе представить как Flux + FSD, так и MVU + FSD и т.д.