Как стать автором
Обновить

Архитектура SPA-приложения биржи в 2019 году

Время на прочтение42 мин
Количество просмотров17K

Приветствую, хабровчане!


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



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


Задание от бизнеса


Разработать SPA-приложение для торгового интерфейса, в котором можно:


  • увидеть список торговых пар, сгруппированных по торгуемой валюте;
  • при нажатии на торговую пару увидеть информацию по текущей цене, изменении за 24 часа, «стакан заявок»;
  • изменить язык приложения на английский / русский;
  • изменить тему на темную / светлую.

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


Так как в ТЗ от заказчика нет технических требований, пусть будут комфортные для разработки:


  • кроссбраузерность: 2 последние версии популярных браузеров (без IE);
  • ширина экрана: >= 1240px;
  • дизайн: по аналогии с другими биржами, т.к. дизайнера еще не наняли.

Теперь время определить используемые инструменты и библиотеки. Я буду руководствоваться принципами разработки «под ключ» и KISS, то есть брать только те opensource библиотеки, для самостоятельной реализации которых потребовалось бы неадекватно много времени, включая время на обучение будущих коллег-разработчиков.


  • система управления версиями: Git + Github;
  • backend: API CoinGecko;
  • сборка / траниспиляция: Webpack + Babel;
  • установщик пакетов: Yarn (npm 6 некорректно обновлял зависимости);
  • контроль качества кода: ESLint + Prettier + Stylelint;
  • view: React (посмотрим, насколько удобны Hooks);
  • store: MobX;
  • автотесты: Cypress.io (комплексное решение на javascript вместо модульной сборки вроде Mocha/Karma+Chai+Sinon+Selenium+Webdriver/Protractor);
  • стили: SCSS через PostCSS (гибкость настройки, дружит с Stylelint);
  • графики: HighStock (настраивать намного проще, чем TradingView, но для реального приложения взял бы последний);
  • регистрация ошибок: Sentry;
  • утилиты: Lodash (экономия времени);
  • роутинг: под ключ;
  • локализация: под ключ;
  • работа с запросами: под ключ;
  • метрики быстродействия: под ключ;
  • типизация: не в мою смену.

Таким образом, из библиотек в итоговом файле приложения окажутся только React, MobX, HighStock, Lodash и Sentry. Считаю это оправданным, так как они имеют отличную документацию, быстродействие и знакомы многим разработчикам.


Контроль качества кода


Я предпочитаю разбивать зависимости в package.json на смысловые части, поэтому первым шагом после инициации git-репозитория сгруппирую все, что касается стиля кода в папке ./eslint-custom, указав в package.json:


{
  "scripts": {
    "upd": "yarn install --no-lockfile"
  },
  "dependencies": {
    "eslint-custom": "file:./eslint-custom"
  }
}

Обычный yarn install не проверит, изменились ли зависимости внутри eslint-custom, поэтому буду использовать yarn upd. В целом такая практика выглядит более универсальной, так как девопсам не придется менять рецепт деплоя, если разработчикам понадобится изменить метод установки пакетов.


Файлом yarn.lock пользоваться нет смысла, так как все зависимости будут без «крышечек» semver (в виде "react": "16.8.6"). Опыт показал, что лучше вручную обновлять версии и тщательно их тестировать в рамках отдельных задач, чем полагаться на lock-файл, предоставляя авторам пакетов возможность сломать приложение минорным обновлением в любой момент (счастливчики, кто с этим не сталкивался).


В пакете eslint-custom зависимости будут следующие:


eslint-custom/package.json
{
  "name": "eslint-custom",
  "version": "1.0.0",
  "description": "Custom linter rules for this project",
  "license": "MIT",
  "dependencies": {
    "babel-eslint": "10.0.1",
    "eslint": "5.16.0",
    "eslint-config-prettier": "4.1.0",
    "eslint-plugin-import": "2.17.2",
    "eslint-plugin-prettier": "3.0.1",
    "eslint-plugin-react": "7.12.4",
    "eslint-plugin-react-hooks": "1.6.0",
    "prettier": "1.17.0",
    "prettier-eslint": "8.8.2",
    "stylelint": "10.0.1",
    "stylelint-config-prettier": "5.1.0",
    "stylelint-prettier": "1.0.6",
    "stylelint-scss": "3.6.0"
  }
}

Чтобы связать три инструмента, понадобилось 5 вспомогательных пакетов (eslint-plugin-prettier, eslint-config-prettier, stylelint-prettier, stylelint-config-prettier, prettier-eslint) — такую цену приходится платить сегодня. Для максимального удобства не хватает только автоматической сортировки imports, но, к сожалению, этот плагин при переформатировании файла теряет строки.


Конфигурационные файлы для всех инструментов будут в формате *.js (eslint.config.js, stylelint.config.js), чтобы на них самих работало форматирование кода. Правила пусть будут в формате *.yaml, разбитые по смысловым модулям. Полные версии конфигураций и правил — в репозитории.


Осталось дописать команды в основной package.json...


{
  "scripts": {
    "upd": "yarn install --no-lockfile",
    "format:js": "eslint --ignore-path .gitignore --ext .js -c ./eslint-custom/eslint.config.js --fix",
    "format:style": "stylelint --ignore-path .gitignore --config ./eslint-custom/stylelint.config.js  --fix"
  }
}

… и настроить свой IDE на применение форматирования при сохранении текущего файла. Для гарантии при создании коммита необходимо использовать git-хук, который будет проверять и форматировать все файлы проекта. Почему не только те, которые присутствуют в коммите? Для принципа коллективной ответственности за всю кодовую базу, чтобы ни у кого не было соблазна обойти валидацию. Для этого же при создании коммита все предупреждения линтера будут считаться ошибками с помощью --max-warnings=0.


{
  "husky": {
    "hooks": {
      "pre-commit": "npm run format:js -- --max-warnings=0 ./ && npm run format:style ./**/*.scss"
    }
  }
}

Сборка / траниспиляция


Снова воспользуюсь модульным подходом и вынесу все настройки Webpack и Babel в папку ./webpack-custom. Конфиг будет опираться на следующую структуру файлов:


.
|-- webpack-custom
|   |-- config
|   |-- loaders
|   |-- plugins
|   |-- rules
|   |-- utils
|   `-- package.json
|   `-- webpack.config.js

Грамотно настроенный сборщик предоставит:


  • возможность писать код, используя синтаксис и возможности последней EcmaScript спецификации, включая удобные proposals (здесь точно пригодятся декораторы классов и их свойств для MobX);
  • локальный сервер с Hot Reloading;
  • метрики производительности сборки;
  • проверку на цикличные зависимости;
  • анализ структуры и размера итогового файла;
  • оптимизацию и минификацию для production сборки;
  • интерпретацию модульных *.scss файлов и возможность вынесения готовых *.css файлов из бандла;
  • inline-вставку *.svg файлов;
  • полифиллы / стилевые префиксы для целевых браузеров;
  • решение проблемы с кэшированием файлов на production.

А также будет удобно конфигурироваться. Эту задачу решу с помощью двух *.env файлов-примеров:


.frontend.env.example
AGGREGATION_TIMEOUT=0
BUNDLE_ANALYZER=false
BUNDLE_ANALYZER_PORT=8889
CIRCULAR_CHECK=true
CSS_EXTRACT=false
DEV_SERVER_PORT=8080
HOT_RELOAD=true
NODE_ENV=development
SENTRY_URL=false
SPEED_ANALYZER=false
PUBLIC_URL=false

# https://webpack.js.org/configuration/devtool
DEV_TOOL=cheap-module-source-map

.frontend.env.prod.example
AGGREGATION_TIMEOUT=0
BUNDLE_ANALYZER=false
BUNDLE_ANALYZER_PORT=8889
CIRCULAR_CHECK=false
CSS_EXTRACT=true
DEV_SERVER_PORT=8080
HOT_RELOAD=false
NODE_ENV=production
SENTRY_URL=false
SPEED_ANALYZER=false
PUBLIC_URL=/habr_exchange/dist

# https://webpack.js.org/configuration/devtool
DEV_TOOL=false

Таким образом, для запуска сборки нужно создать файл с названием .frontend.env и обязательным присутствием всех параметров. Данный подход решит сразу несколько проблем: не нужно делать раздельные конфигурационные файлы для Webpack и поддерживать их согласованность; локально можно настроить насколько это нужно определенному разработчику; девопсы при деплое будут лишь копировать файл для production-сборки (cp .frontend.env.prod.example .frontend.env), обогащая значениями из хранилища, соответственно frontend-разработчики имеют возможность управлять рецептом через переменные без задействования админов. Дополнительно можно будет сделать пример конфигурации для стендов (например, с source maps).


