Розглядаємо Redux, створюючи власне Сховище станів

19 хв. читання

Redux – цікавий інструмент. І за своєю суттю простий. Але чомусь він здається складним. У цій статті ми заглибимося в основні концепції Redux, щоб зрозуміти внутрішню механіку Сховища (Store).

Ми зрозуміємо, що ж «під капотом» у Redux, Сховища, редюсерів (reducers) та дій (actions) — як вони насправді працюють. Це допоможе нам писати кращий код, ефективніше його налагоджувати і точно знати, що насправді він робить. Ми вивчимо все це, створивши власне Сховище на TypeScript.

Зміст

  • Термінологія
    • Дії
    • Редюсери
    • Сховище
  • API Сховища
    • Store.dispatch()
    • Store.subscribe()
    • Store.value
  • Контейнер Сховища
    • Структура даних стану
    • Оновлення дерева стану
  • Створення функціоналу Редюсера
    • Створення Редюсера
    • Реєстрація Редюсера
    • Виклик Редюсера в Cховищі
    • Перетворення initialState
  • Включення підписників
    • Підписники Сховища
    • Скасування підписки на Сховище
  • Фінальний код
  • Підсумки

Термінологія

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

Дії

Не намагайтеся думати про дії як про JavaScript API, дії мають мету — вони інформують Сховище про наші наміри.

Ви, по суті, передаєте інструкцію, наприклад: «Сховище! В мене є для тебе інструкція, онови будь ласка дерево стану цими даними.»

Інтерфейс дії в TypeScript виглядає наступним чином:

interface Action {
  type: string;
  payload?: any;
}

payload — необов'язкова властивість, оскільки іноді ми можемо надсилати щось на зразок «завантажувальної» дії, яка не приймає payload параметру, хоча більшу частину часу ми будемо використовувати цю властивість.

Це означає, що ми створимо щось подібне:

const action: Action = {
  type: 'ADD_TODO',
  payload: { label: 'Eat pizza,', complete: false },
};

Так в значній мірі виглядає дія. Рухаємось далі.

Редюсери

Редюсер — чиста функція, яка приймає state нашого застосунку (дерево внутрішнього стану, яке Сховище передає редюсеру), і другий аргумент action (дія, відправлена методом dispatch). Ми отримаємо щось таке:

function reducer(state, action) {
  //... це було легко
}

Гаразд, що ще нам потрібно знати про редюсер? Редюсер отримує стан і для того, щоб зробити щось корисне (наприклад оновити дерево стану), потрібно відреагувати на властивість дії action.type (яку ми щойно бачили вище). Зазвичай це робиться за допомогою switch:

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO': {
      // Думаю, тут потрібно щось зробити…
    }
  }
}

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

function reducer(state = {}, action) {
  switch (action.type) {
    case 'ADD_TODO': {
      return {
        ...state,
        // за допомогою оператора spread, ми зберігаємо існуючий масив todos в новий   
        // масив і тоді додаємо новий todo в кінці
todos: [...state.todos, { label: 'Eat pizza,', complete: false }],
      };
    }
  }
  return state;
}

Зверніть увагу, внизу ми повертаємо state, щоб передати його назад якщо немає співпадіння з жодною із дій. Ви помітите, що я додав state = {} у першому аргументі (який встановлює значення за замовчуванням для параметра). Ці початкові об'єкти стану, як правило, абстрагуються над редюсером. Ми розглянемо це далі у статті.

Останнє, що слід відзначити тут, — незмінність. У кожному case ми повертаємо новий об'єкт, що відображає нові зміни дерева стану, а також існуюче представлення дерева стану. Це означає, що ми маємо трохи змінений об'єкт стану. Те, як ми об'єднали існуючий стан, здійснюється через ...state, в якому ми просто розпаковуємо поточний стан, і додаємо нові властивості після цього.

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

Редюсери є чисто синхронними і в них слід уникати асинхронних дій.

То коли ж вступає в гру action.payload? В ідеалі, прописувати значення прямо в редюсер не варто, хіба що це щось таке просте як булевий перемикач значення з false на true. Повертаючись до вихідної точки дотримання правила «чистої функції», ми отримуємо доступ до властивості action.payload, що передається в аргументах функції для отримання будь-яких даних, які ми надіслали через дію:

