Як уникнути зайвої складності стану додатки [переклад]



Реалізації Flux, такі як Redux, мотивують нас приділяти більше уваги проектування стану програми. Виявляється, це нетривіальне завдання. Це схоже на класичний приклад з теорії хаосу, коли, здавалося б, нешкідливий помах крил метелика веде до далекосяжних наслідків. Нижче наведено поради, які допоможуть вам краще організувати стан програми.

Що таке стан програми
Згідно Вікіпедії, програма зберігає дані у змінних, представлених в пам'яті комп'ютера. Значення цих змінних в певний момент часу і є станом додатки.

Важливо додати до визначення слово «мінімальне». Проектуючи стан програми, потрібно намагатися працювати з мінімальним набором даних, і ігнорувати ті змінні, які можуть бути розраховані на його основі.

В додатках з односпрямованим потоком даних (Flux) стан знаходиться в сховищах (stores). Виконання дій (actions) призводить до зміни стану, в результаті чого вистави (views), які підписані на його зміни, оновлюються у відповідності з новими даними.



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

Нижче наведено поради, які будуть корисні, навіть якщо ви не використовуєте Redux. Вони можуть стати в нагоді і тим, хто взагалі не використовує Flux.

1. Стан не зобов'язана повторювати структуру відповіді сервера
Локальне стан додатки часто ґрунтується на даних, отриманих від сервера. Якщо програма використовується для відображення серверних даних, є спокуса використати цю структуру на клієнта.

В якості прикладу візьмемо додаток для управління інтернет-магазином. Менеджер буде використовувати його для управління товарами. Список товарів приходить з сервера і повинен бути збережений на локальному стан, щоб бути отрисованным в уявленнях. Припустимо, з сервера приходить такий JSON:

{
"total": 117,
"offset": 0,
"products": [
{
"id": "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",
"title": "Blue Shirt",
"price": 9.99
},
{
"id": "aec17a8e-4793-4687-9be4-02a6cf305590",
"title": "Red Hat",
"price": 7.99
}
]
}

Список товарів приходить як масив об'єктів, так чому б не зберегти його в такому вигляді в локальному стані?

В даному випадку, вибір сервером масиву для передачі списку товарів може бути пов'язаний з розбиттям на сторінки, з дробленням списку на частини для порціонної завантаження, з униканням повторної відправки даних для економії трафіку. Все це – обґрунтовані міркування, але, в цілому, вони не пов'язані з тими міркуваннями, якими ми керуємося при моделюванні локального стану програми.

2. Об'єкт краще масиву
В цілому, масиви – не найкраща структура даних для використання в стані додатки. Уявіть, що потрібно отримати або оновити конкретний товар зі списку. Якщо ми хочемо змінити ціну, або потрібно застосувати оновлення від сервера, итерирование по масиву для пошуку конкретного товару буде не таким зручним, як його отримання за ID в об'єкті товарів.

Ми можемо оновити дані з прикладу таким чином:

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99
}
}
}

Але що якщо важливий порядок елементів у списку? Наприклад, якщо ми повинні виводити товари саме в тому порядку, який вказаний в масиві з відповіді сервера? В такому випадку, ми можемо зберегти додатковий масив ідентифікаторів:

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99
}
},
"productIds": [
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",
"aec17a8e-4793-4687-9be4-02a6cf305590"
]
}

Примітка: ця структура відмінно працює з компонентом React Native ListView. Рекомендована версія cloneWithRows чекає саме такий формат.

3. Стан не зобов'язане мати той формат, в якому уявлення приймають дані
Зрештою, подання малюються на основі стану. Можливість уникнути додаткових перетворень, зберігаючи дані стану в потрібному для уявлень вигляді, здається привабливою.

Повернемося до нашого прикладу з системою управління інтернет-магазином. Припустимо, що кожен товар може бути у наявності, або відсутні. Для цього ми можемо додати поле outOfStock в об'єкт товару:

{
"id": "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",
"title": "Blue Shirt",
"price": 9.99,
"outOfStock": false
}

Наше додаток має відобразити список всіх відсутніх товарів. Як ви пам'ятаєте, компонент React Native ListView чекає два аргументи для методу cloneWithRows: об'єкт з рядками і масив ID цих рядків. Хочеться підготувати таку структуру заздалегідь і зберігати її в стан, щоб не перетворювати дані при передачі в компонент. Структура стану буде виглядати так:

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99,
"outOfStock": false
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99,
"outOfStock": true
}
},
"outOfStockProductIds": ["aec17a8e-4793-4687-9be4-02a6cf305590"]
}

Хороша ідея, чи не так? Насправді, немає.

Причина, як і раніше, у тому, що уявлення керуються іншими міркуваннями. Їх основне завдання – відобразити інформацію в максимально зручному для користувача вигляді, а це може суперечити нашому прагненню зберігати в змозі додатка лише необхідний мінімум даних. Більше того, різні уявлення можуть показувати один набір даних в різному вигляді, а це може призводити до дублювання даних в змозі.

Це підводить нас до наступного пункту.

4. Ніколи не дублюйте дані в змозі
Якщо при оновленні даних потрібні одночасні зміни у двох місцях для збереження цілісності, значить дані в стані дублюються. Припустимо, що в прикладі вище, один товар отримує статус «немає в наявності». Щоб врахувати це оновлення, нам потрібно змінити поле outOfStock в об'єкті товару та додати його ID масив outOfStockProductIds – два оновлення.

Проблема вирішується просто – видаляється зайва сутність. Оновлюючи дані лише в одному місці, ми виключаємо ризик порушити цілісність.

Якщо ми відмовляємося від масиву outOfStockProductIds, нам потрібно знайти спосіб підготовки даних для вистав. Поширеною в Redux практикою є використання селекторов:

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99,
"outOfStock": false
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99,
"outOfStock": true
}
}
}

// selector
function outOfStockProductIds(state) {
return _.keys(_.pickBy(state.productsById, (product) => product.outOfStock)); 
}

Селектор – це чиста функція, яка отримує на вхід стан програми та повертає отриману частину для подальшого використання. Ден Абрамов рекомендує розташовувати селектор разом з reducer'ом, оскільки вони, як правило, тісно пов'язані. Використовувати селектор ми будемо подання в mapStateToProps.

Життєздатною альтернативою видалення масиву є видалення властивості outOfStock в кожному об'єкті товару. У цьому разі, єдиним джерелом інформації про наявність товару є масив з ID. Правда, у відповідності з пунктом 2, можливо, буде краще перетворити цей масив в об'єкт:

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99 
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99
}
},
"outOfStockProductMap": {
"aec17a8e-4793-4687-9be4-02a6cf305590": true
}
}

// selector
function outOfStockProductIds(state) {
return _.keys(state.outOfStockProductMap); 
}

5. Ніколи не зберігайте вторинні дані в змозі
Зберігання в змозі даних, які отримані в результаті обчислень на основі інших даних із стану, що порушує принцип SSOT (Single source of truth), оскільки оновлення даних вимагає змін у декількох місцях для збереження цілісності.

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

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

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99,
"discount": 1.99
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99,
"discount": 0
}
}
}

// selector
function filteredProductIds(state, filter) {
return _.keys(_.pickBy(state.productsById, (product) => {
if (filter == "ALL_PRODUCTS") return true;
if (filter == "NO_DISCOUNTS" && product.discount == 0) return true;
if (filter == "ONLY_DISCOUNTS" && product.discount > 0) return true;
return false;
})); 
}

Селектори виконуються при кожному оновленні стану програми перед перемальовуванням подання. Якщо у вас складні селектори і ви дбаєте про продуктивності, використовуйте мемоизацию для кешування результатів розрахунків. В бібліотеці Reselect це вже реалізовано.

