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

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

Содержание

  1. Цель публикации

  2. Очень сжатое описание проекта

    1. Основная цель проекта

    2. Не являются целями

    3. Список пакетов проекта

      1. Reactive

      2. Core

      3. Mix Models

      4. Store

  3. Быстрый старт

  4. Более детальный обзор пакетов с примерами

    1. Reactive

    2. Core

    3. Mix Models

      1. Почему миксины?

      2. Примеры описания и использования

      3. Простой пример

      4. Откат состояния

      5. Сохранение

      6. Валидация

      7. Дополнительное поле

      8. Сложная модель

    4. Store

      1. Простая коллекция

      2. Коллекция с доступом ко внешнему хранилищу

      3. Класс и сервис хранилища

  5. Известные ограничения

  6. Заключение

  7. Ссылки

Цель публикации

Целью публикации является не только описание имеющегося решения, но и выяснение наличия, либо отсутствия интереса к проекту со стороны разработчиков. Дело в том, что самые трудные части документации все еще отсутствуют, количество разработчиков проекта сократилось с двух до одного (меня), мое свободное время ограничено весьма скромными временными рамками, и тратить его на никому не нужный проект банально жалко. Поэтому, если кто‑либо захочет помочь правками, написанием документации, конструктивной критикой в комментариях или любым иным способом — буду рад.

Очень сжатое описание проекта

VueEnt это набор из четырех пакетов, три из которых независимы друг от друга. Поддерживаются как Vue 3, так и Vue 2.7, либо Vue 2.6 с плагином @vue/composition-api.

Основная цель проекта

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

Не являются целями

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

Список пакетов проекта

Reactive

Пакет @vueent/reactive — это набор декораторов TypeScript, которые позволяют прозрачно использовать ref— и computed‑свойства Vue в качестве полей ES‑классов.

Core

Пакет @vueent/core, несмотря на название — маленькая библиотека, которая добавляет такие сущности, как контроллеры и сервисы, во Vue‑приложение.

Mix Models

Пакет @vueent/mix-models — библиотека, предоставляющая классы реактивных моделей для NoSQL‑моделей с опциональными возможностями сохранения, отката состояния (rollback) и динамической валидации. Именно этот пакет является самым большим и важным во всем проекте.

Store

Экспериментальный пакет @vueent/store предоставляет классы для реализации централизованного хранилища коллекций моделей, то есть представляет собой сильно упрощенный аналог ember‑data, но без поддержки реляционных связей.

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

Быстрый старт

Для тех, кому лень читать текст, но хочется пощупать решение на рабочем проекте, подготовлен специальный проект:

git clone https://github.com/vueent/vueent-quick-start.git
cd vueent-quick-start
npm i
npm run dev

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

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

Более детальный обзор пакетов с примерами

Reactive

Когда я впервые увидел Vue Composition API, то сразу же подумал о том, что теперь реактивность удобно использовать не только внутри компонентов, но и за их пределами, например в каких‑нибудь классах. Однако хотелось бы видеть обычные свойства, а не объекты, в качестве полей классов, ведь сегодня это поле класса не реактивно, а завтра ситуация может измениться. Если вы пробовали использовать ref и computed в качестве свойств классов, то могли заметить, что это не слишком‑то удобно. Итак, встречайте декораторы tracked (использует ref) и calculated (использует computed):

import { tracked, calculated } from '@vueent/reactive';

class MyClass {
  @tracked public num = 2;
  @tracked public factor = 3;

  @calculated public get mul() {
    console.log('calculate mul');

    return this.num * this.factor;
  }
}

const my = new MyClass();

my.factor = 4;

console.log(my.mul); // "calculate mul", 8 - работает, как ожидалось
console.log(my.mul); // 8 - повторного вычисления нет, как и положено

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

"moduleResolution": "node",
"useDefineForClassFields": false,
"experimentalDecorators": true

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

Core

Идея @vueent/core состоит в том, чтобы пересмотреть структуру приложений на Vue. Из коробки нам предлагают использовать директорию views или pages (если речь про Nuxt) для расположения компонентов‑маршрутов, хотя сам по себе Vue вообще не накладывает каких‑либо ограничений на структуру файлов. Получается, что входной точкой маршрута является компонент. Несмотря на то, что в документации по Vue я не обнаружил определения компонента (поправьте, если я слеп), но там написано следующее:

Components allow us to split the UI into independent and reusable pieces, and think about each piece in isolation.

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

То есть компонент являет собой не что иное, как представление, говоря терминами того же MVVM. Если понимать компонент, как представление, то он не должен включать в себя бизнес‑логику, хотя именно это слишком часто встречается в проектах на Vue и React. Для инкапсуляции бизнес‑логики и доступа к данным можно использовать сервисы, а для отделения слоя представления от сервисов можно использовать контроллеры (хотя, возможно, название не самое подходящее). Именно сервисы и контроллеры с ленивой инициализацией и предоставляет пакет @vueent/core. В качестве референсных решений можно взять Angular или Ember. Также во время написания этого текста натолкнулся на публикацию здесь, на хабре, неплохо описывающую концепт.

Чтобы использовать контроллеры и сервисы в проекте, необходимо сперва инициализировать ядро:

// file: ./vueent.ts
import { initVueent } from '@vueent/core';

export const {
  useVueent,
  registerService,
  registerController,
  useService,
  useController,
  injectService,
  injectController
} = initVueent();

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

// file: ./services/clicker.ts
import { Service } from '@vueent/core';
import { tracked } from '@vueent/reactive';

import { registerService } from '@/vueent';

export default class ClickerService extends Service {
  @tracked private _counter = 0; // реактивное свойство

  public get counter() {
    return this._counter;
  }

  public increment() {
    ++this._counter;
  }
}

registerService(ClickerService); // нужно обязательно зарегистрировать серсис

Контроллер приложения, предоставляющий доступ к реактивному свойству и методу, изменяющему его:

// file: ./app.ts
import { Controller } from '@vueent/core';

import { registerController, injectService as service } from '@/vueent';
import ClickerService from '@/services/clicker';

export default class AppController extends Controller {
  // ленивая инициализация сервиса
  @service(ClickerService) private readonly clicker!: ClickerService;

  public readonly date: number;

  public get counter() {
    return this.clicker.counter; // возврат реактивного свойства сервиса
  }

  constructor(date: number) {
    super();
    this.date = date;
  }

  public init() {
    console.log('onBeforeMount');
  }

  public reset() {
    console.log('onBeforeUnmount');
  }

  public destroy() {
    console.log('onUnmounted'); // остановка наблюдателей (watchers), таймеров и так далее
  }

  public increment() {
    this.clicker.increment();
  }
}

registerController(AppController); // контроллер также обязательно зарегистрировать

Наконец, компонент приложения:

<!-- file: app.vue:template -->
<template>
<div>
  <div>Started at: {{ timestamp }}</div>
  <div>Button clicks: {{ counter }}</div>
  <div>
    <button type="button" @click="increment">Increment</button>
  </div>
</div>
</template>
// file: app.vue:script
import { defineComponent, computed } from 'vue';

import { useController } from '@/vueent';

import AppController from './app';

function setup() {
  // создаем экземпляр контроллера с параметрами.
  const controller = useController(AppController, new Date().getTime());

  const increment = () => controller.increment();
  const counter = computed(() => controller.counter);

  return {
    timestamp: controller.date, // нереактивное значение
    counter, // вычисляемое свойство
    increment
  };
}

export default defineComponent({ setup });

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

Подробную документацию по данному пакету можно найти по ссылке.

Mix Models

Что ж, пришло время самой объемной и самой, на мой взгляд, полезной части библиотеки. Как было сказано выше, пакет @vueent/mix-models позволяет создавать реактивные модели.

Как можно понять из названия, классы строятся на основе миксинов.

Почему миксины?

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

  1. Наследование классов в парадигме ООП

  2. Использование функций

  3. Использование прототипов

  4. Использование миксинов

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

class BaseModel {}

class RollbackModel extends BaseModel {}

class SaveModel extends RollbackModel {}

class ValidateModel extends SaveModel {}

// содержит все возможные функции, даже если они не нужны.
class MyModel extends ValidateModel {}

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

interface A {
  n: number;
}

// создает простой объект с числовым полем n
function a(): A {
  return { n: 0 };
}

// создает объект с полем digits и методом print
function b() {
  return {
    digits: 2,
    print() {
      console.log(((this as unknown) as A).n.toFixed(this.digits));
    }
  };
}

// создает объект с методом add, суммирующим значение свойства n и аргумент op
function c() {
  return {
    add(op: number): void {
      ((this as unknown) as A).n += op;
    }
  };
}

// формируется итоговый объект модели
const my = { ...a(), ...b(), ...c() };

