Хабрахабр

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

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

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

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

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

Разработать 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:

, "dependencies": { "eslint-custom": "file:./eslint-custom" }
}

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

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

В пакете 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" }
}

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

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

Осталось дописать команды в основной 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" }
}

Для гарантии при создании коммита необходимо использовать git-хук, который будет проверять и форматировать все файлы проекта. … и настроить свой IDE на применение форматирования при сохранении текущего файла. Для принципа коллективной ответственности за всю кодовую базу, чтобы ни у кого не было соблазна обойти валидацию. Почему не только те, которые присутствуют в коммите? Для этого же при создании коммита все предупреждения линтера будут считаться ошибками с помощью --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

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

То есть, если при локальной разработке включить HOT_RELOAD и CSS_EXTRACT, то при
изменении файлов стилей будут перезагружаться только они — но, к сожалению, все, а не только измененный файл. Для отделения стилей в файлы при включенном CSS_EXTRACT буду использовать mini-css-extract-plugin — он позволяет использовать Hot Reloading. С выключенным же 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' });

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

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

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

  • в 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 при аналогичных настройках.

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

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

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

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

Сейчас все легко решается с помощью Custom CSS Properties. Раньше для создания тем приходилось делать несколько версий *.css файлов и перезагружать страницу при смене темы, загружая нужный набор стилей. Данную технологию поддерживают все целевые браузеры текущего приложения, но есть и полифиллы для 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);
}

Позже расскажу, почему так удобнее, чем сразу хранить в javascript. Для того, чтобы эти переменные применялись глобально, их нужно записать в document.documentElement, соответственно нужен небольшой парсер, чтобы преобразовать этот файл в 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

Новый цвет автоматически подставится в themes.scss, сработает Hot Reload и приложение моментально преобразится. IDE WebStorm, как видно на скриншоте, показывает цвета на панели слева и по клику на цвет открывает палитру, где можно его сменить. Это именно тот уровень удобства разработки, который и ожидается в 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';

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

Компоненты буду группировать постранично, кроме общих вроде 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; };
}

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

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

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; }
}

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

Таким образом, из 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); });
}

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

Локализация и 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);

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

Декоратор 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 — только сам текст.

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

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

Работа с 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,
};

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

Теперь необходим стор, который двусторонне свяжет 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(); }
}

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

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

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);

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

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

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

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

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

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

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);

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

Метрики

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

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

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 секунд, и отправится в некую систему мониторинга (в данном случае в консоль) единым массивом.

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

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

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

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

Для максимального удобства нужно синхронизировать возможности встроенного в него 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. Лучше спроектировать это на этапе создания архитектуры. Если же выпустить продукт хотя бы без одного из описанных элементов, велика вероятность, что он понадобится в то время, когда внедрение займет месяцы и будет сопряжено со сложным рефакторингом. Всем интересной разработки!

Весь код

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»