Это 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
, но таким образом мы получаем ошибку без нормального описания
В двух компонентах заберем публичный геттер
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;
},
Я сделала тестовый компонент для проверки, куда добавила несколько присваиваний, вот результат
Объект
Мы оборачивали примитив в объект, так что работа с остальными объектами естественно будет схожа. Но когда мы разговариваем про обычный объект, то у нас появляются дополнительные кейсы, которые нужно учесть.
В случае, когда у нашего объекта статичная структура, т.е. мы изначально его инициализировали, и добавление/удалений свойств не требуется, а лишь изменение существующих, то действуют те же правила, которые мы рассматривали в секции с примитивами.
Пример для объекта со статичной структурой
А что если нам нужно добавить поле? Документация 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 части поговорим об экземплярах класса и удобных функциях, чтобы можно было встраивать класс в компонент проще.