my.add(2.2222);
my.print(); // 2.22

// новый объект, все методы также дублируются
const my2 = { ...a(), ...b(), ...c() };

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

interface Creator<T extends A> {
  new (...args: any[]): T;
  (...args: any[]): T;
}

interface A {
  n: number;
}

// создает пустой прототип и конструктор для объекта с полем n
function a() {
  const proto = {};

  return {
    setup: function(this: A) {
      Object.setPrototypeOf(this, proto);
      this.n = 0;

      return this;
    } as Creator<A>,
    proto
  };
}

interface B {
  digits: number;
  print(): void;
}

// добавляет к прототипу метод print и подмешивает в функцию-конструктор создание поля digits.
function b<T extends A>({ proto, setup }: { proto: any; setup: Creator<T> }) {
  proto.print = function(this: T & B) {
    console.log(this.n.toFixed(this.digits));
  };

  return {
    setup: function(this: T & B, ...args: any[]) {
      setup.call(this, ...args);

      this.digits = 2;

      return this;
    } as Creator<T & B>,
    proto
  };
}

interface C {
  add(op: number): void;
}

// подмешивает в прототип метод add
function c<T extends A>({ proto, setup }: { proto: any; setup: Creator<T> }) {
  proto.add = function(this: T & C, op: number) {
    this.n += op;
  };

  return { proto, setup: setup as Creator<T & C> };
}


const ABC = c(b(a())).setup; // формируем нужный прототип и конструктор
const abc = new ABC(); // создаем экземпляр

abc.add(2.2222);
abc.print(); // 2.22

const AB = b(a()).setup;
const ab = new AB();

ab.n = 2;
ab.print(); // 2.00

const AC = c(a()).setup;
const ac = new AC();

ac.add(2.2222);
console.log(ac.n); // 2.2222

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

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

Примеры описания и использования

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

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

Простой пример

Для начала стоит рассмотреть самый простой пример объявления модели без миксинов:

import type { Base } from '@vueent/mix-models';
import { BaseModel } from '@vueent/mix-models';

// описание структуры с данными
export interface Data {
  id: number;
  name: string;
}

// функция, возвращаяющая базовое состояние данных
export function makeInitialData(): Data {
  return { id: 0, name: '' };
}

// промежуточный класс DataModel, который необходим для применения миксинов,
// так как BaseModel является обобщенным классом
class DataModel extends BaseModel<Data> {}

// публичный тип модели
export type ModelType = Base<Data>;

// класс модели
export class Model extends DataModel {
  /**
   * @param initialData - стартовое состояние данных
   * @param react - делать данные модели реактивными или нет
   */
  constructor(initialData?: Data, react = true) {
    // первый аргумент указывает - какое поле считать первичным ключом модели,
    // поддержки составных ключей нет, можно оставить его пустым, передав пустую строку
    super('id', initialData ?? makeInitialData(), react);
  }
}

// функция, порождающая экземпляры модели, но возвращающая только публичный тип
export function create(initialData?: Data, react = true): ModelType {
  return new Model(initialData, react);
}

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

Откат состояния

Теперь добавим миксин отката состояния:

import type { Base, Rollback, RollbackPrivate } from '@vueent/mix-models';
import { BaseModel, mixRollback, mix } from '@vueent/mix-models';

// описание структуры с данными
export interface Data {
  id: number;
  name: string;
}

// функция, возвращаяющая базовое состояние данных
export function makeInitialData(): Data {
  return { id: 0, name: '' };
}

// маска, показывающая - какие поля откатывать при вызове rollback
// в данном примере поле name будет сброшено, а поле id - нет
// маску можно не указывать вовсе, тогда исходное состояние будет возвращено целиком
export const rollbackMask = {
  name: true
} as const;

// промежуточный класс DataModel, который необходим для применения миксинов,
// так как BaseModel является обобщенным классом
class DataModel extends BaseModel<Data> {}

// публичный тип модели, который не включает в себя публичные поля
// и методы миксина отката состояния
export type ModelType = Base<Data> & Rollback;

// так как TypeScript не позволяет автоматически выводить тип при применении
// миксинов, то необходимо явно добавить типы в определение интерфейса
// класса, иначе приватные и публичные методы и свойства миксинов
// не будут доступны внутри класса
export interface Model extends DataModel, RollbackPrivate<Data> {}

// класс модели
export class Model extends mix<Data, typeof DataModel>(DataModel, mixRollback(rollbackMask)) {
  /**
   * @param initialData - стартовое состояние данных
   * @param react - делать данные модели реактивными или нет
   */
  constructor(initialData?: Data, react = true) {
    // первый аргумент указывает - какое поле считать первичным ключом модели,
    // поддержки составных ключей нет, можно оставить его пустым, передав пустую строку
    super('id', initialData ?? makeInitialData(), react);
  }
}

// функция, порождающая экземпляры модели, но возвращающая только публичный тип
export function create(initialData?: Data, react = true): ModelType {
  return new Model(initialData, react);
}

Не трудно заметить, что, убрав типы, можно получить куда как более короткий вариант:

import { BaseModel, mixRollback, mix } from '@vueent/mix-models';

export function makeInitialData(): Data {
  return { id: 0, name: '' };
}

export const rollbackMask = {
  name: true
};

export class Model extends mix(BaseModel, mixRollback(rollbackMask)) {
  /**
   * @param {Record<string, unknown>=} initialData - стартовое состояние данных
   * @param {boolean=} react - делать данные модели реактивными или нет
   */
  constructor(initialData = undefined, react = true) {
    super('id', initialData ?? makeInitialData(), react);
  }
}

Правда, в таком случае, теряются все плюшки TypeScript, поэтому вернемся к варианту с типами. Дело в том, что TypeScript не умеет автоматически выводить тип для миксинов, если базовый класс является обобщенным. Именно поэтому нужно дополнительно явно описывать интерфейс Model вручную. Также в миксинах нельзя использовать модификаторы доступа (private, protected, public), и, чтобы все поля и методы, которые не должны быть доступны снаружи модели, были скрыты, приходится отдельно определять публичный интерфейс модели в виде ModelType. Выходит, что у нас две цепочки наследования для типов, одна формирует публичный интерфейс модели, а вторая — приватный. Благодаря наличию ручного описания приватного интерфейса Model, языковой сервер TypeScript подскажет — какие поля есть в классе, если мы захотим с ними работать при описании самого класса модели, скажем, добавить метод, который выполняет rollback только в том случае, если первичный ключ равен нулю:

// объявляем интерфейс, используемый в публичном интерфейсе модели
interface ConditionalRollback {
  conditionalRollback(): void;
}

export type ModelType = Base<Data> & Rollback & ConditionalRollback;

export interface Model extends DataModel, RollbackPrivate<Data> {}

export class Model extends mix<Data, typeof DataModel>(DataModel, mixRollback(rollbackMask)) {
  constructor(initialData?: Data, react = true) {
    super('id', initialData ?? makeInitialData(), react);
  }

  public conditionalRollback(): void {
    // поле pk имеет тип unknown и возвращает значение того поля, что было
    // указано в качестве первичного ключа в конструкторе
    if (!this.pk) this.rollback();
  }
}

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

Примечание: Динамически проверить наличие того или иного миксина можно при помощи вызова метода модели hasMixin(mixin: Function): boolean, передав ему в качестве аргумента функцию миксина, например, mixRollback.

Попробуем создать парочку экземпляров и поработать с ними
import { create } from '@/models/simple';

const m1 = create();
const m2 = create({ id: 2, name: 'Jane' });

// все данные находятся в поле data экземпляра модели
m1.data.id = 1;
m1.data.name = 'John';

// флаг dirty у m1 выставлен в true, так как свойства были изменены
console.log(m1.dirty, JSON.stringify(m1.data)); // true {"id":1,"name":"John"}
// флаг dirty у m2 выставлен в false, так как значение при инициализации не менялось
console.log(m2.dirty, JSON.stringify(m2.data)); // false {"id":2,"name":"Jane"}

// вызов rollback сбрасывает флаг dirty
m1.rollback();
m2.rollback();

console.log(m1.dirty, JSON.stringify(m1.data)); // false {"id":1,"name":""}
console.log(m2.dirty, JSON.stringify(m2.data)); // false {"id":2,"name":"Jane"}

// для того, чтобы сборщик мусора мог освободить реактивные свойства,
// экземпляры моделей нужно явно вручную уничтожать, когда они больше не нужны
// после этого работать с ними корректно уже нельзя
m1.destroy();
m2.destroy();

// флаг instanceDestroyed указывает на то, что экземпляр удален
console.log(m1.instanceDestroyed); // true
console.log(m2.instanceDestroyed); // true

Примечание: Более подробное описание флагов доступно на страницах документации.

