Когда-то давно, мне повезло работать на проекте, где все обращения к backend были обернуты в action-creators с использованием библиотеки redux-thunk. Эта библиотека имеет культовое значение для экосистемы Redux, так как позволяет писать обращения к backend без применение генераторов, в отличие от redux-saga.

export const getRepos = (username) => {
  return async (dispatch) => {
    const result = {
      repos: [],
    };
    dispatch(loadingIn Progress (true));
    try {
      /*await */ fetch(
        `https://api.github.com/users/${username}/repos?sort=updated`
      )
      .then((data) => data.json ())
      .then((json) => (result.repos = json));
    } finally {
      dispatch(loadingInProgress (false));
    }
    dispatch(loadingSuccess (result));
  };
};

В рамках NDA я не могу назвать организацию и опубликовать оригинал. Но я могу написать псевдокод. Как видно, в результате исполнения getRepos, из-за закомментированного ключевого слова await, в целевой reducer состояния улетит пустой массив repos. На боевом проекте же его просто забыли написать :-)

import React from "react";
import PropTypes from "prop-types"; 

class RepoList extends React.Component {
  componentDidMount = () => { 
    setTimeout(() => this.forceUpdate(), 1_000);
  };
  render = () => {
    const { repos, hasError, isLoading } = this.props;
    ...

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

Проблема

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

Является ли эта функция pure функцией?

const sum = (a, b) => a + b;

На первый взгляд, да, однако…

const obj = new class {
  toString() { 
    return Math.random().toString(36).substring(7);
  };
};

console.log(sum(obj, obj)) // uretdln9iue
console.log(sum(obj, obj)) // 61347arNc9q
console.log(sum(obj, obj)) // 529n518wn718 

Верным вариантом написания pure функции будет функция с определенными входными параметрами и возвращаемым значением.

const sum = (a: number, b: number): number => a + b;

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

interface IObj {
  toString(): string;
}

Рассмотрим исходный код примера применения селекторов в redux-form, страница Selecting Form Values Example.

import { connect } from 'react-redux'
import { formValueSelector } from 'redux-form' 

...

const selector = formValueSelector('selectingFormValues')
SelectingFormValuesForm = connect((state) => {
  const hasEmailValue = selector(state, 'hasEmail')
  const favoriteColorValue = selector(state, 'favoriteColor')
  const { firstName, lastName } = selector(state, 'firstName', 'lastName')
  return {
    hasEmailValue,
    favoriteColorValue,
    canSubscribeByEmail: firstName && lastName,
    fullName: `${firstName || ''} S{lastName || ''}`,
  };
})(SelectingFormValuesForm);

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

Функция mapStateToProps при подобном использовании имеет бесконечный список возвращаемых значений, он расширяется при последующих итерациях разработки вместе с правками ui. Эта функция имеет не одну, а несколько исполняемых ей задач, нет соглашения каких именно.

Решение

Коллеги! Пишите свои шаблонизаторы. Возможно, я покажусь старомодным, но применение JSON шаблонов для генерации форм позволит убрать копипасту и решить следующие проблемы.

Автоматизировать создание внутреннего состояния формы и сохранение изменений

Огромное колличество кода приложения приходится на копипасту Create-Read-Update-Delete. Если нужно восстановить ввод при повторном открытии страницы без сохранения, вы не обойдетесь без шаблонизатора

Принцип единой ответственности

Свойство canSubscribeByEmail внутри селектора выше не привязано к полю напрямую и в любой момент, при повторном использовании другим программистом, произойдет коллизия. JSON шаблон позволяет разнести коллбеки isDisabledisInvalidisVisible непосредственно к полям, что обеспечит единую ответственность

Можно упростить адаптивную верстку

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

Наличие шаблонизатора критично для списочных форм

Если для backend существует единый стандарт json:api, например, nest-paginate, то для frontend я часто натыкался на копипасту состояний фильтров, сортировок и верстки списочной формы через <List> и <ListItem>. Это неправильно.

Пример использования шаблонизаторов в коде

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

Списочная форма
Списочная форма
const filters = [
  {
    type: FieldType.Text,
    name: 'firstName',
    title: 'First name',
  },
  {
    type: FieldType.Text,
    name: 'lastName',
    title: 'Last name',
  },
  {
    type: FieldType.Text,
    name: 'occupation',
    title: 'Occupation',
  }
];

const sortModel = [
  {
    field: 'KPI',
    sort: 'asc',
  }
];

const columns = [
  {
    type: ColumnType.Component,
    headerName: 'Avatar',
    width: () => 65,
    phoneOrder: 1,
    minHeight: 60,
    element: ({ avatar }) => (
      /*<Box style={{ display: 'flex', alignItems: 'center' }}>
        <Avatar
          src={avatar}
          alt={avatar}
        />
      </Box>*/
    ),
  },
  {
    type: ColumnType.Compute,
    primary: true,
    field: 'name',
    headerName: 'Name',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 135),
    compute: ({ firstName, lastName }) => `${firstName} ${lastName}`,  
  },
  {
    type: ColumnType.Text,
    field: 'occupation',
    headerName: 'Occupation',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 115),
  },
  {
    type: ColumnType.Component,
    secondary: true,
    field: 'KPI',
    headerName: 'KPI index',
    width: (fullWidth) => Math.max(fullWidth * 0.15, 200),
    minHeight: 30,
    element: ({ KPI, id }) => (
      /*<div 
        style={{
          display: 'flex',
          justifyContent: 'flex-start',
          alignItems: 'center',
          cursor: 'pointer',
        }}
        onClick={(e) => {
          e.preventDefault();
          e.stopPropagation();
          ioc.routerService.push(`/indicators/${id}`)
        }}
      > 
        <span style={{
        color: KPI < 50 ? '#FA5F5A' : KPI < 70 ? '#FE9B31' : '#7FB537',
        display: 'flex',
        alignItems: 'center'
      }}>
        <span style={{ fontWeight: '900', marginRight: '1em' }}>
          {`${KPI}%`}
        </span>
          ({KPI < 50 ? 'Review needed' : KPI < 70 ? 'Warning' : 'High'})
        </span>
      </div>*/
    )
  },
  {
    type: ColumnType.Text,
    field: 'gender',
    headerName: 'Gender',
    width: (fullWidth) => Math.max(fullWidth * 0.08, 65),
  },
  {
    type: ColumnType.Text,
    field: 'age',
    headerName: 'Age',
    width: () => 50,
  },
  {
    type: ColumnType.Text,
    field: 'phone',
    headerName: 'Phone number',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 150),
  },
  {
    type: ColumnType.Text,
    field: 'email',
    headerName: 'Email',
    width: (fullWidth) => Math.max(fullWidth * 0.15, 215),
  },
  {
    type: ColumnType.Component,
    field: 'countryFlag',
    headerName: 'Country',
    width: (fullWidth) => Math.max(fullWidth * 0.12, 150),
    element: CountryFlag,
  },
  {
    type: ColumnType.Action,
    headerName: 'Actions',
    sortable: false,
    width: (fullWidth) => Math.max(fullWidth * 0.05, 50),
  },
];