Для отделения стилей в файлы при включенном CSS_EXTRACT буду использовать mini-css-extract-plugin — он позволяет использовать Hot Reloading. То есть, если при локальной разработке включить HOT_RELOAD и CSS_EXTRACT, то при
изменении файлов стилей будут перезагружаться только они — но, к сожалению, все, а не только измененный файл. С выключенным же CSS_EXTRACT обновляться будет только измененный стилевой модуль.


HMR для работы с React Hooks включается достаточно стандартно:


  • webpack.HotModuleReplacementPlugin в plugins;
  • hot: true в параметрах webpack-dev-server;
  • react-hot-loader/babel в babel-loader plugins;
  • options.hmr: true в mini-css-extract-plugin;
  • export default hot(App) в главном компоненте приложения;
  • @hot-loader/react-dom вместо обычного react-dom (удобно через resolve.alias: { 'react-dom': '@hot-loader/react-dom' });

Текущая версия react-hot-loader не поддерживает мемоизацию компонентов с помощью React.memo, так что при написании декораторов для MobX надо будет учесть это для удобства локальной разработки. Еще одно вызванное этим неудобство — при включенной настройке Highlight Updates в React Developer Tools при любом взаимодействии с приложением обновляются все компоненты. Поэтому при локальной работе над оптимизацией производительности следует отключать настройку HOT_RELOAD.


Оптимизация сборки в Webpack 4 выполняется автоматически при указании mode: 'development' | 'production'. В данном случае положусь на стандартную оптимизацию (+ включение параметра keep_fnames: true в terser-webpack-plugin для сохранения названия компонентов), так как она уже качественно настроена.


Отдельного внимания заслуживает разбиение на чанки и контроль клиентского кэширования. Для корректной работы нужно:


  • в output.filename для js и css файлов указать isProduction ? '[name].[contenthash].js' : '[name].js' (с расширением .css соответственно), чтобы название файла опиралось на его содержание;
  • в optimization изменить параметры на chunkIds: 'named', moduleIds: 'hashed', чтобы внутренний счетчик модулей в webpack не менялся;
  • вынести runtime в отдельный чанк;
  • вынести группы кэширования в splitChunks (для данного приложения достаточно четырех точек — lodash, sentry, highcharts и vendor для остальных зависимостей из node_modules). Так как первые три будут обновляться редко, то они останутся в кэше браузера клиента максимально долго.

webpack-custom/config/configOptimization.js
/**
 * @docs: https://webpack.js.org/configuration/optimization
 *
 */

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  runtimeChunk: {
    name: 'runtime',
  },
  chunkIds: 'named',
  moduleIds: 'hashed',
  mergeDuplicateChunks: true,
  splitChunks: {
    cacheGroups: {
      lodash: {
        test: module => module.context.indexOf('node_modules\\lodash') !== -1,
        name: 'lodash',
        chunks: 'all',
        enforce: true,
      },
      sentry: {
        test: module => module.context.indexOf('node_modules\\@sentry') !== -1,
        name: 'sentry',
        chunks: 'all',
        enforce: true,
      },
      highcharts: {
        test: module =>
          module.context.indexOf('node_modules\\highcharts') !== -1,
        name: 'highcharts',
        chunks: 'all',
        enforce: true,
      },
      vendor: {
        test: module => module.context.indexOf('node_modules') !== -1,
        priority: -1,
        name: 'vendor',
        chunks: 'all',
        enforce: true,
      },
    },
  },
  minimizer: [
    new TerserPlugin({
      terserOptions: {
        keep_fnames: true,
      },
    }),
  ],
};

Для ускорения сборки в этом проекте использую thread-loader — при параллелизации на 4 процесса он дал ускорение сборки на 90%, что лучше, чем у happypack при аналогичных настройках.


Настройки для лоадеров, в том числе для babel, в отдельные файлы (вроде .babelrc) выносить, полагаю, излишне. А вот конфигурацию кроссбраузерности удобнее держать в параметре browserslist основного package.json, так как он используется также для autoprefixer'а стилей.


Для удобства работы с Prettier сделал параметр AGGREGATION_TIMEOUT, который позволяет установить задержку между обнаружением изменений в файлах и пересборкой приложения в режиме dev-server. Так как я настроил переформатирование файлов при сохранении в IDE, то это вызывает 2 пересборки — первую на сохранение исходного файла, вторую на завершение форматирования. 2000 миллисекунд обычно достаточно, чтобы webpack дождался финальной версии файла.


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


Стилевые темы


Раньше для создания тем приходилось делать несколько версий *.css файлов и перезагружать страницу при смене темы, загружая нужный набор стилей. Сейчас все легко решается с помощью Custom CSS Properties. Данную технологию поддерживают все целевые браузеры текущего приложения, но есть и полифиллы для IE.


Допустим, будет 2 темы — light и dark, наборы цветов для которых будут находиться в


styles/themes.scss
.light {
  --n0: rgb(255, 255, 255);
  --n100: rgb(186, 186, 186);
  --n10: rgb(249, 249, 249);
  --n10a3: rgba(249, 249, 249, 0.3);
  --n20: rgb(245, 245, 245);
  --n30: rgb(221, 221, 221);
  --n500: rgb(136, 136, 136);
  --n600: rgb(102, 102, 102);
  --n900: rgb(0, 0, 0);

  --b100: rgb(219, 237, 251);
  --b300: rgb(179, 214, 252);
  --b500: rgb(14, 123, 249);
  --b500a3: rgba(14, 123, 249, 0.3);
  --b900: rgb(32, 39, 57);

  --g400: rgb(71, 215, 141);
  --g500: rgb(61, 189, 125);
  --g500a1: rgba(61, 189, 125, 0.1);
  --g500a2: rgba(61, 189, 125, 0.2);

  --r400: rgb(255, 100, 100);
  --r500: rgb(255, 0, 0);
  --r500a1: rgba(255, 0, 0, 0.1);
  --r500a2: rgba(255, 0, 0, 0.2);
}

.dark {
  --n0: rgb(25, 32, 48);
  --n100: rgb(114, 126, 151);
  --n10: rgb(39, 46, 62);
  --n10a3: rgba(39, 46, 62, 0.3);
  --n20: rgb(25, 44, 74);
  --n30: rgb(67, 75, 111);
  --n500: rgb(117, 128, 154);
  --n600: rgb(255, 255, 255);
  --n900: rgb(255, 255, 255);

  --b100: rgb(219, 237, 251);
  --b300: rgb(39, 46, 62);
  --b500: rgb(14, 123, 249);
  --b500a3: rgba(14, 123, 249, 0.3);
  --b900: rgb(32, 39, 57);

  --g400: rgb(0, 220, 103);
  --g500: rgb(0, 197, 96);
  --g500a1: rgba(0, 197, 96, 0.1);
  --g500a2: rgba(0, 197, 96, 0.2);

  --r400: rgb(248, 23, 1);
  --r500: rgb(221, 23, 1);
  --r500a1: rgba(221, 23, 1, 0.1);
  --r500a2: rgba(221, 23, 1, 0.2);
}

Для того, чтобы эти переменные применялись глобально, их нужно записать в document.documentElement, соответственно нужен небольшой парсер, чтобы преобразовать этот файл в javascript объект. Позже расскажу, почему так удобнее, чем сразу хранить в javascript.


webpack-custom/utils/sassVariablesLoader.js
function convertSourceToJsObject(source) {
  const themesObject = {};
  const fullThemesArray = source.match(/\.([^}]|\s)*}/g) || [];

  fullThemesArray.forEach(fullThemeStr => {
    const theme = fullThemeStr
      .match(/\.\w+\s{/g)[0]
      .replace(/\W/g, '');
    themesObject[theme] = {};

    const variablesMatches =
      fullThemeStr.match(/--(.*:[^;]*)/g) || [];

    variablesMatches.forEach(varMatch => {
      const [key, value] = varMatch.split(': ');
      themesObject[theme][key] = value;
    });
  });

  return themesObject;
}

function checkThemesEquality(themes) {
  const themesArray = Object.keys(themes);

  themesArray.forEach(themeStr => {
    const themeObject = themes[themeStr];
    const otherThemesArray = themesArray.filter(t => t !== themeStr);

    Object.keys(themeObject).forEach(variableName => {
      otherThemesArray.forEach(otherThemeStr => {
        const otherThemeObject = themes[otherThemeStr];

        if (!otherThemeObject[variableName]) {
          throw new Error(
            `checkThemesEquality: theme ${otherThemeStr} has no variable ${variableName}`
          );
        }
      });
    });
  });
}

