От Редакса к хукам

В Реакте в версии 16.7.0 появились Hooks (дальше по тексту — хуки). Это API, которое позволяет использовать локальный стейт без использования классов. И среди них есть один, который, как мне кажется, может заменить собой Редакс.

В этой статье я предполагаю, что вы знаете разницу между функциональными компонентами и классами, в курсе о локальном стейте и жизненном цикле компонентов и о том, как работает Редакс. Без этого вникнуть будет трудно, но всё в одну статью я бы не уместил, так что вот ¯\_(ツ)_/¯

Что за хуки?

Раньше, чтобы использовать стейт в компоненте, приходилось писать класс. Хуки позволяют использовать стейт в функциональных компонентах без применения классов.

Например, здесь мы используем хук useState, чтобы создать и использовать переменную counter:

import React, {useState} from 'react'
const SimpleComponent = () => {
  const [counter, setCounter] = useState(0)
  // в первый раз значение counter будет равно тому, что мы передаём в useState
  // затем — тому, что мы установим через setCounter
  return <div>{counter}</div>
}

useState возвращает кортеж из значения и функции, которая будет это значение обновлять.

Из‑за того, что в одном компоненте можно использовать несколько хуков по несколько раз, у них есть ограничения и правила. Подробнее об этом написано в документации и рассказано в докладе на Реакт‑конфе.

useReducer

useReducer — это хук, который по принципу работы схож с редьюсерами из Редакса.

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState)
  // initialState — начальное состояние
  // reducer — функция, которая принимает state и action 
  //           и обрабатывает изменение состояния
  //
  // state — текущее состояние
  // dispatch — функция, которая будет дёргать экшены, 
  //            чтобы обновить состояние

  return <div>Hello world</div>
}

По принципу работы это и есть Редакс. Проблема только в том, что переменные state и dispatch находятся внутри области видимости функции App, а значит использовать этот редьюсер в других компонентах у нас не получится.

…Если только мы не используем контекст.

Context API

Чтобы решить проблему с тем, что разные компоненты должны делить общее состояние, мы можем использовать состояние корневого компонента и пробрасывать его через пропсы вниз по дереву.

Это работает, но если дерево большое, то и пробрасывать пропсы приходится через каждый уровень вложенности. Возникает так называемый prop drilling. Схематично это выглядит, как на картинке слева:

Редакс помимо работы с состоянием решал и эту проблему тоже. С ним мы создаём лишь одно хранилище, к которому может обращаться любой компонент. Таким образом нам не требуется пробрасывать свойства через всё дерево.

Ту же проблему решает и контекст в Реакте. Мы создаём контекст, через провайдер указываем, что именно нужно хранить и передавать, а через консьюмер — в каком компоненте эти значения забирать и использовать:

import {createContext} from 'react'
// создаём контекст
const StoreContext = createContext()

const App = () => (
  // через провайдер в свойстве value указываем значение,
  // которое нам надо хранить и как-то использовать в других компонентах
  <StoreContext.Provider value={{meaningOfLife: 42}}>
    <OtherComponent />
  </StoreContext.Provider>
)

const OtherComponent = () => {
  <StoreContext.Consumer>
    // через консьюмер получаем доступ к значению
    {({meaningOfLife}) => (
      <div>{meaningOfLife}</div>
    )}
  </StoreContext.Consumer>
}

Вызывать консьюмер можно где угодно, и это позволяет делить состояние между компонентами. И тут возникает мысль, нельзя ли заменить Редакс на смесь хуков и контекста. Ну и эт, вроде, можно.

Пример

Я написал простенькое приложение с использованием Редакса и с использованием контекста и хуков. Это счётчик, значение которого можно менять кнопками, либо меняя значение в инпуте, а также обнулять его нажатием на кнопку из другого компонента.

По структуре оно будет состоять из корневого компонента App, компонента формы Form и ещё одного компонента с кнопкой внизу Display. Схематично можно изобразить так:

Компоненты Form и Display зависят от хранилища, в котором содержится состояние приложения. Все события кнопок и инпута вызывают экшены, которые будут обновлять хранилище.

С использованием Редакса

Вначале создадим хранилище и редьюсер, который будет заниматься его обновлением:

// reducers.js
import {combineReducers} from 'redux'

const app = (state, action) => {
  switch(action.type) {
    case 'PLUS':
      return {...state, counter: state.counter + 1}

    case 'MINUS':
      return {...state, counter: state.counter - 1}

    case 'MAGIC':
      return {...state, counter: Math.floor(Math.random() * 100)}

    case 'CHANGE':
      return {...state, counter: +action.value}

    case 'RESET':
      return {...initialState}

    default:
      return state
  }
}

export default combineReducers({ app })

// index.js
import {createStore} from 'redux'
import rootReducer from './reducers'
import App from './App'

// создаём хранилище
const store = createStore(rootReducer)

// используем через провайдер
render(
  <Provider store={store}><App /></Provider>,
  document.getElementById('app'))

Дальше создадим экшены, которые будут вызываться событиями кнопок и инпута:

// actions.js
export const plus = () => ({ type: 'PLUS' })
export const minus = () => ({ type: 'MINUS' })
export const magic = () => ({ type: 'MAGIC' })
export const reset = () => ({ type: 'RESET' })
export const change = e => ({ 
  value: e.target.value,
  type: 'CHANGE', 
})

Чтобы привязать какой‑то компонент к хранилищу, используем connect:

import {connect} from 'react-redux'
import {reset} from './actions'

// app — часть хранилища;
// reset — экшен;
// всё это мы привязали через connect перед экспортом ниже
const Display = ({app, reset}) => {
  const {counter} = app

  return <footer>
    <p>Another component knows that counter equals to {counter} as well!</p>
    <p>
      It even can 
      <button onClick={reset}>reset the coutner</button>
    </p>
  </footer>
}

// мапим свойства из хранилища и экшены
// на пропсы компонента
export default connect(
  state => ({ app: state.app }),
  {reset}
)(Display)

В результате приложение будет работать так.

Контекст + хуки

Теперь напишем то же самое без использования Редакса. Используем useReducer:

// store.js
export const initialState = {counter: 0}

// редьюсер точно такой же, как в прошлый раз 
export const reducer = (state, action) => {
  switch(action.type) {
    // ...
  }
}

// index.js
import {reducer, initialState} from './store'

const App = () => {
  // создаём в корневом компоненте хранилище
  // и функцию для его обновления
  const [state, dispatch] = useReducer(reducer, initialState)
  return <div></div>
}

Чтобы пробросить значения из хранилища компонентам, воспользуемся контекстом:

// context.js
import {createContext} from 'react'
export const StoreContext = createContext()

// index.js
import {reducer, initialState} from './store'
// импортируем созданный контекст
import {StoreContext} from './context'

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  // используем провайдер, чтобы передать в контекст 
  // хранилище и функцию для обновления
  return (
    <StoreContext.Provider value={{dispatch, state}}>
      <Form />
      <Display />
    </StoreContext.Provider>
  )
}

export default App

Чтобы привязать какой‑то компонент, используем консьюмер:

import React from 'react'
// импортируем контекст 
import {StoreContext} from './context'
// экшены точно такие же, как в прошлый раз
import {reset} from './actions'

const Display = () => (
  // получаем доступ к тому, что хранится в контексте
  <StoreContext.Consumer>
    // в нашем случае — state и dispatch
    {({state, dispatch}) => (
      <footer>
        // используем state, чтобы вывести значение счётчика
        <p>{state.counter}</p>
        // используем dispatch, чтобы дёрнуть экшен
        <button onClick={() => dispatch(reset())}>reset</button>
      </footer>
    )}
  </StoreContext.Consumer>
)

export default Display

А ещё можно сделать код чище, заменив консьюмер на useContext:

import React, {useContext} from 'react'
import {StoreContext} from './context'
import {reset} from './actions'

const Display = () => {
  // вызываем useContext, передавая аргументом нужный контекст
  const {state, dispatch} = useContext(StoreContext)

  return (
    // убираем консьюмер
    <footer>
      <p>{state.counter}</p>
      <button onClick={() => dispatch(reset())}>reset</button>
    </footer>
  )
}

export default Display

И работает оно точно так же.

А чо по весу и перформансу?

Я не удивился, когда бандл ужался на 12 кБ: с Редаксом — 166, без него — 154. Это логично, меньше зависимостей — меньше вес.

А вот прирост в скорости обработки экшенов и отрисовки меня слегка удивил. Я проводил измерения с помощью console.time и performance.measure. Средние значения за 100 итераций вышли такими:

 console.timeperformance.measure
Redux12 мс13 мс
Context + hooks9 мс8 мс

Минусы

Вызов экшенов стал чуть более многословным из‑за прямого использования dispatch. И если работать с контекстом без useContext, придётся использовать паттерн render‑prop, из‑за чего может подняться щит‑сторм ¯\_(ツ)_/¯

Но если серьёзно, хуки — пока что в стадии RFC, и возможно многое поменяется. Поэтому переписывать свои приложения на них не советует даже Дэн Абрамов. То есть это экспериментальная фигня.

Хотя выглядит всё равно заманчиво :–)

Ссылки

Документация

Доклады

Статьи со сравнениями

Измерения перформанса

Исходники и примеры

Грязная архитектура

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

Проблема

Современный фронтенд — сложный. Много бизнес‑логики перекочевало из сервера в браузер. Сингл‑пейджи, большие приложения со сложной структурой, логикой и прочим — этим добром сейчас никого не удивишь.

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

Ну и сроки, да. Они всегда горят. Разработчики стали той собакой, у которой «всё хорошо»:

Появились инструменты для решения проблем с рутиной. Манипуляции с ДОМ, запросы к серверу, обработка состояния, хранение данных — со всем этим нам стали помогать библиотеки и фреймворки.

Инструменты — это суперздорово, потому что они снимают с разработчиков нагрузку и позволяют сосредоточиться на бизнес‑логике приложения. Хорошо же? Да, хорошо, но только это чё‑то не работает, как задумано.

Мы всё ещё не уделяем достаточно внимания бизнес‑логике, а упарываемся по модным библиотекам (привет, Реакт!) и фреймворкам (привет, Ангуляр!). И спорим, что лучше, что хуже, на чём приложения моднее.

Нам кажется, что инструменты предлагают решения для структуры проекта. Но фреймворк или библиотека не должны диктовать ни структуру проекта, ни правила общения компонентов системы друг с другом, ни что‑либо ещё.

Инструмент не должен вообще ничего вам диктовать

Инструмент должен делать работу, для которой его придумали. Молоток не говорит плотнику, как ему сколачивать детали друг с другом, потому что плотник использует молоток как подручное средство.

Не должно быть никаких «Реакт‑приложений» или «Редакс‑приложений», потому что это инструменты. Реакт должен рисовать пиксели на экране. Редакс должен работать с состоянием. Всё.

А как надо, умник?

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

Бизнес‑правила — скелет, вокруг которого наворачивается мясо

Мясо — всё, что заточено под проект: фреймворк, веб‑натив‑гибрид, ватевер. И бизнес‑правила по‑хорошему никак не должны от этого зависеть.

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

Хорошая архитектура по Мартину это луковица из нескольких слоёв. Ядро — сущности, объекты, которые содержат критичные для бизнеса правила и данные для их работы. На втором уровне — юзкейсы, модели общения между пользователем и сущностями. Фреймворки находятся на самом внешнем слое. 

При грамотно спроектированной архитектуре приложения, вы можете выкинуть Реакт и переписать всё за вечер на Вью. И приложение не поломается, потому что интерфейс зависит от бизнес‑правил, а не наоборот.

