Architecture d'application Exchange SPA en 2019

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:


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

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
 /** * @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, }, }), ], }; 

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:

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

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:


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

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'; /** * @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); } } 

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 { /** * @param rootStore {RootStore} */ 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) { /** *   -   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; }; } 

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 { /** * @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; } } 

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
 /* 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); }); } 

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); /** * @returns {RootStore} * */ export function useStore() { return React.useContext(storeContext); } 

, observer:


 import React from 'react'; import { observer } from 'utils'; import { useStore } from 'hooks'; function TestComponent() { const store = useStore(); return <div>{store.i18n.currentLanguage}</div>; } export const TestComponentConnected = observer(TestComponent); 

TestLocalization — useLocalization:


src/hooks/useLocalization.js
 import _ from 'lodash'; import { declOfNum } from 'utils'; import { useStore } from './useStore'; const showNoTextMessage = false; function replaceDynamicParams(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithValues = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { messageWithValues = formattedMessage.replace(`{${paramName}}`, value); }); return messageWithValues; } function replacePlurals(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithPlurals = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { const pluralPattern = new RegExp(`{${paramName}:\\s([^}]*)}`); const pluralMatch = formattedMessage.match(pluralPattern); if (pluralMatch && pluralMatch[1]) { messageWithPlurals = formattedMessage.replace( pluralPattern, declOfNum(value, pluralMatch[1].split(',')) ); } }); return messageWithPlurals; } export function useLocalization(filename, messages) { const { i18n: { i18n, currentLanguage }, } = useStore(); return function getLn(text, values) { const key = _.findKey(messages, message => message === text); const localizedText = _.get(i18n, [filename, key]); if (!localizedText && showNoTextMessage) { console.error( `useLocalization: no localization for lang '${currentLanguage}' in ${filename} ${key}` ); } let formattedMessage = localizedText || text; formattedMessage = replaceDynamicParams(values, formattedMessage); formattedMessage = replacePlurals(values, formattedMessage); return formattedMessage; }; } 

replaceDynamicParams replacePlurals — , , , , , ..


Webpack — __filename — , , . , — , , . , :


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

ru.json :


src/localization/ru.json
 { "src\\components\\TestLocalization\\TestLocalization.js": { "hello": "  {count} {count: ,,}" } } 

, . src/localization/en.json « » setLocalization I18nStore.


«» React Message:


src/components/Message/Message.js
 import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; function Message(props) { const { filename, messages, text, values } = props; const getLn = useLocalization(filename, messages); return getLn(text, values); } const ConnectedMessage = observer(Message); export function init(filename, messages) { return function MessageHoc(props) { const fullProps = { filename, messages, ...props }; return <ConnectedMessage {...fullProps} />; }; } 

__filename ( id ), , :


 const Message = require('components/Message').init( __filename, messages ); <Message text={messages.hello} values={{ count: 1 }} /> 

