Эта статья - экстракт четырёхлетнего опыта разработки на реакте. В начале карьеры я столкнулся с багом библиотеки react-player, пофиксил его, а дальше предложил и реализовал там рефакторинг. Мейнтейнер даже упомянул моё имя отдельно в readme, за что огромное ему спасибо. Давайте рассмотрим идеи, приготовленные в результате охоты на цели frontend-комьюнити сегодня.

С появлением хуков реакт изменился. Нам больше не нужно абстрагировать логику громоздкими классами. Не нужно создавать сущности, предназначенные для рендера, но содержащие в себе лишь функционал (HOC). Компонентный дуализм "логика/рендер" постепенно сходит на нет, всё встаёт на свои места. Нужно отрендерить? Пиши компонент. Есть общая логика? Делай хук.

...или выноси её в глобальный стейт

Экосистема реакта предоставляет несколько популярных библиотек для хранения глобального стейта. Redux, MobX, Effector, Recoil... Понятно, у всех свои плюсы и минусы. Однажды мне пришло в голову экстрагировать лучшее из увиденного и написать нечто своё. Результат выглядит очевидно выигрывающим в ux-надёжности и лаконичности. А если совсем откровенно, писать код на этой библиотеке чистый кайф. Спешу поделиться ей с вами, несмотря на недописанную документацию.

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

Основные принципы floorm

  1. ID. Фронтовые хранилища данных мало отличаются от серверных БД. В них так же хранятся сущности, подлежащие созданию/редактированию/удалению. Доступ к сущностям на сервере чаще всего производится через два параметра: имя таблицы и id. В floorm точно так же. Можно получить/изменить/удалить сущность, зная её id и орм, в которой она расположена.

  2. Лаконичность. Каждые две строчки кода, которые можно переписать в одну, не теряя читаемости - огромный плюс для проекта. Любой лишний символ создаёт шум. Если переиспользовать шумные принципы везде, количество кода увеличивается, начинаешь теряться в содержании файлов. Затрудняется поиск целевых мест для фикса багов. Усложняется понимание проекта, возрастает порог входа. На деле, всем известные DRY - только часть глобальной цели увеличения лаконичности и, как следствие, читаемости кода. В floorm даже не нужно писать провайдера данных для приложения. Они хранятся в файле из node_modules и копируются в локальные стейты компонентов. Вес же библиотеки мал невообразимо: всего 17.6 кб. Это в ~30 раз меньше связки react + react-redux и в сотни раз меньше других аналогов.

  3. Минимальный api-интерфейс. Программисты считаются умными и наверное поэтому любят усложнять. Отчего такой популярностью пользуется redux-saga с её десятками экспортируемых функций - загадка не из лёгких. Также не понимаю, зачем в redux-form флаг invalid соседствует с флагом valid. Кто-то не умеет пользоваться оператором "!"? Апи библиотеки должно быть примитивным для лёгкого усвоения, быстрого ориентирования в документации и обеспечения консистентности использования от проекта к проекту.

  4. Простые типы данных. Redux выигрывает MobX в простоте. Нативные js-объекты и массивы не вызывают сложности восприятия. Effector с Recoil'ом взяли этот плюс себе на вооружение. В floorm мы идём по тому же пути. Нет вещи более обескураживающей, чем написать console.log, и увидеть там монстра. И нет ничего полезнее, чем написать мок отображаемых данных за пятнадцать секунд.

  5. Связи сущностей - это важно. Изменение связанных сущностей - довольно неприятная процедура для иммутабельного проекта сегодня. При изменении имени юзера следует изменить его в постах, сообщениях, комментариях и везде, где бы он ни располагался на экране. Redux-way для этого использует подход нормализации данных, который поддерживается утилитой normalizr. С ними гемор остаётся только при удалении. При удалении следует отфильтровать id из списков и удалить в объектах, возвращаемых всеми редьюсерами. Кроме того, никто не застрахован от ошибки. Можно тупо не заметить, что удаляемая в детальном попапе сущность также есть в списке на этой же странице, и краш "can not read property of undefined" обеспечен. А если в проекте крутятся сокеты и нужна поддержка изменений извне, критичность этого момента повышается. С floorm иммутабельное обновление родителей автоматизировано. А в архитектуре проекта предусмотрен файл для хранения межсущностных связей.

Апи

Из floorm экспортируется 8 функций, которые можно разбить на 5 категорий.

  1. orm(name, descFunction) - функция для задания связей между сущностями проекта

  2. door(orm) и stone(name, desc) - слой для чтения/записи/удаления сущностей

  3. useDoor(door) и useStone(stone) - хуки для доступа к данным проекта в компонентах (поддерживает Suspense)

  4. watch(orm, watcher) и watchId(orm, id, watcher) - функции для отслеживания изменений всех либо одной из сущностей орм

  5. flat(orm, id) - хелпер для ликвидации рекурсий при отправке сущностей на сервер

