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

Вообще если проанализировать статьи по сравнению MobX и Redux, можно заметить несколько часто повторяющихся тезисов:

  • "MobX проще в изучении, чем Redux"

  • "MobX более производительнее Redux"

  • "В MobX нужно писать меньше кода, чем в Redux"

  • Однако, "Масштабирование на Redux проще, чем на MobX", а потому "MobX подходит для небольших приложений, а Redux для больших"

  • "Процесс дебага на Redux проще, чем на MobX"

  • "Сообщество Redux обширнее, чем у MobX"

  • "В MobX дается слишком много свободы"

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

Глупо отрицать, что в сравнении с Redux сообщество MobX не такое большое - одних только скачивай на npmjs.com у Redux больше в 8 раз чем у MobX. Однако, с остальными минусами я с трудом могу согласиться.

Проблемы с дебагом, как мне кажется, либо являются исключительно субъективными, либо появляются в следствие плохой архитектуры приложения. Сам же я, например, никогда проблем с дебагом MobX не испытывал.

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

И вот мы добрались до сладкого. Я не просто так выделил проблему излишней свободы в MobX. Ведь она вполне реальна. Создавай сторы как угодно; используй их в компоненте, предварительно импортируя, используй их с помощью инъекции или создавай их в компоненте. Подходов к использованию MobX в React можно придумать невероятно много. И MobX даже не пытается рекомендовать какой-то один "хороший" подход. А в совокупности с тем, что его "легко изучить", эта проблема только усугубляется, так как, быстро выучив основные концепции MobX, некоторые разработчики считают, что они определенно точно знают, как нужно продумывать архитектуру.

Но все-таки проблема архитектуры приложения не связана с MobX напрямую. Странно обвинять пистолет, если ты сам выстрелил себе в ногу. Проблема в том, что разработчики не выбрали какой-то определенный подход к использованию этого инструмента. А ведь прорабатывать подход с нуля вовсе не обязательно - существующие концепции неплохо справляются с задачей по проработке архитектуры. И как вы наверняка догадались, в рамках данной статьи я попытаюсь показать, как паттерн MVVM может в достаточной степени сформировать ту строгость, о недостатке которой говорится в подобных статьях.

Для этой статьи я написал небольшую библиотеку, с помощью которой можно было бы использовать паттерн MVVM. Она состоит буквально из 3 функций и 2 классов. Далее по ходу статьи я буду часто к ней обращаться.

Что такое паттерн MVVM?

Основное преимущество паттерна MVVM (Model View ViewModel) заключается в разделении разработки графического интерфейса и логики.

Взгляните на короткий пример
import { action, observable, makeObservable } from 'mobx';
import { view, ViewModel } from '@yoskutik/react-vvm';

class CounterViewModel extends ViewModel {
  @observalbe count = 0;

  constructor() {
    super();
    makeObservable(this);
  }

  @action increate = () => {
    this.count++;
  };
}

const Counter = view(CounterViewModel)(({ viewModel }) => (
  <div>
    <span>Counter: {viewModel.count}</span>
    <button onClick={() => viewModel.increate()}>increase</button>
  </div>
));

И я говорю о полном делении логики и отображения. В подходе MVVM ваши умные компоненты могут состоять исключительно из JSX кода. Таким коротким примером я всего лишь хотел вас заинтересовать. Но давайте не забегать вперед, и сначала обозначим, как можно использовать MVVM в React-приложениях.

MVVM в контексте React-приложений

Невозможно описать все плюсы MVVM, не рассказав об особенностях реализации этого паттерна в React-приложениях. Для себя я выделил всего 4 правила в реализации MVVM:

  1. Каждая вьюмодель должна быть привязана ко вью и, следовательно, при удалении вью из разметки вьюмодель должна "умирать";

  2. Каждая вьюмодель может (и зачастую будет) являться стором;

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

  4. Вью и вьюмодели знают о существовании друг друга.

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

А теперь о преимуществах

Наверняка, вы задались вопросом: "Но почему вьюмодель должна умирать, когда вью уходит из разметки?". Ответ довольно прост. Если компонент более не находится в виртуальном DOM'е, с большой вероятностью необходимости в хранении данных для отображения этого компонента нет. Эта оптимизация позволяет освобождать память приложения, когда некоторые куски данных не используются.

И это лишь одно из немногих преимуществ.

Context API и абстракция данных

Третье "правило" моей реализации говорит, что вьюмодель может использоваться на любой глубине внутри вью. Понятно, что речь идет о Context API. И его вполне можно использовать вместе с паттерном MVVM.

Использование MVVM вместe c Context API
import { makeObservable, observable } from 'mobx';
import { childView, view, ViewModel } from '@yoskutik/react-vvm';

class SomeViewModel extends ViewModel {
  @observable field1 = '';

  constructor() {
    super();
    makeObservable(this);
  }

  doSomething = () => {};
}

// ChildView не создает вьюмодель и должен быть расположен где-то внутри
// View. Таким образом он сможет взаимодействовать с SomeViewModel.
const ChildView = childView<SomeViewModel>()(({ viewModel }) => (
  <div>
    <span>{viewModel.field1}</span>
    <button onClick={viewModel.doSomething}>Click in a child of  view</button>
  </div>
));

const View = view(SomeViewModel)(({ viewModel }) => (
  <div>
    <span>{viewModel.field1}</span>
    <button onClick={viewModel.doSomething}>Click in a view</button>

    <ChildView />
  </div>
));

По мне так тут прослеживается довольно четкая аналогия с Redux, который исключительно на Context API и существует. Однако, между подходом Redux и MobX с MVVM есть существенная разница.

Взгляните на эти схемы
Различие Redux и MobX + MVVM
Различие Redux и MobX + MVVM

Каждый узел в графе на схеме является React-компонентом.

В подходе Redux создается один стор. И этот стор доступен во всем приложении. В подходе MVVM стор создается только для вью. И в данном случае создается всего 3 стора. При этом синий стор и компоненты, его использующие, не знают о существовании желтого и красного стора, они никак не могут прочитать их данные или как-то на них повлиять.

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

Вложенные вью

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

На такой случай было введено дополнительное понятие - родительская вьюмодель. Родительской вьюмоделью для "красной" будет являться "желтая".

А в коде использование родительской вьюмодели может выглядеть так
import { view, ViewModel } from '@yoskutik/react-vvm';

class ViewModel1 extends ViewModel {
  doSomething = () => {};
}

class ViewModel2 extends ViewModel<ViewModel1> {
  onClick = () => {
    this.parent.doSomething();
  };
}