— useLocalization ( currentLanguage , Message — . , , .


, ( , , , / production). id , messages.js *.json , . ( / ), production. , , .


MobX + Hooks . , backend, , , .


API


( backend, ) — , , . , . :


src/stores/CurrentTPStore.js
 import _ from 'lodash'; import { makeObservable } from 'utils'; import { apiRoutes, request } from 'api'; @makeObservable export class CurrentTPStore { /** * @param rootStore {RootStore} */ constructor(rootStore) { this.rootStore = rootStore; } id = ''; symbol = ''; fullName = ''; currency = ''; tradedCurrency = ''; low24h = 0; high24h = 0; lastPrice = 0; marketCap = 0; change24h = 0; change24hPercentage = 0; fetchSymbol(params) { const { tradedCurrency, id } = params; const { marketsList } = this.rootStore; const requestParams = { id, localization: false, community_data: false, developer_data: false, tickers: false, }; return request(apiRoutes.symbolInfo, requestParams) .then(data => this.fetchSymbolSuccess(data, tradedCurrency)) .catch(this.fetchSymbolError); } fetchSymbolSuccess(data, tradedCurrency) { const { id, symbol, name, market_data: { high_24h, low_24h, price_change_24h_in_currency, price_change_percentage_24h_in_currency, market_cap, current_price, }, } = data; this.id = id; this.symbol = symbol; this.fullName = name; this.currency = symbol; this.tradedCurrency = tradedCurrency; this.lastPrice = current_price[tradedCurrency]; this.high24h = high_24h[tradedCurrency]; this.low24h = low_24h[tradedCurrency]; this.change24h = price_change_24h_in_currency[tradedCurrency]; this.change24hPercentage = price_change_percentage_24h_in_currency[tradedCurrency]; this.marketCap = market_cap[tradedCurrency]; return Promise.resolve(); } fetchSymbolError(error) { console.error(error); } } 

, , . fetchSymbol , id , . , — ( @action.bound ), Sentry :


src/utils/initSentry.js
 import * as Sentry from '@sentry/browser'; export function initSentry() { if (SENTRY_URL !== 'false') { Sentry.init({ dsn: SENTRY_URL, }); const originalErrorLogger = console.error; console.error = function consoleErrorCustom(...args) { Sentry.captureException(...args); return originalErrorLogger(...args); }; } } 

, :


src/api/_api.js
 import _ from 'lodash'; import { omitParam, validateRequestParams, makeRequestUrl, makeRequest, validateResponse, } from 'api/utils'; export function request(route, params) { return Promise.resolve() .then(validateRequestParams(route, params)) .then(makeRequestUrl(route, params)) .then(makeRequest) .then(validateResponse(route, params)); } export const apiRoutes = { symbolInfo: { url: params => `https://api.coingecko.com/api/v3/coins/${params.id}`, params: { id: omitParam, localization: _.isBoolean, community_data: _.isBoolean, developer_data: _.isBoolean, tickers: _.isBoolean, }, responseObject: { id: _.isString, name: _.isString, symbol: _.isString, genesis_date: v => _.isString(v) || _.isNil(v), last_updated: _.isString, country_origin: _.isString, coingecko_rank: _.isNumber, coingecko_score: _.isNumber, community_score: _.isNumber, developer_score: _.isNumber, liquidity_score: _.isNumber, market_cap_rank: _.isNumber, block_time_in_minutes: _.isNumber, public_interest_score: _.isNumber, image: _.isPlainObject, links: _.isPlainObject, description: _.isPlainObject, market_data: _.isPlainObject, localization(value, requestParams) { if (requestParams.localization === false) { return true; } return _.isPlainObject(value); }, community_data(value, requestParams) { if (requestParams.community_data === false) { return true; } return _.isPlainObject(value); }, developer_data(value, requestParams) { if (requestParams.developer_data === false) { return true; } return _.isPlainObject(value); }, public_interest_stats: _.isPlainObject, tickers(value, requestParams) { if (requestParams.tickers === false) { return true; } return _.isArray(value); }, categories: _.isArray, status_updates: _.isArray, }, }, }; 

request :


  1. apiRoutes ;
  2. , route.params, , omitParam ;
  3. URL route.url — , , — get- URL;
  4. fetch, JSON;
  5. , route.responseObject route.responseArray ( ). , — , ;
  6. / / / , ( fetchSymbolError ) .

. , Sentry, response:



— ( ), .



, , . , :


  • ;
  • pathname search;
  • / ;
  • location ;
  • beforeEnter, isLoading, ;
  • : , , , beforeEnter, ;
  • / ;
  • / ;
  • .

«», -, — . :


src/routes.js
 export const routes = { marketDetailed: { name: 'marketDetailed', path: '/market/:market/:pair', masks: { pair: /^[a-zA-Z]{3,5}-[a-zA-Z]{3}$/, market: /^[a-zA-Z]{3,4}$/, }, beforeEnter(route, store) { const { params: { pair, market }, } = route; const [symbol, tradedCurrency] = pair.split('-'); const prevMarket = store.marketsList.currentMarket; function optimisticallyUpdate() { store.marketsList.currentMarket = market; } return Promise.resolve() .then(optimisticallyUpdate) .then(store.marketsList.fetchSymbolsList) .then(store.rates.fetchRates) .then(() => store.marketsList.fetchMarketList(market, prevMarket)) .then(() => store.currentTP.fetchSymbol({ symbol, tradedCurrency, }) ) .catch(error => { console.error(error); }); }, }, error404: { name: 'error404', path: '/error404', }, }; 

src/routeComponents.js
 import { MarketDetailed } from 'pages/MarketDetailed'; import { Error404 } from 'pages/Error404'; export const routeComponents = { marketDetailed: MarketDetailed, error404: Error404, }; 

, , — <Link route={routes.marketDetailed}> , . Webpack , .


, location .


src/stores/RouterStore.js
 import _ from 'lodash'; import { makeObservable } from 'utils'; import { routes } from 'routes'; @makeObservable export class RouterStore { /** * @param rootStore {RootStore} */ constructor(rootStore) { this.rootStore = rootStore; this.currentRoute = this._fillRouteSchemaFromUrl(); window.addEventListener('popstate', () => { this.currentRoute = this._fillRouteSchemaFromUrl(); }); } currentRoute = null; _fillRouteSchemaFromUrl() { const pathnameArray = window.location.pathname.split('/'); const routeName = this._getRouteNameMatchingUrl(pathnameArray); if (!routeName) { const currentRoute = routes.error404; window.history.pushState(null, null, currentRoute.path); return currentRoute; } const route = routes[routeName]; const routePathnameArray = route.path.split('/'); const params = {}; routePathnameArray.forEach((pathParam, i) => { const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') === 0) { const paramName = pathParam.replace(':', ''); params[paramName] = urlParam; } }); return Object.assign({}, route, { params, isLoading: true }); } _getRouteNameMatchingUrl(pathnameArray) { return _.findKey(routes, route => { const routePathnameArray = route.path.split('/'); if (routePathnameArray.length !== pathnameArray.length) { return false; } for (let i = 0; i < routePathnameArray.length; i++) { const pathParam = routePathnameArray[i]; const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') !== 0) { if (pathParam !== urlParam) { return false; } } else { const paramName = pathParam.replace(':', ''); const paramMask = _.get(route.masks, paramName); if (paramMask && !paramMask.test(urlParam)) { return false; } } } return true; }); } replaceDynamicParams(route, params) { return Object.entries(params).reduce((pathname, [paramName, value]) => { return pathname.replace(`:${paramName}`, value); }, route.path); } goTo(route, params) { if (route.name === this.currentRoute.name) { if (_.isEqual(this.currentRoute.params, params)) { return false; } this.currentRoute.isLoading = true; this.currentRoute.params = params; const newPathname = this.replaceDynamicParams(this.currentRoute, params); window.history.pushState(null, null, newPathname); return false; } const newPathname = this.replaceDynamicParams(route, params); window.history.pushState(null, null, newPathname); this.currentRoute = this._fillRouteSchemaFromUrl(); } } 