module.exports = function sassVariablesLoader(source) {
  const themes = convertSourceToJsObject(source);

  checkThemesEquality(themes);

  return `module.exports = ${JSON.stringify(themes)}`;
};

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


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


src/utils/setTheme.js
import themes from 'styles/themes.scss';

const root = document.documentElement;

export function setTheme(theme) {
  Object.entries(themes[theme]).forEach(([key, value]) => {
    root.style.setProperty(key, value);
  });
}

Предпочитаю перевести эти css-переменные в стандартные для *.scss:


src/styles/constants.scss


IDE WebStorm, как видно на скриншоте, показывает цвета на панели слева и по клику на цвет открывает палитру, где можно его сменить. Новый цвет автоматически подставится в themes.scss, сработает Hot Reload и приложение моментально преобразится. Это именно тот уровень удобства разработки, который и ожидается в 2019 году.


Принципы организации кода


В данном проекте буду придерживаться дублирования названий папок компонентов, файлов и стилей, например:

.
|-- components
|   |-- Chart
|   |   `-- Chart.js
|   |   `-- Chart.scss
|   |   `-- package.json

Соответственно, package.json будет иметь содержание { "main": "Chart.js" }. Для компонентов с множественными именованными экспортами (например, утилит) название главного файла будет начинаться с подчеркивания:


.
|-- utils
|   `-- _utils.js
|   `-- someUtil.js
|   `-- anotherUtil.js
|   `-- package.json

А остальные файлы будут экспортироваться в виде:


export * from './someUtil';
export * from './anotherUtil';

Это позволит избавиться от дублирования названий файлов, чтобы не теряться в десятке открытых index.js / style.scss. Можно решить это и плагинами к IDE, но почему бы и не универсальным способом.


Компоненты буду группировать постранично, кроме общих вроде Message / Link, а также по возможности использовать именованные экспорты (без export default) для поддержания однообразия названий, простоты рефакторинга и поиска по проекту.


Настройка рендеринга и хранилища MobX


Файл, который служит entry point для Webpack, будет выглядеть следующим образом:


src/app.js
import './polyfill';
import './styles/reset.scss';
import './styles/global.scss';

import { initSentry, renderToDOM } from 'utils';
import { initAutorun } from './autorun';
import { store } from 'stores';

import App from 'components/App';

initSentry();
initAutorun(store);
renderToDOM(App);

Так как при работе с observables в консоли выводится что-то вроде Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration}, в полифиллах сделаю утилиту для приведения в стандартный вид:


src/polyfill.js
import { toJS } from 'mobx';

console.js = function consoleJsCustom(...args) {
  console.log(...args.map(arg => toJS(arg)));
};

Также в основном файле подключаются глобальные стили и нормализация стилей для разных браузеров, при наличии ключа для Sentry в .env.frontend начинают логироваться ошибки, создается MobX хранилище, инициируется слежение за изменениями параметров с помощью autorun и обернутый в react-hot-loader компонент монтируется в DOM.


Само хранилище будет представлять из себя не-observable класс, параметрами которого будут не-observable классы с observable параметрами. Таким образом подразумевается, что набор параметров не будет динамическим — следовательно, приложение будет более предсказуемым. Это одно из немногих мест, где пригодится JSDoc, чтобы включить автодополнение в IDE.


src/stores/RootStore.js
import { I18nStore } from './I18nStore';
import { RatesStore } from './RatesStore';
import { GlobalStore } from './GlobalStore';
import { RouterStore } from './RouterStore';
import { CurrentTPStore } from './CurrentTPStore';
import { MarketsListStore } from './MarketsListStore';

/**
 * @name RootStore
 */
export class RootStore {
  constructor() {
    this.i18n = new I18nStore(this);
    this.rates = new RatesStore(this);
    this.global = new GlobalStore(this);
    this.router = new RouterStore(this);
    this.currentTP = new CurrentTPStore(this);
    this.marketsList = new MarketsListStore(this);
  }
}

Пример MobX стора можно разобрать на примере GlobalStore, у которого будет на данный момент единственное назначение — хранить и устанавливать текущую стилевую тему.


src/stores/GlobalStore.js
import { makeObservable, setTheme } from 'utils';
import themes from 'styles/themes.scss';

const themesList = Object.keys(themes);

@makeObservable
export class GlobalStore {
  /**
   * @param rootStore {RootStore}
   */
  constructor(rootStore) {
    this.rootStore = rootStore;

    setTheme(themesList[0]);
  }

  themesList = themesList;
  currentTheme = '';

  setTheme(theme) {
    this.currentTheme = theme;
    setTheme(theme);
  }
}

Иногда параметрам и методом класса вручную с помощью декораторов устанавливают тип, например:


export class GlobalStore {
  @observable
  currentTheme = '';

  @action.bound
  setTheme(theme) {
    this.currentTheme = theme;
    setTheme(theme);
  }
}

Но смысла в этом не вижу, так как старый Proposal декораторов класса поддерживает их автоматическую трансформацию, поэтому достаточно следующей утилиты:


src/utils/makeObservable.js
import { action, computed, decorate, observable } from 'mobx';

export function makeObservable(target) {
  /**
   * Для методов - биндим контекст this + все изменения сторов
   * выполняем в одной транзакции
   *
   * Для геттеров - оборачиваем в computed
   *
   */

  const classPrototype = target.prototype;
  const methodsAndGetters = Object.getOwnPropertyNames(classPrototype).filter(
    methodName => methodName !== 'constructor'
  );

  for (const methodName of methodsAndGetters) {
    const descriptor = Object.getOwnPropertyDescriptor(
      classPrototype,
      methodName
    );

    descriptor.value = decorate(classPrototype, {
      [methodName]:
        typeof descriptor.value === 'function' ? action.bound : computed,
    });
  }

  return (...constructorArguments) => {
    /**
     * Параметры, за исключением rootStore, трансформируем в
     * observable
     *
     */

    const store = new target(...constructorArguments);
    const staticProperties = Object.keys(store);

    staticProperties.forEach(propName => {
      if (propName === 'rootStore') {
        return false;
      }

      const descriptor = Object.getOwnPropertyDescriptor(store, propName);

      Object.defineProperty(
        store,
        propName,
        observable(store, propName, descriptor)
      );
    });

    return store;
  };
}

Для использования необходимо откорректировать плагины в loaderBabel.js: ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }], а в настройках ESLint соответственно выставить parserOptions.ecmaFeatures.legacyDecorators: true. Без этих настроек в target декоратора передается только дескриптор класса без прототипа, и, несмотря на тщательное исследование текущей версии Proposal, я не нашел способа обернуть методы и статические свойства.


В целом настройка хранилища закончена, но хорошо бы еще раскрыть потенциал MobX autorun. Для этого как нельзя лучше подойдут задачи типа «дождаться ответа от сервера авторизации» или «загрузить переводы с сервера», после чего записать ответы в стор и непосредственно отрендерить приложение в DOM. Поэтому забегу немного в будущее и создам стор с локализацией:


src/stores/I18nStore.js
import { makeObservable } from 'utils';
import ru from 'localization/ru.json';
import en from 'localization/en.json';

const languages = {
  ru,
  en,
};

const languagesList = Object.keys(languages);

@makeObservable
export class I18nStore {
  /**
   * @param rootStore {RootStore}
   */
  constructor(rootStore) {
    this.rootStore = rootStore;

    setTimeout(() => {
      this.setLocalization('ru');
    }, 500);
  }

  i18n = {};
  languagesList = languagesList;
  currentLanguage = '';

  setLocalization(language) {
    this.currentLanguage = language;
    this.i18n = languages[language];
    this.rootStore.global.shouldAppRender = true;
  }
}

Как видно, есть некие файлы *.json с переводами, а в конструкторе класса эмулируется асинхронная загрузка с помощью setTimeout. При его выполнении в недавно созданном GlobalStore проставляется маркер this.rootStore.global.shouldAppRender = true.


Таким образом, из app.js нужно перенести функцию рендеринга в файл autorun.js:


src/autorun.js
/* eslint-disable no-unused-vars */

import { autorun } from 'mobx';

import { renderToDOM } from 'utils';
import App from 'components/App';

const loggingEnabled = true;

function logReason(autorunName, reaction) {
  if (!loggingEnabled || reaction.observing.length === 0) {
    return false;
  }

  const logString = reaction.observing.reduce(
    (str, { name, value }) => `${str}${name} changed to ${value}; `,
    ''
  );

  console.log(`autorun-${autorunName}`, logString);
}

/**
 * @param store {RootStore}
 */
export function initAutorun(store) {
  autorun(reaction => {
    if (store.global.shouldAppRender) {
      renderToDOM(App);
    }

    logReason('shouldAppRender', reaction);
  });
}

В функции initAutorun может быть сколько угодно autorun конструкций с коллбэками, которые сработают только при собственной инициации и изменении переменной внутри конкретного коллбэка. В данном случае в консоль будет выведено autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true;, и вызван рендеринг приложения в DOM. Мощный инструмент, позволяющий логировать все изменения в сторе и соответственно на них реагировать.


Локализация и React Hooks


Перевод на другие языки — одна из самых объемных задач, в небольших компаниях зачастую недооцененная в десятки раз, а в крупных — излишне переусложненная. От ее реализации зависит, сколько нервов и времени не будет потрачено впустую сразу у нескольких отделов в компании. Затрону в статье только клиентскую часть с заделом на будущую интеграцию с другими системами.


Для удобства разработки фронтенда необходимо иметь возможность:


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

Под эти условия подходит, к примеру, следующая схема: в каждом компоненте с текстами будет лежать файл messages.js с базовыми значениями для разработки (которые в идеале никогда не увидит клиент) в виде обычного объекта с параметрами. Вставка в компонент будет происходить однострочно с помощью хука. Полное название параметра будет формироваться автоматически по пути к файлу в проекте (при необходимости можно легко обфусцировать / сократить), что исключит пересечение названий. Функции преобразования текста (вставка переменных, склонений, чисел) выполняются последовательно. Должно получиться удобно.


Так как уже есть стор с локализацией, в котором лежит currentLanguage и объект i18n с потенциально присутствующими переводами, можно написать хук, который будет получать оттуда тексты.


src/components/TestLocalization.js
import React from 'react';

import { observer } from 'utils';
import { useLocalization } from 'hooks';

const messages = {
  hello: 'У вас {count} {count: сообщение,сообщения,сообщений}',
};

function TestLocalization() {
  const getLn = useLocalization(__filename, messages);

  return <div>{getLn(messages.hello, { count: 1 })}</div>;
}

export const TestLocalizationConnected = observer(TestLocalization);

Сам функциональный компонент имеет имя по названию файла, а на экспорт идет подключенный к MobX-стору автоматически обновляемый компонент с суффиксом, к примеру, Connected. Возможно, стоит внести подобное правило именования в ESLint, чтобы явно отличать подключенные к стору компоненты.


Декоратор observer представляет собой обертку над mobx-react-lite/useObserver, которая при выключенном HOT_RELOAD оптимизирует обновление компонентов с помощью React.memo (в прошлом PureMixin / PureComponent), а при включенном просто оборачивает в useObserver все содержимое компонента:


src/utils/observer.js
import { useObserver } from 'mobx-react-lite';
import React from 'react';

function copyStaticProperties(base, target) {
  const hoistBlackList = {
    $$typeof: true,
    render: true,
    compare: true,
    type: true,
  };

  Object.keys(base).forEach(key => {
    if (base.hasOwnProperty(key) && !hoistBlackList[key]) {
      Object.defineProperty(
        target,
        key,
        Object.getOwnPropertyDescriptor(base, key)
      );
    }
  });
}

export function observer(baseComponent, options) {
  const baseComponentName = baseComponent.displayName || baseComponent.name;

  function wrappedComponent(props, ref) {
    return useObserver(function applyObserver() {
      return baseComponent(props, ref);
    }, baseComponentName);
  }
  wrappedComponent.displayName = baseComponentName;

  let memoComponent = null;
  if (HOT_RELOAD === 'true') {
    memoComponent = wrappedComponent;
  } else if (options.forwardRef) {
    memoComponent = React.memo(React.forwardRef(wrappedComponent));
  } else {
    memoComponent = React.memo(wrappedComponent);
  }

  copyStaticProperties(baseComponent, memoComponent);
  memoComponent.displayName = baseComponentName;

  return memoComponent;
}

Внимания заслуживает только передача displayName на каждом этапе, чтобы в React-инспекторе были красивые названия элементов (на stack trace ошибок не влияет).


Теперь нужен хук для вставки RootStore:


src/hooks/useStore.js
import React from 'react';
import { store } from 'stores';

const storeContext = React.createContext(store);

/**
 * @returns {RootStore}
 *
 */
export function useStore() {
  return React.useContext(storeContext);
}

Который можно легко использовать в любом компоненте, обернутом в observer:


import React from 'react';

import { observer } from 'utils';
import { useStore } from 'hooks';

function TestComponent() {
  const store = useStore();

  return <div>{store.i18n.currentLanguage}</div>;
}

export const TestComponentConnected = observer(TestComponent);

Возвращаясь к созданному выше компоненту TestLocalization — осталось лишь сделать хук useLocalization:


src/hooks/useLocalization.js
import _ from 'lodash';

import { declOfNum } from 'utils';

import { useStore } from './useStore';

const showNoTextMessage = false;

function replaceDynamicParams(values, formattedMessage) {
  if (!_.isPlainObject(values)) {
    return formattedMessage;
  }

  let messageWithValues = formattedMessage;

  Object.entries(values).forEach(([paramName, value]) => {
    messageWithValues = formattedMessage.replace(`{${paramName}}`, value);
  });

  return messageWithValues;
}

function replacePlurals(values, formattedMessage) {
  if (!_.isPlainObject(values)) {
    return formattedMessage;
  }

  let messageWithPlurals = formattedMessage;

  Object.entries(values).forEach(([paramName, value]) => {
    const pluralPattern = new RegExp(`{${paramName}:\\s([^}]*)}`);
    const pluralMatch = formattedMessage.match(pluralPattern);

    if (pluralMatch && pluralMatch[1]) {
      messageWithPlurals = formattedMessage.replace(
        pluralPattern,
        declOfNum(value, pluralMatch[1].split(','))
      );
    }
  });

  return messageWithPlurals;
}

export function useLocalization(filename, messages) {
  const {
    i18n: { i18n, currentLanguage },
  } = useStore();

  return function getLn(text, values) {
    const key = _.findKey(messages, message => message === text);
    const localizedText = _.get(i18n, [filename, key]);

    if (!localizedText && showNoTextMessage) {
      console.error(
        `useLocalization: no localization for lang '${currentLanguage}' in ${filename} ${key}`
      );
    }

    let formattedMessage = localizedText || text;
    formattedMessage = replaceDynamicParams(values, formattedMessage);
    formattedMessage = replacePlurals(values, formattedMessage);

    return formattedMessage;
  };
}

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


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


useLocalization: no localization for lang 'ru' in src\components\TestLocalization\TestLocalization.js hello

Если же добавить локализацию для данного поля в ru.json:


src/localization/ru.json
{
  "src\\components\\TestLocalization\\TestLocalization.js": {
    "hello": "У вас {count} {count: сообщение,сообщения,сообщений}"
  }
}

То все заработает, как и ожидалось. А при добавлении в файл src/localization/en.json аналогичного перевода заработает и смена языков «на лету» с помощью метода setLocalization из I18nStore.


Можно сделать и «привычный» в экосистеме React компонент Message:


src/components/Message/Message.js
import React from 'react';

import { observer } from 'utils';
import { useLocalization } from 'hooks';

function Message(props) {
  const { filename, messages, text, values } = props;

  const getLn = useLocalization(filename, messages);

  return getLn(text, values);
}

const ConnectedMessage = observer(Message);

export function init(filename, messages) {
  return function MessageHoc(props) {
    const fullProps = { filename, messages, ...props };

    return <ConnectedMessage {...fullProps} />;
  };
}

Так как нужно каждый раз передавать переменную __filename (либо каждый раз уникальный id как в страшном сне разработчика), то импорт этого компонента будет немного необычным, однако использование стандартным:


const Message = require('components/Message').init(
  __filename,
  messages
);

<Message text={messages.hello} values={{ count: 1 }} />

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


В завершение темы можно подумать, как удобнее в будущем состыковать этот подход с системой локализации (под ней подразумеваю административное приложение, в котором переводчики узнают о недостатках переводов, делают свои предложения в виде черновиков, менеджер / тестировщик проводят проверку на стенде и прикрепляют определенные черновики к релизам приложения в production). Так как в текущей схеме уникальные id параметров привязаны к пути к файлу, то можно при деплое на стенд пробегаться по всем messages.js и формировать *.json файл со списком всех переменных, привязанный к выкладываемой ветке. Затем этот файл автоматически загружать в систему локализации и дожидаться от переводчиков подходящих переводов (а в системе им подсветятся недостающие / удаленные), после чего осуществлять выкладку в production. Семантичность названий параметров и указание на файлы, в которых были правки, очень поможет переводчикам.


В целом в связке MobX + Hooks клиентская локализация выглядит удобно. Для перевода констант и сообщений, приходящих с backend, нужно будет написать функцию, работающую непосредственно в сторе, с однотипным механизмом.


Работа с API


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


src/stores/CurrentTPStore.js
import _ from 'lodash';

import { makeObservable } from 'utils';
import { apiRoutes, request } from 'api';

@makeObservable
export class CurrentTPStore {
  /**
   * @param rootStore {RootStore}
   */
  constructor(rootStore) {
    this.rootStore = rootStore;
  }

  id = '';
  symbol = '';
  fullName = '';
  currency = '';
  tradedCurrency = '';
  low24h = 0;
  high24h = 0;
  lastPrice = 0;
  marketCap = 0;
  change24h = 0;
  change24hPercentage = 0;

  fetchSymbol(params) {
    const { tradedCurrency, id } = params;
    const { marketsList } = this.rootStore;

    const requestParams = {
      id,
      localization: false,
      community_data: false,
      developer_data: false,
      tickers: false,
    };

    return request(apiRoutes.symbolInfo, requestParams)
      .then(data => this.fetchSymbolSuccess(data, tradedCurrency))
      .catch(this.fetchSymbolError);
  }
  fetchSymbolSuccess(data, tradedCurrency) {
    const {
      id,
      symbol,
      name,
      market_data: {
        high_24h,
        low_24h,
        price_change_24h_in_currency,
        price_change_percentage_24h_in_currency,
        market_cap,
        current_price,
      },
    } = data;

    this.id = id;
    this.symbol = symbol;
    this.fullName = name;
    this.currency = symbol;
    this.tradedCurrency = tradedCurrency;
    this.lastPrice = current_price[tradedCurrency];
    this.high24h = high_24h[tradedCurrency];
    this.low24h = low_24h[tradedCurrency];
    this.change24h = price_change_24h_in_currency[tradedCurrency];
    this.change24hPercentage =
      price_change_percentage_24h_in_currency[tradedCurrency];
    this.marketCap = market_cap[tradedCurrency];

    return Promise.resolve();
  }
  fetchSymbolError(error) {
    console.error(error);
  }
}

К примеру, есть стор, содержащий информацию об открытой торговой паре. Для получения данных вызывается метод fetchSymbol, в который передается id необходимой валюты и валюта, к которой идет торговля. Далее выполняется запрос через утилиту, при успехе — в единой транзакции обновляются данные в сторе (так как все методы автоматически оборачиваются в @action.bound), а при ошибке она логируется в Sentry благодаря декоратору в функции инициализации:


src/utils/initSentry.js
import * as Sentry from '@sentry/browser';

export function initSentry() {
  if (SENTRY_URL !== 'false') {
    Sentry.init({
      dsn: SENTRY_URL,
    });

    const originalErrorLogger = console.error;
    console.error = function consoleErrorCustom(...args) {
      Sentry.captureException(...args);

      return originalErrorLogger(...args);
    };
  }
}

Данный запрос наиболее показателен, так как использует сразу весь функционал валидации запросов:


src/api/_api.js
import _ from 'lodash';

import {
  omitParam,
  validateRequestParams,
  makeRequestUrl,
  makeRequest,
  validateResponse,
} from 'api/utils';

export function request(route, params) {
  return Promise.resolve()
    .then(validateRequestParams(route, params))
    .then(makeRequestUrl(route, params))
    .then(makeRequest)
    .then(validateResponse(route, params));
}

export const apiRoutes = {
  symbolInfo: {
    url: params => `https://api.coingecko.com/api/v3/coins/${params.id}`,
    params: {
      id: omitParam,
      localization: _.isBoolean,
      community_data: _.isBoolean,
      developer_data: _.isBoolean,
      tickers: _.isBoolean,
    },
    responseObject: {
      id: _.isString,
      name: _.isString,
      symbol: _.isString,
      genesis_date: v => _.isString(v) || _.isNil(v),
      last_updated: _.isString,
      country_origin: _.isString,

      coingecko_rank: _.isNumber,
      coingecko_score: _.isNumber,
      community_score: _.isNumber,
      developer_score: _.isNumber,
      liquidity_score: _.isNumber,
      market_cap_rank: _.isNumber,
      block_time_in_minutes: _.isNumber,
      public_interest_score: _.isNumber,

      image: _.isPlainObject,
      links: _.isPlainObject,
      description: _.isPlainObject,
      market_data: _.isPlainObject,
      localization(value, requestParams) {
        if (requestParams.localization === false) {
          return true;
        }

        return _.isPlainObject(value);
      },
      community_data(value, requestParams) {
        if (requestParams.community_data === false) {
          return true;
        }

        return _.isPlainObject(value);
      },
      developer_data(value, requestParams) {
        if (requestParams.developer_data === false) {
          return true;
        }

        return _.isPlainObject(value);
      },
      public_interest_stats: _.isPlainObject,

      tickers(value, requestParams) {
        if (requestParams.tickers === false) {
          return true;
        }

        return _.isArray(value);
      },
      categories: _.isArray,
      status_updates: _.isArray,
    },
  },
};

