[Из песочницы] Архитектура SPA-приложения биржи в 2019 году

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

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

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


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

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


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

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

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


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

Теперь время определить используемые инструменты и библиотеки. Я буду руководствоваться принципами разработки «под ключ» и 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=/exchange_habr/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
pyb5jfs2g07r-ypnnkoudkdxe7s.png

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 
{getLn(messages.hello, { count: 1 })}
; } 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 
{store.i18n.currentLanguage}
; } 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 ;
  };
}

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

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


Из особенностей — при использовании в компоненте хука 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: _.isArra
    
            

© Habrahabr.ru