const View2 = view(ViewModel2)(({ viewModel }) => (
  <button onClick={viewModel.onClick} />
));

const View1 = view(ViewModel1)(({ viewModel }) => (
  <div>
    <View2 />
  </div>
));

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

Пример использования родительской вьюмодели с типизацией в виде интерфейса
import { view, ViewModel } from '@yoskutik/react-vvm';
import { ISomeViewModel } from './ISomeViewModel';

class ViewModel1 extends ViewModel implements ISomeViewModel { ... }

class ViewModel2 extends ViewModel implements ISomeViewModel { ... }

// Тип родительской вьюмодели будет ISomeViewModel
class ViewModel3 extends ViewModel<ISomeViewModel> { ... }

const View3 = view(ViewModel3)(({ viewModel }) => (
  <div />
));

// View3 может использоваться в разных view
const View1 = view(ViewModel1)(({ viewModel }) => (
  <View3 />
));

const View2 = view(ViewModel2)(({ viewModel }) => (
  <View3 />
));

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

Полное разделение логики и отображения

Как я написал выше, в MVVM сепарация логики и отображения может выйти на новый уровень. Мне невероятно сильно нравится концепция того, что умные компоненты могут состоять исключительно из JSX кода. По мне так это сильно упрощает анализ React компонент.

Создание обработчиков во вьюмодели

Начнем с простого. Обработчики событий по типу onClick, onInput и т.п. по определению MVVM можно определять во вьюмодели. В самом первом примере во вьюмодели я создал функцию increase, которую далее вызвал в компоненте. Но мне ничего не мешало бы назвать функцию как onClick, тогда в компоненте можно было бы использовать её напрямую

const Counter = view(CounterViewModel)(({ viewModel }) => (
  <div>
    <span>Counter: {viewModel.count}</span>
    <button onClick={viewModel.onClick}>increase</button>
  </div>
));

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

Вьюмодель знает о пропсах вью

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

Пример использования пропсов в обработчиках событий
import { FC } from 'react';
import { view, ViewModel } from '@yoskutik/react-vvm';

type WindowProps = {
  title: string;
  onClose: () => void;
}

class WindowViewModel extends ViewModel<unknown, WindowProps> {
  onCloseClick = () => {
    // do something else
    this.viewProps.onClose();
  };
}

export const Window: FC<WindowProps> = view(WindowViewModel)(({ viewModel, title }) => (
  <div className="window">
    <h1>{title}</h1>
    <button onClick={viewModel.onCloseClick}>close</button>
  </div>
));

А ещё мне показалось логичным сделать поле viewProps наблюдаемым (observable). Таким образом можно создавать реакции на изменение определенных пропсов. И такие реакции по мне гораздо проще читать нежели реакции, создаваемые в useEffect. Хотя, вероятно, это субъективное мнение.

Пример реакции с useEffect
import { FC, useCallback, useEffect } from 'react';

type WindowProps = {
  title: string;
  state: 'warn' | 'error';
  onClose: () => void;
}

export const Window: FC<WindowProps> = ({ title, state, onClose }) => {
  const onCloseClick = useCallback(() => {
    // do something
    onClose();
  }, [onClose]);
  
  useEffect(() => {
    console.log(state);
  }, [state]);
  
  return (
    <div className={`window window--${state}`}>
      <h1>{title}</h1>
      <button onClick={viewModel.onCloseClick}>close</button>
    </div>
  );
};

Пример реакции в MVVM
import { FC } from 'react';
import { view, ViewModel } from '@yoskutik/react-vvm';

type WindowProps = {
  title: string;
  state: 'warn' | 'error';
  onClose: () => void;
}

class WindowViewModel extends ViewModel<unknown, WindowProps> {
  protected onViewMounted() {
    this.reaction(() => this.viewProps.state, state => {
      console.log(state);
    });
  }

  onCloseClick = () => {
    // do something else
    this.viewProps.onClose();
  };
}

export const Window: FC<WindowProps> = view(WindowViewModel)(({ viewModel, title, state }) => (
  <div className={`window window--${state}`}>
    <h1>{title}</h1>
    <button onClick={viewModel.onCloseClick}>close</button>
  </div>
));

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

Меньшая зависимость от хуков жизненного цикла

Про хуки я уже начал говорить в прошлом блоке. Создавать реакции на определенные объекты через useEffect больше необходимости нет. Но useEffeсt нужен не только для реакций - его можно также использовать для обработки состояний жизненного цикла компонента. Например, при монтировании, размонтировании и обновлении. И все эти этапы жизненного цикла вполне могут обрабатываться внутри вьюмодели.

Пример
import { observable, makeObservable } from 'mobx';
import { view, ViewModel } from '@yoskutik/react-vvm';

class ComponentViewModel extends ViewModel {
  @observable data = undefined;

  constructor() {
    super();
    makeObservable(this);
  }

  // Например, в моей реализации эта функция замещает вызов
  // useLayoutEffect(() => { ... }, []);
  protected onViewMountedSync() {
    fetch('url')
      .then(res => res.json())
      .then(res => this.doSomething(res));
  }

  // А эта частично замещает
  // useEffect(() => { ... });
  protected onViewUpdated() {
    console.log('Some functionality after component updated');
  }

  doSomething = (res: any) => {};
}

export const Component = view(ComponentViewModel)(({ viewModel }) => (
  <div>
    {viewModel.data}
  </div>
));

Количество пропсов

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

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

В сравнении с Redux

Чтобы вы ещё лучше осознали всю прелесть MobX с MVVM, я попробую сравнить эту связку с Redux.

Посмотрите на пример ниже. Я расписал тот же код, что в примере по хукам для MVVM, но написанный на Redux.

Пример на Redux
import { FC, useEffect, useLayoutEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IReduxStore } from '../../store';
import { doSomething } from './slice';

export const Component: FC = () => {
  const data = useSelector((state: IReduxStore) => state.slice.data);
  const dispatch = useDispatch();

  useLayoutEffect(() => {
    fetch('url')
      .then(res => res.json())
      .then(res => dispatch(doSomething(res)));
  }, []);

  useEffect(() => {
    console.log('Some functionality after component render');
  });

  return (
    <div>
      {data}
    </div>
  );
}

В этом примере видна сила полноценного деления логики и отображения. В примере с Redux компонент ответственен за обновление состояния. Экшены объявлены где-то в другом месте, но они должны быть использованы внутри компонента. В примере же с MVVM вью не вызывает хуки и никаким образом не обрабатывает данные. Он их только использует.