Схема работы функции request следующая:


  1. принимает объект из apiRoutes и параметры для запроса;
  2. проверяет соответствие параметров запроса схеме, описанной в route.params, при этом опуская валидирующие функции, заданные с помощью omitParam;
  3. формирует итоговый URL запроса исходя из route.url — если это функция, то передает в нее параметры запроса, если строка — то просто добавляет get-параметры к URL;
  4. выполняет запрос с помощью fetch, возвращая преобразованный в объект JSON;
  5. проверяет соответствие параметров ответа схеме, описанной в route.responseObject либо route.responseArray (если ожидается ответ в виде массива). Первым аргументом в функцию валидации передается значение, а вторым — исходные параметры запроса, чтобы иметь возможность динамической валидации;
  6. при любом несовпадении параметров запроса / ответа / адреса запроса / статуса ответа выбрасывается исключение с понятным сообщением, которое ловится в методе стора (в данном случае fetchSymbolError) и логируется.

Подобная схема позволяет быть полностью уверенным в получаемых данных и оперативно реагировать на залогированные ошибки. Например, так выглядит сообщение в Sentry, если не совпал тип одной из переменных в response:



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


Роутинг и отказоустойчивость


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


  • единое хранилище всех роутов с возможностью использовать элементы в ссылках;
  • динамические параметры в pathname и search;
  • валидация динамических параметров регулярным выражением / функцией;
  • двусторонняя синхронизация location и состояния приложения в сторах;
  • возможность вызова асинхронных функций в beforeEnter, с передачей в компонент параметра isLoading, пока происходит выполнение;
  • возможность указать стратегию отказоустойчивости в случаях: не совпала маска, не найден подходящий роут, не найден компонент, исключение в beforeEnter, исключение при асинхронных загрузках данных;
  • поддержка событий перехода назад / вперед в браузере;
  • возможность полного / частичного отката до состояния перед переходом на роут;
  • поддержка анимации переходов между состояниями.

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


