Сервисная архитектура во Vue 2 | Проектирование класса (примитивы и объекты) +1


Это 2 часть цикла статей о сервисной архитектуре во Vue 2. В 1 части я рассказала о том, какие способы выноса логики популярны на данный момент, почему они меня не устраивали, и чего я хотела достичь.

UPD к 1 части

В 1 части не все меня поняли, и думали, что я решаю какую-то конкретную задачу и просили пример или уточняющие данные. Эта серия статей не о конкретной задаче и конкретном примере.

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

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

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

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

О чем мои статьи? О том, как можно использовать классы во Vue 2. Какое это отношение имеет к сервисной архитектуре? Прямое, это один из способов, как организовать сервис. С этого все и начинается, без практического удобного решения, как эти сервисы встраивать, они не будут появляться в проекте.

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

О встройке класса во Vue 2

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

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

Примитив

Я решила начать с того, чтобы попытаться встроить примитивное свойство из класса в компонент. Логика простая, сам примитив не передашь, он запишется и все, связи нет. Тогда я вспомнила про функцию ref из Vue 3 (docs), где все примитивы они предлагают обернуть в объект по структуре:

{ value: <some_val> }

Итак, пробуем создать класс

class Example {
  someString = {
    value: 'someString',
  };
}

const example = new Example();

Пытаемся встроить свойство в пару компонентов как-то так

<template>
  <div>
    <span>Value: {{ someString.value }}</span>
  </div>
</template>
export default {
  data() {
    return {
      someString: example.someString,
    };
  },
}

Нам же еще нужно это свойство изменить, добавляем кнопку и метод

<template>
  ...
    <button @click="changeValue">Change value</button>
  ...
</template>
...
  methods: {
    changeValue() {
      this.someString.value = 'someAnotherString';
    },
  },
...

И наконец, проверяем

Окей, это работает, и это оказалось проще, чем я думала
Окей, это работает, и это оказалось проще, чем я думала

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

Вся эта реактивность во Vue с его геттерами и сеттерами была покрыта некоторой мистикой, хоть я и смотрела видео до этого с объяснениями этой технологии, но все равно не задумывалась о том, что если мы передадим ссылку на объект, то связь будет, ей некуда будет деваться. Хотя если бы в фреймворке сделали бы хотя бы shallow copy, то пришлось бы как-то вертеться.

Хорошо, а если бы мы хотели менять через метод класса? Как бы мы могли сделать это?

Давайте добавим метод

class Example {
  ...
  changeValue() {
    this.someString.value = 'anotherString';
  }
}

В компонент мы могли бы встроить его как-то так

methods: {
  changeValue() {
    example.changeValue();
  },
},

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

export default {
  data() {
    return {
      ...
      changeValue: example.changeValue.bind(example),
    };
  },
}

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

Давайте проверим

Через метод тоже отрабатывает корректно
Через метод тоже отрабатывает корректно
Полный пример

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

Давайте сделаем наше свойство для компонента в формате read-only и заставим производить изменения только через методы.

Создадим класс с псевдо-приватным свойством (покажем это визуально) и методом для его изменения

class Example {
  _privateString = {
    value: 'I\'m private',
  }

  changePrivateString() {
    this._privateString.value = 'My value is changed from method';
  }
}

const example = new Example();

Но как нам ограничить его изменения внешне, из компонента? Я реализовала это через геттер с Proxy (docs). Добавим геттер

class Example {
  ...
  get privateString() {
    return new Proxy(this._privateString, {
      // Запрещаем изменение
      set() {
        throw new Error('This property is read-only');
      },
    });
  }
}

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

В set необязательно кидать exception, достаточно вернуть из сеттера false, но таким образом мы получаем ошибку без нормального описания

Как выглядит ошибка при `return false;`
Как выглядит ошибка при `return false;`
Ошибка с exception, читабельно
Ошибка с exception, читабельно

В двух компонентах заберем публичный геттер

data() {
  return {
    privateString: example.privateString,
  };
},

Но в первом компоненте попытаемся изменить с компонента через присваивание

methods: {
  changeValue() {
    this.privateString.value = 'Should cause error';
  },
},

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

data() {
  return {
    ...
    changeValue: example.changePrivateString.bind(example),
  };
},

Давайте проверим