Сохранение

Теперь наша модель умеет делать откат собственного состояния, но без возможности сохранения, это не имеет большого смысла, поэтому добавим возможность сохранять данные при помощи другого миксина:

import type {
  Base,
  Rollback,
  RollbackPrivate,
  Save,
  SavePrivate,
  SaveOptions,
  CreateFunc,
  UpdateFunc,
  DestroyFunc
} from '@vueent/mix-models';
import { BaseModel, mixRollback, mixSave, mix } from '@vueent/mix-models';

// описание структуры с данными
export interface Data {
  id: number;
  name: string;
}

// функция, возвращаяющая базовое состояние данных
export function makeInitialData(): Data {
  return { id: 0, name: '' };
}

// маска, показывающая - какие поля откатывать при вызове rollback
// в данном примере поле name будет сброшено, а поле id - нет
// маску можно не указывать вовсе, тогда исходное состояние будет возвращено целиком
export const rollbackMask = {
  name: true
} as const;

// промежуточный класс DataModel, который необходим для применения миксинов,
// так как BaseModel является обобщенным классом
class DataModel extends BaseModel<Data> {}

// публичный тип модели, который не включает в себя публичные поля и методы миксинов
export type ModelType = Base<Data> & Rollback & Save;

// так как TypeScript не позволяет автоматически выводить тип при применении
// миксинов, то необходимо явно добавить типы в определение интерфейса
// класса, иначе приватные и публичные методы и свойства миксинов
// не будут доступны внутри класса
export interface Model extends DataModel, RollbackPrivate<Data>, SavePrivate<Data> {}

// класс модели
export class Model extends mix<Data, typeof DataModel>(DataModel, mixRollback(rollbackMask), mixSave()) {
  /**
   * @param initialData - стартовое состояние данных
   * @param react - делать данные модели реактивными или нет
   * @param saveOptions - объект с функциями создания, обновления и удаления объекта из хранилища
   */
  constructor(initialData?: Data, react = true, saveOptions?: SaveOptions<Data>) {
    // первый аргумент указывает - какое поле считать первичным ключом модели,
    // поддержки составных ключей нет, можно оставить его пустым, передав пустую строку
    super('id', initialData ?? makeInitialData(), react, saveOptions);

    // если идентификатор задан при создании экземпляра, то считаем,
    // что загружен объект из хранилища. Этот шаг не автоматизирован,
    // так как стояла задача дать как можно больше свободы разработчику
    if (this.pk) this._flags.new = false;
  }
}

// функция, порождающая экземпляры модели, но возвращающая только публичный тип
export function create(
  initialData?: Data,
  react = true,
  params: {
    create?: CreateFunc<Data>;
    update?: UpdateFunc<Data>;
    destroy?: DestroyFunc<Data>;
  } = {}
): ModelType {
  const saveOptions: SaveOptions<Data> = { mixinType: 'save', ...params };

  return new Model(initialData, react, saveOptions);
}

Класс получил метод save, который сохраняет произведенные изменения в хранилище. Как вы, наверное, заметили, функции, выполняющие операции создания, изменения и удаления объекта из хранилища (свойства параметра saveOptions) не прописаны в самой модели. Это сделано для того, чтобы отвязать класс модели функций работы с хранилищем, будь оно локальным, либо удаленным. По задумке, эти функции подставляются автоматически коллекцией из сервиса Store, но о нем речь пойдет ниже, в соответствующем разделе, а пока передадим эти функции самостоятельно при создании экземпляра:

Взаимодействие с хранилищем
import type { Data } from '@/models/simple';
import { create } from '@/models/simple';

let counter = 0; // счетчик для первичных ключей
const storage = new Map<number, Data>(); // хранилище, имитация сервера

const saveOptions = {
  // функция, которая создает объект в хранилище и возвращает его вместе с первичным ключом
  create: (data: Data): Data => {
    const id = ++counter; // генерируем первичный ключ
    const record = { ...data, id }; // создаем копию объекта

    storage.set(id as number, record); // сохраняем в хранилище

    return record;
  },
  // функция, которая обновляет объект в хранилище
  update: (id: unknown, data: Data): Data => {
    if (!storage.has(id as number)) throw new Error('resource not found');

    const updated = { ...data }; // создаем копию объекта

    storage.set(id as number, updated);

    return updated;
  },
  // функция, которая удаляет объект из хранилища
  destroy: (id: unknown): void => {
    if (!storage.has(id as number)) throw new Error('resource not found');

    storage.delete(id as number);
  }
};

// задаем стартовое состояние хранилища
storage.set(1, { id: 1, name: 'John' });
++counter;

// значение для m1 берем из хранилища
const m1 = create({ ...storage.get(1)! }, true, saveOptions);
const m2 = create(undefined, true, saveOptions);

console.log(m1.new, m1.pk, JSON.stringify(m1.data)); // false 1 {"id":1,"name":"John"}
console.log(m2.new, m2.pk, JSON.stringify(m2.data)); // true 0 {"id":0,"name":""}

m2.data.name = 'Jane';

console.log(m2.dirty); // true

// во время выполнения операции (создание) будут выставлены флаги
// saving и creating
await m2.save();

// флаг dirty сбросился после сохранения
console.log(m2.dirty); // false
// флаг new также сброшен, а первичный ключ получил значение
console.log(m2.new, m2.pk, JSON.stringify(m2.data)); // false 2 {"id":2,"name":"Jane"}

m1.data.name = 'John Doe';

console.log(m1.dirty); // true

// теперь на время выполнения операции будут выставлены флаги
// saving и updating
await m1.save();

// флаг dirty сброшен после сохранения
console.log(m1.dirty); // false
// первичный ключ не изменился, так как объект был обновлен, а не создан
console.log(m1.pk); // 1

// выставляем флаг deleted в true
m2.delete();

// объект помечен как удаленный
console.log(m2.deleted); // true
// но так как удаление выполнено локально, а не в хранилище,
// то флаг destroyed не выставлен
console.log(m2.destroyed); // false

// соответственно, при удалении выставляются флаги
// saving, destroying
await m2.save();

// объект помечен, как удаленный в хранилище, им все еще можно пользоваться,
// но сохранять уже нельзя
console.log(m2.destroyed); // true
// экземпляр все еще не удален
console.log(m2.instanceDestroyed); // false

// очищаем экземпляры, позволяем сборщику мусора выполнить свою работу
m1.destroy();
m2.destroy();

// экземпляры очищены, сборщик мусора доволен нами
console.log(m1.instanceDestroyed); // true
console.log(m2.instanceDestroyed); // true

В примере выше функции синхронные, но могут быть и асинхроннымми. Если используются контроллеры из пакета @vueent/core, то освобождение экземпляров лучше вызывать в методе destroy, либо в хуке onUnmounted Vue. Также у моделей есть хуки beforeSave, afterSave, beforeCreate, afterCreate, но их рассмотрение выходит за рамки данной публикации, ознакомиться с хуками можно в документации.

Валидация

Что ж, пора переходить к самому вкусному - валидации:

import type {
  Base,
  Rollback,
  RollbackPrivate,
  Save,
  SavePrivate,
  SaveOptions,
  Validate,
  ValidatePrivate,
  ValidationBase,
  ValidateOptions,
  Options,
  PatternAssert,
  CreateFunc,
  UpdateFunc,
  DestroyFunc
} from '@vueent/mix-models';
import { BaseModel, mixRollback, mixSave, mixValidate, mix } from '@vueent/mix-models';

// описание структуры с данными
export interface Data {
  id: number;
  name: string;
}

// функция, возвращаяющая базовое состояние данных
export function makeInitialData(): Data {
  return { id: 0, name: '' };
}

// маска, показывающая - какие поля откатывать при вызове rollback
// в данном примере поле name будет сброшено, а поле id - нет
// маску можно не указывать вовсе, тогда исходное состояние будет возвращено целиком
export const rollbackMask = {
  name: true
} as const;

// правила валидации, они должны возвращать true или строку текстом ошибки
export const validations = {
  // проверяем, что имя не пустая строка, и что длина не превышает 255 символов
  name: (v: any) => {
    if (!(v as string).length) return 'Enter name';
    else if ((v as string).length > 255) return 'Unexpected name length';
    else return true;
  }
} as const;

// промежуточный класс DataModel, который необходим для применения миксинов,
// так как BaseModel является обобщенным классом
class DataModel extends BaseModel<Data> {}

// генерируем тип для объекта, отвечающего за валидацию на основе правил и интерфейса с данными
export type Validations = PatternAssert<typeof validations, Data>;

// публичный тип модели, который не включает в себя приватные поля и методы миксинов
export type ModelType = Base<Data> & Rollback & Save & Validate<Validations>;