— routes.js . — 404. , « », , , — , 'test-test'.


currentRoute , params ( URL) isLoading: true . React- Router:


src/components/Router.js
 import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; import { routeComponents } from 'routeComponents'; function getRouteComponent(route, isLoading) { const Component = routeComponents[route.name]; if (!Component) { console.error( `getRouteComponent: component for ${ route.name } is not defined in routeComponents` ); return null; } return <Component isLoading={isLoading} />; } function useBeforeEnter() { const store = useStore(); const { currentRoute } = store.router; React.useEffect(() => { if (currentRoute.isLoading) { const beforeEnter = _.get(currentRoute, 'beforeEnter'); if (_.isFunction(beforeEnter)) { Promise.resolve() .then(() => beforeEnter(currentRoute, store)) .then(() => { currentRoute.isLoading = false; }) .catch(error => console.error(error)); } else { currentRoute.isLoading = false; } } }); return currentRoute.isLoading; } function Router() { const { router: { currentRoute }, } = useStore(); const isLoading = useBeforeEnter(); return getRouteComponent(currentRoute, isLoading); } export const RouterConnected = observer(Router); 

, , currentRoute == null . — isLoading === true , false , route.beforeEnter ( ). console.error , , .


, — , . React- 2 :


  1. componentWillMount / componentDidMount / useEffect , , . — , «». — — ;
  2. ( ) , . — — , . — / — real-time , / .

, — ( , , ..), .