А как же фигак‑фигак и в продакшен?

Ну да, есть такое. Сроки горят, прототипы нинужны, на тесты нет времени. Плавали, знаем.

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

Кроме того, что такую систему было бы проще развивать и масштабировать, её было бы гораздо проще тестировать. Когда ядро ни от чего не зависит, то и фейков для тестирования требуется меньше. А про раздельный деплой ядра и всего остального я вообще молчу.

Сейчас вот я обновляю критичные части проекта, делая их более независимыми и выделяя их в сущности с регламентированными свойствами и правилами поведения. Это действительно делает логику работы приложения прозрачнее. Но отвязаться от выбранной изначально структуры на удивление трудно.

Ну и как тогда начать проект?

Вполне логичный вопрос. Какую структуру выбрать, куда класть файлы, как их называть, и всё такое. Если раньше фреймворк приносил с собой готовое решение, то теперь надо его придумать самим.

Но за меня на этот вопрос уже ответил Дэн Абрамов в твитере:

Всё зависит от бизнес‑правил. Проектируйте, опираясь на них

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

А когда начнёте писать код, старайтесь откладывать решение о выборе инструментов до самого последнего момента. Пишите так, чтобы к ядру можно было прикрутить что угодно. Всё должно зависеть от бизнес‑правил, не наоборот. Вот тогда приложение действительно будет масштабируемым и тестируемым.

Ссылки

Конспект «Чистой архитектуры»

Дэн Абрамов в твитере

«Кликни меня!» на RxJS

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

Если вы уже работали с этой технологией, скорее всего, ничего нового не узнаете. Пост ориентирован на таких как я — которым из RxJS знаком только JS.

Игру будем писать на Тайпскрипте. Отличий от обычного JS будет немного, но всё же стоит глянуть документацию по нему, чтобы знать, как объявляются типы переменных и возвращаемых из функции значений.

Почему Тайпскрипт

Мне давно хотелось его распробовать, а RxJS написан как раз на нём. Я подумал, почему бы не добавить себе ограничений и головной боли, ну и вот ¯\_(ツ)_/¯

Что такое RxJS

RxJS — это имплементация ReactiveX для JS.
ReactiveX в свою очередь, если верить главной их сайта, — это АПИ для асинхронной работы с наблюдаемыми потоками. Я тож не сразу въехал, будем разбираться по порядку.

Если выражаться примитивно, поток — это последовательность чего‑то: событий, данных, преобразований и т. д. Представьте себе чатик, в котором вы с кем‑то переписываетесь. Последовательность сообщений в нём — это и есть поток.

Поток наблюдаемый, если мы можем на него подписаться — объявить функцию, которая будет обрабатывать каждый новый элемент. Чатик — это наблюдаемый поток, если мы сидим и читаем каждое новое сообщение.

Польза потоков в том, что они дают возможность не обрабатывать, например, события по одному, а объединить их и работать сразу с набором событий.

Наблюдатель и наблюдаемый

ReactiveX в основе использует шаблон «Наблюдатель». Два основных понятия, которые нам понадобятся — это observer (наблюдатель) и observable (наблюдаемый).

Наблюдаемым будет поток: он отправляет элементы из какого‑то источника по одному за раз. Это можно представить как ручей, по которому плывут кораблики‑элементы.

Наблюдатель — это объект, который знает, что с элементами из потока надо делать и как их обработать. Это можно представить как ребёнка, который хочет кораблики собрать и унести домой.

Наблюдатель связан с наблюдаемым через подписку (subscribe) — функцию, которая передаёт элементы от наблюдаемого наблюдателю. Это похоже на сетку, которая ловит проплывающие кораблики. Когда новый кораблик попадает в сетку, ребёнок его замечает и может забрать себе.

Наблюдаемый поток знает, как сообщить:

  • что появился новый элемент;
  • произошла ошибка;
  • элементы закончились.

На всё это наблюдатель может как‑то реагировать.

Диаграммы