src/routes.js
export const routes = {
  marketDetailed: {
    name: 'marketDetailed',
    path: '/market/:market/:pair',
    masks: {
      pair: /^[a-zA-Z]{3,5}-[a-zA-Z]{3}$/,
      market: /^[a-zA-Z]{3,4}$/,
    },
    beforeEnter(route, store) {
      const {
        params: { pair, market },
      } = route;
      const [symbol, tradedCurrency] = pair.split('-');
      const prevMarket = store.marketsList.currentMarket;

      function optimisticallyUpdate() {
        store.marketsList.currentMarket = market;
      }

      return Promise.resolve()
        .then(optimisticallyUpdate)
        .then(store.marketsList.fetchSymbolsList)
        .then(store.rates.fetchRates)
        .then(() => store.marketsList.fetchMarketList(market, prevMarket))
        .then(() =>
          store.currentTP.fetchSymbol({
            symbol,
            tradedCurrency,
          })
        )
        .catch(error => {
          console.error(error);
        });
    },
  },
  error404: {
    name: 'error404',
    path: '/error404',
  },
};

src/routeComponents.js
import { MarketDetailed } from 'pages/MarketDetailed';
import { Error404 } from 'pages/Error404';

export const routeComponents = {
  marketDetailed: MarketDetailed,
  error404: Error404,
};

