Salutations, Khabrovites!
Je lis cette ressource depuis sa fondation, mais le temps d'écrire un article n'est apparu que maintenant, ce qui signifie qu'il est temps de partager notre expérience avec la communauté. Pour les développeurs débutants, j'espère que cet article contribuera à améliorer la qualité de la conception, et pour les expérimentés, il servira de liste de contrôle afin de ne pas oublier les éléments importants au stade architectural. Pour les impatients, le dépôt final et la démo .
Supposons que vous soyez dans une «entreprise de rêve» - l'un des échanges avec un libre choix de technologies et de ressources pour tout faire «comme il se doit». Pour le moment, tout ce que la société possède est
Affectation commerciale
Développer une application SPA pour l'interface de trading, dans laquelle vous pouvez:
- voir une liste des paires de trading regroupées par devise;
- lorsque vous cliquez sur une paire de trading pour voir les informations au cours actuel, un changement en 24 heures, un «verre d'ordres»;
- changer la langue de l'application en anglais / russe;
- changez le thème en foncé / clair.
La tâche est assez courte, ce qui vous permettra de vous concentrer sur l'architecture, plutôt que d'écrire de gros volumes de fonctionnalités métier. Le résultat des efforts initiaux devrait être un code logique et réfléchi qui vous permet de procéder directement à la mise en œuvre de la logique métier.
Puisqu'il n'y a aucune exigence technique dans l'énoncé de travail du client, laissez-le être à l'aise pour le développement:
- compatibilité entre navigateurs : 2 dernières versions de navigateurs populaires (sans IE);
- largeur de l'écran :> = 1240px;
- conception : par analogie avec d'autres échanges, comme Le designer n'a pas encore été embauché.
Il est maintenant temps de déterminer quels outils et bibliothèques sont utilisés. Je serai guidé par les principes du développement "clé en main" et KISS , c'est-à -dire que je ne prendrai que les bibliothèques opensource qui nécessiteraient un temps insuffisant pour une mise en œuvre indépendante, y compris le temps de former les futurs collègues développeurs.
- système de contrôle de version : Git + Github;
- backend : API CoinGecko;
- montage / transport : Webpack + Babel;
- installateur de packages : Yarn (npm 6 dépendances incorrectement mises à jour);
- contrôle de la qualité du code : ESLint + Prettier + Stylelint;
- vue : Réagissez (voyons à quel point les crochets sont pratiques);
- magasin : MobX;
- autotests : Cypress.io (une solution javascript complète au lieu d'un assemblage modulaire comme Mocha / Karma + Chai + Sinon + Selenium + Webdriver / Protractor);
- styles : SCSS via PostCSS (flexibilité de configuration, amis avec Stylelint);
- graphiques : HighStock (la configuration est beaucoup plus facile que TradingView , mais pour une application réelle, je prendrais ce dernier);
- rapport d' erreur : Sentry;
- Utilitaires : Lodash (gain de temps);
- routage : clé en main;
- localisation : clé en main;
- travailler avec des demandes : clé en main;
- mesures de performance : clé en main;
- typification : pas Ă mon quart de travail.
Ainsi, seuls React, MobX, HighStock, Lodash et Sentry proviendront des bibliothèques du fichier d'application final. Je pense que cela est justifié, car ils ont une excellente documentation, des performances et sont familiers à de nombreux développeurs.
Contrôle qualité du code
Je préfère diviser les dépendances dans package.json en parties sémantiques, donc la première étape après avoir lancé le référentiel git est de regrouper tout ce qui concerne le style de code dans le dossier ./eslint-custom , en spécifiant dans package.json :
{ "scripts": { "upd": "yarn install --no-lockfile" }, "dependencies": { "eslint-custom": "file:./eslint-custom" } }
L' yarn install
normale de yarn install
ne vérifiera pas si les dépendances dans eslint-custom ont changé, donc j'utiliserai la mise yarn upd
. En général, cette pratique semble plus universelle, car les développeurs n'auront pas à modifier la recette de déploiement si les développeurs doivent changer la méthode d'installation des packages.
Il n'y a aucun intérêt à utiliser le fichier yarn.lock, car toutes les dépendances seront sans "couvertures" (sous la forme de "react": "16.8.6"
). L'expérience a montré qu'il vaut mieux mettre à jour manuellement les versions et les tester soigneusement dans le cadre de tâches individuelles que de s'appuyer sur un fichier de verrouillage, donnant aux auteurs de packages la possibilité de casser l'application avec une mise à jour mineure à tout moment (les chanceux qui ne l'ont pas rencontré).
Le package eslint-custom aura les dépendances suivantes:
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" } }
Pour connecter les trois outils, il a fallu 5 packages auxiliaires ( eslint-plugin-prettier, eslint-config-prettier, stylelint-prettier, stylelint-config-prettier, prettier-eslint ) - vous devez payer un tel prix aujourd'hui. Pour une commodité maximale, seul le tri automatique des importations n'est pas suffisant, mais, malheureusement, ce plugin perd des lignes lors du reformatage d'un fichier.
Les fichiers de configuration pour tous les outils seront au format * .js ( eslint.config.js , stylelint.config.js ) afin que la mise en forme du code fonctionne sur eux. Les règles peuvent être au format * .yaml , ventilées par modules sémantiques. Les versions complètes des configurations et des règles se trouvent dans le référentiel .
Il reste Ă ajouter les commandes dans le package principal.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" } }
... et configurez votre IDE pour appliquer le formatage lors de l'enregistrement du fichier actuel. Pour garantir cela, lors de la création d'un commit, vous devez utiliser un hook git qui vérifiera et formatera tous les fichiers du projet. Pourquoi pas seulement ceux qui sont présents dans le commit? Pour le principe de responsabilité collective pour l'ensemble de la base de code, afin que personne ne soit tenté de contourner la validation. Pour ce faire, lors de la création d'une validation, tous les avertissements de linter seront considérés comme des erreurs en utilisant --max-warnings=0
.
{ "husky": { "hooks": { "pre-commit": "npm run format:js -- --max-warnings=0 ./ && npm run format:style ./**/*.scss" } } }
Assemblage / Transport
Encore une fois, je vais utiliser l'approche modulaire et supprimer tous les paramètres de Webpack et Babel dans le dossier ./webpack-custom. La configuration s'appuiera sur la structure de fichiers suivante:
. |
Un constructeur correctement configuré fournira:
- la capacité d'écrire du code en utilisant la syntaxe et les capacités de la dernière spécification EcmaScript, y compris des propositions pratiques (les décorateurs de classe et leurs propriétés pour MobX sont certainement utiles ici);
- serveur local avec rechargement Ă chaud;
- mesures des performances de l'assemblage;
- vérification des dépendances cycliques;
- analyse de la structure et de la taille du fichier résultant;
- optimisation et minification pour l'assemblage de production;
- interprétation des fichiers * .scss modulaires et possibilité de supprimer les fichiers * .css terminés d'un ensemble;
- fichiers insérés en ligne * .svg ;
- préfixes polyfill / style pour les navigateurs cibles;
- résoudre le problème de la mise en cache des fichiers en production.
Il sera également configuré de manière pratique. Je vais résoudre ce problème à l'aide de deux exemples de fichiers * .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
Ainsi, pour démarrer l'assemblage, vous devez créer un fichier avec le nom .frontend.env et la présence obligatoire de tous les paramètres. Cette approche résoudra plusieurs problèmes à la fois: pas besoin de créer des fichiers de configuration séparés pour Webpack et de maintenir leur cohérence; localement, vous pouvez configurer combien un développeur particulier en a besoin; les développeurs de déploiement copient uniquement le fichier de l'assemblage de production ( cp .frontend.env.prod.example .frontend.env
), enrichissant les valeurs du référentiel, les développeurs frontaux ont donc la possibilité de gérer la recette via des variables sans utiliser d'administrateurs. De plus, il sera possible de faire un exemple de configuration pour les stands (par exemple, avec des cartes sources).
Pour séparer les styles en fichiers avec CSS_EXTRACT activé, j'utiliserai le plugin mini-css-extract- il vous permet d'utiliser le rechargement à chaud. Autrement dit, si vous activez HOT_RELOAD et CSS_EXTRACT pour le développement local, puis avec
seuls les styles seront rechargés lors de la modification des fichiers de styles - mais, malheureusement, tout, pas seulement le fichier modifié. Avec CSS_EXTRACT désactivé, seul le module de style modifié sera mis à jour.
HMR pour travailler avec React Hooks est inclus assez standard:
webpack.HotModuleReplacementPlugin
dans les plugins;hot: true
dans les paramètres webpack-dev-server ;react-hot-loader/babel
dans les plugins babel-loader ;options.hmr: true
dans mini-css-extract-plugin ;export default hot(App)
dans le composant principal de l'application;- @ hot-loader / react-dom au lieu du react-dom habituel (commodément via
resolve.alias: { 'react-dom': '@hot-loader/react-dom' }
);
La version actuelle de react-hot-loader ne prend pas en charge la mémorisation de composants à l'aide de React.memo
, donc lors de l'écriture de décorateurs pour MobX, vous devrez en tenir compte pour la commodité du développement local. Un autre inconvénient causé par cela est que lorsque Highlight Updates est activé dans React Developer Tools, tous les composants sont mis à jour lors de toute interaction avec l'application. Par conséquent, lorsque vous travaillez localement sur l'optimisation des performances, le paramètre HOT_RELOAD doit être désactivé.
L'optimisation de build dans Webpack 4 est effectuée automatiquement lorsque le mode : 'development' | 'production'
mode : 'development' | 'production'
. Dans ce cas, je me fie à l'optimisation standard (+ inclusion du paramètre keep_fnames: true
dans le plugin terser-webpack- pour enregistrer le nom des composants), car il est déjà bien réglé.
La séparation des morceaux et le contrôle de la mise en cache du client méritent une attention particulière. Pour un fonctionnement correct, vous avez besoin de:
- dans output.filename pour les fichiers js et css, spécifiez
isProduction ? '[name].[contenthash].js' : '[name].js'
isProduction ? '[name].[contenthash].js' : '[name].js'
(avec l'extension .css respectivement) afin que le nom du fichier soit basé sur son contenu; - dans l'optimisation, changez les paramètres en
chunkIds: 'named', moduleIds: 'hashed'
afin que le compteur de module interne dans webpack ne change pas; - mettre le runtime dans un bloc séparé;
- déplacer les groupes de cache vers splitChunks (quatre points suffisent pour cette application - lodash, sentinelle, highcharts et vendeur pour les autres dépendances de node_modules ). Étant donné que les trois premiers seront rarement mis à jour, ils resteront dans le cache du navigateur du client aussi longtemps que possible.
webpack-custom / config / configOptimization.js 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, }, }), ], };
Pour accélérer l'assemblage dans ce projet, j'utilise un chargeur de threads - lorsqu'il est parallélisé à 4 processus, il a donné une accélération de 90% à l'assemblage, ce qui est mieux que happypack avec les mêmes paramètres.
Les paramètres pour les chargeurs, y compris pour babel, dans des fichiers séparés (comme .babelrc ) à éteindre , je pense, ne sont pas nécessaires. Mais la configuration multi-navigateur est plus pratique à conserver dans le paramètre Browserslist du package.json principal, car il est également utilisé pour les styles de préfixe automatique.
Pour la commodité de travailler avec Prettier, j'ai créé le paramètre AGGREGATION_TIMEOUT , qui vous permet de définir le délai entre la détection des changements dans les fichiers et la reconstruction de l'application en mode dev-server. Puisque j'ai configuré le reformatage des fichiers lors de l'enregistrement dans l'IDE, cela provoque 2 reconstructions - la première pour enregistrer le fichier d'origine, la seconde pour terminer le formatage. 2000 millisecondes suffisent généralement au webpack pour attendre la version finale du fichier.
Le reste de la configuration ne mérite pas une attention particulière, car il est divulgué dans des centaines de supports de formation pour les débutants, vous pouvez donc procéder à la conception de l'architecture de l'application.
Thèmes de style
Auparavant, pour créer des thèmes, vous deviez créer plusieurs versions des fichiers * .css et recharger la page lors du changement de thème, en chargeant le jeu de styles souhaité. Maintenant, tout est facilement résolu en utilisant des propriétés CSS personnalisées . Cette technologie est prise en charge par tous les navigateurs cibles de l'application actuelle, mais il existe également des polyfills pour IE.
Disons qu'il y a 2 thèmes - clair et foncé, dont les jeux de couleurs seront
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); }
Pour que ces variables soient appliquées globalement, elles doivent être écrites dans document.documentElement
, respectivement, un petit analyseur est nécessaire pour convertir ce fichier en objet javascript. Plus tard, je vous dirai pourquoi c'est plus pratique que de le stocker immédiatement en 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)}`; };
Ici, la cohérence est vérifiée par cela - c'est-à -dire la correspondance complète de l'ensemble de variables, avec la différence de laquelle l'assemblage tombe.
Lorsque vous utilisez ce chargeur, un assez bel objet avec des paramètres est obtenu, et quelques lignes pour l'utilitaire de changement de thème suffisent:
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); }); }
Je préfère traduire ces variables css en variables standard pour * .scss :
src / styles / constants.scss L'IDE WebStorm, comme le montre la capture d'écran, affiche les couleurs dans le panneau de gauche et en cliquant sur la couleur, une palette s'ouvre où vous pouvez la modifier. La nouvelle couleur est automatiquement substituée dans themes.scss , Hot Reload fonctionnera et l'application sera instantanément transformée. C'est exactement le niveau de commodité de développement attendu en 2019.
Principes d'organisation du code
Dans ce projet, je m'en tiendrai aux noms de dossier en double pour les composants, les fichiers et les styles, par exemple:
. |
En conséquence, package.json aura le contenu { "main": "Chart.js" }
. Pour les composants avec plusieurs exportations nommées (par exemple, les utilitaires), le nom du fichier principal commencera par un trait de soulignement:
. |
Et le reste des fichiers sera exporté en tant que:
export * from './someUtil'; export * from './anotherUtil';
Cela vous permettra de vous débarrasser des noms de fichiers en double afin de ne pas vous perdre dans les dix premiers index.js / style.scss ouverts . Vous pouvez résoudre ce problème avec les plugins IDE, mais pourquoi pas de manière universelle.
Je vais regrouper les composants page par page, à l'exception des composants généraux comme Message / Lien, et aussi, si possible, utiliser des exportations nommées (sans export default
) pour maintenir l'uniformité des noms, la facilité de refactorisation et la recherche de projet.
Configurer le rendu et le stockage MobX
Le fichier qui sert de point d'entrée pour Webpack ressemblera à ceci:
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);
Puisque lorsque vous travaillez avec des observables, la console affiche quelque chose comme Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration}
, en polyfills Je vais faire un utilitaire de standardisation:
src / polyfill.js import { toJS } from 'mobx'; console.js = function consoleJsCustom(...args) { console.log(...args.map(arg => toJS(arg))); };
De plus, les styles globaux et la normalisation des styles pour différents navigateurs sont connectés dans le fichier principal, s'il existe une clé pour Sentry dans .env.fr, les erreurs commencent à se connecter, le stockage MobX est créé, le suivi des changements de paramètres avec l'exécution automatique est lancé et le composant enveloppé dans react-hot-loader est monté dans le DOM.
Le référentiel lui-même sera une classe non observable dont les paramètres sont des classes non observables avec des paramètres observables. Ainsi, il est entendu que l'ensemble des paramètres ne sera pas dynamique - par conséquent, l'application sera plus prévisible. C'est l'un des rares endroits où JSDoc est utile pour activer l'auto-complétion dans l'EDI.
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'; 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); } }
Un exemple de magasin MobX peut être analysé à l'aide de l'exemple de GlobalStore, qui n'aura pour le moment que le but - de stocker et de définir le thème de style actuel.
src / stores / GlobalStore.js import { makeObservable, setTheme } from 'utils'; import themes from 'styles/themes.scss'; const themesList = Object.keys(themes); @makeObservable export class GlobalStore { constructor(rootStore) { this.rootStore = rootStore; setTheme(themesList[0]); } themesList = themesList; currentTheme = ''; setTheme(theme) { this.currentTheme = theme; setTheme(theme); } }
Parfois, les paramètres et la méthode de la classe définissent manuellement le type à l'aide de décorateurs, par exemple:
export class GlobalStore { @observable currentTheme = ''; @action.bound setTheme(theme) { this.currentTheme = theme; setTheme(theme); } }
Mais je ne vois aucun intérêt à cela, car les anciens décorateurs de classe de proposition prennent en charge leur transformation automatique, l'utilitaire suivant suffit donc:
src / utils / makeObservable.js import { action, computed, decorate, observable } from 'mobx'; export function makeObservable(target) { 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) => { 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; }; }
Pour l'utiliser, vous devez ajuster les plugins dans loaderBabel.js : ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }]
, et dans les paramètres ESLint, définissez parserOptions.ecmaFeatures.legacyDecorators: true
conséquence. Sans ces paramètres, seul le descripteur de classe sans prototype est transmis au décorateur cible, et malgré des recherches minutieuses de la version actuelle de Proposition , je n'ai pas trouvé de moyen d'envelopper les méthodes et les propriétés statiques.
En général, la configuration du stockage est terminée, mais il serait bien de libérer le potentiel de l'exécution automatique de MobX. À cet effet, les tâches comme «attendre une réponse du serveur d'autorisation» ou «télécharger les traductions du serveur», puis écrire les réponses sur le serveur et rendre directement l'application dans le DOM, sont les mieux adaptées. Par conséquent, je vais courir un peu dans le futur et créer un magasin avec localisation:
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 { 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; } }
Comme vous pouvez le voir, il existe des fichiers * .json avec des traductions, et le chargement asynchrone utilisant setTimeout est émulé dans le constructeur de classe. Lorsqu'il est exécuté, le GlobalStore récemment créé est marqué avec this.rootStore.global.shouldAppRender = true
.
Ainsi, depuis app.js, vous devez transférer la fonction de rendu dans le fichier autorun.js :
src / autorun.js 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); } export function initAutorun(store) { autorun(reaction => { if (store.global.shouldAppRender) { renderToDOM(App); } logReason('shouldAppRender', reaction); }); }
Dans la fonction initAutorun , il peut y avoir un certain nombre de constructions à exécution automatique avec des rappels qui ne fonctionneront que lorsqu'elles initieront et modifieront elles-mêmes une variable à l'intérieur d'un rappel particulier. Dans ce cas, autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true;
et a provoqué le rendu de l'application dans le DOM. Un outil puissant qui vous permet d'enregistrer toutes les modifications dans le magasin et d'y répondre en conséquence.
Localisation et réactivité des crochets
La traduction dans d'autres langues est l'une des tâches les plus volumineuses, dans les petites entreprises, elle est souvent sous-estimée des dizaines de fois, et dans les grandes entreprises, elle est inutilement trop compliquée. En fonction de sa mise en œuvre, combien de nerfs et de temps ne seront pas perdus à la fois dans plusieurs départements de l'entreprise. Je ne mentionnerai dans l'article que la partie client avec un backlog pour une future intégration avec d'autres systèmes.
Pour la commodité du développement frontal, vous devez être capable de:
- définir des noms sémantiques pour les constantes;
- Insérer des variables dynamiques
- indiquer singulier / pluriel;
- — -;
- ;
- / ;
- ;
- () ;
- () , .
, , : messages.js ( ) . . ( / ), . ( , , ) . .
, currentLanguage i18n , , .
src/components/TestLocalization.js import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; const messages = { hello: ' {count} {count: ,,}', }; function TestLocalization() { const getLn = useLocalization(__filename, messages); return <div>{getLn(messages.hello, { count: 1 })}</div>; } export const TestLocalizationConnected = observer(TestLocalization);
, MobX- , , Connected. , ESLint, .
observer mobx-react-lite/useObserver
, HOT_RELOAD React.memo ( PureMixin / PureComponent ), useObserver :
src/utils/observer.js import { useObserver } from 'mobx-react-lite'; import React from 'react'; function copyStaticProperties(base, target) { const hoistBlackList = { $$typeof: true, render: true, compare: true, type: true, }; Object.keys(base).forEach(key => { if (base.hasOwnProperty(key) && !hoistBlackList[key]) { Object.defineProperty( target, key, Object.getOwnPropertyDescriptor(base, key) ); } }); } export function observer(baseComponent, options) { const baseComponentName = baseComponent.displayName || baseComponent.name; function wrappedComponent(props, ref) { return useObserver(function applyObserver() { return baseComponent(props, ref); }, baseComponentName); } wrappedComponent.displayName = baseComponentName; let memoComponent = null; if (HOT_RELOAD === 'true') { memoComponent = wrappedComponent; } else if (options.forwardRef) { memoComponent = React.memo(React.forwardRef(wrappedComponent)); } else { memoComponent = React.memo(wrappedComponent); } copyStaticProperties(baseComponent, memoComponent); memoComponent.displayName = baseComponentName; return memoComponent; }
displayName
, React- ( stack trace ).
RootStore:
src/hooks/useStore.js import React from 'react'; import { store } from 'stores'; const storeContext = React.createContext(store); export function useStore() { return React.useContext(storeContext); }
, observer:
import React from 'react'; import { observer } from 'utils'; import { useStore } from 'hooks'; function TestComponent() { const store = useStore(); return <div>{store.i18n.currentLanguage}</div>; } export const TestComponentConnected = observer(TestComponent);
TestLocalization — useLocalization:
src/hooks/useLocalization.js import _ from 'lodash'; import { declOfNum } from 'utils'; import { useStore } from './useStore'; const showNoTextMessage = false; function replaceDynamicParams(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithValues = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { messageWithValues = formattedMessage.replace(`{${paramName}}`, value); }); return messageWithValues; } function replacePlurals(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithPlurals = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { const pluralPattern = new RegExp(`{${paramName}:\\s([^}]*)}`); const pluralMatch = formattedMessage.match(pluralPattern); if (pluralMatch && pluralMatch[1]) { messageWithPlurals = formattedMessage.replace( pluralPattern, declOfNum(value, pluralMatch[1].split(',')) ); } }); return messageWithPlurals; } export function useLocalization(filename, messages) { const { i18n: { i18n, currentLanguage }, } = useStore(); return function getLn(text, values) { const key = _.findKey(messages, message => message === text); const localizedText = _.get(i18n, [filename, key]); if (!localizedText && showNoTextMessage) { console.error( `useLocalization: no localization for lang '${currentLanguage}' in ${filename} ${key}` ); } let formattedMessage = localizedText || text; formattedMessage = replaceDynamicParams(values, formattedMessage); formattedMessage = replacePlurals(values, formattedMessage); return formattedMessage; }; }
replaceDynamicParams replacePlurals — , , , , , ..
Webpack — __filename — , , . , — , , . , :
useLocalization: no localization for lang 'ru' in src\components\TestLocalization\TestLocalization.js hello
ru.json :
src/localization/ru.json { "src\\components\\TestLocalization\\TestLocalization.js": { "hello": " {count} {count: ,,}" } }
, . src/localization/en.json « » setLocalization I18nStore.
«» React Message:
src/components/Message/Message.js import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; function Message(props) { const { filename, messages, text, values } = props; const getLn = useLocalization(filename, messages); return getLn(text, values); } const ConnectedMessage = observer(Message); export function init(filename, messages) { return function MessageHoc(props) { const fullProps = { filename, messages, ...props }; return <ConnectedMessage {...fullProps} />; }; }
__filename ( id ), , :
const Message = require('components/Message').init( __filename, messages ); <Message text={messages.hello} values={{ count: 1 }} />
— useLocalization ( currentLanguage , Message — . , , .
, ( , , , / production). id , messages.js *.json , . ( / ), production. , , .
MobX + Hooks . , backend, , , .
API
( backend, ) — , , . , . :
src/stores/CurrentTPStore.js import _ from 'lodash'; import { makeObservable } from 'utils'; import { apiRoutes, request } from 'api'; @makeObservable export class CurrentTPStore { 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 :
- apiRoutes ;
- , route.params, , omitParam ;
- URL
route.url
— , , — get- URL; - fetch, JSON;
- ,
route.responseObject
route.responseArray
( ). , — , ; - / / / , ( fetchSymbolError ) .
. , Sentry, response:

— ( ), .
, , . , :
- ;
- pathname search;
- / ;
- location ;
- beforeEnter, isLoading, ;
- : , , , beforeEnter, ;
- / ;
- / ;
- .
«», -, — . :
src/routes.js export const routes = { marketDetailed: { name: 'marketDetailed', path: '/market/:market/:pair', masks: { pair: /^[a-zA-Z]{3,5}-[a-zA-Z]{3}$/, market: /^[a-zA-Z]{3,4}$/, }, beforeEnter(route, store) { const { params: { pair, market }, } = route; const [symbol, tradedCurrency] = pair.split('-'); const prevMarket = store.marketsList.currentMarket; function optimisticallyUpdate() { store.marketsList.currentMarket = market; } return Promise.resolve() .then(optimisticallyUpdate) .then(store.marketsList.fetchSymbolsList) .then(store.rates.fetchRates) .then(() => store.marketsList.fetchMarketList(market, prevMarket)) .then(() => store.currentTP.fetchSymbol({ symbol, tradedCurrency, }) ) .catch(error => { console.error(error); }); }, }, error404: { name: 'error404', path: '/error404', }, };
src/routeComponents.js import { MarketDetailed } from 'pages/MarketDetailed'; import { Error404 } from 'pages/Error404'; export const routeComponents = { marketDetailed: MarketDetailed, error404: Error404, };
, , — <Link route={routes.marketDetailed}>
, . Webpack , .
, location .
src/stores/RouterStore.js import _ from 'lodash'; import { makeObservable } from 'utils'; import { routes } from 'routes'; @makeObservable export class RouterStore { constructor(rootStore) { this.rootStore = rootStore; this.currentRoute = this._fillRouteSchemaFromUrl(); window.addEventListener('popstate', () => { this.currentRoute = this._fillRouteSchemaFromUrl(); }); } currentRoute = null; _fillRouteSchemaFromUrl() { const pathnameArray = window.location.pathname.split('/'); const routeName = this._getRouteNameMatchingUrl(pathnameArray); if (!routeName) { const currentRoute = routes.error404; window.history.pushState(null, null, currentRoute.path); return currentRoute; } const route = routes[routeName]; const routePathnameArray = route.path.split('/'); const params = {}; routePathnameArray.forEach((pathParam, i) => { const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') === 0) { const paramName = pathParam.replace(':', ''); params[paramName] = urlParam; } }); return Object.assign({}, route, { params, isLoading: true }); } _getRouteNameMatchingUrl(pathnameArray) { return _.findKey(routes, route => { const routePathnameArray = route.path.split('/'); if (routePathnameArray.length !== pathnameArray.length) { return false; } for (let i = 0; i < routePathnameArray.length; i++) { const pathParam = routePathnameArray[i]; const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') !== 0) { if (pathParam !== urlParam) { return false; } } else { const paramName = pathParam.replace(':', ''); const paramMask = _.get(route.masks, paramName); if (paramMask && !paramMask.test(urlParam)) { return false; } } } return true; }); } replaceDynamicParams(route, params) { return Object.entries(params).reduce((pathname, [paramName, value]) => { return pathname.replace(`:${paramName}`, value); }, route.path); } goTo(route, params) { if (route.name === this.currentRoute.name) { if (_.isEqual(this.currentRoute.params, params)) { return false; } this.currentRoute.isLoading = true; this.currentRoute.params = params; const newPathname = this.replaceDynamicParams(this.currentRoute, params); window.history.pushState(null, null, newPathname); return false; } const newPathname = this.replaceDynamicParams(route, params); window.history.pushState(null, null, newPathname); this.currentRoute = this._fillRouteSchemaFromUrl(); } }
— routes.js . — 404. , « », , , — , 'test-test'.
currentRoute , params ( URL) isLoading: true
. React- Router:
src/components/Router.js import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; import { routeComponents } from 'routeComponents'; function getRouteComponent(route, isLoading) { const Component = routeComponents[route.name]; if (!Component) { console.error( `getRouteComponent: component for ${ route.name } is not defined in routeComponents` ); return null; } return <Component isLoading={isLoading} />; } function useBeforeEnter() { const store = useStore(); const { currentRoute } = store.router; React.useEffect(() => { if (currentRoute.isLoading) { const beforeEnter = _.get(currentRoute, 'beforeEnter'); if (_.isFunction(beforeEnter)) { Promise.resolve() .then(() => beforeEnter(currentRoute, store)) .then(() => { currentRoute.isLoading = false; }) .catch(error => console.error(error)); } else { currentRoute.isLoading = false; } } }); return currentRoute.isLoading; } function Router() { const { router: { currentRoute }, } = useStore(); const isLoading = useBeforeEnter(); return getRouteComponent(currentRoute, isLoading); } export const RouterConnected = observer(Router);
, , currentRoute == null
. — isLoading === true
, false , route.beforeEnter
( ). console.error
, , .
, — , . React- 2 :
- componentWillMount / componentDidMount / useEffect , , . — , «». — — ;
- ( ) , . — — , . — / — real-time , / .
, — ( , , ..), .
beforeEnter , : « », ( , , ), — ( — 500 ; ; , ; ..). «» , MVP .
:
src/components/Link.js import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; function checkRouteParamsWithMasks(route, params) { if (route.masks) { Object.entries(route.masks).forEach(([paramName, paramMask]) => { const value = _.get(params, paramName); if (paramMask && !paramMask.test(value)) { console.error( `checkRouteParamsWithMasks: wrong param for ${paramName} in Link to ${ route.name }: ${value}` ); } }); } } function Link(props) { const store = useStore(); const { currentRoute } = store.router; const { route, params, children, onClick, ...otherProps } = props; checkRouteParamsWithMasks(route, params); const filledPath = store.router.replaceDynamicParams(route, params); return ( <a href={filledPath} onClick={e => { e.preventDefault(); if (currentRoute.isLoading) { return false; } store.router.goTo(route, params); if (onClick) { onClick(); } }} {...otherProps} > {children} </a> ); } export const LinkConnected = observer(Link);
route , params ( ) ( ) href . , beforeEnter , . «, », , — .
Mesures
- ( , , , , ) . . .
— , — , . :
src/api/utils/metrics.js import _ from 'lodash'; let metricsArray = []; let sendMetricsCallback = null; export function startMetrics(route, apiRoutes) { return function promiseCallback(data) { clearTimeout(sendMetricsCallback); const apiRouteName = _.findKey(apiRoutes, route); metricsArray.push({ id: apiRouteName, time: new Date().getTime(), }); return data; }; } export function stopMetrics(route, apiRoutes) { return function promiseCallback(data) { const apiRouteName = _.findKey(apiRoutes, route); const metricsData = _.find(metricsArray, ['id', apiRouteName]); metricsData.time = new Date().getTime() - metricsData.time; clearTimeout(sendMetricsCallback); sendMetricsCallback = setTimeout(() => { console.log('Metrics sent:', metricsArray); metricsArray = []; }, 2000); return data; }; }
middleware request :
export function request(route, params) { return Promise.resolve() .then(startMetrics(route, apiRoutes)) .then(validateRequestParams(route, params)) .then(makeRequestUrl(route, params)) .then(makeRequest) .then(validateResponse(route, params)) .then(stopMetrics(route, apiRoutes)) .catch(error => { stopMetrics(route, apiRoutes)(); throw error; }); }
, , 2 , ( ) . — , — , ( ) , .
- — .
end-to-end , Cypress. : ; , ; Continious Integration.
javascript Chai / Sinon , . , , — ./tests, package.json — "dependencies": { "cypress": "3.2.0" }
. Webpack :
tests/cypress/plugins/index.js const webpack = require('../../../node_modules/@cypress/webpack-preprocessor'); const webpackConfig = require('../../../webpack-custom/webpack.config'); module.exports = on => { const options = webpack.defaultOptions; options.webpackOptions.module = webpackConfig.module; options.webpackOptions.resolve = webpackConfig.resolve; on('file:preprocessor', webpack(options)); };
. module ( ) resolve ( ). ESLint ( describe , cy ) eslint-plugin-cypress . , :
tests/cypress/integration/mixed.js describe('Market Listing good scenarios', () => { it('Lots of mixed tests', () => { cy.visit('/market/usd/bch-usd'); cy.location('pathname').should('equal', '/market/usd/bch-usd');
Cypress fetch, , :
tests/cypress/support/index.js import { apiRoutes } from 'api'; let polyfill = null; before(() => { const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js'; cy.request(polyfillUrl).then(response => { polyfill = response.body; }); }); Cypress.on('window:before:load', window => { delete window.fetch; window.eval(polyfill); window.fetch = window.unfetch; }); before(() => { cy.server(); cy.route(`${apiRoutes.symbolsList.url}**`).as('symbolsList'); cy.route(`${apiRoutes.rates.url}**`).as('rates'); cy.route(`${apiRoutes.marketsList.url}**`).as('marketsList'); cy.route(`${apiRoutes.symbolInfo.url({ id: 'bitcoin-cash' })}**`).as( 'symbolInfo' ); cy.route(`${apiRoutes.chartData.url}**`).as('chartData'); });
, .
, ?
, - . , , - / - / .
, , , - , - , (real-time , serviceWorker, CI, , , -, , ..).
( Gzip) :

React Developer Tools :

React Hooks + MobX , Redux. , . , , , . . !
Update 13.07.2019
, , :
1. yarn.lock , yarn install --force
, "upd": "yarn install && yarn add file:./eslint-custom && yarn add file:./webpack-custom"
. ESLint Webpack.
2. webpack-custom/config/configOptimization.js, ,
lodash: { test: module => module.context.indexOf('node_modules\\lodash') !== -1, name: 'lodash', chunks: 'all', enforce: true, }
lodash: { test: /node_modules[\\/]lodash/, name: 'lodash', chunks: 'all', enforce: true, }
3. useLocalization(__filename, messages)
, —
const messages = { hello: { value: ' {count} {count: ,,}', name: "src/components/TestLocalization/TestLocalization.hello", } };
,
const messagesDefault = { hello: ' {count} {count: ,,}', }; export const messages = Object.keys(messagesDefault).reduce((acc, key) => { acc[key] = { value: messagesDefault[key], name: __dirname.toLowerCase().replace(/\\\\/g, '/') + '.' + key, }; return acc; }, {});
IDE , Webpack:
webpack-custom/utils/messagesLoader.js module.exports = function messagesLoader(source) { if (source.indexOf('export const messages = messagesDefault;') !== -1) { return source.replace( 'export const messages = messagesDefault;', ` export const messages = Object.keys(messagesDefault).reduce((acc, key) => { acc[key] = { value: messagesDefault[key], name: __dirname.toLowerCase().replace(/\\\\/g, '/') + '.' + key, }; return acc; }, {}); ` ); } return source; };
, messages.js :
const messagesDefault = { someText: '', }; export const messages = messagesDefault;
, app.js , messages.js , *.json :
src/utils/checkLocalization.js import _ from 'lodash'; import ru from 'localization/ru.json'; const showNoTextMessage = true; export function checkLocalization() { const context = require.context('../', true, /messages\.js/); const messagesFiles = context.keys(); const notLocalizedObject = {}; messagesFiles.forEach(path => { const fileExports = context(path); const { messages } = fileExports; _.values(messages).forEach(({ name, value }) => { if (ru[name] == null) { notLocalizedObject[name] = value; } }); }); if (showNoTextMessage && _.size(notLocalizedObject) > 0) { console.log( 'No localization for lang ru:', JSON.stringify(notLocalizedObject, null, 2) ); } }
,
No localization for lang ru: { "src/components/TestLocalization/TestLocalization.hello": " {count} {count: ,,}" }
*.json — , , .
3. Lodash someResponseParam: _.isString
, . :
src/utils/validateObjects.js import _ from 'lodash'; import { createError } from './createError'; import { errorsNames } from 'const'; export const validators = { isArray(v) { return _.isArray(v); }, isString(v) { return _.isString(v); }, isNumber(v) { return _.isNumber(v); }, isBoolean(v) { return _.isBoolean(v); }, isPlainObject(v) { return _.isPlainObject(v); }, isArrayNotRequired(v) { return _.isArray(v) || _.isNil(v); }, isStringNotRequired(v) { return _.isString(v) || _.isNil(v); }, isNumberNotRequired(v) { return _.isNumber(v) || _.isNil(v); }, isBooleanNotRequired(v) { return _.isBoolean(v) || _.isNil(v); }, isPlainObjectNotRequired(v) { return _.isPlainObject(v) || _.isNil(v); }, omitParam() { return true; }, }; validators.isArray.notRequired = validators.isArrayNotRequired; validators.isString.notRequired = validators.isStringNotRequired; validators.isNumber.notRequired = validators.isNumberNotRequired; validators.isBoolean.notRequired = validators.isBooleanNotRequired; validators.isPlainObject.notRequired = validators.isPlainObjectNotRequired; export function validateObjects( { validatorsObject, targetObject, prefix }, otherArg ) { if (!_.isPlainObject(validatorsObject)) { throw new Error(`validateObjects: validatorsObject is not an object`); } if (!_.isPlainObject(targetObject)) { throw new Error(`validateObjects: targetObject is not an object`); } Object.entries(validatorsObject).forEach(([paramName, validator]) => { const paramValue = targetObject[paramName]; if (!validator(paramValue, otherArg)) { const validatorName = _.findKey(validators, v => v === validator); throw createError( errorsNames.VALIDATION, `${prefix || ''}${paramName}${ _.isString(validatorName) ? ` [${validatorName}]` : '' }` ); } }); }
— , someResponseParam [isString]
, , . someResponseParam: validators.isString.notRequired
, . , , someResponseArray: arrayShape({ someParam: isString })
, .
5. , , . — ( body
isEntering
, isLeaving
) beforeLeave ( Prompt react-router ), false- ,
somePage: { path: '/some-page', beforeLeave(store) { return store.modals.raiseConfirm(' ?'); }, }
, - . , :
— /some/long/auth/path
beforeEnter /auth
— beforeEnter /auth
, — /profile
— beforeEnter /profile
, , , /profile/edit
, — window.history.pushState
location.replace
. , . « » « componentDidMount », , .
6. (, ) :
src/utils/withState.js export function withState(target, fnName, fnDescriptor) { const original = fnDescriptor.value; fnDescriptor.value = function fnWithState(...args) { if (this.executions[fnName]) { return Promise.resolve(); } return Promise.resolve() .then(() => { this.executions[fnName] = true; }) .then(() => original.apply(this, args)) .then(data => { this.executions[fnName] = false; return data; }) .catch(error => { this.executions[fnName] = false; throw error; }); }; return fnDescriptor; }
src/stores/CurrentTPStore.js import _ from 'lodash'; import { makeObservable, withState } from 'utils'; import { apiRoutes, request } from 'api'; @makeObservable export class CurrentTPStore { constructor(rootStore) { this.rootStore = rootStore; this.executions = {}; } @withState fetchSymbol() { return request(apiRoutes.symbolInfo) .then(this.fetchSymbolSuccess) .catch(this.fetchSymbolError); } fetchSymbolSuccess(data) { return Promise.resolve(); } fetchSymbolError(error) { console.error(error); } }
src/components/TestComponent.js import React from 'react'; import { observer } from 'utils'; import { useStore } from 'hooks'; function TestComponent() { const store = useStore(); const { currentTP: { executions } } = store; return <div>{executions.fetchSymbol ? '...' : ''}</div>; } export const TestComponentConnected = observer(TestComponent);
, .
, , . — , , , , - .