Чтобы понять концепцию потоков лучше, документация к RxJS предлагает так называемые marble diagrams. На них изображены шарики, которые как бы нанизаны на нитку.

Эти шарики — это элементы в потоке. Нитка — это линия времени, направленная слева направо. Если элемент стоит левее, значит он появился раньше.

(Было бы конечно круче это всё показывать анимацией: ну типа элементы один за другим падают вниз, проходят преобразование, падают дальше.)

Применимо к игре

Чтобы написать игру, надо определить, какие события мы собираемся обрабатывать и что мы с ними хотим делать.

Мы будем отслеживать движение мыши и проверять, где находится курсор. Если он находится в пределах 15 пикселей от кнопки, то будем перерисовывать кнопку.

Если чуть ближе к коду, то у нас будет поток из событий перемещения мыши. Мы их будем чистить и оставлять только координаты {x, y}. Затем будем фильтровать координаты, проверяя находится ли курсор достаточно близко от кнопки:

Начинаем пилить

В RxJS поток можно сделать из чего угодно: из массива, промиса, событий в браузере. Например, из массива его можно сделать с помощью оператора from:

import {from} from 'rxjs'

// выталкивает по одному элементу из массива, пока они не закончатся
const arraySource = from([1, 2, 3, 4, 5])

Источником для нашего потока будет событие движения мыши по экрану. Чтобы создать источник из браузерного события, мы будем использовать fromEvent:

import {fromEvent} from 'rxjs/observable/fromEvent'

const source = fromEvent(document, 'mousemove')

Теперь браузерное событие mousemove будет отслеживаться в пределах document, и на каждое перемещение будет появляться новый элемент в source.

Эти элементы мы будем преобразовывать и фильтровать. После каждого преобразования мы будем получать новый observable с элементами, с которыми опять сможем как‑то взаимодействовать.

Операторы

Операторы — это функции, которыми можно преобразовывать элементы после того, как observable их отправил.

Чтобы применить несколько преобразований по очереди, нам потребуется pipe. Это метод, который занимается композицией операторов, то есть применяет их по порядку.

import {map, filter} from 'rxjs/operators'

// ...

const observable = source.pipe(
  map(...),
  filter(...)
)

Оператор map нам нужен, чтобы применить к каждому элементу какую‑то функцию.

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

map((event: MouseEvent): MouseCoords => ({ x: event.x, y: event.y }))

MouseCoords — это тип данных, который мы создадим для работы с координатами. Он будет представлять из себя объект с полями {x, y}. Создавать новый тип необязательно, но так понятнее, с чем мы работаем.

type MouseCoords = {
  x: number,
  y: number,
}

// ...

map((event: MouseEvent): MouseCoords => ({ x: event.x, y: event.y }))

Оператор filter будет выбирать события, которые нам подходят.

Событие нам подходит, если курсор находится в пределах 15 пикселей от кнопки по обеим осям.

const shouldUpdateApp = ({x, y}: MouseCoords): boolean => {
  const {top, left, widthRange, heightRange} = state.get()
  const padding = 15

  return inRange(x, left - padding, widthRange + padding) 
      && inRange(y, top - padding, heightRange + padding)
}

// ...

filter(shouldUpdateApp)

И тогда код observable будет выглядеть так:

const source = fromEvent(document, 'mousemove')

const observable = source.pipe(
  map((event: MouseEvent): MouseCoords => 
    ({ x: event.x, y: event.y })),
  filter(shouldUpdateApp))

Подписка на события

Каждый элемент в потоке стремится попасть в subscribe, где он будет как‑то обработан.

Метод subscribe принимает три функции‑аргумента. Первая функция обрабатывает новые элементы, вторая — ошибку, если она возникнет, третья — окончание потока:

observable.subscribe(
  // onNext, вызывается при появлении новых элементов, el — новый элемент
  (el) => {},

  // onError, вызывается, если произошла ошибка, er — объект ошибки
  (er) => {},

  // onCompleted, вызывается, когда поток завершён
  () => {},
)