// так как TypeScript не позволяет автоматически выводить тип при применении
// миксинов, то необходимо явно добавить типы в определение интерфейса
// класса, иначе приватные и публичные методы и свойства миксинов
// не будут доступны внутри класса
export interface Model extends DataModel, RollbackPrivate<Data>, SavePrivate<Data>, ValidatePrivate<Validations> {}

// класс модели
export class Model extends mix<Data, typeof DataModel>(
  DataModel,
  mixRollback(rollbackMask),
  mixSave(),
  mixValidate(validations)
) {
  /**
   * @param initialData - стартовое состояние данных
   * @param react - делать данные модели реактивными или нет
   * @param options - набор опций экзмепляра
   */
  constructor(initialData?: Data, react = true, ...options: Options[]) {
    // первый аргумент указывает - какое поле считать первичным ключом модели,
    // поддержки составных ключей нет, можно оставить его пустым, передав пустую строку
    super('id', initialData ?? makeInitialData(), react, ...options);

    // если идентификатор задан при создании экземпляра, то считаем,
    // что загружен объект из хранилища. Этот шаг не автоматизирован,
    // так как стояла задача дать как можно больше свободы разработчику
    if (this.pk) this._flags.new = false;
  }
}

// функция, порождающая экземпляры модели, но возвращающая только публичный тип
// при создании можно указать не только функции для работы с хранилищем, но и другой
// набор правил валидации, который, правда, все равно должен соответствовать
// структуре данных модели
export function create(
  initialData?: Data,
  react = true,
  params: {
    validations?: ValidationBase;
    create?: CreateFunc<Data>;
    update?: UpdateFunc<Data>;
    destroy?: DestroyFunc<Data>;
  } = {}
): ModelType {
  const options: Array<ValidateOptions | SaveOptions<Data>> = [];

  if (params.validations) options.push({ mixinType: 'validate', validations: params.validations });
  if (params.create || params.update || params.destroy)
    options.push({
      mixinType: 'save',
      create: params.create,
      update: params.update,
      destroy: params.destroy
    });

  return new Model(initialData, react, ...options);
}

Для обращения к объекту валидации есть поле с коротким именем v, тип которого соответствует автоматически выведенному типу Validations нашей модели. В виду особенностей внутренней реализации, доступ к дочерним элементам объекта или массива осуществляется через промежуточное поле c (children), например: m1.v.c.name. Здесь name — это объект, с некоторым количеством полей и методов, в частности: invalid, dirty, message, dirtyMessage, touch(), anyChildDirty, anyChildInvalid и некоторыми другими. Валидация массива будет рассмотрена ниже в более сложном примере.

Можно проверить работу правил
import { create } from '@/models/simple';

const m1 = create();

// данные невалидны, но еще не были изменены
console.log(m1.v.dirty, m1.v.invalid, m1.v.anyChildInvalid); // false true true
// поэтому сообщение об ошибке будет пустым
console.log(m1.v.c.name.dirtyMessage); // ""
// а вот просто поле message показывает текущую ошибку,
// если правило валидации нарушено
console.log(m1.v.c.name.message); // Enter a name

m1.data.name = 'Jane';

// теперь валидация проходит успешно, но флаг валидации dirty все еще false,
// так как его нужно вручную сбрасывать при помощи метода touch()
console.log(m1.v.dirty, m1.v.invalid); // false false

// фиксируем изменение поля name
m1.v.c.name.touch();

console.log(m1.v.dirty, m1.v.invalid); // true false

// вновь делаем поле пустым, нарушая правила валидации
m1.data.name = '';

// теперь сообщение выводится, так как флаг dirty сброшен
// это полезно для динамической (или живой) валидации
console.log(m1.v.c.name.dirtyMessage); // Enter a name

// сбрасываем флаги dirty
m1.v.reset();

// флаги сброшены
console.log(m1.v.dirty, m1.v.anyChildDirty); // false false
// сообщение об ошибке также не выводится
console.log(m1.v.c.name.dirtyMessage); // ""
// хотя все правила проверены
console.log(m1.v.c.name.message); // Enter a name
// на любом уровне можно проверить - есть ли в поддереве нарушения правил
console.log(m1.v.anyChildInvalid); // true

m1.data.name = (() => new Array(256).fill('a').join(''))();
// устанавливаем флаги dirty во всем дереве валидации
m1.v.touch();

// сообщение об ошибке обновлено
console.log(m1.v.c.name.dirtyMessage); // Unexpected name length

// очищаем экземпляр модели
m1.destroy();

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

Список свойств, соответствующий интерфейсу данных
Список свойств, соответствующий интерфейсу данных
Список свойств объекта валидации
Список свойств объекта валидации
Ошибка при попытке доступа к несуществующим поля объекта валидации
Ошибка при попытке доступа к несуществующим поля объекта валидации

Дополнительное поле

Дополним модель числовым полем age:

import type {
  Base,
  Rollback,
  RollbackPrivate,
  Save,
  SavePrivate,
  SaveOptions,
  Validate,
  ValidatePrivate,
  ValidationBase,
  ValidateOptions,
  Options,
  PatternAssert,
  CreateFunc,
  UpdateFunc,
  DestroyFunc
} from '@vueent/mix-models';
import { BaseModel, mixRollback, mixSave, mixValidate, mix } from '@vueent/mix-models';

// описание структуры с данными
export interface Data {
  id: number;
  name: string;
  age: string;
}

// в хранилище возраст хранится в виде числа, но для формы, поля которой нужно заполнять
// часто удобней использовать строковые значения
export interface EncodedData {
  id: number;
  name: string;
  age: number;
}

// функция, возвращаяющая базовое состояние данных
export function makeInitialData(): Data {
  return { id: 0, name: '', age: '' };
}

// маска, показывающая - какие поля откатывать при вызове rollback
// в данном примере поле name будет сброшено, а поле id - нет
// маску можно не указывать вовсе, тогда исходное состояние будет возвращено целиком
export const rollbackMask = {
  name: true,
  age: true
} as const;

// правила валидации, они должны возвращать true или строку текстом ошибки
export const validations = {
  // проверяем, что имя не пустая строка, и что длина не превышает 255 символов
  name: (v: any) => {
    if (!(v as string).length) return 'Enter name';
    else if ((v as string).length > 255) return 'Unexpected name length';
    else return true;
  },
  // проверяем, что строка является целочисленной и не превышает трех символов
  age: (v: any) => {
    if (!(v as string).length) return 'Enter an age';
    else if ((v as string).length > 3) return 'Unexpected age length';
    else if (!/^\d+$/.test(v)) return 'Age should be an integer';
    else return true;
  }
} as const;

// промежуточный класс DataModel, который необходим для применения миксинов,
// так как BaseModel является обобщенным классом
class DataModel extends BaseModel<Data> {}

// генерируем тип для объекта, отвечающего за валидацию на основе правил и интерфейса с данными
export type Validations = PatternAssert<typeof validations, Data>;

// публичный тип модели, который не включает в себя приватные поля и методы миксинов
export type ModelType = Base<Data> & Rollback & Save & Validate<Validations>;

// так как TypeScript не позволяет автоматически выводить тип при применении
// миксинов, то необходимо явно добавить типы в определение интерфейса
// класса, иначе приватные и публичные методы и свойства миксинов
// не будут доступны внутри класса
export interface Model extends DataModel, RollbackPrivate<Data>, SavePrivate<Data>, ValidatePrivate<Validations> {}

// класс модели
export class Model extends mix<Data, typeof DataModel>(
  DataModel,
  mixRollback(rollbackMask),
  mixSave(),
  mixValidate(validations)
) {
  /**
   * @param initialData - стартовое состояние данных
   * @param react - делать данные модели реактивными или нет
   * @param options - набор опций экзмепляра
   */
  constructor(initialData?: Data, react = true, ...options: Options[]) {
    // первый аргумент указывает - какое поле считать первичным ключом модели,
    // поддержки составных ключей нет, можно оставить его пустым, передав пустую строку
    super('id', initialData ?? makeInitialData(), react, ...options);

    // если идентификатор задан при создании экземпляра, то считаем,
    // что загружен объект из хранилища. Этот шаг не автоматизирован,
    // так как стояла задача дать как можно больше свободы разработчику
    if (this.pk) this._flags.new = false;
  }
}

// функция, порождающая экземпляры модели, но возвращающая только публичный тип
// при создании можно указать не только функции для работы с хранилищем, но и другой
// набор правил валидации, который, правда, все равно должен соответствовать
// структуре данных модели
export function create(
  initialData?: Data,
  react = true,
  params: {
    validations?: ValidationBase;
    create?: CreateFunc<Data>;
    update?: UpdateFunc<Data>;
    destroy?: DestroyFunc<Data>;
  } = {}
): ModelType {
  const options: Array<ValidateOptions | SaveOptions<Data>> = [];

  if (params.validations) options.push({ mixinType: 'validate', validations: params.validations });
  if (params.create || params.update || params.destroy)
    options.push({
      mixinType: 'save',
      create: params.create,
      update: params.update,
      destroy: params.destroy
    });

  return new Model(initialData, react, ...options);
}