beforeEnter , : « », ( , , ), — ( — 500 ; ; , ; ..). «» , MVP .


:


src/components/Link.js
 import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; function checkRouteParamsWithMasks(route, params) { if (route.masks) { Object.entries(route.masks).forEach(([paramName, paramMask]) => { const value = _.get(params, paramName); if (paramMask && !paramMask.test(value)) { console.error( `checkRouteParamsWithMasks: wrong param for ${paramName} in Link to ${ route.name }: ${value}` ); } }); } } function Link(props) { const store = useStore(); const { currentRoute } = store.router; const { route, params, children, onClick, ...otherProps } = props; checkRouteParamsWithMasks(route, params); const filledPath = store.router.replaceDynamicParams(route, params); return ( <a href={filledPath} onClick={e => { e.preventDefault(); if (currentRoute.isLoading) { return false; } store.router.goTo(route, params); if (onClick) { onClick(); } }} {...otherProps} > {children} </a> ); } export const LinkConnected = observer(Link); 

route , params ( ) ( ) href . , beforeEnter , . «, », , — .


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'); //    ,       cy.wait('@symbolsList') .its('response.body') .should(data => { expect(data).to.be.an('array'); }); //    cy.wait('@rates'); cy.wait('@marketsList'); cy.wait('@symbolInfo'); cy.wait('@chartData'); //       cy.get('#marketTab-eth').click(); cy.location('pathname').should('equal', '/market/eth/bch-usd'); cy.wait('@rates'); cy.wait('@marketsList'); //    cy.contains(''); cy.get('#langSwitcher-en').click(); cy.contains('Markets list'); //    cy.get('body').should('have.class', 'light'); cy.get('#themeSwitcher-dark').click(); cy.get('body').should('have.class', 'dark'); }); }); 

Cypress fetch, , :