Давайте пройдёмся по ним по порядку.

orm(name, descFunction)

orm - это функция для задания связей между сущностями. Принимает первым аргументом name. Второй аргумент - descFunction: функция, возвращающая desc (объект, описывающий орм связи).

import { orm } from 'floorm'

// в ключе author сущности book записана сущность её автора
const bookOrm = orm('book', () => ({
  author: authorOrm
}))

// в ключе books сущности author записан массив его книг
const authorOrm = orm('author', () => ({
  books: [bookOrm]
}))

door(orm) и stone(name, desc)

door и stone - это абстракции, предоставляющие методы для работы с данными. Они являются прослойкой между орм и хуками реакта.

door даёт доступ к сущностям переданной орм. Методы door требуют id для понимания, о каком инстансе идёт речь. door принимает orm в качестве аргумента и возвращает объект с методами.

stone при инициализации создаёт свою собственную сущность. Эта сущность живёт в одном экземпляре на весь проект. Методам stone id не требуется, так как он специализируется на одном единственном инстансе.

В stone передаются name и desc. Возвращается так же объект с методами.

import { door, stone } from 'floorm'
import { bookOrm } from '@/hotel/orm'

// bookDoor для работы с сущностями книг из bookOrm
const bookDoor = door(bookOrm)

console.log(bookDoor)
/*
  {
    // имя bookOrm
    name: 'book'

    // добавление/изменение книги 
    put: id, diff => {...},

    // получение последней версии книги
    get: id => {...},

    // удаление книги
    remove: id => {...},

    // получение массива всех книг, добавленных в bookOrm через put
    all: => {...},

    // возвращает флаг о состоянии загрузки id
    loading: id => {...},
  }
*/

// favoriteBooksStone создаёт один на проект массив
// (любимые книги текущего пользователя)
const favoriteBooksStone = stone('favoriteBooks', [bookOrm])

console.log(favoriteBooksStone)
/*
  {
    // имя stone
    name: 'favoriteBooks'

    // добавление/изменение массива
    put: diff => {...},

    // получение последней версии массива favoriteBooks
    get: => {...},

    // возвращает флаг о состоянии загрузки
    // (true после favoriteBooksStone.put(Promise))
    loading: => {...},
  }
*/

door привязан к своей orm и в целях дебага наследует её имя. stone добавляет в проект новую сущность, которую следует называть отдельно.

Общие методы door отличаются от stone наличием id. id нужен door для доступа к целевму объекту.

Кроме того, в bookDoor есть метод remove. Этот метод позволяет удалить книгу из проекта одним вызовом. bookDoor.remove(1) отфильтрует массивы, содержащие книгу с данным id, а в родительских объектах установит на её место null. Всё в соответствии descriptionFunc, переданной при инициализации родителей bookOrm (в нашем проекте это favoriteBooks и author.books).

stone метод remove незачем, ведь если требуется удалить оттуда сущность, можно просто написать favoriteAuthorsStone.put(null). У инстансов stone родителей нет.

Хуки

useDoor(door, id) и useStone(stone) - хуки, они возвращают значения door либо stone в контексте реакт компонента и отвечают за перерисовку при изменениях.

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

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

Обратите внимание, что для изменения сущностей не требуется специфического контекста. Мы можем спокойно выносить функции прямо на уровень модуля, в котором есть доступ к door/stone.

Взаимодействие с апи в примерах ниже упрощено для облегчения усвоения материала.

// hotel/book.js
import { door, useDoor } from 'floorm'
import { bookOrm } from 'hotel/orm'
import api from 'api'

const bookDoor = door(bookOrm)

// useBook - хук, принимающий id
// возвращает объект книги вместе с методами её изменения
export const useBook = id => {
  useEffect(() => {
    fetchBook(id)
  }, [])

  return {
    book: useDoor(bookDoor, id),
    // при вызове bookDoor.put не нужно передавать новую книгу целиком
    // можно передать лишь изменённые свойства,
    // и floorm сгенерирует полноценный объект новой книги
    // кроме того, библиотека изменит книгу в родительских сущностях
    // согласно маппингу орм
    changeBookName: name => changeBook(id, { name })
  }
}

const fetchBook = id =>
  bookDoor.put(id, api.book.get(id))

const changeBook = (id, diff) => {
  const nextBook = bookDoor.put(id, diff)
  api.book.post(id, nextBook)
}

Пример stone: один на проект массив favoriteBooks, любимые книги пользователя.

// hotel/favoriteBooks.js
import { stone, useStone } from 'floorm'
import { bookOrm } from 'hotel/orm'
import api from 'api'