При появлении нового элемента мы будем вызывать функцию updateApp, которая будет генерировать случайные координаты для кнопки, обновлять состояние приложения и перерисовывать кнопку:

observable.subscribe(() => updateApp())

const updateApp = () => {
  const {left, top} = getNewPosition()
  state.update({ left, top })

  applyStyle(button, {
    left: `${left}px`,
    top: `${top}px`,
  })
}

Результаты

Я не буду подробно останавливаться на классе, который управляет состоянием приложения, и функциях‑хелперах. Исходный код всего‑всего можно посмотреть на Гитхабе.

Сама игрушка получилась очень простой, хотя для знакомства с RxJS вполне ок.

Конечно, там ещё куча всякого, о чём я не рассказал: создание потоков из промисов, Subject, Scheduler, куча операторов, работу которых иногда без специального сервиса не разберёшь. Но для начала — сойдёт.

Ссылки

Сделяль

Шаблон «Наблюдатель» и ФРП

Документация RxJS

Операторы

Книги и сервисы

Разделение функций на команды и запросы

Майкл Физерс в своей книге «Эффективная работа с легаси‑кодом» советует разделять методы и функции на две категории: команды и запросы. Это очень простая идея, но она помогает сделать код более читаемым и предсказуемым, а идеи методов и функций — прозрачными.

Для удобства дальше по тексту я буду и методы классов, и функции называть просто функциями, чтобы не писать каждый раз «функция или метод».

Что за команды и запросы такие?

Запрос — функция, которая возвращает результат и не имеет сайд‑эффектов. Например:

const isEqual = (a, b) => a === b

class DeepThought {
  answerTheUltimateQuestionOfLifeTheUniverseAndEverything() {
   return 42
  }
}

Команда — функция, которая меняет состояние системы и ничего не возвращает. Пример команды, которая увеличивает значение счётчика:

const state = { counter: 0 }
const increaseCounter = () => { state.counter++ }

// или классом:
class Counter {
  constructor() {
    this.state = 0
  }

  increase() {
    this.state++
  }
}

Физерс советует не смешивать логику команд и запросов. То есть функция может быть либо командой, либо запросом, но не может быть тем и другим одновременно. Плюсов в таком подходе я заметил несколько.

Понятно, что происходит

Разделение на запросы и команды позволяет избегать ситуаций, когда без выполнения кода непонятно, что этот код делает и какой результат получится. Например, в этой строке непонятно, что окажется в updated:

// новый рейтинг, айдишник пользователя, что-то ещё? 
const updated = userAccount.updateRating(10)

Да и в принципе из названия функции updateRating не очевидно, что она вообще может что‑то вернуть. Если мы хотим, чтобы название было говорящим, то придётся написать нечто вроде:

// ну... видимо, тут рейтинг возвращается? хотя хз, всё равно проверить надо
const updatedRating = userAccount.updateRatingAndGetUpdatedValue(10)

Здесь мы видим два действия, поэтому правильнее сделать две функции. В нашем случае — одну команду и один запрос:

// команда для обновления рейтинга в сервисе
userAccount.updateRating(10)

// запрос для получения нового значения рейтинга
const updatedRating = userAccount.getRating()

SRP автоматом

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

Допустим, вот сложная функция‑солянка updateSubscriptions. Она принимает на вход массив тегов, на которые подписывается пользователь, создаёт объект подписки, проверяет доступна ли она для текущего пользователя, активен ли пользователь, добавляет подписку в массив, обновляет рейтинг:

class UserAccount {
  constructor(userAccountInfo) {
    const {
      rating=0, 
      active=false,
      subscriptions=[],
    } = userAccountInfo

    this.subscriptions = subscriptions
    this.active = active
    this.rating = rating
  }

  _createLabel(tag) { 
    return `Label for ${tag}` 
  }

  _defineType(tag) { 
    return tag.includes('secret_code')
      ? TYPES.PREMIUM
      : TYPES.REGULAR
  }