const actions = [
  {
    type: ActionType.Menu,
    options: [
      {
        action: 'resort-action',
      },
    ]
  },
];

const chips = [
  {
    label: 'High KPI',
    name: 'high_kpi',
    color: '#7FB537',
  },
  {
    label: 'Warning KPI',
    name: 'warning_kpi',
    color: '#FE9B31',
  },
  {
    label: 'Review KPI',
    name: 'review_kpi',
    color: '#FA5F5A',
  }
];

const rowActions = [
  {
    label: 'Show perfomace indicators',
    action: 'indicators-action',
  },
];

const heightRequest = () => window.innerHeight - 70;
const widthRequest = () => window.innerWidth - 20;

export const ProfilesPage = () => {

  const apiRef = useRef<IListApi>(null);

  const pickerHandler = async ({
    firstName,
    lastName,
  }, {
    limit,
    offset,
  }) => {

    let rows = await Promise.resolve(mock) as IRowData[];

    if (firstName) {
      rows = rows.filter((row) => row.firstName.includes(firstName));
    }

    if (lastName) {
      rows = rows.filter((row) => row.lastName.includes(lastName));
    }

    const { length: total } = rows;

    rows = rows.slice(offset, limit + offset);

    return {
      rows,
      total,
    };

  };

  const [selectedRows, setSelectedRows] = useState<RowId[]>([]);

  const handleRowAction = (person: IPerson) => {
    ioc.routerService.push(`/indicators/${person.id}`)
  }

  const handleAction = (name: string) => {
    if (name === 'create'){
      ioc.routerService.push(`/profiles-list/create`);
    }
  }

  const handleClick = (person: IPerson) => {
    ioc.routerService.push(`/profiles-list/${person.id}`);
  };

  const handleSelectedRows = (rows: RowId[]) => {
    setSelectedRows(rows)
    console.log(rows)
  };

  return (
    <ListTyped<IFilterData, IPerson>
      ref={apiRef}
      title="Profiles"
      filterLabel="Filters"
      selectionMode={SelectionMode.Multiple}
      heightRequest={heightRequest}
      widthRequest={widthRequest}
      rowActions={rowActions}
      actions={actions}
      filters={filters}
      columns={columns}
      handler={handler}
      onSelectedRows={handleSelectedRows}
      onRowAction={handleRowAction}
      onRowClick={handleClick}
      onAction={handleAction}
      sortModel={sortModel}
      chips={chips}
    />
  );
};
Форма элемента списка
Форма элемента списка