const favoriteBooksStone = stone('favoriteBooks', [bookOrm])

export const useFavoriteBooks = () => {
  useEffect(() => {
    fetchFavoriteBooks()
  }, [])

  return {
    favoriteBooks: useStone(favoriteBooksStone),
    addBookToFavorite: addBookToFavorite
  }
}

const fetchFavoriteBooks = () => favoriteBooksStone.put(
  api.favoriteBooks.get()
)

const addBookToFavorite = newFavoriteBook => {
  favoriteBooksStone.put(
    [newFavoriteBook, ...favoriteBooksStone.get()]
  )
  api.favoriteBooks.add(newFavoriteBook)
}

Вспомогательные функции

watch(orm, watcher) и watchId(orm, id, watcher) отслеживают изменения. В аргументы watcher floorm передаёт item и prevItem при изменении их либо их детей. Эти функции предназначены для библиотек.

flat(orm, id) подставлет id вместо всех дочерних orm. Нужно при отправке на сервер для предодвращения рекурсий.

Что дальше?

Кажется, это всё, что следует знать для формирования первого взгляда на floorm. Лаконично, не правда ли?

Далее настоятельно рекомендую поиграться с библиотекой в песочнице. Реакта там нет, но отчётливо видно, как детские сущностей меняют родителей: автоматически и иммутабельно. Поиграться с кодом, использующим floorm - лучший способ донести основную концепцию до своей интуиции.

Кроме того, можно посмотреть пример архитектуры приложения в папке demo из репозитория. Там используется эксперементальная версия react-router с предзагрузкой данных и Suspense. Будьте готовы написать в консоли git clone для полноценного понимания, о чём идёт речь. В этом demo есть логгер, который пока не вынесен в отдельный npm пакет, но настроен вырасти в браузерное расширение наподобие redux-devtools.

Заключение

Программисты ленивы, и это правильно: машины должны работать за нас.

Итак, у нас есть легчайший стейт-менеджер (17.6 кб) с автоматизацией программисткой рутины. На нём не надо писать кучи файлов при внедрении микро-фичи. С ним не приходится окунаться в spread-hell для изменений глубоких свойств объектов, несмотря на поддержание иммутабельности. Если нужно его изучить, достаточно освоиться в нескольких функциях. А количество строк кода, которое требуется для написания логики интерфейсов, сокращется с ним до минимума. Надеюсь, вы поиграетесь в песочнице, посмотрите demo и увидите, какая чудесная штуковина этот floorm.

Это моя первая техническая статья. Я отдаю отчёт в том, что она далека от идеала. Всё реализовано силами одного человека. Прошу быть адекватными в критике.

И напишу ещё вскольз.

floorm может лечь в основу библиотеке, абстрагирующей всё клиент-серверное взаимодействие, касающееся валидации и изменения сущностей проекта на обеих сторонах. Я говорю о полноценной абстракции кода клиента и сервера. Живя без неё, нам приходится писать одну и ту же логику два раза. Если бы такая абстракция существовала, сократилось бы количество потенциальных мест присутствия багов, а скорость разработки приложений стала бы почти в два раза быстрее.

Кроме того, в связке с экспериментальным react-router, эта несуществующая библиотека может предоставить лёкгий способ написания UX, при котором пользователю нужно ждать загрузки данных всего один раз за сессию: при инициализации приложения. Представляете? Переходишь по ссылке и получаешь следующую страницу моментально, словно она всегда была на твоём компе.

У меня есть достойные внимания наработки на эту тему. Если результат публикации статьи окажется удовлетворительным, я намерен её развивать. floorm выглядит здесь надёжным фундаментом.

И повторюсь. Приветствуется любая помощь в поддержании проекта. Будь то замечания по статье, идеи показательных примеров, желание писать документацию или заниматься переводами на английский. Может быть, вы захотите показать floorm на React Conf (мой разговорный английский хромает) или можете предоставить спонсирование. Что угодно: пишите мне, и вы получите что желаете.

Интернет будущего в наших руках. У кого с этой мыслью получается засыпать по ночам?

Thanks

Спасибо всей команде ORM Пачка. Кроме того, хочется выразить особенную признательность Максиму Индыкову и Павлу Голубеву. Благодаря идеям, которые пропогандируют эти ребята, и сформировалась библиотека floorm.

Комментарии (2)


  1. grigorylug
    11.11.2021 19:06
    +1

    Интересный подход, понравилось отсутствие бойлерплэйта

    Фронтовые хранилища данных мало отличаются от серверных БД

    хрюкнул :D


    1. ozze_zam Автор
      14.11.2021 15:09

      согласен, подход гита, основанный на разницах, красиво вписывается в концепцию сущностей