Что ещё интересно, так это то, что кода в обоих примерах одинаковое количество. На самом деле в MVVM его чуть больше, но кода в конструкторе можно избежать (об этом ниже). Но при этом в Redux нужно ещё создать слайс, а следовательно в Redux кода приходится писать больше.

Вообще количество кода - это отдельный приятный бонус в сравнении MobX и Redux. Я думаю, разработчики, использующие Redux, часто задумываются о том, как же много повторяющегося кода приходится им писать. Redux Toolkit улучшил, конечно, ситуацию, но полноценно избавиться от повторяющегося кода не удалось. Нужно вызывать хук, чтобы получить значение стора; нужно вызывать хук, чтобы получить функцию dispose; нужно указывать специфичные имена для экшенов и/или слайсов; и т.п.

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

В примерах же с MobX задумываться о типизации нужно редко. Я создал класс вьюмодели а затем использую его объект. И TypeScript сам расставит всю типизацию.

Вместе с этим, в Redux нужно обязательно думать о мемоизации. Количество useMemo и useCallback на квадратный сантиметр кода в Redux может разительно отличаться от этого количества в MobX с MVVM.

Ну и пропсы. На моей практике в Redux довольно часто встречается дриллинг пропсов, хотя бы в рамках вложенности в 1-2 слоя. А, как я написал выше, в MVVM таких проблем меньше.

Но это ещё не все

Согласитесь, MVVM уже выглядит довольно-таки неплохо. Но я решил пойти ещё дальше и немного "прокачать" свою реализацию MVVM.

Автоматический вызов диспозеров

Документация MobX говорит, что вы всегда должны вызывать dispose реакций. Это правило помогает избегать проблем с возникновением утечек памяти. Но следовать этому правилу не очень удобно. Но не в подходе MVVM.

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

Пример создания реакций с автоматической очисткой
import { intercept, makeObservable, observable, observe, when } from 'mobx';
import { ViewModel } from '@yoskutik/react-vvm';

export class SomeViewModel extends ViewModel {
  @observable field = 0;

  constructor() {
    super();
    makeObservable(this);

    // Для создания реакции можно использовать функцию-алиас
    this.reaction(() => this.field, value => this.doSomething(value));

    // Для создания авторана можно также использовать функцию-алиас
    this.autorun(() => {
      this.doSomething(this.field);
    });

    // А для создания остальных типов обзерваций можно использовать
    // функцию addDisposer

    // observe
    this.addDisposer(
      observe(this, 'field', ({ newValue }) => this.doSomething(newValue))
    );

    // intercept
    this.addDisposer(
      intercept(this, 'field', change => {
        this.doSomething(change.newValue);
        return change;
      }),
    );

    // when
    const w = when(() => this.field === 1);
    w.then(() => this.doSomething(this.field));
    this.addDisposer(() => w.cancel());
  }

  doSomething = (field: number) => {};
}

Удобная конфигурация

Подход MVVM можно гибко подстроить под себя. Например, моя реализация позволяет настроить то, как создается вьюмодель, а также позволяет назначить компонент-обертку для всех вью и чайлдвью. И эти 2 настройки гораздо мощнее, чем вам может показаться. Разумеется, эти настройки можно использовать в том числе и для дебага, но это не основное их назначение.

Например, вы можете сделать вызов функции makeObservable автоматическим, чтобы не вызывать у каждой вьюмодели эту функцию в отдельности.

Автоматический вызов makeObservable
import { makeObservable, observable } from 'mobx';
import { configure, ViewModel } from '@yoskutik/react-vvm';

configure({
  vmFactory: VM => {
    const viewModel = new VM();
    makeObservable(viewModel);
    return viewModel;
  },
});

class SomeViewModel extends ViewModel {
  // Теперь field1 будет observable, задумываться о вызове makeObservable
  // не нужно
  @observable field1 = 0;

  // Однако, в конструкторе поле ещё не является observable, поэтому
  // реакции лучше добавлять в onViewMounted или в onViewMountedSync
  protected onViewMounted(): void {
    this.reaction(() => this.field1, () => {
      // do something
    });
  }
}

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

Пример с DI
import { computed, makeObservable, observable } from 'mobx';
// Использовать именно tsyringe не обязательно, подойдет любая DI библиотека
import { injectable, container, singleton } from 'tsyringe';
import { configure, ViewModel } from '@yoskutik/react-vvm';

configure({
  vmFactory: VM => container.resolve(VM),
});

// Это пример того самого маленького независимого стора
@singleton()
class SomeOuterClass {
  @observable field1 = 0;

  constructor() {
    makeObservable(this);
  }

  doSomething = () => {
    // do something
  };
}

@injectable()
class SomeViewModel extends ViewModel {
  @computed get someGetter() {
    return this.someOuterClass.field1;
  }

  // И теперь благодаря DI мы можем спокойно получать нужные нам классы,
  // просто указав их в конструкторе
  constructor(private someOuterClass: SomeOuterClass) {
    super();
    makeObservable(this);
  }

  viewModelDoSomething = () => {
    this.someOuterClass.doSomething();
  }
}

// Вы так же можете получить объект синглтон класса в любом участке вашего
// приложения 
const instance = container.resolve(SomeOuterClass);

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

Пример с Error Boundary
import { Component, ReactElement, ErrorInfo } from 'react';
import { configure } from '@yoskutik/react-vvm';

class ErrorBoundary extends Component<{ children: ReactElement }, { hasError: boolean }> {
  static getDerivedStateFromError() {
    return { hasError: true };
  }

  state = {
    hasError: false,
  };

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error(error, info);
  }

  render() {
    return !this.state.hasError && this.props.children;
  };
}

configure({
  Wrapper: ErrorBoundary,
});

Так значит MVVM - это ультимативное решение всех проблем?

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

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

Снова граф

Снова граф React-компонет. Желтым цветом я пометил компоненты, которые зависят от одного набора данных.

Если такие компоненты находятся относительно близко друг к другу, то вполне реально разместить данные где-нибудь в родительской вьюмодели. Однако, с увеличением глубины, количество используемых родителей может возрастать. Согласитесь, если придется получать данные через viewModel.parent.parent.parent.parent.data, то это уже совсем неудобно. Да и если хранить во вьюмодели данные, которые нужны парочке детей на огромной глубине внутри, то вьюмодель будет невероятно сильно разрастаться.

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

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

Конец

Спасибо за уделенное время. Не стесняйтесь делиться своим мнением в комментариях, с удовольствием почитаю их.