Компоненты, соответствующие роутам, вынесены в отдельный файл для защиты от цикличной зависимости — если в компонентах использовать удобные конструкторы ссылок вида <Link route={routes.marketDetailed}>, то возникнет цикличный импорт. Webpack в некоторых случаях умеет справляться с этим, но лучше не полагаться на удачу.


Теперь необходим стор, который двусторонне свяжет location и подходящий роут из списка выше.


src/stores/RouterStore.js
import _ from 'lodash';

import { makeObservable } from 'utils';
import { routes } from 'routes';

@makeObservable
export class RouterStore {
  /**
   * @param rootStore {RootStore}
   */
  constructor(rootStore) {
    this.rootStore = rootStore;
    this.currentRoute = this._fillRouteSchemaFromUrl();

    window.addEventListener('popstate', () => {
      this.currentRoute = this._fillRouteSchemaFromUrl();
    });
  }

  currentRoute = null;

  _fillRouteSchemaFromUrl() {
    const pathnameArray = window.location.pathname.split('/');
    const routeName = this._getRouteNameMatchingUrl(pathnameArray);

    if (!routeName) {
      const currentRoute = routes.error404;
      window.history.pushState(null, null, currentRoute.path);

      return currentRoute;
    }

    const route = routes[routeName];
    const routePathnameArray = route.path.split('/');

    const params = {};

    routePathnameArray.forEach((pathParam, i) => {
      const urlParam = pathnameArray[i];

      if (pathParam.indexOf(':') === 0) {
        const paramName = pathParam.replace(':', '');
        params[paramName] = urlParam;
      }
    });

    return Object.assign({}, route, { params, isLoading: true });
  }

  _getRouteNameMatchingUrl(pathnameArray) {
    return _.findKey(routes, route => {
      const routePathnameArray = route.path.split('/');

      if (routePathnameArray.length !== pathnameArray.length) {
        return false;
      }

      for (let i = 0; i < routePathnameArray.length; i++) {
        const pathParam = routePathnameArray[i];
        const urlParam = pathnameArray[i];

        if (pathParam.indexOf(':') !== 0) {
          if (pathParam !== urlParam) {
            return false;
          }
        } else {
          const paramName = pathParam.replace(':', '');
          const paramMask = _.get(route.masks, paramName);

          if (paramMask && !paramMask.test(urlParam)) {
            return false;
          }
        }
      }

      return true;
    });
  }

  replaceDynamicParams(route, params) {
    return Object.entries(params).reduce((pathname, [paramName, value]) => {
      return pathname.replace(`:${paramName}`, value);
    }, route.path);
  }

  goTo(route, params) {
    if (route.name === this.currentRoute.name) {
      if (_.isEqual(this.currentRoute.params, params)) {
        return false;
      }

      this.currentRoute.isLoading = true;
      this.currentRoute.params = params;

      const newPathname = this.replaceDynamicParams(this.currentRoute, params);

      window.history.pushState(null, null, newPathname);

      return false;
    }

    const newPathname = this.replaceDynamicParams(route, params);

    window.history.pushState(null, null, newPathname);

    this.currentRoute = this._fillRouteSchemaFromUrl();
  }
}

Схема работает достаточно просто — в конструкторе стора осуществляется поиск подходящего роута из routes.js и проверка всех динамических параметров по маске. Если роут не найдет или параметр не соответствует маске — происходит редирект на страницу с ошибкой 404. Разумеется, при развитии роутера нужно включить возможность «найти максимально похожий роут и перейти на него с дефолтными параметрами», и эту же стратегию использовать, если данные все же прошли маску, но некорректные — например, пользователь попробовал запросить данные по торговой паре 'test-test'.


Далее в currentRoute записывается подходящий роут, обогащенный объектом params (значения переменных из URL) и isLoading: true. Теперь свою работу может начать React-компонент Router:


src/components/Router.js
import React from 'react';
import _ from 'lodash';

import { useStore } from 'hooks';
import { observer } from 'utils';
import { routeComponents } from 'routeComponents';

function getRouteComponent(route, isLoading) {
  const Component = routeComponents[route.name];

  if (!Component) {
    console.error(
      `getRouteComponent: component for ${
        route.name
      } is not defined in routeComponents`
    );

    return null;
  }

  return <Component isLoading={isLoading} />;
}

function useBeforeEnter() {
  const store = useStore();
  const { currentRoute } = store.router;

  React.useEffect(() => {
    if (currentRoute.isLoading) {
      const beforeEnter = _.get(currentRoute, 'beforeEnter');

      if (_.isFunction(beforeEnter)) {
        Promise.resolve()
          .then(() => beforeEnter(currentRoute, store))
          .then(() => {
            currentRoute.isLoading = false;
          })
          .catch(error => console.error(error));
      } else {
        currentRoute.isLoading = false;
      }
    }
  });

  return currentRoute.isLoading;
}

function Router() {
  const {
    router: { currentRoute },
  } = useStore();
  const isLoading = useBeforeEnter();

  return getRouteComponent(currentRoute, isLoading);
}

export const RouterConnected = observer(Router);

Когда рендерится этот компонент, хранилище уже давно инициализировалось и нашло подходящий роут, поэтому смысла проверять на currentRoute == null нет. Основная идея компонента — если у текущего роута параметр isLoading === true, то передавать этот параметр в компонент и менять его на false только после того, как полностью выполнится route.beforeEnter (если есть). Вместо описанной в принципах роутинга необходимости применять стратегии отказоустойчивости здесь постыдный console.error, как напоминание о том, что работы еще предстоит море.


Следующий вопрос, который предстоит решить — где и когда запрашивать данные, необходимые для страницы. В React-сообществе распространены 2 подхода:


  1. компоненты в цикле componentWillMount / componentDidMount / useEffect сами определяют, какие методы у сторов вызвать, чтобы получить данные. В этом случае они могут работать модульно — показывать внутри себя анимированные лоадеры, при ошибке одного из запросов показывать заглушку и кнопку «перезагрузить». Слабое место — когда нескольким компонентам нужны одинаковые данные — исправляется вынесением общих запросов на уровень общего родителя;
  2. глобальный компонент страницы (либо роут) в едином месте делает все запросы за данными, которые нужны потомкам. Преимущество — возможность использовать общую стратегию отказоустойчивости — особенно полезно, когда абсолютно все данные и компоненты на странице должны работать. Слабое место — невозможность обновить данные конкретного компонента / виджета — решается настройкой real-time обновления по запросам, на которые страница подписывается / отписывается в едином месте.

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


Поэтому все запросы буду делать в методе beforeEnter, по схеме: «оптимистичное обновление», последовательные запросы за данными (в будущем, конечно, необходимо использовать параллельные с возможностью прерывания), при различных ошибках — различные стратегии исправления (блокирование всей страницы с перезапросом всех данных несколько раз — если сервер отвечает 500 ошибками; откат до предыдущего состояния приложения если сервер отвечает некорректными данными; нотификация о том, что определенный блок с данными сейчас недоступен; редирект на роут с дефолтными параметрами и т.п.). В данном «скелете» приложения это все не реализовано, так как для самого продукта на этапе MVP пользы принесет мало.


Последним элементом роутинга будет компонент для создания ссылок:


src/components/Link.js
import React from 'react';
import _ from 'lodash';

import { useStore } from 'hooks';
import { observer } from 'utils';

function checkRouteParamsWithMasks(route, params) {
  if (route.masks) {
    Object.entries(route.masks).forEach(([paramName, paramMask]) => {
      const value = _.get(params, paramName);

      if (paramMask && !paramMask.test(value)) {
        console.error(
          `checkRouteParamsWithMasks: wrong param for ${paramName} in Link to ${
            route.name
          }: ${value}`
        );
      }
    });
  }
}

function Link(props) {
  const store = useStore();
  const { currentRoute } = store.router;
  const { route, params, children, onClick, ...otherProps } = props;

  checkRouteParamsWithMasks(route, params);

  const filledPath = store.router.replaceDynamicParams(route, params);

  return (
    <a
      href={filledPath}
      onClick={e => {
        e.preventDefault();

        if (currentRoute.isLoading) {
          return false;
        }

        store.router.goTo(route, params);

        if (onClick) {
          onClick();
        }
      }}
      {...otherProps}
    >
      {children}
    </a>
  );
}

export const LinkConnected = observer(Link);