  isSubscriptionValid(subscription) {
    // сложная логика валидации
    return true
  }

  updateSubscriptions(tags) {
    tags.forEach(tag => {
      const label = this._createLabel(tag)
      const type = this._defineType(tag)
      const subscription = {label, type}

      if (this.isSubscriptionValid(subscription) && this.active) {
        this.subscriptions.push(subscription)

        if (type === TYPES.PREMIUM) this.rating += 5
        else this.rating++
      }
    })
  }
}

Упростим, разбив функции на команды и запросы.

class UserAccount {
  // ...конструктор и старые функции

  // запрос: нет сайд-эффектов, возвращаем объект подписки
  _createSubscription(tag) {
    const label = this._createLabel(tag)
    const type = this._defineType(tag)
    return {label, type}
  }

  // тоже запрос
  isUserActive() {
    return this.active
  }

  // команда: возвращаемого результата нет, 
  // изменяется состояние поля subscriptions
  appendSubscription(subscription) {
    this.subscriptions.push(subscription)
  }

  // тоже команда
  increaseRatingBy(delta) {
    this.rating += delta
  }

  // снова запрос: возвращаем количество баллов, не меняя состояния системы
  _calcRatingDeltaForSubscription(subscription) {
    if (subscription.type === TYPES.PREMIUM) return 5
    return 1
  }

  updateSubscriptions(tags) {
    // увидели, что можно сделать ранний выход
    if (!this.isUserActive()) return

    tags.forEach(tag => {
      // созданием подписки теперь занимается запрос;
      // мы не привязываемся к структуре данных подписок; 
      // стало видно, что работу с подписками неплохо бы вынести в отдельный класс
      const subscription = this._createSubscription(tag)
      if (!this.isSubscriptionValid(subscription)) continue

      // добавление подписки не привязано к структуре данных;
      // мы можем заменить массив на стек, и достаточно будет подправить
      // только appendSubscription, чтобы функциональность осталась рабочей
      this.appendSubscription(subscription)

      // здесь теперь нет магических чисел;
      // подсчёт изменения рейтинга находится в одном месте
      const ratingDelta = this._calcRatingDeltaForSubscription(subscription)

      // обновление рейтинга называется адекватно;
      // стало ясно, что эта функция делает
      this.increaseRatingBy(ratingDelta)
    })
  }
}

По сути updateSubscriptions стала фасадом для других функций. Она даёт общее понятное название для действий, которые мы хотим вызвать по порядку.

Сами функции стали проще, выполняют меньше действий, а действия сгруппированы по «темам». Из‑за этого стало видно, что часть действий лучше вынести в другой класс, потому что у них «другая тема».

Кроме того, нам больше не требуется выполнять код, чтобы понять, что в нём происходит. Отсюда — меньше возможностей для появления ошибок.

Удобнее тестировать

Простые сущности удобнее тестировать. Нам не требуется накручивать окружение и создавать 100500 фейковых объектов, чтобы запустить тест. Нужен только тот фейк, который требуется для теста конкретной функции или класса.

Это, конечно, скорее заслуга SRP, но тем не менее.

Раздельная масштабируемость

Мы можем развивать команды независимо от запросов и наоборот. Польза в том, что всякие извращённо‑сложные выборки данных перестают захламлять код класса или функции. Все запросы мы можем вытащить в отдельное место и там их хранить.

То же и с командами: ничто не мешает сделать команды абстрактными, вынести их в отдельный файл и использовать их сквозь всё приложение.

Правда иногда это оверкил

Есть ситуации, когда такое разделение излишне, или когда просто смешивать логику удобнее. Допустим вот сервис, который регистрирует нового пользователя:

const userId = remoteUserApi.signup({login, password})

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

Или мы можем не иметь контроля над этой частью кода. Тогда если мы хотим использовать разбиение на команды и запросы, придётся писать диспетчер, который будет это разруливать. А это — дополнительная абстракция, которая может усложнить проект.