На сервере числовые поля хранятся в виде чисел, но для форм актуально делать поля строками, поэтому мы разделили интерфейсы Data и EncodedData.

Комплексный тест модели, использующий функции изо всех миксинов
import type { Data, EncodedData } from '@/models/simple';
import { create } from '@/models/simple';

// функция нормализации преобразует объект с данными в формат хранилища
function normalize(data: Data): EncodedData {
  return {
    id: data.id,
    name: data.name,
    age: Number(data.age)
  };
}

// функция денормализации преобразует объект с данными из формата хранилища во внутренний
function denormalize(encoded: EncodedData): Data {
  return {
    id: encoded.id,
    name: encoded.name,
    age: String(encoded.age)
  };
}

let counter = 0; // счетчик для первичных ключей
const storage = new Map<number, EncodedData>(); // хранилище, имитация сервера

const params = {
  // функция, которая создает объект в хранилище и возвращает его вместе с первичным ключом
  create: (data: Data): Data => {
    const id = ++counter; // генерируем первичный ключ
    const record = { ...data, id }; // создаем копию объекта

    storage.set(id as number, normalize(record)); // сохраняем в хранилище

    return record;
  },
  // функция, которая обновляет объект в хранилище
  update: (id: unknown, data: Data): Data => {
    if (!storage.has(id as number)) throw new Error('resource not found');

    const updated = { ...data }; // создаем копию объекта

    storage.set(id as number, normalize(updated));

    return updated;
  },
  // функция, которая удаляет объект из хранилища
  destroy: (id: unknown): void => {
    if (!storage.has(id as number)) throw new Error('resource not found');

    storage.delete(id as number);
  }
};

// задаем стартовое состояние хранилища
storage.set(1, { id: 1, name: 'John', age: 20 });
++counter;

// значение для m1 берем из хранилища
const m1 = create(denormalize(storage.get(1)!), true, params);
const m2 = create(undefined, true, params);

console.log(m1.new, m1.pk, JSON.stringify(m1.data)); // false 1 {"id":1,"name":"John","age":"20"}
console.log(m2.new, m2.pk, JSON.stringify(m2.data)); // true 0 {"id":0,"name":"","age":""}

m2.data.name = 'Jane';
m2.v.c.name.touch();
m2.data.age = 'twenty';

console.log(m2.dirty); // true
// имя было изменено, и проверка прошла успешно
console.log(m2.v.c.name.dirty, m2.v.c.name.invalid); // true false
// возврат тоже был изменен, но флаг dirty у свойства объекта валидации выставлен не был
console.log(m2.v.c.age.dirty, m2.v.c.age.invalid); // false true
// экземпляр не проходит проверку
console.log(m2.v.invalid); // true

m2.data.age = '20';

// ошибка исправлена, но флаг все еще не выставлен
console.log(m2.v.c.age.dirty, m2.v.c.age.invalid); // false false
// экземпляр проходит проверку
console.log(m2.v.invalid); // false

m2.v.c.age.touch();

// теперь флаг выставлен
console.log(m2.v.c.age.dirty, m2.v.c.age.invalid); // true false

// во время выполнения операции (создание) будут выставлены флаги
// saving и creating
await m2.save();

// флаг изменения состояния модели после сохранения сбрасывается автоматически,
// а такой же флаг объекта валидации не, нуобходимо вызывать метод `v.reset()` или `rollback()`
console.log(m2.dirty, m2.v.dirty); // false true

m2.v.reset();

// теперь все ожидаемо
console.log(m2.dirty, m2.v.dirty); // false false

// флаг new также сброшен, а первичный ключ получил значение
console.log(m2.new, m2.pk, JSON.stringify(m2.data)); // false 2 {"id":2,"name":"Jane","age":"20"}

// изменим поле name первой модели и зафиксируем изменения в объекте валидации
m1.data.name = 'John Doe';
m1.v.c.name.touch();

console.log(JSON.stringify(m1.data)); // {"id":1,"name":"John Doe","age":"20"}

// оба флага dirty выставлены
console.log(m1.dirty, m1.v.dirty); // true true

// произведем откат состояния
m1.rollback();

// проверим откат состояния
console.log(m1.dirty, m1.v.dirty, JSON.stringify(m1.data)); // false false {"id":1,"name":"John","age":"20"}

// выставляем флаг deleted в true
m1.delete();

// соответственно, при удалении выставляются флаги
// saving, destroying
await m1.save();

// объект помечен, как удаленный в хранилище, им все еще можно пользоваться,
// но сохранять уже нельзя
console.log(m1.destroyed); // true

// очищаем экземпляры, позволяем сборщику мусора выполнить свою работу
m1.destroy();
m2.destroy();

// экземпляры очищены, сборщик мусора доволен нами
console.log(m1.instanceDestroyed); // true
console.log(m2.instanceDestroyed); // true

Сложная модель

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

Пример описания сложноструктурированной модели
// file: ./utilities/validators.ts
// функции для проверки десятизначного номера телефона и имени.
export const phoneRegex = /^(([0-9]){10})$/;
export const nameRegex = /^[0-9a-zA-Z. \\-]+$/;

export function phone(value: string): boolean {
  return phoneRegex.test(value);
}

export function name(value: string): boolean {
  return nameRegex.test(value);
}
// file: ./models/human.ts
import type {
  Base,
  Rollback,
  RollbackPrivate,
  Save,
  SavePrivate,
  SaveOptions,
  Validate,
  ValidatePrivate,
  ValidationBase,
  ValidateOptions,
  Options,
  PatternAssert,
  CreateFunc,
  UpdateFunc,
  DestroyFunc
} from '@vueent/mix-models';
import { BaseModel, mixRollback, mixSave, mixValidate, mix } from '@vueent/mix-models';
import { v9s, simplify } from 'v9s';
import { hex, email, integer } from 'v9sx';

import { phone, name } from '@/utilities/validators';

// описание структуры данных модели
export interface Credentials {
  first: string;
  second: string;
  last: string;
}

export interface Document {
  // поле fakeId удобно использовать для привязки ключа в шаблонах Vue
  // внутри цикла `v-for`. Hмя может быть любым, это просто условность
  fakeId: number;
  id: string;
  filename: string;
}

export interface Value {
  fakeId: number;
  val: string;
}

export interface Item {
  fakeId: number;
  values: Value[];
}

export interface Data {
  id: string;
  // на самом деле, использование массива примитивных значений в 
  // качестве поля модели не очень удобно, можно преобразовывать
  // в массив объектов, но здесь оставлен именно массив строк
  // для демонстрации примера
  phones: string[];
  phone: string;
  email: string;
  age: string;
  credentials: Credentials;
  documents: Document[];
  items: Item[];
}

// так как внутреннее представление структуры данных модели
// отличается от представления в хранилище,
// то внешнее представление описывается отдельно
export type EncodedCredentials = Credentials;

// из-за того, что разница только в одном поле, можно сократить запись
export type EncodedDocument = Omit<Document, 'fakeId'>;

export type EncodedValue = Omit<Value, 'fakeId'>;

// здесь сократить не выйдет, так как различаются типы вложенных полей
export interface EncodedItem {
  values: EncodedValue[];
}

export interface EncodedData {
  id: string;
  phones: string[];
  phone: string;
  email: string;
  age: number;
  credentials: EncodedCredentials;
  documents: EncodedDocument[];
  items: EncodedItem[];
}

export function makeInitialData(): Data {
  return {
    id: '',
    phones: [],
    phone: '',
    email: '',
    age: '',
    credentials: {
      first: '',
      second: '',
      last: ''
    },
    documents: [],
    items: []
  };
}

const rollbackMask = {
  id: false,
  phones: true,
  phone: true,
  email: true,
  age: true,
  // правила для полей вложенного объекта могут задаваться по отдельности
  credentials: {
    first: true,
    second: true,
    last: true
  },
  documents: true,
  items: true
} as const;

// альтернативная маска, может быть передана при вызове метода `rollback()`
export const alternativeRollbackMask = {
  id: false,
  phones: true,
  phone: true,
  email: false,
  age: true,
  credentials: {
    first: false,
    second: false,
    last: true
  },
  documents: {
    // для того, чтобы обозначить, что вложенное поле массив, указывается `$array: true`
    $array: true,
    // дальше можно отдельно указать - какие поля должны или не должны быть сброшены
    id: false,
    filename: true
  },
  items: {
    $array: true,
    // можно указать список индексов массива, для которых будет применено правило
    $index: [0]
    values: false
  }
} as const;