Этот компонент принимает параметр route, валидирует переданные динамические params (если есть) на этапе создания ссылки (чтобы фронтенд сам себя не смог поломать при клике) и заполняет href заполненным адресом. Кроме этого, если у текущего роута все еще загружаются данные в методе beforeEnter, переход по ссылке блокируется. Можно показывать нотификацию из разряда «подождите, идет загрузка», либо откладывать переход до завершения текущей загрузки, либо прерывать все запросы и форсированно переходить на новую страницу — в зависимости от потребности.


Метрики


Касательно бизнес-метрик (переходы на страницы, клики по кнопкам, отправка заполненных форм, количество возникновения ошибок, поведение пользователя на странице) в общем случае достаточно Яндекс.Вебвизор или аналога с автоматическим сбором. Для приложения биржи не нужна система замера сложнодостижимых целей вроде заполнения многостраничных форм и выполнения комплекса действий.


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


src/api/utils/metrics.js
import _ from 'lodash';

let metricsArray = [];
let sendMetricsCallback = null;

export function startMetrics(route, apiRoutes) {
  return function promiseCallback(data) {
    clearTimeout(sendMetricsCallback);
    const apiRouteName = _.findKey(apiRoutes, route);

    metricsArray.push({
      id: apiRouteName,
      time: new Date().getTime(),
    });

    return data;
  };
}

export function stopMetrics(route, apiRoutes) {
  return function promiseCallback(data) {
    const apiRouteName = _.findKey(apiRoutes, route);
    const metricsData = _.find(metricsArray, ['id', apiRouteName]);

    metricsData.time = new Date().getTime() - metricsData.time;

    clearTimeout(sendMetricsCallback);
    sendMetricsCallback = setTimeout(() => {
      console.log('Metrics sent:', metricsArray);
      metricsArray = [];
    }, 2000);

    return data;
  };
}

И включив эти две middleware в функцию request:


export function request(route, params) {
  return Promise.resolve()
    .then(startMetrics(route, apiRoutes))
    .then(validateRequestParams(route, params))
    .then(makeRequestUrl(route, params))
    .then(makeRequest)
    .then(validateResponse(route, params))
    .then(stopMetrics(route, apiRoutes))
    .catch(error => {
      stopMetrics(route, apiRoutes)();

      throw error;
    });
}

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


Теперь можно приступать к кодированию бизнес-логики и компонентов — в этом демо полностью реализовал первоначальное задание заказчика.


Интеграционные тесты


Про принципы и значение end-to-end тестирования информации много, в том числе в документации к выбранному инструменту Cypress. С точки зрения разработки в них важны: легкость развертывания; удобство поддержки и развития, в том числе для тестировщиков; легкость встраивания в Continious Integration.


Так как данный инструмент состоит всего из одного пакета и написан на javascript с похожим на Chai / Sinon синтаксисом, обычно проблем с развитием инструментария не возникает. Однако не радует долгая установка самого пакета, поэтому устанавливать желательно в отдельную папку, в моем случае — ./tests, в package.json которого будет единственная зависимость — "dependencies": { "cypress": "3.2.0" }


После установки инструмент сам создаст структуру папок с многочисленными примерами для обучения. Для максимального удобства нужно синхронизировать возможности встроенного в него Webpack и общей конфигурации проекта:


tests/cypress/plugins/index.js
const webpack = require('../../../node_modules/@cypress/webpack-preprocessor');
const webpackConfig = require('../../../webpack-custom/webpack.config');

module.exports = on => {
  const options = webpack.defaultOptions;

  options.webpackOptions.module = webpackConfig.module;
  options.webpackOptions.resolve = webpackConfig.resolve;

  on('file:preprocessor', webpack(options));
};

Для этого потребовалась установка всего лишь одного пакета в основной проект. Синхронизировать достаточно лишь module (для использования идентичного синтаксиса) и resolve (чтобы работали все алиасы и импорты файлов как в основном проекте). В плагинах ESLint для корректного распознавания глобальных переменных (вроде describe, cy) нужен дополнительный плагин eslint-plugin-cypress. На этом настройка закончена, и вот так может выглядеть проверочный тест:


tests/cypress/integration/mixed.js
describe('Market Listing good scenarios', () => {
  it('Lots of mixed tests', () => {
    cy.visit('/market/usd/bch-usd');
    cy.location('pathname').should('equal', '/market/usd/bch-usd');

    // Проверка ответа на запрос, хотя для этого уже есть валидаторы
    cy.wait('@symbolsList')
      .its('response.body')
      .should(data => {
        expect(data).to.be.an('array');
      });

    // Дожидаемся всех запросов
    cy.wait('@rates');
    cy.wait('@marketsList');
    cy.wait('@symbolInfo');
    cy.wait('@chartData');

    // Проверяем переход на другую торгуемую валюту
    cy.get('#marketTab-eth').click();
    cy.location('pathname').should('equal', '/market/eth/bch-usd');
    cy.wait('@rates');
    cy.wait('@marketsList');

    // Проверяем изменение локализации
    cy.contains('Рынки');
    cy.get('#langSwitcher-en').click();
    cy.contains('Markets list');

    // Проверяем изменение темы
    cy.get('body').should('have.class', 'light');
    cy.get('#themeSwitcher-dark').click();
    cy.get('body').should('have.class', 'dark');
  });
});

Так как на текущий момент Cypress не поддерживает протокол fetch, можно применить полифилл, и заодно указать роуты для запросов:


tests/cypress/support/index.js
import { apiRoutes } from 'api';

let polyfill = null;

before(() => {
  const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js';
  cy.request(polyfillUrl).then(response => {
    polyfill = response.body;
  });
});

Cypress.on('window:before:load', window => {
  delete window.fetch;
  window.eval(polyfill);
  window.fetch = window.unfetch;
});

before(() => {
  cy.server();
  cy.route(`${apiRoutes.symbolsList.url}**`).as('symbolsList');
  cy.route(`${apiRoutes.rates.url}**`).as('rates');
  cy.route(`${apiRoutes.marketsList.url}**`).as('marketsList');
  cy.route(`${apiRoutes.symbolInfo.url({ id: 'bitcoin-cash' })}**`).as(
    'symbolInfo'
  );
  cy.route(`${apiRoutes.chartData.url}**`).as('chartData');
});

Собственно все, можно создавать удобные команды и обучать тестировщиков.


Всего лишь, так просто?


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


А в это время, честно отдавая себе отчет, что по описанным принципам работа сделана едва ли на половину, месяц-два добрабатывать, тщательно покрывая юнит-тестами утилиты перед тем, как переходить к следующим этапам архитектуры (real-time взаимодействие, подключение serviceWorker, интеграция в CI, кроссбраузерность и полифиллы, гибкое управление правами отображения и функционирования элементов, настройка бизнес-метрик, мобильная версия, автоматизация рутинных задач и т.п.).


Размеры итоговых файлов (с Gzip) вполне адекватны:



И структура компонентов в React Developer Tools выглядит очень приятно:



В целом работать в связке React Hooks + MobX понравилось намного больше, чем с Redux. Надеюсь, мой взгляд на архитектуру приложения поможет избежать лишних затрат времени. Если же выпустить продукт хотя бы без одного из описанных элементов, велика вероятность, что он понадобится в то время, когда внедрение займет месяцы и будет сопряжено со сложным рефакторингом. Лучше спроектировать это на этапе создания архитектуры. Всем интересной разработки!


Весь код



Update 13.07.2019


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


1. Отсутствие yarn.lock приводило к разным хешам после сборки, как и использование yarn install --force, но проблема легко решилась скриптом "upd": "yarn install && yarn add file:./eslint-custom && yarn add file:./webpack-custom". В данном случае обновляются зависимости только у не влияющих на итоговую сборку ESLint и Webpack.


2. Пути к папкам в webpack-custom/config/configOptimization.js, описаны не кроссплатформенно, и вместо


lodash: {
  test: module => module.context.indexOf('node_modules\\lodash') !== -1,
  name: 'lodash',
  chunks: 'all',
  enforce: true,
}

должно быть


lodash: {
  test: /node_modules[\\/]lodash/,
  name: 'lodash',
  chunks: 'all',
  enforce: true,
}

3. При работе с локализацией передавать каждый раз параметры в useLocalization(__filename, messages) утомительно, выход — переделать параметры в объекты вида


const messages = {
  hello: {
    value: 'У вас {count} {count: сообщение,сообщения,сообщений}',
    name: "src/components/TestLocalization/TestLocalization.hello",
  }
};

например, с помощью функции