Ссылки по теме

Книги:

И остальное:

Чистая архитектура. Часть 3

В прошлый раз мы говорили о связанности и сочетаемости компонентов систем, а также обсудили подробнее цели хорошей архитектуры. Сегодня подробнее рассмотрим бизнес‑правила, уровни архитектуры, немного поговорим о шаблонах и тестах.

Глава 20. Бизнес‑правила

Коротко:

  • Бизнес‑правила — это правила или процедуры, которые приносят или сохраняют бизнесу деньги.
  • Специфические для приложения правила — это дополнение к бизнес‑правилам.

Бизнес‑правила — это правила или процедуры, которые приносят или сохраняют бизнесу деньги, при этом неважно, выполнены эти процедуры на компьютере или вручную. Такие правила называют критичными правилами, а данные, с которыми работают эти правила — критичными данными.

Сущность — это контейнер, который содержит набор критичных правил и данных для их работы.

Специфические для приложения правила называются юзкейсами. Они описывают модели общения между пользователем и сущностями. Сущности не должны знать ничего о юзкейсах, которые их контролируют (юзкейсы — это дополнения к сущностям).

Юзкейсы зависят от входных данных и производят выходные данные, но при этом не зависят от формы, в которой эти данные передаются.

Глава 21. Говорящая архитектура

Коротко:

  • Архитектура — это не про фреймворки.
  • Хорошая архитектура рассказывает, какую систему она описывает, а не на чём построена.

Фреймворки — это инструмент, а не материал. Архитектура должна основываться на юзкейсах, а не на фреймворках. Хорошая архитектура позволяет откладывать решения о выборе фреймворка и менять их, если придётся.

Веб — это механизм ввода‑вывода.

Хорошая архитектура рассказывает, какую систему она описывает, а не на чём построена.

Глава 22. Чистая архитектура

Коротко, чистая архитектура:

  • не зависит от фреймворков;
  • легко тестируется;
  • не зависит от пользовательского интерфейса;
  • не зависит от базы данных;
  • не зависит от каких‑либо внешних агентов.

Внешние слои могут зависеть от внутренних, но не наоборот: 

Сущности инкапсулируют критичные бизнес‑правила. Юзкейсы содержат правила, специфические для приложения. Адаптеры интерфейсов конвертируют данные из формата, удобного для юзкейсов, в формат удобный для внешних слоёв.

Главы 23–25

Коротко:

  • Сделать систему тестируемой помогает шаблон простого объекта.
  • Скрыть сложную логику можно за фасадами.
  • Стоит почаще задаваться вопросом «а понадобится ли мне это?».

Глава 26. Корневой компонент

Коротко:

  • Корневой компонент — тот, который создаёт, управляет и контролирует все остальные.
  • Корневой компонент должен быть снаружи архитектуры системы.

Входная точка в приложение — корневой компонент, тот, который создаёт, управляет и контролирует все остальные. О нём следует думать, как о плагине к приложению в целом. Этот плагин задаёт начальные условия, настройки, подтягивает необходимые внешние ресурсы, а затем передаёт это всё приложению. Он должен быть как бы снаружи архитектуры системы.

Глава 27. Сервисы

Коротко:

  • Сервисы слабо‑связаны, но так бывает не всегда.
  • Сервисы помогают достичь независимого деплоя, но так тоже бывает не всегда ¯\_(ツ)_/¯
  • Архитектура определяется не сервисами как таковыми, а границами между значимыми компонентами системы.

Глава 28. Тесты

Коротко:

  • Тесты — часть системы.
  • Если тесты сильно связаны с компонентами, то небольшое изменение может уронить сотни тестов.

Что дальше

В шестой части книги описываются детали реализации: БД, веб, фреймворки; а также несколько примеров. Это я рекомендую прочесть внимательно самим.

Вместе с этой книгой советую прочитать пару других:

Предыдущие части:

И ссылки из конспекта:

Раньше ↓