function reducer(state = {}, action) {
  switch (action.type) {
    case 'ADD_TODO': {
      // дає мені нові дані
      const todo = action.payload;
      // склади нову структуру даних
      const todos = [...state.todos, todo];
      // поверни нове представлення стану
      return {
        ...state,
        todos,
      };
    }
  }
  return state;
}

Сховище

Поняття «сховище» і «стан» часто плутають. Сховище є вашим контейнером, а стан живе в контейнері. Сховище — об'єкт з API, який дає змогу взаємодіяти з вашим станом, змінюючи його, запитуючи його значення тощо.

Я думаю, ми готові почати будувати власне користувацьке Сховище. І всі ці окремі поняття стануть на свої місця.

Ще одна річ, яку хотілося б згадати, це те що Redux – «лише структурований процес оновлення властивості об'єкта».

API Сховища

Наш приклад Redux Сховища матиме лише кілька відкритих властивостей і методів. Тоді ми використаємо наше Сховище як показано нижче, передаючи всі редюсери та початковий стан для застосунку:

const store = new Store(reducers, initialState);

Store.dispatch()

Метод dispatch дозволить представити інструкцію нашому Сховищу, повідомивши, що ми маємо намір змінити дерево стану. Це обробляється через редюсер, з яким ми щойно ознайомились.

Store.subscribe()

Метод subscrbe дозволить передати функцію підписника в Сховище, через яке, після зміни дерева стану, можна передати нові зміни стану як аргумент в колбек .subscribe ().

Store.value

Властивість value буде налаштована як гетер (getter) і вона поверне дерево внутрішнього стану (щоб ми могли отримати доступ до властивостей).

Контейнер Сховища

Як ми знаємо, Сховище містить стан, а також дозволяє відправляти дії та підписуватись на оновлення дерева стану. Почнемо з класу Сховища:

export class Store {
  constructor() {}
  dispatch() {}
  subscribe() {}
}

Виглядає добре, але бракує об'єкта стану. Давайте додамо його:

export class Store {
  private state: { [key: string]: any };
  constructor() {
    this.state = {};
  }
  get value() {
    return this.state;
  }
  dispatch() {}
  subscribe() {}
}

Я використовую TypeScript, щоб встановити, що об'єкт стану складаєтиметься з ключів типу string, з будь-якими значеннями. Тому що це саме те, що нам потрібно для нашої структури даних.

Ми також додали get value () {}, який повертає об'єкт стану, коли він доступний як властивість, тобто console.log (store.value);.

У нас вийшло, створимо екземпляр:

const store = new Store();

Вуаля.

На цьому етапі можна викликати dispatch, якщо захочемо:

store.dispatch({
  type: 'ADD_TODO',
  payload: { label: 'Eat pizza', complete: false },
});

Але нічого не відбудеться, тому зосередимось на методі dispatch і передачі дії:

export class Store {
  // ...
  dispatch(action) {
    // Оновлюйте дерево стану тут!
  }
  // ...
}

Добре, всередині dispatch потрібно оновити дерево стану. Та для початку – як воно хоча б виглядає?

Структура даних нашого стану

Для цієї статті, структура даних виглядатиме так:

{
  todos: {
    data: [],
    loaded: false,
    loading: false,
  }
}

Чому? Ми вже знаємо, що редюсери оновлюють дерево стану. У справжньому застосунку у нас буде багато редюсерів, які відповідають за оновлення різних частин дерева — які ми часто називаємо «фрагментами» стану. Кожен фрагмент управляється редюсером.

У цьому випадку, властивість todos у дереві стану — фрагмент todos — буде керуватися редюсером. Який на цей момент керує властивостями data, loaded і loading цього фрагменту. Ми використовуємо loaded та loading, тому що, коли виконуємо асинхронні завдання, такі як отримання JSON через HTTP, то хочемо контролювати різні кроки, починаючи від ініціювання запиту — до його виконання.

Повернемося до нашого методу dispatch.

Оновлення дерева стану

Для дотримання патерну незмінності, потрібно властивості стану призначити нове представлення стану як новий об'єкт. Цей новий об'єкт складається з будь-яких змін, які ми плануємо зробити в дереві стану через дію.

Наприклад, проігноруємо той факт, що редюсери існують і оновимо стан вручну:

export class Store {
  // ...
  dispatch(action) {
    this.state = {
      todos: {
        data: [...this.state.todos.data, action.payload],
        loaded: true,
        loading: false,
      },
    };
  }
  // ...
}

Після того як ми відправили дію 'ADD_TODO', дерево стану виглядає так:

{
  todos: {
    data: [{ label: 'Eat pizza', complete: false }],
    loaded: false,
    loading: false,
  }
}

Написання функції редюсера

Тепер ми розуміємо, що редюсер оновлює фрагмент стану. Почнемо з визначення цього початкового фрагменту:

export const initialState = {
  data: [],
  loaded: false,
  loading: false,
};

Створення редюсера

Далі у функції редюсера потрібно вказати аргумент state із значенням по замовчуванню initialState. Це налаштовує редюсер для початкового завантаження, коли ми викликаємо його у Сховищі, щоб зв'язати усі початкові стани у всіх редюсерах.

export function todosReducer(
  state = initialState,
  action: { type: string, payload: any }
) {
  // не забудьте повернути мене
  return state;
}

На цьому етапі ми можемо відгадати решту редюсера:

export function todosReducer(
  state = initialState,
  action: { type: string, payload: any }
) {
  switch (action.type) {
    case 'ADD_TODO': {
      const todo = action.payload;
      const data = [...state.data, todo];
      return {
        ...state,
        data,
      };
    }
  }
  return state;
}

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

Повертаючись до Сховища, зараз ми маємо:

export class Store {
  private state: { [key: string]: any };

  constructor() {
    this.state = {};
  }

  get value() {
    return this.state;
  }

  dispatch(action) {
    this.state = {
      todos: {
        data: [...this.state.todos.data, action.payload],
        loaded: true,
        loading: false,
      },
    };
  }
}

Тепер потрібно отримати здатність додавати редюсери до Сховища:

export class Store {
  private state: { [key: string]: any };
  private reducers: { [key: string]: Function };

  constructor(reducers = {}, initialState = {}) {
    this.reducers = reducers;
    this.state = {};
  }
}

Також ми встановлюємо initialState для Сховища, щоб ми могли передати стан коли звернемось до Сховища.

Реєстрація редюсера

Щоб зареєструвати редюсер, ми повинні запам'ятати властивість todos в нашому очікуваному дереві стану і прив'язати функцію редюсера до нього. Пам'ятайте, що ми керуємо фрагментом стану, що називається «todos»:

const reducers = {
  todos: todosReducer,
};
const store = new Store(reducers);

Це чарівна частина, де властивість todos є результатом виклику Сховищем todosReducer, який, як ми знаємо, повертає новий стан на основі конкретної дії.

Виклик редюсера в Сховищі

Причина, чому редюсери називають «редюсерами», полягає в тому що вони зменшують новий стан. Подумайте про Array.prototype.reduce, де залишається одне значення. У нашому випадку це одне значення — нове представлення стану. Схоже, нам потрібен цикл.

Обгорнемо нашу «зменшувальну» логіку у функцію, яку тут я назвав reduce:

export class Store {
  // ...
  dispatch(action) {
    this.state = this.reduce(this.state, action);
  }
  private reduce(state, action) {
    // обраховуйте і повертайте стан
    return {};
  }
}

Коли ми надсилаємо дію, ми насправді викликаємо метод reduce який ми створили в класі Сховища, і передаємо стан та дію всередині. Це називається кореневим редюсером. Ви помітите, що він приймає state і action — так само, як і наш todosReducer.

Поринемо у private reduce метод , тому що це найважливіший крок для того, щоб композиція дерева стану була готовою.

export class Store {
  private state: { [key: string]: any };
  private reducers: { [key: string]: Function };

  constructor(reducers = {}, initialState = {}) {
    this.reducers = reducers;
    this.state = {};
  }
  dispatch(action) {
    this.state = this.reduce(this.state, action);
  }
  private reduce(state, action) {
    const newState = {};
    for (const prop in this.reducers) {
      newState[prop] = this.reducers[prop](state[prop], action);
    }
    return newState;
  }
}

Що відбувається тут:

  • Ми створюємо об'єкт newState, який буде містити нове дерево стану
  • Ітеруємо this.reducers, який реєструємо у Сховищі
  • Зберігаємо кожну властивість редюсера, наприклад todos, в newState
  • Викликаємо кожен редюсер, по одному за раз, і викликаємо його, передаючи фрагмент стану (через state[prop]) та дію

Значенням prop у цьому випадку є todos. Тому можете думати про це так:

newState.todos = this.reducers.todos(state.todos, action);

Перетворення initialState