const messagesDefault = {
  hello: 'У вас {count} {count: сообщение,сообщения,сообщений}',
};

export const messages = Object.keys(messagesDefault).reduce((acc, key) => {
  acc[key] = {
   value: messagesDefault[key],
   name: __dirname.toLowerCase().replace(/\\\\/g, '/') + '.' + key,
  };

  return acc;
}, {});

Но так как в IDE в таком случае перестает работать автодополнение, это преобразование можно выполнять в лоадере Webpack:


webpack-custom/utils/messagesLoader.js
module.exports = function messagesLoader(source) {
  if (source.indexOf('export const messages = messagesDefault;') !== -1) {
    return source.replace(
      'export const messages = messagesDefault;',
      `
  export const messages = Object.keys(messagesDefault).reduce((acc, key) => {
    acc[key] = {
      value: messagesDefault[key],
      name: __dirname.toLowerCase().replace(/\\\\/g, '/') + '.' + key,
    };
    return acc;
  }, {});
    `
    );
  }

  return source;
};

Соответственно, файлы messages.js теперь имеют содержание:


const messagesDefault = {
  someText: 'Завершить',
};

export const messages = messagesDefault;

Также для повышения удобства работы с текстами очень пригодилась утилита, вызываемая в app.js, которая пробегается по всем файлам messages.js и показывает, каких ключей не хватает в *.json файлах локализации:


src/utils/checkLocalization.js
import _ from 'lodash';

import ru from 'localization/ru.json';

const showNoTextMessage = true;

export function checkLocalization() {
  const context = require.context('../', true, /messages\.js/);
  const messagesFiles = context.keys();
  const notLocalizedObject = {};

  messagesFiles.forEach(path => {
    const fileExports = context(path);
    const { messages } = fileExports;
    _.values(messages).forEach(({ name, value }) => {
      if (ru[name] == null) {
        notLocalizedObject[name] = value;
      }
    });
  });

  if (showNoTextMessage && _.size(notLocalizedObject) > 0) {
    console.log(
      'No localization for lang ru:',
      JSON.stringify(notLocalizedObject, null, 2)
    );
  }
}

Таким образом, в консоли всплывет сообщение вида


No localization for lang ru: {
  "src/components/TestLocalization/TestLocalization.hello": "У вас {count} {count: сообщение,сообщения,сообщений}"
}

которое нужно просто скопировать в соответствующий *.json файл — разумеется, только если в проекте нет отдельной системы для работы с переводами, а тексты лежат в репозитории.


3. Валидаторы для АПИ в виде функций Lodash вроде someResponseParam: _.isString хотя и работают, но не расширяемы и не дают возможность вывести текст ошибки в удобном виде. Поэтому лучше обернуть их в кастомный объект:


src/utils/validateObjects.js
import _ from 'lodash';

import { createError } from './createError';
import { errorsNames } from 'const';

export const validators = {
  isArray(v) {
    return _.isArray(v);
  },
  isString(v) {
    return _.isString(v);
  },
  isNumber(v) {
    return _.isNumber(v);
  },
  isBoolean(v) {
    return _.isBoolean(v);
  },
  isPlainObject(v) {
    return _.isPlainObject(v);
  },

  isArrayNotRequired(v) {
    return _.isArray(v) || _.isNil(v);
  },
  isStringNotRequired(v) {
    return _.isString(v) || _.isNil(v);
  },
  isNumberNotRequired(v) {
    return _.isNumber(v) || _.isNil(v);
  },
  isBooleanNotRequired(v) {
    return _.isBoolean(v) || _.isNil(v);
  },
  isPlainObjectNotRequired(v) {
    return _.isPlainObject(v) || _.isNil(v);
  },

  omitParam() {
    return true;
  },
};

validators.isArray.notRequired = validators.isArrayNotRequired;
validators.isString.notRequired = validators.isStringNotRequired;
validators.isNumber.notRequired = validators.isNumberNotRequired;
validators.isBoolean.notRequired = validators.isBooleanNotRequired;
validators.isPlainObject.notRequired = validators.isPlainObjectNotRequired;

export function validateObjects(
  { validatorsObject, targetObject, prefix },
  otherArg
) {
  if (!_.isPlainObject(validatorsObject)) {
    throw new Error(`validateObjects: validatorsObject is not an object`);
  }

  if (!_.isPlainObject(targetObject)) {
    throw new Error(`validateObjects: targetObject is not an object`);
  }

  Object.entries(validatorsObject).forEach(([paramName, validator]) => {
    const paramValue = targetObject[paramName];

    if (!validator(paramValue, otherArg)) {
      const validatorName = _.findKey(validators, v => v === validator);
      throw createError(
        errorsNames.VALIDATION,
        `${prefix || ''}${paramName}${
          _.isString(validatorName) ? ` [${validatorName}]` : ''
        }`
      );
    }
  });
}

Такая небольшая обертка позволяет включать название валидатора в текст ошибки — например, с сервера пришел некорректный параметр someResponseParam [isString], сразу давая понять, какой тип был ожидаем. Также можно указать someResponseParam: validators.isString.notRequired, что сделает параметр необязательным. В реальном проекте в дополнение к этим валидаторам понадобились динамические, которые выбирают тип ответа в зависимости от отправленных параметров, а также проверки массивов вида someResponseArray: arrayShape({ someParam: isString }), которые без проблем интегрируются в подобную схему.


5. Роутинг, как и ожидалось, потребовалось значительно развить. Основное изменение — поддержка анимированных переходов между страницами (добавление к body классов isEntering, isLeaving с соответствующей задержкой) и запрет перехода на другие страницы в виде функции beforeLeave (аналог Prompt из react-router), в которой можно выводить предупреждения и блокировать переход при возврате false-значения в промисе, например


somePage: {
  path: '/some-page',
  beforeLeave(store) {
    return store.modals.raiseConfirm('Вы действительно хотите покинуть страницу?');
  },
}

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


— при заходе на /some/long/auth/path ее beforeEnter редиректит на алиас /auth
— beforeEnter роута /auth проверяет авторизацию, и если человек авторизован — то редиректит на /profile
— beforeEnter роута /profile делает запрос за данными, и если видит, что у пользователя не заполнено имя, редиректит на /profile/edit


Эти редиректы проходят быстро и незаметно, при необходимости можно не сохранять их в истории браузера — то есть вместо window.history.pushState использовать location.replace. При этом зоны ответственности четко разграничены, и любое комбинирование редиректов линейно приведет к единственно верному адресу страницы. В этом подход «ходить за данными в роутере» на голову выше подхода «ходить за данными в componentDidMount компонента», если иметь в виду критически важные для отображения запросы, а не загрузку словаря для селекта при его первичном рендере.


6. Классическая задача по отображению лоадера в кнопках при отправке запроса (например, при сабмите форм) элегантно решилась одним декоратором:


src/utils/withState.js
export function withState(target, fnName, fnDescriptor) {
  const original = fnDescriptor.value;

  fnDescriptor.value = function fnWithState(...args) {
    if (this.executions[fnName]) {
      return Promise.resolve();
    }

    return Promise.resolve()
      .then(() => {
        this.executions[fnName] = true;
      })
      .then(() => original.apply(this, args))
      .then(data => {
        this.executions[fnName] = false;
        return data;
      })
      .catch(error => {
        this.executions[fnName] = false;
        throw error;
      });
  };

  return fnDescriptor;
}

src/stores/CurrentTPStore.js
import _ from 'lodash';

import { makeObservable, withState } from 'utils';
import { apiRoutes, request } from 'api';

@makeObservable
export class CurrentTPStore {
  /**
   * @param rootStore {RootStore}
   */
  constructor(rootStore) {
    this.rootStore = rootStore;
    this.executions = {};
  }

  @withState
  fetchSymbol() {
    return request(apiRoutes.symbolInfo)
      .then(this.fetchSymbolSuccess)
      .catch(this.fetchSymbolError);
  }
  fetchSymbolSuccess(data) {
    return Promise.resolve();
  }
  fetchSymbolError(error) {
    console.error(error);
  }
}

src/components/TestComponent.js
import React from 'react';

import { observer } from 'utils';
import { useStore } from 'hooks';

function TestComponent() {
  const store = useStore();
  const { currentTP: { executions } } = store;

  return <div>{executions.fetchSymbol ? 'Загружается...' : 'Загружен'}</div>;
}

export const TestComponentConnected = observer(TestComponent);

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


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

Теги:
Хабы:
Всего голосов 32: ↑26 и ↓6+20
Комментарии26

Публикации

Истории

Работа

Ближайшие события