Попытка изменения через компонент приводит к ошибке, через метод все меняется корректно
Попытка изменения через компонент приводит к ошибке, через метод все меняется корректно
Полный пример

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

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

set(obj, prop, value) {
  // Запрещаем добавление новых полей
  if (prop !== 'value') {
    throw new Error('Only accesible property is "value"');
  }

  // Запрещаем записывать НЕ строку
  if (typeof value !== 'string') {
    throw new TypeError('Value must be string');
  }

  // Проводим операцию присваивания
  obj[prop] = value;

  // Сигнализируем, что ошибки не возникло
  return true;
},

Я сделала тестовый компонент для проверки, куда добавила несколько присваиваний, вот результат

Через проверку прошло только присваивание полю `value` строкового значения
Через проверку прошло только присваивание полю `value` строкового значения
Полный пример

Объект

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

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

Пример для объекта со статичной структурой

А что если нам нужно добавить поле? Документация Vue подсказывает выход - использовать Vue.set. Работает ли это, если мы вызовем это в классе, а не в компоненте? Давайте проверим.

Сделаем класс со свойством-объектом, импортируем туда Vue, и сделаем метод, где мы добавим новое свойство с помощью set

import Vue from 'vue';

class Example {
  testObject = {
    oldField: 'I was here from the beginning!'
  }

  addNewField() {
    Vue.set(this.testObject, 'newField', 'I was added recently!');
  }
}

const example = new Example();

Сделаем пару тестовых компонентов, где мы получим наше свойство и метод. Для теста давайте добавим туда еще дополнительный метод, который будет добавлять новое свойство из компонента с помощью this.$set

export default {
  data() {
    return {
      testObject: example.testObject,
      addNewField: example.addNewField.bind(example),
    };
  },
  methods: {
    addField() {
      this.$set(this.testObject, 'fieldFromComponent', 'I was added from component!');
    }
  }
};

Итак, время истины

Все работает корректно, реактивность не пропадает
Все работает корректно, реактивность не пропадает
Полный пример

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

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

class Example {
  obj = {
    value: null,
  }

  fillObj() {
    this.obj.value = { field: 'field' };
  }
}

Выведем в паре компонентов, вызовем метод и проверим

Все меняется корректно
Все меняется корректно
Полный пример

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

Но во время тестирования я обнаружила любопытную особенность, связанную с this.$set. Так как это достаточно узкий кейс, в статье я на этом останавливаться не буду, welcome на страничку в моей документации, там я это описала.

Пример readonly объекта

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

Компонент формы
<template>
  <div>
    <p>
      <input
        v-model="form.firstName"
        placeholder="Имя"
        :class="{ 'error': errors.firstName }"
      /><br/>
      <span v-if="errors.firstName" class="error">
        {{ errors.firstName }}
      </span>
    </p>

    <p>
      <input
        v-model="form.age"
        placeholder="Возраст"
        :class="{ 'error': errors.age }"
      /><br />
      <span v-if="errors.age" class="error">
        {{ errors.age }}
      </span>
    </p>
  </div>
</template>
import ValidatedForm from '@example-services/ValidatedForm';

export default {
  data() {
    return {
      form: ValidatedForm.form,
      errors: ValidatedForm.errors,
    };
  },
}

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

Полный пример (и в том числе, как выглядит класс, для того чтобы компонент выглядел так), смотрите в моей документации.

Пример валидации объекта

Это получилась довольно насыщенная статья, я хотела расписать все детали работы с классом, но в эту часть влезла только работа с примитивами и объектами.

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




Комментарии (1):

  1. ionicman
    /#24948020 / +3

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

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

    У нас огромное и сложное SPA с кучей бизнес-логики и ни разу не нужно было городить какие-то отдельные, внешние классы, да ещё и связывать их с компонентами. Если общее (данные, логика) - все лежит в сторе, если компонентное - в компоненте(ах).

    Все ещё не понятно, почему вы класс с данными и какой-то логикой называете сервисом. Сервисом чего он является?

    Если вам действительно необходим некий общий сервис с реактивностью, почему просто не импортировать его и вызывать его фии для получения или установки каких-то его состояний? Реактивность можно добавить, через computed/watch в самом компоненте, где это необходимо, причём будет чётко видно что за сервис и как вы его используете, без какой либо внутренней магии.

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

    P. S. Зачем используете proxy для readonly? Почему не обычный сеттер?