const fields = [
  {
    type: FieldType.Group,
    fieldBottomMargin: "0",
    fields: [
      {
        type: FieldType.Group,
        columns: "2",
        phoneColumns: '12',
        tabletColumns: '2',
        style: {
          overflow: 'hidden',
        },
        fields: [
          {
            type: FieldType.Component,
            element: ({
              avatar
            }) => (
              /*<AutoSizer target={document.body} selector={`.${MAIN_CONTENT}`}>
                {({ height }) => (
                  <img
                    style={{
                      background: '#0003',
                      height: height,
                      width: 'calc(100% - 10px)',
                      objectFit: 'contain',
                    }}
                    src={avatar}
                    loading="lazy"
                  />
                )}
              </AutoSizer>*/
            )
          },
          {
            type: FieldType.Rating,
            fieldBottomMargin: "0",
            name: "rating",
            defaultValue: 3
          }
        ]
      },
      {
        type: FieldType.Group,
        fieldBottomMargin: "0",
        columns: "10",
        phoneColumns: '12',
        tabletColumns: '10',
        fields: [
          {
            type: FieldType.Group,
            className: MAIN_CONTENT,
            fields: [
              {
                type: FieldType.Div,
                style: {
                  display: 'grid',
                  gridTemplateColumns: 'auto 1fr 1fr',
                },
                fields: [
                  {
                    type: FieldType.Checkbox,
                    fieldBottomMargin: "0",
                    title: "Enabled",
                  },
                  {
                    type: FieldType.Text,
                    outlined: false,
                    title: "Identificator",
                    name: "id",
                  },
                  {
                    type: FieldType.Group,
                    fields: [
                      {
                        name: "id",
                        type: FieldType.Text,
                        outlined: false,
                        title: "Outer ID",
                      },
                    ]
                  },

                ],
              },
              {
                name: 'firstName',
                type: FieldType.Text,
                title: 'First name',
                description: 'Required',
              },
              {
                name: 'lastName',
                type: FieldType.Text,
                title: 'Last name',
                description: 'Required',
              },
              {
                name: 'age',
                type: FieldType.Text,
                title: 'Age',
              },
              {
                name: 'occupation',
                type: FieldType.Text,
                title: 'Occupation',
                description: 'Required',
              },
              {
                name: 'KPI',
                type: FieldType.Text,
                title: 'KPI index',
              },
              {
                type: FieldType.Combo,
                title: "Gender",
                placeholder: "Choose",
                name: "gender",
                itemList: [
                  "Male",
                  "Female",
                  "Other"
                ]
              },
            ]
          },
          {
            type: FieldType.Group,
            fieldBottomMargin: "0",
            columns: "12",
            fields: [
              {
                type: FieldType.Line,
                title: "Contact Data"
              },
              {
                name: 'email',
                type: FieldType.Text,
                title: 'E-mail',
              },
              {
                name: 'country',
                type: FieldType.Text,
                title: 'Country',
              },
              {
                name: 'phone',
                type: FieldType.Text,
                title: 'Phone number',
              },
            ]
          },
        ]
      }
    ]
  }
]

