Pull to refresh

Заменяем Redux c помощью Observables и React Hooks

Website developmentJavaScriptReactJSTypeScript
Translation
Tutorial
Original author: Simon Trény

Управление состоянием — одна из важнейших задач, решаемых в разработке на React. Было создано множество инструментов в помощь разработчикам для решения этой задачи. Наиболее популярным инструментом является Redux — небольшая библиотека, созданная Дэном Абрамовым, и предназначенная помочь разработчикам использовать паттерн проектирования Flux в их приложениях. В этой статье мы разберем, действительно ли нам нужен Redux, и посмотрим, как мы можем заменить его более простым подходом, в основе которого лежат Observable и React Hooks.


Зачем нам вообще нужен Redux?


Redux так часто ассоциируется с React, что многие разработчики используют его, не задумываясь зачем им нужен именно Redux. React позволяет легко синхронизировать компонент и его состояние с помощью setState() / useState(). Но все становится сложнее, как только состояние начинает использоваться сразу несколькими компонентами. Самое очевидное решение совместного использования общего состояния между несколькими компонентами — это переместить его (состояние) к их общему родителю. Но такое решение «в лоб» очень быстро может привести к сложностям: если компоненты находятся далеко друг от друга в иерархии компонентов, то для обновления общего состояния потребуется множество пробрассываний через свойства компонентов. React Context может помочь уменьшить количество пробрассываний, но объявление нового контекста каждый раз, когда какое-либо состояние начинает использоваться совместно с еще одним компонентом, потребует все больших усилий и в конце концов может привести к ошибкам.


Redux решает эти проблемы, представляя объект Store, который содержит целиком все состояние приложения. В компоненты, которым требуется доступ к состоянию, этот Store внедряется с помощью функции connect. Также эта функция гарантирует, что при изменении состояния все компоненты, зависящие от него, будут перерисованы. Наконец, чтобы изменить состояние, компоненты должны отправлять action, которые запускают reducer для вычисления нового измененного состояния.



Когда в первый раз понял концепции Redux


Что не так с Redux?


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


Всеми этими действиями Redux усложняет понимание кода, его рефакторинг и отладку. При чтении кода, написанного кем-то другим, часто бывает трудно выяснить что выполняется при отправке action. Для начала нам придется погрузиться в код action creator, чтобы найти соответствующий тип действия, а затем найти reducers, которые обрабатывают этот тип действия. Все может стать еще сложнее, если используются некоторые middlewares, как, например, redux-saga, что делает контекст решения еще более неявным.


И, наконец, при использовании TypeScript, Redux может разочаровать. По замыслу action — это просто строки, связанные с дополнительными параметрами. Существуют способы написания хорошо типизированного кода Redux с помощью TypeScript, но это может быть очень утомительно и опять же может привести к увеличению объема кода, который нам придется написать.



Ощущения от изучения кода написанного с помощью Redux


Observable и hook: простой подход к управлению состоянием.


Заменяем Store c помощью Observable


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


На TypeScript реализовать такой класс довольно просто:


type Listener<T> = (val: T) => void;
type Unsubscriber = () => void;

export class Observable<T> {
    private _listeners: Listener<T>[];

    constructor(private _val: T) {}

    get(): T {
        return this._val;
    }

    set(val: T) {
        if (this._val !== val) {
            this._val = val;
            this._listeners.forEach(l => l(val));
        }
    }

    subscribe(listener: Listener<T>): Unsubscriber {
        this._listeners.push(listener);
        return () => {
            this._listeners = this._listeners.filter(l => l !== listener);
        };
    }
}

Если сравнить этот класс c Redux Store, вы увидите, что они довольно похожи: get() соответствует getState(), а subscribe() — то же самое. Основное отличие заключается в методе dispatch(), который был заменен более простым методом set(), позволяющим изменять содержащееся в нем значение без необходимости полагаться на reducer. Другое существенное отличие состоит в том, что, в противоположность Redux, мы будем использовать множество Observable вместо одного Store, содержащего всё состояние.


Заменяем reducer сервисами


Теперь Observable можно применить для хранения общего состояния, но нам все еще нужно переместить логику, содержащуюся в reducer. Для этого мы используем концепцию сервисов. Сервисы — это классы, которые реализуют всю бизнес-логику наших приложений. Давайте попробуем переписать reducer Todo из туториала Redux в сервис Todo, используя Observable:


import { Observable } from "./observable";

export interface Todo {
    readonly text: string;
    readonly completed: boolean;
}

export enum VisibilityFilter {
    SHOW_ALL,
    SHOW_COMPLETED,
    SHOW_ACTIVE,
}

export class TodoService {
    readonly todos = new Observable<Todo[]>([]);
    readonly visibilityFilter = new Observable(VisibilityFilter.SHOW_ALL);

    addTodo(text: string) {
        this.todos.set([...this.todos.get(), { text, completed: false }]);
    }

    toggleTodo(index: number) {
        this.todos.set(this.todos.get().map(
            (todo, i) => (i === index ? { text: todo.text, completed: !todo.completed } : todo)
        ));
    }

    setVisibilityFilter(filter: VisibilityFilter) {
        this.visibilityFilter.set(filter);
    }
}

Сравнивая это с reducer Todo, мы можем отметить следующие различия:


  • Action были заменены методами, избавляя от необходимости объявлять action type, сам action и action creator.
  • Больше не нужно писать большой switch для маршрутизации между action type. Динамическая диспетчеризация Javascript (то есть вызовы методов) берет это на себя.
  • И что наиболее важно, сервис содержит и изменяет состояние, которым он управляет. Это большое концептуальное отличие от reducers, которые являются чистой функцией.