const validations = {
  phones: {
    // через указание свойства $each задается правило, которое будет применено к каждому элементу массива
    $each: simplify(v9s<string>().minLength(1, 'Enter phone number').use(phone, 'Invalid phone format')),
    // этот параметр задает правило для самого массива или объекта
    $self: simplify(v9s<string>().minLength(1, 'Invalid phones'))
  },
  phone: simplify(v9s<string>().minLength(1, 'Enter phone number').use(phone, 'Invalid phone format')),
  email: simplify(
    v9s<string>()
      .minLength(1, 'Enter E-mail')
      .maxLength(255, 'Maximum E-mail length exceeded')
      .use(email, 'Invalid E-mail format')
  ),
  age: simplify(
    v9s<string>()
      .minLength(1, 'Enter age')
      .use(integer, 'Age must be an integer value', Number)
      .gte(0, 'Age cannot be negative')
      .lte(150, 'Age cannot exceed 150 years')
  ),
  credentials: {
    // при помощи свойства $sub задается набор правил для вложенного объекта
    $sub: {
      first: simplify(
        v9s<string>()
          .minLength(1, 'Enter first name')
          .maxLength(255, 'Maximum first name length exceeded')
          .use(name, 'Remove invalid characters')
      ),
      second: simplify(
        v9s<string>()
          .maxLength(255, 'Maximum second name length exceeded')
          .use(name, 'Remove invalid characters')
          .or(v9s<string>().strictLength(0, 'Remove invalid characters'))
      ),
      last: simplify(
        v9s<string>()
          .minLength(1, 'Enter last name')
          .maxLength(255, 'Maximum last name length exceeded')
          .use(name, 'Remove invalid characters')
      )
    }
  },
  documents: {
    // в качестве значения для $each также можно задать объект с полями
    $each: {
      id: simplify(
        v9s<string>()
          .minLength(1, 'Enter document id')
          .strictLength(32, 'Document id length must be equal to 32 characters')
          .use(hex, 'Document id must be a hex string')
      ),
      filename: simplify(
        v9s<string>().minLength(1, 'Enter document filename').maxLength(1024, 'Maximun document filename length exceeded')
      )
    }
  },
  items: {
    $each: {
      values: {
        $each: {
          val: simplify(
            v9s<string>()
              .minLength(1, 'Enter value')
              .minLength(6, 'Value length must exceed 6 characters')
              .maxLength(255, 'Maximum value length exceeded')
          )
        }
      }
    }
  }
} as const;

export type Validations = PatternAssert<typeof validations, Data>;

class DataModel extends BaseModel<Data> {}

export type ModelType = Base<Data> & Rollback & Save & Validate<Validations>;

export interface Model extends DataModel, RollbackPrivate<Data>, SavePrivate<Data>, ValidatePrivate<Validations> {}

export class Model extends mix<Data, typeof DataModel>(
  DataModel,
  mixRollback(rollbackMask),
  mixSave(),
  mixValidate(validations)
) {
  constructor(initialData?: Data, react = true, ...options: Options[]) {
    super('id', initialData ?? makeInitialData(), react, ...options);

    if (this.pk) this._flags.new = false;
  }

  // состояние валидации будет автоматически сброшено после сохранения модели
  afterSave(): void {
    this.v.reset();
  }
}

В связи с ограничениями функции watch во Vue 3 и Vue 2.7, при добавлении или удалении элементов из массива, необходимо заменять массив целиком, в противном случае, валидатор не сможет корректно отследить изменения. Можно использовать следующую функцию, либо аналоги:

const splice = <T = any>(arr: Array<T>, start = 0, count = 1, ...items: T[]) => [
  ...arr.slice(0, start),
  ...items,
  ...arr.slice(start + count)
];

Стоит отметить, что правила маски для отката состояния имеют некоторые ограничения, в частности, нет возможности задать разные правила отката для разных наборов индексов массива, но так как эта задача довольно редко встречается на практике, то пока больших проблем нет. Также нет поддержки отката или блокировки отката отдельных индексов для массивов, состоящих из значений примитивных типов (например: string[]).

Store

@vueent/store — это экспериментальный пакет, так что его стоит рассматривать, скорее, как работающий концепт, нежели продуманное и проверенное решение, в отличие от предыдущих трех пакетов, которые уже использовались мною в некоторых проектах. Тем, кто знаком с ember-data многое покажется знакомым, так и задумано. Идея состоит в том, чтобы, кроме модели, описать еще класс-коллекцию, и использовать его — как единую точку доступа к моделям определенного типа. Все коллекции объединяются в хранилище (store), которое может быть как отдельным классом, так и одним из сервисов @vueent/core.

Простая коллекция

Возьмем простую модель из предыдущего раздела и напишем для нее класс-коллекцию, сперва в минимально допустимом виде:

// file: ./collections/simple.ts
import { Collection } from '@vueent/store';

import type { Data, EncodedData, ModelType } from '@/models/simple';
import { Model } from '@/models/simple';
import * as api from '@/api/simple'; // некоторый набор функций, для вызова API мнимого сервера

// при объявлении коллекции нужно указать все те типы, что перечислены ниже
// если у нас нет EncodedData, то можно передать просто Data
export class SimpleCollection extends Collection<Model, Data, EncodedData, ModelType> {
  constructor() {
    // передаем конструктор модели в родительский конструктор
    super({ construct: Model });
  }
}

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

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

  1. Поиск в локальном кэше коллекции

  2. Выгрузка экземпляра

  3. Очистка коллекции

Проверка базовых функций коллекции
import { SimpleCollection } from '@/collections/simple';

// создадим экземпляр коллекции
const coll = new SimpleCollection();

// класс коллекции уже содержит метод create, так что функцию в модуле модели описывать не нужно
const m1 = coll.create();

// зададим идентификатор и имя
m1.data.id = 1;
m1.data.name = 'John';
m1.data.age = '25';

// создадим еще два экземпляра, сразу с данными
const m2 = coll.create({ id: 2, name: 'Jane', age: '20' });
const m3 = coll.create({ id: 3, name: 'Samantha', age: '19' });

// произведем поиск в локальном кэше коллекции, поиск производится по первичному ключу
const m1dup = coll.peekOne(1);

// так как экземпляр был создан как новый, то есть флаг new установлен,
// то такой экземпляр не учитывается при поиске, после успешного сохранения
// экземпляр будет добавлен в группу для поиска автоматически
console.log(m1dup); // null

/// повторим поиск с другим параметром
const m2dup = coll.peekOne(2);

// так как производится поиск в локальном кэше, то будет возвращен
// тот же экземпляр модели.
// как мы помним, в конструкторе модели мы специально прописывали условие для сброса флага new,
// если первичный ключ указан при создании модели
console.log(m2dup === m2); // true

// произведем локальный поиск, задав фильтр, этот же объект с фильтром
// можно задать вторым параметром для метода `peekOne()`
const models = coll.peek({ localFilter: data => Number(data.age) >= 20 });

// так как у Samantha возраст 19, а John игнорируется при поиске,
// то в результирующем массиве должен быть только один объект
console.log(models.length); // 1

// проверим, что в списке именно те модели, что мы ожидаем
console.log(models.includes(m1), models.includes(m2)); // false true

// создадим экземпляр с тем же id, что и у Samantha
try {
  coll.create({ id: 3, name: 'Sam', age: '20' });
} catch (e) {
  console.log((e as Error).message); // duplicate primary key
}

// поправим возраст у Samantha
m3.data.age = '20';

// повторим поиск
const models2 = coll.peek({ localFilter: data => Number(data.age) >= 20 });

// результатов, ожидаемо, два
console.log(models2.length); // 2

// проверим, что в списке именно те модели, что мы ожидаем
console.log(models2.includes(m2), models2.includes(m3)); // true true

// выгрузим запись с первичным ключом 3, передав uid экземпляра в метод
// `unload()` коллекции. Свойство uid генерируется автоматически,
// при создании каждого нового экземпляра модели
// теперь экземпляры нужно уничтожать через unload, а не прямым вызовом
// метода `destroy()` модели
coll.unload(m3.uid);

// проверяем работу предыдущего шага
console.log(m3.instanceDestroyed); // true

coll.destroy(); // очищает коллекцию, вызывая `unloadAll()`

// очищены все экземпляры, даже те, что недоступны при поиске
console.log(m1.instanceDestroyed, m2.instanceDestroyed); // true, true

То есть, даже для моделей, которые не должны быть сохранены где-либо, коллекции все же дают некоторые возможности.

Коллекция с доступом ко внешнему хранилищу

Добавим поддержку CRUD-операций к нашей коллекции.

// file: ./collections/simple.ts
import { Collection } from '@vueent/store';

import type { Data, EncodedData, ModelType } from '@/models/simple';
import { Model } from '@/models/simple';
import * as api from '@/api/simple'; // некоторый набор функций, для вызова API мнимого сервера

// при объявлении коллекции нужно указать все те типы, что перечислены ниже
export class SimpleCollection extends Collection<Model, Data, EncodedData, ModelType> {
  constructor() {
    super({
      construct: Model, // конструктор
      // следующие функции опциональны, они отвечают, собственно, за
      // создание, удаление, изменение, загрузку одной и нескольких записей из
      // хранилища. В качестве первичного ключа будет использовано то свойство, которое
      // указано первым параметром при вызове родительского конструктора (super)
      // в классе модели.
      createData: (data: EncodedData): Promise<unknown> => {
        return api.create(data);
      },
      destroyData: (id: unknown): Promise<void> => {
        return api.destroy({ id: id as number });
      },
      updateData: (id: unknown, data: EncodedData): Promise<unknown> => {
        return api.update({ ...data, id: id as number });
      },
      loadOneData: (pk: unknown): Promise<EncodedData> => {
        return api.findOne({ id: pk as number });
      },
      // параметр options представляет собой объект, содержащий опционально queryParams,
      // набор опций внутри объекта queryParams задается разработчиком
      loadManyData: async (options: {
        queryParams?: {
          ids?: number[];
          name?: string;
          age?: number;
        };
      }): Promise<EncodedData[]> => {
        const response = await api.find(options.queryParams ? options.queryParams : {});

        return response.items;
      }
    });
  }

  // в примерах для модели, функции normalize и denormalize были отдельными фунциями,
  // здесь они делают ровно тоже самое, но включены в класс коллекции. По умолчанию
  // эти методы возвращают плоскую копию объекта, так что для постых моделей их
  // можно не описывать. Также сюда можно вставить валидацию входящих значений.
  public normalize(encoded: EncodedData): Data {
    return {
      id: encoded.id,
      name: encoded.name,
      age: String(encoded.age)
    };
  }

  public denormalize(data: Data): EncodedData {
    return {
      id: data.id,
      name: data.name,
      age: Number(data.age)
    };
  }
}

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

Модуль api
// file: ./api/simple/requests.ts
// здесь зададим интерфейсы для запросов, удобно синхронизировать
// с конечными точками сервера
import type { EncodedData } from '@/models/simple';

export interface Find {
  ids?: number[];
  name?: string;
  age?: number;
}

export interface FindOne {
  id: number;
}

export type Create = EncodedData;

export type Update = EncodedData;

export interface Destroy {
  id: number;
}
// file: ./api/simple/responses.ts
// здесь аналогичным образом расположим интерфейсы ответов
import type { EncodedData } from '@/models/simple';

export interface Find {
  items: EncodedData[];
}

export type FindOne = EncodedData;
export type Create = EncodedData;
export type Update = EncodedData;
// file: ./api/simple/endpoints.ts
// здесь расположены функции, выполняющие закпросы к конечным точки нашего "сервера"
import * as storage from '@/storage'; // модуль, имитирующий сервер

import type * as requests from './requests';
import type * as responses from './responses';

// сериализация-десериализация здесь делается через JSON просто для наглядности

export async function find(req: requests.Find): Promise<responses.Find> {
  const response = await storage.find(req);

  return JSON.parse(response);
}

export async function findOne(req: requests.FindOne): Promise<responses.FindOne> {
  const response = await storage.findOne(req.id);

  return JSON.parse(response);
}

export async function create(req: requests.Create): Promise<responses.Create> {
  const response = await storage.create(JSON.stringify(req));

  return JSON.parse(response);
}

export async function update(req: requests.Update): Promise<responses.Update> {
  const response = await storage.update(req.id, JSON.stringify(req));

  return JSON.parse(response);
}

export async function destroy(req: requests.Destroy): Promise<void> {
  await storage.destroy(req.id);
}
// file: ./api/simple/index.ts
export * from './endpoints';

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

Модуль storage
// file: ./storage.ts
import type { EncodedData } from '@/models/simple';

let idCounter = 0; // счетчик идентификаторов
const storage = new Map<number, string>(); // хранилище
// простая функция-обертка для наглядности
const genPk = () => ++idCounter;

// небольшая обертка, сразу десериализующая значение из хранилища
function get(key: number): any | undefined {
  const result = storage.get(key);

  return result ? JSON.parse(result) : undefined;
}

// еще одна обертка, в цикле возвращающая десериализованные значения хранилища
function forEach(callback: (value: any) => void) {
  for (const [, item] of storage) {
    callback(JSON.parse(item));
  }
}

// примитивная реализация поиска по параметрам
export async function find(queryParams?: { ids?: number[]; name?: string; age?: number }): Promise<string> {
  const res: { items: EncodedData[] } = { items: [] };

  if (queryParams?.ids?.length) {
    for (const id of queryParams.ids) {
      const item = get(id);

      if (item) res.items.push(item);
    }
  } else if (queryParams) {
    const filters: Array<(v: EncodedData) => boolean> = [];

    if (queryParams.name) {
      filters.push((v: EncodedData) => v.name === queryParams.name);
    } else if (queryParams.age) {
      filters.push((v: EncodedData) => v.age === queryParams.age);
    }

    forEach(item => {
      if (filters.every(filter => filter(item))) res.items.push(item);
    });
  } else {
    forEach(item => res.items.push(item));
  }

  res.items.sort((a, b) => a.id - b.id);

  return JSON.stringify(res);
}

// возвращает значение из хранилища по идентификатору
export async function findOne(id: number): Promise<string> {
  const data = storage.get(id);

  if (!data) throw new Error('resource not found');

  return data;
}

// сохраняет новое значение в хранилище
export async function create(data: string): Promise<any> {
  const item = JSON.parse(data);

  item.id = genPk();

  const encoded = JSON.stringify(item);

  storage.set(item.id, encoded);

  return encoded;
}

// изменяет значение в хранилище
export async function update(id: number, data: string): Promise<string> {
  if (!storage.has(id)) throw new Error('resource not found');

  const item = JSON.parse(data);

  item.id = id;

  const encoded = JSON.stringify(item);

  storage.set(id, encoded);

  return encoded;
}

// удаляет значение из хранилища
export async function destroy(key: number): Promise<void> {
  if (storage.has(key)) storage.delete(key);
}

// очистка хранилища, понадобится между тестами
export function clear() {
  storage.clear();
  idCounter = 0;
}

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

Проверка CRUD-операций коллекции
// загрузим также модуль хранилища, чтобы иметь возможность его очистить в конце теста
import * as storage from '@/storage';
import { SimpleCollection } from '@/collections/simple';

const coll = new SimpleCollection();

// создаем пустой экземпляр
const john = coll.create();

john.data.name = 'John';
john.data.age = '25';

await john.save();

const jane = coll.create();

jane.data.name = 'Jane';
jane.data.age = '20';

await jane.save();

const sam = coll.create();

sam.data.name = 'Samantha';
sam.data.age = '19';

const models = coll.peek();

// так как экземпляр sam не сохранен, то его в списке не будет
console.log(models.length); // 2
// как и ожидается, экземпляры john и jane в массиве
console.log(models.includes(john), models.includes(jane)); // true true

await sam.save();

const models2 = coll.peek();

// теперь все три записи можно достать из кэша
console.log(models2.length); // 3
// проверяем, что наши ожидания оправдались
console.log(models2.includes(john), models2.includes(jane), models2.includes(sam)); // true true true

const johnPk = john.pk as number;

// выгружаем все модели из локального кэша
coll.unloadAll();

const models3 = coll.peek();

// как и ожидалось, ничего в кэше нет
console.log(models3.length); // 0

// все экземпляры освобождены
console.log(john.instanceDestroyed); // true
console.log(jane.instanceDestroyed); // true
console.log(sam.instanceDestroyed); // true

// загружаем данные из хранилища, можно добавить также локальный фильтр, и параметры запроса,
// но их пример будет ниже
const john2 = await coll.findOne(johnPk, { localFilter: data => Number(data.age) > 20 });

if (!john2) {
  console.error('loading failed');
  coll.destroy();
  storage.clear();
  return;
}

// как и ожидалось, экземпляры не совпадают, но модель загрузилась
console.log(john === john2, JSON.stringify(john2.data)); // false {"id":1,"name":"John","age":"25"}

const models4 = await coll.find({
  // зададим параметры запроса
  queryParams: {
    ids: [1, 2, 3, 4]
  },
  reload: false, // возвратит данные из локального кэша, если хотя бы один эземпляр удовлетворит локальному фильтру
  localFilter: data => Number(data.age) > 19
});