tests/cypress/support/index.js
 import { apiRoutes } from 'api'; let polyfill = null; before(() => { const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js'; cy.request(polyfillUrl).then(response => { polyfill = response.body; }); }); Cypress.on('window:before:load', window => { delete window.fetch; window.eval(polyfill); window.fetch = window.unfetch; }); before(() => { cy.server(); cy.route(`${apiRoutes.symbolsList.url}**`).as('symbolsList'); cy.route(`${apiRoutes.rates.url}**`).as('rates'); cy.route(`${apiRoutes.marketsList.url}**`).as('marketsList'); cy.route(`${apiRoutes.symbolInfo.url({ id: 'bitcoin-cash' })}**`).as( 'symbolInfo' ); cy.route(`${apiRoutes.chartData.url}**`).as('chartData'); }); 

, .


, ?


, - . , , - / - / .


, , , - , - , (real-time , serviceWorker, CI, , , -, , ..).


( Gzip) :



React Developer Tools :



React Hooks + MobX , Redux. , . , , , . . !




Update 13.07.2019


, , :


1. yarn.lock , yarn install --force , "upd": "yarn install && yarn add file:./eslint-custom && yarn add file:./webpack-custom" . ESLint Webpack.


2. webpack-custom/config/configOptimization.js, ,


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


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

3. useLocalization(__filename, messages) , —


 const messages = { hello: { value: '  {count} {count: ,,}', name: "src/components/TestLocalization/TestLocalization.hello", } }; 

,


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

IDE , Webpack:


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

, messages.js :


 const messagesDefault = { someText: '', }; export const messages = messagesDefault; 

, app.js , messages.js , *.json :


src/utils/checkLocalization.js
 import _ from 'lodash'; import ru from 'localization/ru.json'; const showNoTextMessage = true; export function checkLocalization() { const context = require.context('../', true, /messages\.js/); const messagesFiles = context.keys(); const notLocalizedObject = {}; messagesFiles.forEach(path => { const fileExports = context(path); const { messages } = fileExports; _.values(messages).forEach(({ name, value }) => { if (ru[name] == null) { notLocalizedObject[name] = value; } }); }); if (showNoTextMessage && _.size(notLocalizedObject) > 0) { console.log( 'No localization for lang ru:', JSON.stringify(notLocalizedObject, null, 2) ); } } 

,


 No localization for lang ru: { "src/components/TestLocalization/TestLocalization.hello": "  {count} {count: ,,}" } 

*.json — , , .


3. Lodash someResponseParam: _.isString , . :


src/utils/validateObjects.js
 import _ from 'lodash'; import { createError } from './createError'; import { errorsNames } from 'const'; export const validators = { isArray(v) { return _.isArray(v); }, isString(v) { return _.isString(v); }, isNumber(v) { return _.isNumber(v); }, isBoolean(v) { return _.isBoolean(v); }, isPlainObject(v) { return _.isPlainObject(v); }, isArrayNotRequired(v) { return _.isArray(v) || _.isNil(v); }, isStringNotRequired(v) { return _.isString(v) || _.isNil(v); }, isNumberNotRequired(v) { return _.isNumber(v) || _.isNil(v); }, isBooleanNotRequired(v) { return _.isBoolean(v) || _.isNil(v); }, isPlainObjectNotRequired(v) { return _.isPlainObject(v) || _.isNil(v); }, omitParam() { return true; }, }; validators.isArray.notRequired = validators.isArrayNotRequired; validators.isString.notRequired = validators.isStringNotRequired; validators.isNumber.notRequired = validators.isNumberNotRequired; validators.isBoolean.notRequired = validators.isBooleanNotRequired; validators.isPlainObject.notRequired = validators.isPlainObjectNotRequired; export function validateObjects( { validatorsObject, targetObject, prefix }, otherArg ) { if (!_.isPlainObject(validatorsObject)) { throw new Error(`validateObjects: validatorsObject is not an object`); } if (!_.isPlainObject(targetObject)) { throw new Error(`validateObjects: targetObject is not an object`); } Object.entries(validatorsObject).forEach(([paramName, validator]) => { const paramValue = targetObject[paramName]; if (!validator(paramValue, otherArg)) { const validatorName = _.findKey(validators, v => v === validator); throw createError( errorsNames.VALIDATION, `${prefix || ''}${paramName}${ _.isString(validatorName) ? ` [${validatorName}]` : '' }` ); } }); } 

— , someResponseParam [isString] , , . someResponseParam: validators.isString.notRequired , . , , someResponseArray: arrayShape({ someParam: isString }) , .


5. , , . — ( body isEntering , isLeaving ) beforeLeave ( Prompt react-router ), false- ,


 somePage: { path: '/some-page', beforeLeave(store) { return store.modals.raiseConfirm('    ?'); }, } 

, - . , :


— /some/long/auth/path beforeEnter /auth
— beforeEnter /auth , — /profile
— beforeEnter /profile , , , /profile/edit


, — window.history.pushState location.replace . , . « » « componentDidMount », , .


6. (, ) :


src/utils/withState.js
 export function withState(target, fnName, fnDescriptor) { const original = fnDescriptor.value; fnDescriptor.value = function fnWithState(...args) { if (this.executions[fnName]) { return Promise.resolve(); } return Promise.resolve() .then(() => { this.executions[fnName] = true; }) .then(() => original.apply(this, args)) .then(data => { this.executions[fnName] = false; return data; }) .catch(error => { this.executions[fnName] = false; throw error; }); }; return fnDescriptor; } 

src/stores/CurrentTPStore.js
 import _ from 'lodash'; import { makeObservable, withState } from 'utils'; import { apiRoutes, request } from 'api'; @makeObservable export class CurrentTPStore { /** * @param rootStore {RootStore} */ constructor(rootStore) { this.rootStore = rootStore; this.executions = {}; } @withState fetchSymbol() { return request(apiRoutes.symbolInfo) .then(this.fetchSymbolSuccess) .catch(this.fetchSymbolError); } fetchSymbolSuccess(data) { return Promise.resolve(); } fetchSymbolError(error) { console.error(error); } } 

src/components/TestComponent.js
 import React from 'react'; import { observer } from 'utils'; import { useStore } from 'hooks'; function TestComponent() { const store = useStore(); const { currentTP: { executions } } = store; return <div>{executions.fetchSymbol ? '...' : ''}</div>; } export const TestComponentConnected = observer(TestComponent); 

, .


, , . — , , , , - .

Source: https://habr.com/ru/post/fr450360/


All Articles