А ещё, как я говорил, я подготовил крошечную библиотеку, буквально 300 строчек кода. Вы можете её использовать, если хотите поиграться с паттерном MVVM. Вот ссылки: npm, github, документация. А ещё можете посмотреть на пару примеров её использования.

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


  1. LabEG
    09.11.2022 11:32
    +1

    https://github.com/LabEG/reca - тот же mobx, со встроенным DI и удобным коннектором к вьюхе. Уже больше года работает в энтерпрайзных проектах без нареканий.


    1. Yoskutik Автор
      09.11.2022 11:37

      Замечательно, что такая библиотека существует. Однако, её разработчики преследуют несколько иные цели, нежели я. MVVM предназначен именно для разделения логики и отображения. И напрямую от DI никак не зависит. Я просто предоставил возможность подключения DI. Причем не какой-то определенной DI библиотеки, а любой


  1. Fen1kz
    09.11.2022 11:37

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

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

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

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


    1. Yoskutik Автор
      09.11.2022 11:41
      +1

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


    1. nin-jin
      09.11.2022 16:42
      +3

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


    1. markelov69
      09.11.2022 17:07
      +3

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

      Ахахах, боже вот это бред


    1. faiwer
      09.11.2022 23:50
      +6

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


      • Модуль А читает данные модуля Б, хотя они формально не связаны. Почему? Ну просто потому что удобно, вот жеж оно лежит. Паразитные хаотические связи. Это потом ооочень тяжело чинить. Изменения в одном месте ломают неожиданные места в другом. Если ещё и без Typescript-а то это игра в сапёра.
      • Код разбросан по всей кодовой базе. Тут у нас action type-ы, тут сами action-creator-ы, тут reducer-ы, тут их саги, тут ещё бог знает что. В то время как логично логику держать цельно. В итоге опытне редаксоводы героически сражаются с лишними абстракциями придумывая сотни разных велосипедов. Сам такой был.
      • По-умолчанию у нас любая часть любого reducer-а может реагировать на любой action. Это приводит к сильно-запутанному коду. Мы снова в ситуации когда модуль А реагирует на action из модуля B, потому что "удобно" и дедлайн. Да и все ж говорят что pub sub "это круто".
      • Производительность вызывает вопросы. Все эти useSelector-ы и connect-ы вызываются вообще всегда на любое изменение вообще все. А дальше песни и пляски с stale props, zombie childrens.
      • Чем меньше мусорного кода тем проще код в поддержке. Redux это тонны бойлерплейта.
      • Весёлые приключения с переиспользованием redux-спаггети когда какой-то модуль понадобился на странице сразу в нескольких экземплярах (каждому свой state). Всё решается, конечно, но выглядит как лютый изврат и ещё больше бойлерплейта.
      • Веселье со всякими race condition-ми и state-ом от ранее умершего компонента, который достался по наследству. Добавляет сложности в те места, где без глобалки (а redux это по сути одна глобальная переменная) таких проблем никогда и не было бы.

      Короче я устал. Резюмируя: redux это распиаренная пачка антипаттернов. И в первую очередь он противопоказан большим проектам. Они с ним реально гниют.


      Единственные плюсы redux-а:


      • Можно весь state упаковать в JSON и переиспользовать. Например восстановить сессию с того же места. Или синхронизировать разные устройства\окружения.
      • Debugging это скорее сильная сторона любого immutable подхода. В том числе и redux-а.


      1. Fen1kz
        10.11.2022 04:58
        -3

        Я думаю вы устали, потому что на мой коммент, что редакс не нужен для больших проектов вы написали "нет. редакс не нужен для больших проектов". Вы если будете читать комментарии, а не просто триггериться на /redux/ig и выдавать пачку аргументов против.


        1. Alexandroppolus
          10.11.2022 10:34

          Я понял Вашу терминологию, но хочу отметить два момента.

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

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


        1. faiwer
          10.11.2022 12:11
          +3

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

          ^ это ваши слова. А мой тезис — redux не нужен вообще. Для крупных проектов типа "видео-редактор" или "кастомных штук", и для малых проектов. Для всех проектов.


  1. nin-jin
    09.11.2022 16:57
    -4

    Это всё хорошо, но зачем MobX, когда уже есть $mol_wire, который минимум в 2 раза лучше по любым параметрам, плюс умеет сам освобождать ресурсы, просовывать IoC контекст, давать всем объектам уникальные человекопонятные имена, абстрагировать от асинхронности и прочее? Ваш пример из начала статьи выглядел бы как-то так:

    import { $mol_wire_solo as mem } from 'mol_wire_lib'
    
    class CounterModel extends Object {
      
    	@mem count( next = 0 ) { return next }
    
    	increase() {
    		this.count( this.count() + 1 )
    	}
    	
    }
    
    class CounterView extends Component< CounterView > {
    	
    	@mem counter() { return new CounterModel }
    	
    	@mem render() {
    		return (
    			<div>
    				<span>Counter: { this.counter().count() }</span>
    				<button onClick={ () => this.couter().increate() }>increase</button>
    			</div>
    		)
    	}
    	
    }

    А потом мы такие, хренак, хотим хранить данные в локальном хранилище:

    class LocalCounterView extends CounterView {
    	
    	@mem localStore() {
    		return new LocalStore
    	}
    	
    	@mem counter() {
    		const model = new CounterModel
    		model.count = next => this.localStore().value( 'counter', next ) ?? 0
    		return model
    	}
    	
    }


    1. Yoskutik Автор
      09.11.2022 17:11
      +2

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

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


      1. nin-jin
        09.11.2022 18:08
        -3

        Опять эта апелляция к популярности вместо технических аргументов. Мы тут менеджеры или программисты?


        1. Yoskutik Автор
          09.11.2022 18:38
          +4

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

          Погоня за быстродействием - это всегда риск, тем более если мы говорим о новых технологиях. А риски должны быть оправданы. И программист должен уметь их оценивать.

          Кстати, если же вы против популярного, и за все быстрое, по такой логике можно начать писать какой-нибудь свой WASM движок на C вместо JS. Или даже свой браузер, где JS не будет и в помине.

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


          1. Yoskutik Автор
            09.11.2022 20:50

            *мне в этом помогает


          1. nin-jin
            10.11.2022 04:21
            -1

            развитое сообщество мне в этом не помогает

            В этом помогает отзывчивый мейнтейнер. Сообщество с более-менее сложной проблемой может только посочувствовать.

            Погоня за быстродействием - это всегда риск

            Обоснуйте.

            вы против популярного, и за все быстрое

            Не выдумывайте. Я за популяризацию лучших технических решений.

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

            Не даёт. Но повышает число найденных ошибок. А чиниться они могут годами.


  1. riogod
    09.11.2022 22:20
    +1

    Не очень согласен с данной концепцией в виду того, что VIewModel является поставщиком данных, но никак не стором. Описанное решение в рамках VVM - да, но MVVM предусматривает еще и модель. Нет необходимости устраивать стор из VM при наличии di контейнера.

    Каждая вьюмодель должна быть привязана ко вью и, следовательно, при удалении вью из разметки вьюмодель должна "умирать";

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

    Каждая вьюмодель может (и зачастую будет) являться стором;

    максимум состояние лоадинга

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

    можно воспользоваться mobx-react-lite и через обсерв ререндерить компоненты. Концепция в принципе должна предполагать, что если вам завтра захочется переписать на Vue, то вы просто перерисуете компоненты

    Вью и вьюмодели знают о существовании друг друга

    а зачем ? точнее не так, зачем VM знать о view ?


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

    Репозитории, ЮзКейсы, Сущности (или модель сущности), и Вьюмодель могут быть извлечены из DI контейнера
    Репозитории, ЮзКейсы, Сущности (или модель сущности), и Вьюмодель могут быть извлечены из DI контейнера

    В данном архитектурном решении разрабатывает N команд и вот основные минусы этого подхода:

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

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

    • Боль и страдания уготованы вам так же, если вы изменяете данные в наблюдаемых объектах mobx через создание нового объекта (к примеру через спред оператор). Как говориться вызывайте спасателей, мы ищем где потекла память.

    • tsyringe - более-менее ведет себя в монолите, но чуть больше (например модульность) - пиши пропало. Связывание контейнеров очень увлекательная игра в которой Вы - не выиграете :) Более-менее ведет себя inversify.

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

    И это только верхушка айсберга. Можно наверное и жить в виде View->ViewModel->Model->Entity, но в сложных решениях необходимо более мелкое разделение что бы была возможность переиспользования.

    Есть конечно и плюсы, но боли тоже много. Советую почитать о чистой архитектуре, раз уж вы пожаловали к нам на дно %)


    1. markelov69
      10.11.2022 00:21
      +1

      ViewModel шикарная вещь на любых проектах, любого размера и сложности. Максимальная простота, минимум кода, все максимально очевидно, ибо элементарно заходишь в любой обработчик какого ни будь клика и там сверху вниз все написано, что происходит, откуда что берется и куда записывается. Вот прямо сразу все это видно и лежит на ладони. Не надо лазать в 10ки файлов, и раскапывать дебри всевозможный абстракций.
      То что ты советуете в виде View->ViewModel->Model->Entity и ещё более мелких разделений это ничто иное как лютейшая дичь, усложнение элементарных вещей на ровном месте, иными словами вы просто берете лопату и копаете под собой, вопрос зачем? Ведь все элементарно. Без шуток, реально элементарно.
      А если руки не из нужного места и вы не умеете писать хороший код и выстраивать архитектуру, то вам ничего не поможет, не чистая архитектура, ни 100500 слоев и т.п.


      1. riogod
        10.11.2022 07:53
        +1

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

        Архитектурные решения для того и создаются, что бы иметь консистентный подход при разработке фичей и разработчик четко понимал, на каком уровне что происходит. Для примера: если мне потребуется в разных частях приложения делать какие-либо действия (например пускай это будет запись и обновление в IndexedDB каких либо данных, или вызов эндпоинта), то в моем случае я из VM просто вызову соответствующий useCase (а в более крупном представлении метод из модели), в вашем же случае, я надеюсь не стоит пояснять, сколько раз вы задублируете код и какие проблемы это потянет если вы захотите изменить логику того или иного флоу.

        То что я советую в виде View->ViewModel->Model - это MVVM как странно бы это не звучало, и эти четыре буковочки значатся в заголовке статьи и лейтмотивом бегут через весь текст. Прошу заметить, что в минусах, описанных мной, не значатся архитектурные проблемы, так как данный подход используется много где в отличии от веба, и он обкатан годами, а только лишь конкретные проблемы в используемых решениях.


        1. markelov69
          10.11.2022 11:38

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

          Так для чего собственно множество уровней? Если по факту все очень просто. Пользователь совершает действие(например кликает на кнопку), срабатывает функция обработчик, в которой вся логика и описывается, сверху вниз, слева направо. Как бы всё супер прозрачно, очевидно и элементарно. Что откуда берется, что куда записывается. Какой бы не был большой и сложный проект, всегда можно легко понять что происходит с таким подходом ибо все а поверхности.

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


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


          1. riogod
            10.11.2022 11:53
            +1

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

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


      1. riogod
        10.11.2022 09:12

        Утро. опечатался. конечно же "это стул - на нем сидят"


    1. Yoskutik Автор
      10.11.2022 09:29
      +1

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

      Наверное, я соглашусь, то, что я показал в статье - это не совсем реализация MVVM. И не только из-за того, что там нет модели. Однако, это подход концептуально близок к этому паттерну, поэтому я так его и назвал.

      В указанном мной подходе необходимости в абстракции модели зачастую нет. Да и в целом, модель из MV-паттернов является абстракцией скорее необходимой для бэкенд разработки. Вы можете, конечно, вместо вьюмодели хранить observable поля в специальном объекте, который бы удалялся при размонтировании вью. Но реально все что изменится в таком подходе - это то, как вы будете получать данные. Не viewModel.field, а viewModel.model.field.

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

      Теперь об остальных замечаниях.

      Я специально разделил понятие DI и MVVM. В моем представлении те данные, которые нужны только одному компоненту логично хранить именно во вьюмодели, а то, что нужно в разрозненных частях, - в DI контейнере. Уже такое деление помогает разработчику, взглянув на данные, понять как и где они используются. Поэтому не делать MVVM стором я бы не стал.

      Учитывая, что я описал свою точку зрения по необходимости модели, ответ на то, нужно ли удалять вьюмодель при размонтировании вью становится очевиден, ведь MVVM хранит его данные. Но даже, если бы она не хранила данные, а содержала бы только логику, я бы все равно советовал её удалять. Во-первых, если ваши вьюмодели являются синглтонами, то вы не сможете использовать несколько вью с одинаковыми вьюмоделями. Во-вторых, вьюмодели должны содержать логику - как минимум функции по обработке состояния. И если не уничтожать вьюмодели, то память приложения будет постепенно засоряться теми данными, которыми пользователь, может и не воспользоваться больше.

      Вашу мысль про mobx-react-lite я не особо понял, если честно. Вы бы не могли её расписать?

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


      1. riogod
        10.11.2022 10:46

        Понятие чистой архитектуры - это всегда условная вещь. 

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

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

        Я как раз подсветил как раз этот момент в минусах, но как только человек разбирается в базовых принципах, процесс работы над фичей происходит гораздо быстрее и гибче. Разработчики используют один и тот же подход на уровне всего приложения и если посадить разработчика на фичу, которую делал другой разраб - у него не возникает "Да #@$@, кто это писал" и ему нет необходимости тратить время что бы понять как оно работает, а так же у него не возникает вопросов "А как мне это написать". Он четко знает, что маппинг - тут, бизнес данные храним здесь, если мне что-то нужно оттуда - я возьму это оттуда.

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

        Не viewModel.field, а viewModel.model.field

        касаемо этого:
        Посредством DI вы внедряете зависимости в VM. К примеру:

        // Model.ts
        @injectable()
        export class SomeModel implements ISomeModel {
          id = '';
          serviceDesc = '';
          serviceName = '';
          systemName = '';
        
          constructor() {
            makeAutoObservable(this);
          }
        
          fillModel(): void {
            this.id = '1';
            this.serviceDesc = 'Some description';
            this.serviceName = 'someService';
            this.systemName = 'someSystem';
          }
        
          dispose(): void {
           this.id = '';
           this.serviceDesc = '';
           this.serviceName = '';
           this.systemName = '';
          }
        }
        
        
        
        //ViewModel.ts
        @injectable()
        export class SomeVM implements ISomeVM  {
          loading = false;
          
          get item(): ISomeModel {
            return this.someModel;
          }
        
          constructor(
            @inject('someModel')
            protected someModel: ISomeModel
          ) {
            makeObservable<ISomeVM>(this, {
              loading: observable,
              item: observable,
              init: action.bound,
              dispose: action.bound
            });
          }
        
          init(): void {
            this.loading = true;
        
            try {
              this.someModel.fillModel();
            } finally {
              this.loading = false;
            }
          }
          
          dispose(): void {
            this.loading = false;
            this.someModel.dispose();
          }
        }
        
        
        
        //ui.tsx
        const SomePage: FC = () => {
        
          // кастомный хук который вытягивает нужную VM из контейнера
          const myVM = useViewModel<ISomeVM>('SomeVM');
          
          if(myVM.loading) {
            return (<>Loading...</>)
          }
          
          return (
            <>
              <div>{myVM.item.id}</div>
              <div>{myVM.item.serviceDesc}</div>
              <div>{myVM.item.serviceName}</div>
              <div>{myVM.item.systemName}</div>
        
              <button onClick={myVM.init} > Загрузить </button>
              <button onClick={myVM.dispose} > Очистить </button>
            </>
          );
        };
        
        export default observer(SomePage);
        // observer - функция из mobx-react-lite которая обсервит состояние и ререндерит компонент.
        

        Маленький пример внедрения зависимостей, эту же модель вы можете внедрить и в какой-либо другой VM в которой она вам понадобиться. А так же, эту VM вы можете использовать в любом компоненте, в котором она понадобиться. Что означает переиспользование. Мы не кладем все яйца в корзину, а разделяем так, что бы можно было переиспользовать.
        Бонусом ко всему - без проблемное тестирование любого участка кода.
        Касаемо хранения VM и моделей в памяти, это копейки, в основном память кушают бизнес данные, которые мы выгружаем методами dispose. Можно очищать к примеру при выходе с роута, а при входе - дергать init метод из VM (Да, описываем это в конфиге роута, желательно использовать агностик, к примеру router5).

        Думаю пример выше ответит на большинство вопросов, но если что-то не ясно, я с радостью объясню.

        Каждый занимается своим делом


        1. Yoskutik Автор
          10.11.2022 11:16
          +1

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


          1. riogod
            10.11.2022 11:41

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


            Для примера приведу пример ????: Мы хотим логировать каждое действие пользователя (клики, вызов эндпоинтов, переход по роутингу) складывать это в какое-либо хранилище и отсылать на сервер когда накопилось N количество данных.
            Решение:
            во всех VM мы внедряем модель "LogCollectorModel" и просто на каждом методе VM мы вызываем метод this.logCollectorModel.addToLog(someLog). В методе модели addToLog, мы проверяем, скопилось ли достаточное количество данных и если да, то отправляем, если нет, то добавляем в хранилище.

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

            А как бы вы решили данную задачу при описанном подходе?


            1. Yoskutik Автор
              10.11.2022 11:56
              +1

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

              А ещё непонятно, почему в вашем примере модель имеет какую-то логику. Если вы хотите использовать "Модель", то она должна только хранить данные.

              Ролевая модель и аутентификация у меня были, и я делал их отдельными DI синглотн контейнерами. Они не были сущностями из паттерна MVVM. В вашем же примере, я бы создал вообще отдельную сущность, как некий middleware, который бы использовал в `configure({ vmFactory })`


              1. riogod
                10.11.2022 12:22

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

                Модель (англ. Model) (так же, как в классической MVC) представляет собой логику работы с данными и описание фундаментальных данных, необходимых для работы приложения.


                Бизнес требования в сложных системах могут быть очень разными и это наиболее простое из того что может быть. Это по сути дела, обычный, самый стандартный логгер. Мы не логируем вызов любого метода VM, мы логируем действия пользователя. Решение через middleware конечно имеет право на жизнь, но я не спроста указал что в логи мы можем писать что угодно в зависимости от ситуации (someLog). И если это не сущности MVVM, то как мы их классифицируем и куда положим? А будет ли это универсальным решением? как нам это использовать например в роутинге что бы залогировать переход?


                1. Yoskutik Автор
                  10.11.2022 12:34
                  +1

                  Мы снова возвращаемся к проблеме гибкости и расширяемости. Без всех вводных хорошего решения не придумать. А продумывать заранее все возможные ситуации - сильно усложнять себе жизнь и замедлять процесс разработки.


                  1. riogod
                    10.11.2022 12:57

                    Вот именно по этому мы и используем готовые архитектурные решения (даже если они нам кажутся избыточными, урезать их не стоит, так как дорабатывать потом - себе дороже) + если в решении MVVM используется модель - мы используем модель. И нам легче общаться между собой, так как используя этот термин, мы подразумеваем четкую концепцию. Мы абсолютно точно знаем, что ВМ в только поставляет данные и никак иначе, потому что так написано. Внедряя зависимости, мы получаем гибкость, консистентность решений и хорошую документированность. Потому как нам не надо описывать что в таком случае мы делаем так, в таком так. У нас одно решение на все случаи.


            1. markelov69
              10.11.2022 12:50

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

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

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

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

              Когда обрастете реализованными проектами и опытом(я имею ввиду реальным, т.е. хотя бы не меньше 15 проектов с нуля реализуете сами), то вы придете к "KISS (Keep it simple, stupid), YAGNI (You aren't gonna need it), Чем меньше, тем лучше (Less is more)" во главе всего. То есть даже сами того не подозревая, т.к. эти принципы станут само собой разумеющимися.

              И так к реализации:


              клики
              - window.addEventListner('click', handler);

              вызов эндпоинтов - Разумеется любой уважающий себя разработчик не дергает энпонты напрямую fetch, axios и т.п. а делает это через специальную обертку, аля apiRequest(method, url, data). Так вот, просто внутри этой обертки логируем. На крайняк что-то такое https://gist.github.com/benjamingr/0433b52559ad61f6746be786525e97e8

              переход по роутингу - либо средствами роутера ловим, либо если роутер самописный, то вообще элементарно, либо window.addEventListener('popstate', handler);


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


              1. riogod
                10.11.2022 13:55

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

                1) в задании предложено реализовать самый обычный логгер (стандартный до не могу). И кто вам сказал про завершающую стадию? Рассматривайте это как бизнес-требование на этапе разработки архитектуры.

                2) Проблемы в вашем решении:
                - не консистентность, а именно в одном случае мы пишем хендлер, в другом мы делаем обертку. если какое либо дейсвие еще понадобиться залогировать, к примеру ошибки на разных уровнях приложения - наверное создадим еще обертку? или 20 оберток.
                - Судя по решению, вы предлагаете в хендлере создать большую лапшу из того что надо записать в лог относительно клика? и каждый раз обрабатывая click - мы столкнемся с тем что эту портянку из ифов надо обработать - производительное ли это решение?
                - popstate - срабатывает только на хистори, но в приложениях не все переходы пишутся в хистори
                - вы не реализовали в вашем описании отправку логов по достижению N элементов.


                1. markelov69
                  10.11.2022 14:23

                  Вот ваше решение:

                  Решение:
                  во всех VM мы внедряем модель "LogCollectorModel" и просто на каждом методе VM мы вызываем метод this.logCollectorModel.addToLog(someLog). В методе модели addToLog, мы проверяем, скопилось ли достаточное количество данных и если да, то отправляем, если нет, то добавляем в хранилище.


                  Чем оно отличается от вызова функции collecor.log(soleLog); во всех местах где это нужно? Во всех обработчиках событий, в функции которая перехватывает роутинг, в функции которая перехватывает вызовы к АПИ, или же просто рядом с каждым конкретном вызовом вызывать данную функцию.

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

                  И в чем проблема? Это в итоге вызывает collecor.log(soleLog);

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

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

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

                  - popstate - срабатывает только на хистори, но в приложениях не все переходы пишутся в хистори

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

                  - вы не реализовали в вашем описании отправку логов по достижению N элементов.

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


                  Всё это к чему, ваш пример задачи с логгером ну реально элементарный. особенно если о нем заранее известно на этапе проектирования. Я не понимаю почему вы из него пытаетесь сделать архитектурную загадку или феномен. Мое "решение" было конечно же шуточным, ибо задача сформулирована так "поди туда не знаю куда, принеси то, не знаю что". Но главный посыл, что она решается элементарно и каких-то трудностей не может вызвать от слова совсем. Возможно в вашей архитектуре из 40 слоев и тонн бойлерплейта да, она может вызвать проблему. Но т.к. я таким не балуюсь и у меня во главе всего KISS, YAGNI, Less is more, такого рода задачи вообще не могут априори никакой проблемы и сложности вызвать. Тем более если о них известно заранее, просто закладываешь это сразу в архитектуру и дело с концом.


                  1. riogod
                    10.11.2022 15:33

                    Чем оно отличается от вызова функции collecor.log(soleLog); во всех местах где это нужно? Во всех обработчиках событий, в функции которая перехватывает роутинг, в функции которая перехватывает вызовы к АПИ, или же просто рядом с каждым конкретном вызовом вызывать данную функцию.

                    Тем , что решение универсальное (вызывается откуда угодно, как угодно и логирует что угодно). Полностью соответствует вашему KISS, так как для описания его мне потребовалось всего пара предложений, куда уж проще и к тому же он лежит в обсуждаемой концепции MVVM. Попробуйте гипотетически придумать ситуацию, в которой оно не сработает или будет не простым.

                    Действительно, пример с логером элементарный, он один и он логирует там где это только необходимо.
                    В случае с eventListener не все так однозначно, он тупо логирует абсолютно все клики и в том числе на тексте - везде. В данном случае в handler нам придется сделать switch-case и разделять контент отправляемый в логгер относительно event.target. Замечу, что данный switch-case будет срабатывать при каждом клике. (я надеюсь не стоит разбирать ситуацию, в которой мы в надежде избавиться от switch-case создаем N eventListener которые крутятся в реалтайме и вызывают проблемы с производительностью).

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

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

                    При неопределенности бизнес требований обычно два варианта, вы либо разрабатываете универсальное решение, либо уточняете требования. Аналитики и бизнес - не разработчики, они не видят всех потенциальных возможных проблем, а мы видим - это наша работа. Logger is just a logger, nothing more. в данном случае.
                    Разработчики React не пишут по бизнес-требованиям, а разрабатывают универсальное решение решающее конкретные задачи в конкретном контексе.

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

                    И модульные окна с оверлеями? Вы там в работе с хистори роутера не умерли? ????

                    шуточное решение или нет, масштабируемость этого решения крайне низкая.
                    В данном контексте мы рассматриваем только MVVM, там нет 40 слоев, а всего лишь 3 - Model.View.ViewModel.
                    Если говорить о решении в рамках большого энтерпрайз приложения которое разрабатывает большое количество разработчиков, то это другая тема, не обсуждаемая здесь.

                    P.S.: На всякий случай - это не я Вам минусы ставлю.


                    1. markelov69
                      10.11.2022 16:09

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

                      Любое, даже самое банальное и простое приложение можно легко превратить в "энтерпрайз" и называть его так, достаточно только разбить в нем все на много слоев абстракций, попытаться предусмотреть все возможные варианты развития событий и заложиться под все подряд, в обязательном порядке строго следовать всем принципам solid, заодно в качестве view заюзать Angular + RxJS, так сказать чтобы жизнь мёдом не казалась и вуаля. Ваше типичное простое приложение тем "энтерпрайз". Просто тупо из-за того, что его усложнили донельзя на ровном месте.

                      Вообще у меня всегда улыбка на лице когда люди говорят "энтерпрайз". Потому что если откинуть все предрассудки и быть откровенными самим с собой, то на самом деле, ничего сложного в этих приложениях нет в 99.9% случаях объективно, все там просто и тривиально. Но, за счет того, что с точки зрения подхода к написанию кода это приложение решили усложнить на 2 порядка, оно стало входить в категорию "знтерпрайз" (которая кстати ни грамма уважения в моих глазах не вызывает).

                      И модульные окна с оверлеями? Вы там в работе с хистори роутера не умерли?

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

                      Тем , что решение универсальное (вызывается откуда угодно, как угодно и логирует что угодно)

                      Так collecor.log(soleLog) точно так же вызывается откуда угодно, как угодно и логирует что угодно)


                      1. riogod
                        10.11.2022 16:46
                        -1

                        Так collecor.log(soleLog) точно так же вызывается откуда угодно, как угодно и логирует что угодно)

                        Тогда залогируйте мне пожалуйста следующее:

                        <a href="#" onClick={vm.someHandler}> link0</a>
                        <a href="#" onClick={vm2.someHandler1}> link1</a>
                        <a href="#" onClick={vm3.someHandler2}> link2</a> // Хочу писать в лог 'Пользователь кликнул на ссылку 1'
                        <a href="#" onClick={vm4.someHandler3}> link3</a> // Хочу писать в лог 'Пользователь кликнул на ссылку 2'
                        <a href="#" onClick={vm5.someHandler4}> link4</a>

                        Вот только эти два варианта.
                        Записи должны где-то сохраняться естественно до передачи.


                      1. markelov69
                        10.11.2022 17:17
                        +2

                        import { collecor } from 'helpers/collector';
                        
                        class Vm3 {
                          someHandler2() {
                            collecor.log("Пользователь кликнул на ссылку 1");
                            // ... Весь остальной код
                          }
                        }
                        
                        class Vm4 {
                          someHandler3() {
                            collecor.log("Пользователь кликнул на ссылку 2");
                            // ... Весь остальной код
                          }
                        }



                      1. riogod
                        10.11.2022 17:20

                        А куда делись eventListener? :)

                        Вы уверены что данное решение будет работать и хранить в себе данные ?


                      1. markelov69
                        10.11.2022 18:19
                        +2

                        А куда делись eventListener? :)

                        Зачем?) Если в задаче явно указано, логировать не все подряд клики, а конкретные. Если все подряд(например как в яндекс.метрика), то eventListener конечно)

                        Я же сказал, решение шуточное, ибо формулировка 'задачи" была вообще без каких либо уточнений)


            1. nin-jin
              10.11.2022 17:04

              во всех VM мы внедряем модель "LogCollectorModel" и просто на каждом методе VM мы вызываем метод this.logCollectorModel.addToLog(someLog)

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


              1. riogod
                10.11.2022 17:13

                Мы рассматриваем только относительно концепции используемой в статье и не погружаемся вглубь, иначе дискуссия растянется на несколько дней или месяцев :) конечно есть куча паттернов, при помощи которых это можно автоматизировать на разных слоях(не только в рендере)


                1. nin-jin
                  10.11.2022 17:14
                  -3

                  Можно разрабатывать и через жопу, конечно, но зачем?


        1. nin-jin
          10.11.2022 16:55
          -1

          Я немного упростил ваш пример:

          export class SomeModel extends Model {
              
              id() {
                  return this.data().id ?? 0
              }
              
              serviceDesc() {
                  return this.data().serviceDesc ?? ''
              }
              
              serviceName() {
                  return this.data().serviceName ?? 'Unnamed Service'
              }
              
              systemName() {
                  return this.data().systemName ?? 'Unnamed System'
              }
          
              // Cached data will be disposed automatically
              @mem data() {
                  sleep( 1000 ) // emulate long loading
                  return {
                      id: '1',
                      serviceDesc: 'Some description',
                      serviceName: 'someService',
                      systemName: 'someSystem',
                  }
              }
          
          }
          
          export class SomePage extends View<SomePage> {
          
              // Используем класс модели из IoC сонтекста
              @mem model() {
                  return new this.$.SomeModel
              }
          
              // Индикатор ожидания появляется автоматически, пока данные грузятся
              @mem render() {
                  return <>
                      <div>{ this.model().id() }</div>
                      <div>{ this.model().serviceDesc() }</div>
                      <div>{ this.model().serviceName() }</div>
                      <div>{ this.model().systemName() }</div>
                  </>
              }
              
          }
          

          Не благодарите.


          1. riogod
            10.11.2022 17:16
            +1

            А дайте пример из прода, где в рамках. Модели и вью реализуется много функционала. Если есть конечно что-нибудь на гите.


            1. nin-jin
              10.11.2022 17:23

              Да полно. Вот, например, модель вики-страницы и её вьюшки. Посмотреть их в деле можно тут.


              1. riogod
                10.11.2022 18:06
                +1

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

                В качестве поиграться, наверное -да, поиграемся. использование в проме - пока не понятно, решению 10 месяцев, комьюнити нет, куда ехать с ишьюсами - не ясно. и лично меня $ - бесят (но это чертовы вьетнамские флэшбеки) :)


                1. nin-jin
                  10.11.2022 18:20

                  решению 10 месяцев

                  Молу лет 7 уже. За это время система реактивности 4 раза переписывалась без существенного изменения апи. Последний раз - год назад, да.

                  комьюнити нет

                  Я ему передам.

                  куда ехать с ишьюсами - не ясно

                  Как обычно - в багтрекер.


  1. Nikitakun1
    10.11.2022 09:29
    +1

    Учитывая, что ViewModel знает все о пропсах компонента, кажется, на деле означает, что в итоге связка ViewModel + реактовский компонент чисто для рендера это с технической точки зрения практически то же самое, что компоненты на классах (ViewModel это как классовый компонент без функции render()) :)


    1. Yoskutik Автор
      10.11.2022 09:36

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

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

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


  1. inoyakaigor
    10.11.2022 15:47
    -1

    Пользуясь случаем прорекламирую наш Мобиксовский чатик