// так как john уже загружен и указан флаг `reload: false`, то экземпляр останется нетронутным
console.log(models4.length, models4.includes(john2), models4.includes(jane), models4.includes(sam)); // 1 true false false

// загружаем все заново, при помощи флага `force` автоматически очищаем замененные экземпляры
const models5 = await coll.find({ force: true });

// экземпляр, загруженный на предыдущем этапе, очищен
console.log(models4[0].instanceDestroyed); // true

// загружено 3 новых экземпляра
console.log(models5.length); // 3

// выделяем значения из массива, проверяем, что первичный ключ сохранился
const john5 = models5.find(m => m.pk === johnPk);
const jane5 = models5.find(m => m.data.name === 'Jane');
const sam5 = models5.find(m => m.data.name === 'Samantha');

if (!john5 || !jane5 || !sam5) {
  console.error('some model was not loaded');
  coll.destroy();
  storage.clear();
  return;
}

// повысим возраст Samantha
sam5.data.age = '20';

// и сохраним данные в хранилище
await sam5.save();

// удаляем Jane
jane5.delete();
await jane5.save();

// удаление произошло не только в хранилище, но и сам экземпляр был освобожден автоматически
console.log(jane5.destroyed, jane5.instanceDestroyed); // true true

// выгружаем все экземпляры моделей
coll.unloadAll();

// загружаем модели по новой
const models6 = await coll.find({ localFilter: data => Number(data.age) > 19 });

// так как Jane уже удалена, то в хранилище объекта нет
console.log(models6.length); // 2
// удостоверяемся, что John и Samantha найдены
console.log(JSON.stringify(models6.find(m => m.data.name === 'John')!.data)); // {"id":1,"name":"John","age":"25"}
console.log(JSON.stringify(models6.find(m => m.data.name === 'Samantha')!.data)); // {"id":3,"name":"Samantha","age":"20"}

// очищаем коллекцию
coll.destroy();
// очищаем хранилище
storage.clear();

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

Класс и сервис хранилища

Осталось лишь два класса в пакете, оба представляют собой централизованное хранилище для коллекций. Один из них (StoreService) реализует сервис для @vueent/core, а второй (Store) — просто класс. Оба имеют один лишь публичный метод get, который возвращает коллекцию по имени класса. Рассмотрим на небольшом примере, использующем созданную нами коллекцию:

import { Store } from '@vueent/store';

import * as storage from '@/storage';
import { SimpleCollection } from '@/collections/simple';

// создаем экземпляр класса хранилища
const store = new Store([new SimpleCollection()]);

// получаем доступ к коллекции и создаем экземпляр модели
const jane = store.get(SimpleCollection).create();

jane.data.name = 'Jane';
jane.data.age = '20';

await jane.save();

console.log(JSON.stringify(jane.data)); // {"id":1,"name":"Jane","age":"20"}

store.get(SimpleCollection).destroy();
storage.clear();

Если попытаться получить из хранилища ту коллекцию, которая в нем не зарегистрирована, то TypeScript выдаст ошибку:

Ошибка при попытке получить доступ к недоступной коллекции
Ошибка при попытке получить доступ к недоступной коллекции

Если же коллекция из хранилища получена корректно, то можем создать экземпляр модели:

Автодополнение с учетом типа экземпляра модели
Автодополнение с учетом типа экземпляра модели

Если класс автоматически осуществляет проверку типов, то добиться такого же эффекта для сервиса мне пока не удалось, приходится вручную перечислять коллекции:

import { StoreService as VueentStoreService } from '@vueent/store';

import { registerService } from '@/vueent';
import { TrivialCollection } from '@/collections/trivial';
import { SimpleCollection } from '@/collections/simple';

// при создании сервиса хранилища явно указываем допустимые коллекции
export default class StoreService extends VueentStoreService<SimpleCollection | TrivialCollection> {
  constructor() {
    super([new SimpleCollection(), new TrivialCollection()]);
  }
}

registerService(StoreService);

Использование сервиса аналогично использованию класса, его также можно подключить в другой сервис или контроллер (injectService), либо подключить напрямую в компонент, при помощи функции useService:

import { useService } from '@/vueent';
import * as storage from '@/storage';
import StoreService from '@/services/store';
import { SimpleCollection } from '@/collections/simple';

// подключаем сервис через функцию ядра
const store = useService(StoreService);
// дальнейшая работа аналогична работе с классом `Store`
const jane = store.get(SimpleCollection).create();

jane.data.name = 'Jane';
jane.data.age = '20';

await jane.save();

console.log(JSON.stringify(jane.data)); // {"id":1,"name":"Jane","age":"20"}

store.get(SimpleCollection).destroy();
storage.clear();

Как видите, ничего сложного. Все примеры работы с простой моделью можно найти в демонстрационном проекте.

Известные ограничения

  • Несмотря на то, что проект разрабатывался максимально отстраненным от самого Vue, пока еще требуется наличие полноценного Vue в зависимостях, так как пакет @vue/reactivity не предоставляет собственной реализации функции watch, которая используется библиотекой для отслеживания некоторых изменений. Без этой зависимости можно было бы свободно интегрировать библиотеку в React, хотя, кажется, React-разработчики в этот момент поперхнулись. Существуют сторонние реализации независимого watch, но задачу интеграции с каким-либо из них еще только предстоит решить.

  • Так как библиотека затачивалась, прежде всего, под работу с большими формами в каких-нибудь админках, CRM- или ERP-системах, то поддержка серверного рендеринга не рассматривалась как приоритетное направление и не тестировалось. Сложно сказать, какой объем изменений необходимо внести для того, чтобы добавить поддержку SSR.

  • Многословность — цена за гибкость и отсутствие магии. Фактически, разработчик может использовать библиотеку на том уровне, на котором пожелает. Можно использовать только декораторы для каких-то утилитарных классов при разработке на Vue, или же отдельно модели без всего остального, в конце-концов, сочетание @vueent/core и Pinia также вполне допустимо.

  • На момент разработки основной части кодовой базы, такие решения как Vuelidate и VeeValidate не имели поддержки ни Vue 3, ни Composition API. Тем не менее, стоит отметить что VueEnt охватывает несколько больший спектр задач, чем приведенные выше примеры, да и для самих цепочек правил можно использовать любую стороннюю совместимую библиотеку — в этом плане VueEnt находится в несколько иной плоскости.

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

Заключение

Если вы дочитали до этой строки, то должен выразить мое огромное почтение и благодарность за потраченное время. Искренне надеюсь, что это — не последняя публикация про данную библиотеку, так как еще есть что рассказать. Потребность в подобном решении возникла в нашей тогда еще команде в связи с ростом проекта: управлять кодовой базой стало крайне тяжело, а хотелось иметь возможность масштабировать проект, отображать индикатор загрузки не на всю страницу, а только для реально изменяемой ее части, разбивать интерфейс на компоненты без необходимости интегрировать элементы бизнес-логики в них, по максимуму использовать декларативный стиль, в конце-концов, иметь единую структуру проекта. Кто-то скажет, что можно было добиться всех поставленных целей при помощи уже имевшихся библиотек и подходов, лишь приложив некоторую долю самодисциплины. Я отвечу: да, можно, более того, мы и пытались сделать именно так, но постепенно объем доработок и правил стал все больше походить на концепт, который захотелось выделить и переиспользовать, который в итоге и воплотился во VueEnt. Мне нравится Vue, я, не без доли иронии, называю его React здорового человека, но и Vue, и React не дают того масштабируемого каркаса, что предлагают Angular и Ember. И, пускай это звучит нелепо, мне бы хотелось, чтобы библиотека VueEnt стала мостиком между хипстерами, выбравшими Vue, кровавым энтерпрайзом с Angular во главе. Спасибо за внимание.

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

Ссылки

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


  1. anonymous
    00.00.0000 00:00

    НЛО прилетело и опубликовало эту надпись здесь


    1. radtie
      00.00.0000 00:00

      Только вот material в ангуляре вот ну совсем не подходит для энтерпрайз решений, он же mobile-first


      1. anonymous
        00.00.0000 00:00

        НЛО прилетело и опубликовало эту надпись здесь


  1. strokoff
    00.00.0000 00:00
    -1

    Хороший материал! Но это уже слишком лонгрид, постарайтесь разбивать публикации на одну-две законченные мысли, так и развивать проще их и фидбек получать, даже при всей заинтересованности и желании вас поддержать, охватить такой объем за раз и обсудить в комментариях - дело возможно на несколько недель, пришлось читать наискось) а так сообщество с вами порционно обсудило бы составные части вашего компонента, вам же некуда спешить?)


    1. Devoter Автор
      00.00.0000 00:00
      +1

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


      1. strokoff
        00.00.0000 00:00

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