export const OneProfilePage = ({
  id,
}) => {

  const [data, setData] = useState(null);

  const handleChange = (data: IPerson, initial: boolean) => {
    if (!initial) {
      setData(data);
    }
  };

  const handleSave = async () => {
    ...
  };

  const handleBack = () => {
    ioc.routerService.push(`/profiles-list`);
  };

  const handler = () => fetch(`/api/v1/profiles/${id}`);

  return (
    <>
      <Breadcrumbs
        title="Profiles"
        disabled={!data}
        subtitle={id}
        onSave={handleSave}
        onBack={handleBack}
      />
      <One
        fields={fields}
        handler={handler}
        onChange={handleChange}
      />
    </>
  );
};

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


  1. tripolskypetr Автор
    05.06.2022 23:23
    +2

    Дело в том, что подобный конфиг отлично валидируется через TypeScript. А далее, всю бизнес-логику можно вынести в Mobx, реализовав композицию классов через инъекцию зависимостей)

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

    Если нужно, я могу рассказать, как правильно расписать современный фронт c base сервисами вида ApiService, AuthService, RouterService и сервисами страниц, взаимодействующими с ними через DI. Так можно применить ООП наиболее эффективно)


  1. AmdY
    05.06.2022 23:11
    +2

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

    Сколько лет ещё надо чтобы вернуться к идеям слоёной архитектуры, начать пользоваться классами, трай кэтчами, await и прочими зарекомендовавшими себя вещами во взрослом программировании? Были же нормальные попытки вроде backbone и ember, а затем пришёл фейсбуковский говнокод с React.


    1. UdarEC
      05.06.2022 23:22

      Во Flutter тоже redux подход достаточно популярен. Наверно, все-таки, нельзя найти идеальную архитектуру и ожидать ее везде встретить.


      1. AmdY
        06.06.2022 18:27

        Вопрос не к редаксу, а к остальному коду. Зачем эти конфиги, если можно делать иерархию объектов, получая валидацию и поддержку IDE, улучшая читабельность, поддержку и даже кода писать пришлось бы меньше.

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

        React напоминает php времён перехода с 3 на 4 версию. При этом ребята на англуре пишут неплохой, читабельный и надёжный код. А как попадается проект с реактом, жди провали спринтов из-за фронта потому что вот такой код пишут и считают его чистым.


        1. nin-jin
          06.06.2022 21:10

          Лучше взять $mol_story и иметь сколько угодно хистори по скольки угодно мутабельным объектам, а не валить всё в одну корзину.


        1. tripolskypetr Автор
          07.06.2022 11:56

          По факту, отличие от конфига в таком коде будет разве что в повсеместном применение оператора new)

          А так, шаблонизатор предоставляет интерфейс IField для конфига, который и предоставляет поддержку автодополнения в IDE и статическую проверку типов


    1. tripolskypetr Автор
      05.06.2022 23:23
      +2

      Дело в том, что подобный конфиг отлично валидируется через TypeScript. А далее, всю бизнес-логику можно вынести в Mobx, реализовав композицию классов через инъекцию зависимостей)

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

      Если нужно, я могу рассказать, как правильно расписать современный фронт c base сервисами вида ApiService, AuthService, RouterService и сервисами страниц, взаимодействующими с ними через DI. Так можно применить ООП наиболее эффективно)


      1. nin-jin
        06.06.2022 02:14
        -2

        Я это всё уже расписал, не утруждайтесь.


        1. tripolskypetr Автор
          06.06.2022 02:47
          +4

          $$$$


          1. nin-jin
            06.06.2022 10:38
            +1

            Переводите их лучше сюда, а то с Хабра я не смогу их вывести.


    1. Alexandroppolus
      06.06.2022 09:16
      +2

      затем пришёл фейсбуковский говнокод с React.

      Проблема не в Реакте. Реакт, если его использовать чисто как представление (чем он по сути и является - по Дэну, "UI как значение", то есть функция от стейта), идеален. А вот когда начинают пилить логику на хуках, редуксах и сагах - то да, здравствуй лапшекод.


      1. AmdY
        06.06.2022 18:36

        Согласен, проблема была не в Реакт. Но сейчас именно они завезли внутрь функций управление стейтом, хуки и эффекты. Из-за чего слои абстракций перемешались. Изначально же этого не было и завезли сейчас в угоду быстрому врайт онли гавнокоду.


  1. AmdY
    06.06.2022 01:55

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

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

    const filters = new Filters(
    	new FilterText('name', 'Name label'), 
      new FilterSelect('type', 'Type label').options('one', 'two')
    );
    filters.add(....);


  1. devlato
    06.06.2022 06:18
    +2

    Redux – написанная на коленке поделка, которая пригодна только для крошечных приложений, а потом приводит к проблемам с поддержкой кода; если честно, хз почему его всё ещё используют в 2022


    1. xadd
      06.06.2022 08:24

      Если хочется строить фронт по CQRS, то юзаем Redux (а больше и нечего), нравится OOP или MVVM, то MobX.


      1. Alexandroppolus
        06.06.2022 09:00

        CQRS превосходно сочетается с ООП/МобХ (те же action и computed). Тут скорее вопрос организации кода в целом.


  1. AccountForHabr
    06.06.2022 07:56
    +1

    А зачем у вас в коде и await и then? Может стоит что то одно оставить а потом уже о чистых функциях думать?


  1. sanchezzzhak
    07.06.2022 09:36

    Тут псевдокод getRepos содержит ошибки.

    Try Catch бесполезен без await

    В fetch нету обработки ошибок через. Catch( () => {})

    В finally нету остановки кода.

    А то что позже вообще молчу срабатывает ещё до того как запрос ответит.

    Пожалуй эта статья только по первому примеру пусть и псевдо кода, вызвало отторжение в принятии полезной информации.


    1. tripolskypetr Автор
      07.06.2022 10:26

      Мужик, а ты читать текст до кода не пробовал?

      Как видно, в результате исполнения getRepos, из-за закомментированного ключевого слова await, в целевой reducer состояния улетит пустой массив repos.