Винесення управління станом компонентів у користувальницькі класи в React

У статті описується ще одна варіація архітектури для React додатків, що з'явилася в результаті написання власного класу для управління станом компонентів. Основна мета запропонованого підходу — спростити і прискорити розробку додатків на React.

У React додатках дані зазвичай можна розділити на 2 види: дані самого додатка (зберігаються в store) і дані, які використовуються конкретним компонентом при візуалізації (зберігаються у state).

Багато розробники виносять роботу зі станом компонента окремо від нього. У популярному Redux стану також прибрані з компонентів. Я теж прийшов до даного рішення, одночасно відмовившись від використання поширених бібліотек начебто Flux, Reflux, Redux. Спочатку я використовував Reflux, але швидко відчув наступні недоліки:

1) Постійно доводилося писати код для оголошення нових actions та підписки на них.
2) Проблеми з-за того, що зміна властивостей всередині об'єкта state не викликає оновлення компонента.

У рішенні, до якого я прийшов, цих недоліків немає. Так як воно добре показало себе на реальному проекті, то я вирішив поділитися їм в даній статті.

Переваги запропонованого архітектурного підходу
1) Менше «марного» коду, який потрібно писати у Flux підходах (оголошення actions, підписка на конкретні дії та інше).

2) Позбавляє від необхідності використовувати системи подій.

3) Автоматичне оновлення компонентів при оновленні даних у сховищах програми.

4) Немає проблем при одночасній роботі з одними і тими ж даними з сховищ в різних компонентах. Наприклад, можна показати одночасно декілька копій компонента, фільтруючого список з різними параметрами. Кожна копія буде відображати свій результат, що залежить від зазначених параметрів і не залежить від параметрів інших копіях компонента.

До даного підходу я написав невелику бібліотеки. Також я додав довідник API і приклади.

Короткий опис прикладів:

1) simple-list — приклад отримання даних зі сховища.
2) list-with-server — той самий приклад, але з отриманням даних з сервера і збереженням їх у сховище.
3) form-editing — приклад форми з редагуванням, биндингом, серверної валідацією і збереженням даних на сервері.
4) filters — приклад фільтрації. Також демонструє, що параметри фільтрації в одному компоненті не впливають на результат фільтрації в іншому компоненті такого ж типу.

У своїй бібліотеці я використовую ObjectPath. ObjectPath дозволяє записувати значення з зазначенням шляху до потрібного поля (властивості) об'єкта, а також зчитувати і перевіряти існування властивостей всередині об'єкта, що зберігає вкладені об'єкти.

В бібліотеці крім самого підходу реалізовані:

1) робота з серверної валідацією. Ця частина писалася для роботи з Django і може не підійти до проектів з серверної валідацією на інших фреймворках.
2) часткова і повна відміна змін – можливість скинути вибрані поля в стані компонента до значень у сховище.
3) вирішення проблеми оновлення компонентів при зміні властивостей всередині об'єкта state.

Опис підходу
Основна ідея – замість використання в компонентах стандартних станів, використовувати свій об'єкт-стан для спрощення контролю його змін. Стандартний state компонента має великий недолік – він не оновлює компонент при змінах вкладених властивостей об'єкта.

Також в даному підході у додатку є кілька сховищ даних. Ці сховища можуть оновлюватися з компонента і ззовні (наприклад, при отриманні нових даних по мережі). Сховище оновлює UIStates (так названі стану, винесені з компонента), підписані на нього. UIState зчитує дані сховища і зберігає їх копії в собі. Перед збереженням у собі, UIState може якось обробити отримані дані. Після отримання даних їх сховища, UIState оновлює компонент. Компонент може зчитувати і записувати значення в UIState.


Схема архітектури. Стрілками показано напрямок потоку даних.

У даній архітектурі є наступні основні сутності:
UIState (UI стейт/стан) – клас, який використовується замість state компонента. Названий так, тому що в ньому зберігаються дані, використовувані компонентом для відображення в поточний момент часу. У кожного компонента створюється свій примірник такого класу. Може підписуватися на зміни різних сховищ, а також може зберігати і будь-які інші дані, як і звичайний state компонента.

При змінах викликає setState({}). Також при ручному зміні окремих полів можна вказувати, оновлювати компонент чи ні.

Цей клас вже реалізований і в більшості випадків не потрібно писати свій. При необхідності можна написати свій, а також успадковані від дефолтного.

Store (сховище) – клас, для зберігання даних програми. Наприклад, для зберігання даних поточного користувача і для зберігання списку товарів. Під кожний вид даних своє сховище. Дані в сховищі відрізняються від даних в UIState до тих пір, поки не викликаний метод для збереження даних у сховище, після якого оновляться UI стану, підписані на нього.

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

Зазвичай на зміну сховища підписуються UIStates, але ніщо не заважає підписатися інших класів.

Stores – Простий клас, який зберігає список усіх сховищ програми.

Ставлення класів:
Store – UIState: багато до багатьох. Як сховище може мати багато передплатників, так і UIState може бути підписаний на безліч сховищ.
UIState – Сomponent: один до одного. Але, також Сomponent може мати кілька UIStates. Хоча, в цьому немає необхідності.

Приклади використання
Повноцінні працюють приклади можна подивитися вже зазначеної раніше посиланням. У прикладах використовується JSX Control Statements для циклів в JSX коді:

Приклад 1 (Простий список з оновленням даних)1. Одним рядком створюємо потрібний сховища даних

import {DefaultStore} from 'ui-states';
class Stores{
//параметра конструктора DefaultStore вказуємо ідентифікатор/ключ сховища, по якому до нього можна буде звертатися.
static customers = new DefaultStore('customers'); 
}

2. У компоненті створюємо UIState зі своєю моделлю даних і підпискою на потрібні сховища

import Stores from './stores.js'
//імпорт з бібліотеки дефолтного класу, який відповідає за роботу з UI станом компонента
import {DefaultUIState} from 'ui-states' 

class List extends Component
componentWillMount() {
//створюємо UIState для даного компонента. 
//В першому параметрі передаємо посилання на компонент. 
//У другому – об'єкт, який зберігає додаткові дані стану компонента, не пов'язані із сховищем. 
//Це практично те ж, що і звичайний state в компонентах. У нього потрібно поміщати все, що потрібно зберігати в стані
//компонента, але не потрібно зберігати в store. Звертатися до цих даних можна через об'єкт model: 
//this.uiState.model.myField.
//В третьому параметрі передається масив об'єктів з параметрами. У кожному такому об'єкті зберігається посилання на store і 
//додаткові параметри, які говорять про те, як працювати з даними сховищем у поточному UIState.
this.uiState = new DefaultUIState(this, null, [{store: Stores.customers }]); 
}

componentWillUnmount() {
this.uiState.removeState(); //видаляємо UIState при демонтуванні компонента
}

handleClick() {
//запис/оновлення даних у сховище
Stores.customers.update({
items: [
{id: 1, name: 'Alexey', city: 'Moscow', email: 'alexey@gmail.com'},
{id: 2, name: 'Andrey', city: 'Bangkok', email: 'andrey@gmail.com'},
{id: 3, name: 'Anatoly', city: 'Singapore', email: 'anatoly@gmail.com'}
]
});
}

render() {
return (
<div>
<button onClick={this.handleClick.bind(this)}>Load data</button>
<br/><br/>
//зчитуємо дані з UIState. У функції get вказується шлях до потрібного властивості в UIState
<For each="item" index="index" of={ this.uiState.get('customers.items', []) }> 
<div key={item.id}>
<span>{item.name} </span>
<span>{item.city} </span>
<span>{item.email}</span>
</div>
</For>
</div>
)
}
}

Все, більше нічого писати не потрібно. Сховище сама сповістить передплатників про своїх змінах. UIState у своєму конструкторі сам підписується на передані йому сховища і оновлює компонент. Вся потрібна логіка написана в 2-х класах: DefaultStore, DefaultUIState. В більшості випадків їх вистачає, але при необхідності будь-який з них можна замінити на свій або унаследоваться від них і розширити їх спадкоємців.

Опишу, як потрібно виконувати читання і запис в uiState.

Читання:
let field1 = this.uiState.store_key.field1;
Або let field1 = this.uiState.get('store_key.field1');
Якщо дані зберігаються тільки в state, без використання Store, то дані зберігаються в об'єкті model: let field1 = this.uiState.model.field1.

Запис:
this.uiState.set('store_key.field1', newValue).
Знову ж таки, якщо дані потрібно зберігати без використання Store, то використовуємо model: this.uiState.set('model.field1', newValue).
Приклад 2 (форма з редагуванням, биндингом, серверної валідацією і збереженням даних на сервері)Приклад досить великий, тому тут код не повний. До того ж це дозволить читачеві не відволікатися на код, не відноситься до справи. На відео показаний приклад в дії:



1. Створення сховища

import {DefaultStore} from 'ui-states';
export default class Stores{
static currentCustomer = new DefaultStore('currentCustomer');
}

2. Клас з мережевою логікою (частковий код)

import Stores from './stores.js'