Доступ к сервисам и Observable из компонентов


Теперь, когда мы заменили «store и reducer из Redux» на «Observable и сервисы», нам нужно сделать сервисы доступными из всех компонентов React. Есть несколько способов сделать это: мы могли бы использовать IoC фреймворк, например, Inversify; использовать контекст React или применить тот же подход, что и в Store Redux, – один глобальный экземпляр для каждого сервиса. В этой статье мы рассмотрим последний подход:


import { TodoService } from "./todoService";

export const todoService = new TodoService();

Теперь мы можем получить доступ к общему состоянию и изменить его из всех наших компонентов React, импортировав экземпляр todoService. Но нам все еще нужно найти способ перерисовки наших компонентов, когда общее состояние изменяется другим компонентом. Чтобы это сделать, мы напишем простой hook, который добавляет переменную состояния к компоненту, подписывается на Observable и обновляет переменную состояния, когда значение Observable изменяется:


import { useEffect, useState } from "react";
import { Observable } from "./observable";

export function useObservable<T>(observable: Observable<T>): T {
    const [val, setVal] = useState(observable.get());

    useEffect(() => {
        setVal(observable.get()); // Добавление от @mayorovp
        return observable.subscribe(setVal);
    }, [observable]);

    return val;
}

Собираем все вместе


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


Давайте перепишем компонент TodoList из учебника Redux, используя новый hook:


import React from "react";
import { useObservable } from "./observableHook";
import { todoService } from "./services";
import { Todo, VisibilityFilter } from "./todoService";

export const TodoList = () => {
    const todos = useObservable(todoService.todos);
    const filter = useObservable(todoService.visibilityFilter);
    const visibleTodos = getVisibleTodos(todos, filter);

    return (
        <div>
            <ul>
                {visibleTodos.map((todo, index) => (
                    <TodoItem key={index} todo={todo} index={index} />
                ))}
            </ul>
            <p>
                Show: <FilterLink filter={VisibilityFilter.SHOW_ALL}>All</FilterLink>,
                <FilterLink filter={VisibilityFilter.SHOW_ACTIVE}>Active</FilterLink>,
                <FilterLink filter={VisibilityFilter.SHOW_ALL}>Completed</FilterLink>
            </p>
        </div>
    );
};

const TodoItem = ({ todo: { text, completed }, index }: { todo: Todo; index: number }) => {
    return (
        <li
            style={{
                textDecoration: completed ? "line-through" : "none",
            }}
            onClick={() => todoService.toggleTodo(index)}
        >
            {text}
        </li>
    );
};

const FilterLink = ({ filter, children }: { filter: VisibilityFilter; children: React.ReactNode }) => {
    const activeFilter = useObservable(todoService.visibilityFilter);
    const active = filter === activeFilter;
    return active ? (
        <span>{children}</span>
    ) : (
        <a href="" onClick={() => todoService.setVisibilityFilter(filter)}>
            {children}
        </a>
    );
};

function getVisibleTodos(todos: Todo[], filter: VisibilityFilter): Todo[] {
    switch (filter) {
        case VisibilityFilter.SHOW_ALL:
            return todos;
        case VisibilityFilter.SHOW_COMPLETED:
            return todos.filter(t => t.completed);
        case VisibilityFilter.SHOW_ACTIVE:
            return todos.filter(t => !t.completed);
    }
}

Как мы видим, мы написали несколько компонентов, которые обращаются к значениям общего состояния (todos и visibilityFilter). Эти значения изменяются просто путем вызова методов из todoService. Благодаря hook useObservable, который подписывается на изменения значений, эти компоненты автоматически перерисовываются при изменении общего состояния.


Вывод


Если мы сравним этот код с подходом Redux, то увидим несколько преимуществ:


  • Краткость: единственное, что нам нужно было сделать — это обернуть значения состояний в Observable и использовать hook useObservable при доступе к этим значениям из компонентов. Нет необходимости объявлять action, action creator, писать или комбинировать reducer или подключать наши компоненты к хранилищу с параметрами mapStateToProps и mapDispatchToProps.
  • Простота: теперь намного легче отслеживать выполнение кода. Понимание того, что на самом деле происходит при нажатии кнопки — это всего лишь вопрос перехода к реализации вызываемого метода. Пошаговое выполнение с помощью отладчика также значительно улучшено, поскольку между нашими компонентами и нашими службами нет промежуточного уровня.
  • Типобезопасность (type-safety) из коробки: от разработчиков TypeScript не потребуется дополнительной работы, чтобы иметь корректно типизированный код. Не нужно объявлять типы для состояния и для каждого action.
  • Поддержка async/await: хотя здесь это не было продемонстрировано, это решение прекрасно работает с асинхронными функциями, значительно упрощая асинхронное программирование. Не нужно полагаться на middleware, такое как redux-thunk, которое для понимания требует глубокие познания в области функционального программирования.

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


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


Примечания


Класс Observable, представленный в этом посте, довольно прост. Его можно заменить более продвинутыми реализациями, такими как micro-observables (наша собственная библиотека) или RxJS.


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


От переводчика: Данная публикация, которую можно рассматривать как введение в управление состоянием с помощью Observable, является продолжением темы затронутой в статьей Управление состоянием приложения с RxJS/Immer как простая альтернатива Redux/MobX, где описывается как можно упростить использование данного подхода.
Tags:reduxobservablerxjstypescript
Hubs: Website development JavaScript ReactJS TypeScript
Total votes 9: ↑9 and ↓0 +9
Views8.1K

Popular right now

Top of the last 24 hours