6. Нормалізуйте пов'язані об'єкти
Наше завдання – зробити так, щоб розвивати і підтримувати програми було комфортно. При цьому працювати з його станом повинно бути зручно. Простоту стану зручніше підтримувати, коли об'єкти з даними незалежні, але що відбувається, якщо об'єкти пов'язані один з одним?

Уявіть, що у нашому прикладі потрібно додати систему керування замовленнями, з допомогою якою покупець може вибрати кілька товарів та оформити замовлення. Припустимо, ми отримуємо від сервера такий JSON зі списком замовлень:

{
"total": 1,
"offset": 0,
"orders": [
{
"id": "14e743f8-8fa5-4520-be62-4339551383b5",
"customer": "John Smith",
"products": [
{
"id": "88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0",
"title": "Blue Shirt",
"price": 9.99,
"giftWrap": true,
"notes": "it's a gift, please remove price tag"
}
],
"totalPrice": 9.99
}
]
}

В замовленні міститься кілька товарів. Виходить, що у нас є дві речі, які потрібно зв'язати між собою. Ми вже знаємо, що нам не обов'язково зберігати дані один в один з ліцензованим сервером структурою. Вона, в даному випадку, призведе до дублювання даних про товарах.

Хороший підхід у даному випадку – нормалізувати дані і працювати з двома об'єктами: один для товарів, а інший для замовлень. Оскільки об'єкти обох типів мають ID, ми можемо використовувати цю властивість для їх взаємозв'язку. Таким чином, стан додатки приймає наступний вигляд:

{
"productsById": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"title": "Blue Shirt",
"price": 9.99
},
"aec17a8e-4793-4687-9be4-02a6cf305590": {
"title": "Red Hat",
"price": 7.99
}
},
"ordersById": {
"14e743f8-8fa5-4520-be62-4339551383b5": {
"customer": "John Smith",
"products": {
"88cd7621-d3e1-42b7-b2b8-8ca82cdac2f0": {
"giftWrap": true,
"notes": "it's a gift, please remove price tag"
}
},
"totalPrice": 9.99
}
}
}

Якщо ми хочемо знайти всі товари певного замовлення, ми проходимо по ключам об'єкта products в замовленні. Кожен ключ — це ID, до якого ми можемо звернутися в об'єкті productsById, щоб отримати більш детальну інформацію про товар. Специфічну для замовлення інформацію про товар, таку як giftWrap, ми можемо знайти в об'єкті замовлення products.

Для нормалізації даних є готова бібліотека – normalizr.

7. До стану програми можна ставитися як до бази даних, що зберігається в пам'яті
Більшість рад вище, напевно, вам вже знайомі. Ми приймаємо такі рішення при проектуванні традиційних баз даних.

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

Ставлення до стану програми до бази даних, що зберігається в пам'яті, допомагає сформувати правильну установку для прийняття більш виважених рішень щодо структури даних.

Ставитеся до стану додатки належним чином
Якщо виділити основну думку статті, то це вона.

В імперативному програмуванні ми схильні ставити на перше місце-код і приділяти менше уваги побудові «коректних» моделей для внутрішніх структур даних, таких як стан. Стану наших додатків часто можуть бути розкидані серед різних менеджерів/контролерів як сукупність внутрішніх змінних.

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

Проектування структури стану програми потребує часу. Ми повинні бути уважні до того, наскільки складним воно стає і скільки зусиль витрачається на його підтримку і розвиток. І, звичайно, як і у випадку з кодом, станом додатка, яке перестає ефективно справлятися зі своїми завданнями, час від часу необхідний рефакторинг.

Від перекладача:

Автор статті: Tal Kol. Оригінал доступний з ссылке.
Спасибі bniwredyc за зауваження і поради щодо перекладу.

Джерело: Хабрахабр

0 коментарів

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