export default class Network {
static getCustomer() {
//Тут якась мережева логіка, яка повертає відповідь з даними.
//В даному прикладі повертаються дані в наступному форматі: 
//{id: 1, name: 'Alexey', city: 'Moscow', email: 'alexey@gmail.com'}, 
//Далі збереження об'єкта з даними, які прийшли від сервера.
//Тут використовується replace, а не update, тому що при update відбувається мердж полів об'єкта з store з новим об'єктом.
//При replace старий об'єкт повністю замінюється новим.
//В даному випадку може змінитися один customer на іншого, тому тут потрібно використовувати replace.
// При збереженні об'єкта або ж при отримання списку підійде update.
Stores.currentCustomer.replace(responceData);
}
}

static saveCustomer(customer) {
//Тут якась мережева логіка, яка відправляє дані на сервер і повертає response з даними. 
//В даному прикладі дані повертаються у наступному форматі:
//{id: 1, name: 'Alexey', city: 'Moscow', email: 'alexey@gmail.com'}, 

//Далі обробка отриманого результату:
if (response.ok) {
Stores.currentCustomer.update(null, responceData);
}
else {
//При помилці оновлюємо не customer-а, а дані для його валідації у формі.
//В даному прикладі повертаються дані в наступному форматі: 
//{name: 'errorMessage', city: 'ErrorMessage', email: 'errorMessage'}. 
//У повернутому об'єкті присутні тільки поля з помилками валідації.
Stores.currentCustomer.update(null, responceData); 
}
}

//Збереження тільки одного поля у формі. У другому параметрі передається шлях до потрібного властивості в сховище.
static saveCustomerCity(customer, pathInStore) {
//Тут якась мережева логіка, яка відправляє дані на сервер і повертає response з даними.
//В даному прикладі повертаються дані в наступному форматі: { city: 'Moscow'}, 
if (response.ok) {
Stores.currentCustomer.updateField(responseData.city, pathInStore);
}
else {
//В даному прикладі повертаються дані в наступному форматі: {city:'errorMessage"}
Stores.currentCustomer.updateField null, responseData.city, pathInStore);
};
}
}

3. Компонент з формою

import Stores from './../stores.js'
import Network from './../network.js' //клас з сітьовими методами
import {DefaultUIState } from 'ui-states'
//Компонент - обгортка над input. В ньому також присутні поле для виводу назви полів і тексту помилки
import InputWrapper from './input-wrapper.js' 

export default class CustomerForm extends Component {
componentWillMount() {
this.uiState = new DefaultUIState(this, null, [{store: Stores.currentCustomer }]);
Network.getCustomer();
}

componentWillUnmount() {
this.uiState.removeState();
}

handleCancel() {
this.uiState.cancelAllChanges(); //скасування всіх змін в UIState. Значення стануть такими ж, як store
}

handleSave() {
Network.saveCustomer(this.uiState.currentCustomer);
}

handleCancelCity() {
this.uiState.cancelChangesByPath('city', mainStore); //скасовує зміни тільки в полі 'city'
}

handleSaveCity() {
Network.saveCustomerCity(this.uiState.currentCustomer);
}

//Перетворення різних даних в props для input, щоб не копіювати один і той же код
mapToInputProps(field) {
return {
type: "text",
name: field,
parentUiState: this.uiState,
pathToField: 'currentCustomer', //повний шлях до поля вийти наступний: this.uiState.currentCustomer
pathToValidationField: 'currentCustomer.validationData' //повний шлях до поля вийде наступний:
//this.uiState.currentCustomer.validationData
};
}

render() {
return (
<div>
<form>
<InputWrapper label="Customer name" {...this.mapToInputProps('name')}/>
<InputWrapper label="Customer city" {...this.mapToInputProps('city')}/>
<InputWrapper label="Customer email" {...this.mapToInputProps('email')}/>
</form>
<button onClick={this.handleCancel.bind(this)}>Cancel</button>
<button onClick={this.handleSave.bind(this)}>Save</button>
<br/><br/>
<button onClick={this.handleCancelCity.bind(this)}>Cancel city only</button>
<button onClick={this.handleSaveCity.bind(this)}>Save city only</button>
</div>
)
}
}

Щодо передачі uiState в InputWraper:
Передавати батьківське стан компонента, і вже тим більше міняти його дочірніх компонентах в більшості випадків не варто. Биндинг, як в даному випадку, скоріш виключення, ніж практика, так як виходить дуже зручно і до того ж не викликається оновлення всієї форми.

Недоліки запропонованого підходу/бібліотеки
Як і у будь-якого рішення, у мого також є свої недоліки. У моїй бібліотеці основним недоліком є складність подальшого розширення класу DefaultUIState, так як в ньому зосереджено багато функціоналу (ручне внесення змін у стан, оновлення даних з сховищ, оновлення конкретного поля зі сховища, скасування змін, валідація).
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.