Останній штрих для об'єкту initialState. Якщо ви хочете використовувати синтаксис Store(reducers, initialState) , щоб передати початковий стан для всього Сховища, потрібно застосувати метод reduce на етапі створення Сховища.

export class Store {
  private state: { [key: string]: any };
  private reducers: { [key: string]: Function };
  constructor(reducers = {}, initialState = {}) {
    this.reducers = reducers;
    this.state = this.reduce(initialState, {});
  }
  // ...
}

Пам'ятаєте, ми говорили, що потрібно повертати state в кінці кожного редюсера? Тепер ви знаєте чому. Ми маємо передавати цю властивість {} як дію, передбачаючи, що умови switch не виконаються, ми залишимось із деревом стану яке передаємо через constructor.

Включення підписників

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

У нашому випадку це буде розглядатися так:

const store = new Store(reducers);
store.subscribe(state => {
  // зробіть щось із state
});

Підписники Сховища

Додамо ще кілька властивостей до нашого Сховища, щоб ми могли налаштувати цю підписку:

export class Store {
  private subscribers: Function[];
  constructor(reducers = {}, initialState = {}) {
    this.subscribers = [];
    // ...
  }
  subscribe(fn) {}
  // ...
}

Тут ми маємо метод subscribe, який тепер приймає функцію (fn) як аргумент. Нам потрібно передати кожну функцію в наш масив subscribers:

export class Store {
  // ...
  subscribe(fn) {
    this.subscribers = [...this.subscribers, fn];
  }
  // ...
}

Це було просто! Отже, де інформувати підписників про те, що щось змінилося? Звичайно в dispatch!

export class Store {
  // ...
  get value() {
    return this.state;
  }
  dispatch(action) {
    this.state = this.reduce(this.state, action);
    this.subscribers.forEach(fn => fn(this.value));
  }
  // ...
}

Знову ж таки супер просто. Кожного разу, коли ми викликаємо dispatch, ми застосовуємо reduce до стану та ітеруємо підписників і передаємо this.value (пам'ятаєте, це наш гетер value).

Ааааале є ще одна річ. Коли ми викликаємо subscribe() ми не отримуємо значення стану в цей же момент. Ми отримаємо його після виклику dispatch(). Будемо повідомляти нових підписників про поточний стан, як тільки вони підписалися:

export class Store {
  // ...
  subscribe(fn) {
    this.subscribers = [...this.subscribers, fn];
    fn(this.value);
  }
  // ...
}

Це було також легко — ми отримуємо fn — функцію через метод підписки, і можемо просто викликати цю функцію, як тільки ми підпишемось, і передати значення дерева стану.

Скасування підписки

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

Все, що нам потрібно зробити, це повернути функцію, яка при виклику скасовуватиме підписку (видаливши функцію зі списку підписників):

export class Store {
  // ...
  subscribe(fn) {
    this.subscribers = [...this.subscribers, fn];
    fn(this.value);
    return () => {
      this.subscribers = this.subscribers.filter(sub => sub !== fn);
    };
  }
  // ...
}

Ми просто використовуємо посилання на функцію, проходимо всіх підписників, перевіряємо, чи поточний підписник не дорівнює нашій fn, і, використовуючи Array.prototype.filter, він магічно видаляється з масиву підписників.

І ми можемо використовувати це наступним чином:

const store = new Store(reducers);
const unsubscribe = store.subscribe(state => {});
destroyButton.on('click', unsubscribe, false);

І це все, що нам потрібно знати.

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

Фінальний код

Ось повна картина і готове рішення:

export class Store {
  private subscribers: Function[];
  private reducers: { [key: string]: Function };
  private state: { [key: string]: any };

  constructor(reducers = {}, initialState = {}) {
    this.subscribers = [];
    this.reducers = reducers;
    this.state = this.reduce(initialState, {});
  }

  get value() {
    return this.state;
  }

  subscribe(fn) {
    this.subscribers = [...this.subscribers, fn];
    fn(this.value);
    return () => {
      this.subscribers = this.subscribers.filter(sub => sub !== fn);
    };
  }

  dispatch(action) {
    this.state = this.reduce(this.state, action);
    this.subscribers.forEach(fn => fn(this.value));
  }

  private reduce(state, action) {
    const newState = {};
    for (const prop in this.reducers) {
      newState[prop] = this.reducers[prop](state[prop], action);
    }
    return newState;
  }
}

Ви можете побачити, що насправді тут мало що відбувається.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 6.2K
Приєднався: 7 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація