Intercambie la arquitectura de la aplicación SPA en 2019

¡Saludos, Khabrovitas!


He estado leyendo este recurso desde su fundación, pero el momento de escribir un artículo ha aparecido solo ahora, lo que significa que es hora de compartir nuestra experiencia con la comunidad. Para los desarrolladores principiantes, espero que este artículo ayude a mejorar la calidad del diseño, y para los experimentados servirá como una lista de verificación para no olvidar elementos importantes en la etapa arquitectónica. Para los impacientes, el repositorio final y la demostración .



Suponga que se encuentra en una "empresa de ensueño", uno de los intercambios con una libre elección de tecnología y recursos para hacer todo "como debería". Por el momento, todo lo que la compañía tiene es


Asignación de negocios


Desarrolle una aplicación SPA para la interfaz comercial, en la que puede:


  • ver una lista de pares comerciales agrupados por moneda;
  • cuando hace clic en un par comercial para ver información al precio actual, un cambio en 24 horas, un "vaso de pedidos";
  • cambiar el idioma de la aplicación a inglés / ruso;
  • cambia el tema a oscuro / claro.

La tarea es bastante corta, lo que le permitirá concentrarse en la arquitectura, en lugar de escribir grandes volúmenes de funcionalidad empresarial. El resultado de los esfuerzos iniciales debe ser un código lógico y reflexivo que le permita proceder directamente a la implementación de la lógica empresarial.


Como no hay requisitos técnicos en la declaración de trabajo del cliente, permítales sentirse cómodos para el desarrollo:


  • compatibilidad entre navegadores : 2 últimas versiones de navegadores populares (sin IE);
  • ancho de pantalla :> = 1240px;
  • diseño : por analogía con otros intercambios, como El diseñador aún no ha sido contratado.

Ahora es el momento de identificar las herramientas y bibliotecas utilizadas. Me guiaré por los principios del desarrollo "llave en mano" y KISS , es decir, tomaré solo aquellas bibliotecas de código abierto que requerirían una cantidad de tiempo inadecuada para implementar de forma independiente, incluido el tiempo para capacitar a futuros desarrolladores.


  • sistema de control de versiones : Git + Github;
  • backend : API CoinGecko;
  • montaje / transporte : Webpack + Babel;
  • instalador del paquete : Yarn (npm 6 dependencias actualizadas incorrectamente);
  • control de calidad del código : ESLint + Prettier + Stylelint;
  • vista : Reaccionar (veamos qué tan convenientes son los ganchos);
  • tienda : MobX;
  • pruebas automáticas : Cypress.io (una solución javascript completa en lugar de un ensamblaje modular como Mocha / Karma + Chai + Sinon + Selenium + Webdriver / Protractor);
  • estilos : SCSS a través de PostCSS (flexibilidad de configuración, amigos con Stylelint);
  • gráficos : HighStock (la configuración es mucho más fácil que TradingView , pero para una aplicación real tomaría la última);
  • informe de errores : Centinela;
  • Utilidades : Lodash (ahorro de tiempo);
  • enrutamiento : llave en mano;
  • localización : llave en mano;
  • trabajar con solicitudes : llave en mano;
  • métricas de rendimiento : llave en mano;
  • tipificación : no en mi turno.

Por lo tanto, solo React, MobX, HighStock, Lodash y Sentry serán de las bibliotecas en el archivo final de la aplicación. Creo que esto está justificado, ya que tienen una excelente documentación, rendimiento y son familiares para muchos desarrolladores.


Código de control de calidad


Prefiero dividir las dependencias en package.json en partes semánticas, por lo que el primer paso después de iniciar el repositorio git es agrupar todo lo que concierne al estilo de código en la carpeta ./eslint-custom , especificando en package.json :


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

La yarn install normal de yarn install no verificará si las dependencias dentro de eslint-custom han cambiado, por lo que yarn upd . En general, esta práctica parece más universal, ya que los desarrolladores no tendrán que cambiar la receta de implementación si los desarrolladores necesitan cambiar el método de instalación de paquetes.


No tiene sentido usar el archivo yarn.lock, ya que todas las dependencias estarán sin semver "covers" (en forma de "react": "16.8.6" ). La experiencia ha demostrado que es mejor actualizar manualmente las versiones y probarlas cuidadosamente como parte de tareas individuales que confiar en un archivo de bloqueo, lo que brinda a los autores de paquetes la oportunidad de romper la aplicación con una actualización menor en cualquier momento (los afortunados que no lo han encontrado).


En el paquete eslint-custom , las dependencias serán las siguientes:


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

Para conectar las tres herramientas, se necesitaron 5 paquetes auxiliares ( eslint-plugin-prettier, eslint-config-prettier, stylelint-prettier, stylelint-config-prettier, prettier-eslint ): hoy debe pagar ese precio. Para la máxima comodidad, solo la ordenación automática de las importaciones no es suficiente, pero, desafortunadamente, este complemento pierde líneas al formatear un archivo.


Los archivos de configuración para todas las herramientas estarán en formato * .js ( eslint.config.js , stylelint.config.js ) para que el formato del código funcione en ellos. Las reglas pueden estar en formato * .yaml , desglosadas por módulos semánticos. Las versiones completas de configuraciones y reglas están en el repositorio .


Queda por agregar los comandos en el paquete 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" } } 

... y configure su IDE para aplicar formato al guardar el archivo actual. Para garantizar esto, al crear una confirmación, debe usar un gancho git que verificará y formateará todos los archivos del proyecto. ¿Por qué no solo aquellos que están presentes en el commit? Por el principio de responsabilidad colectiva para toda la base del código, de modo que nadie se sintiera tentado a eludir la validación. Para hacer esto, al crear una confirmación, todas las advertencias de linter se considerarán errores usando --max-warnings=0 .


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

Asamblea / Transporte


Nuevamente, usaré el enfoque modular y eliminaré todas las configuraciones de Webpack y Babel en la carpeta ./webpack-custom. La configuración se basará en la siguiente estructura de archivos:


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

Un constructor configurado correctamente proporcionará:


  • la capacidad de escribir código usando la sintaxis y las capacidades de la última especificación de EcmaScript, incluidas propuestas convenientes (los decoradores de clase y sus propiedades para MobX son definitivamente útiles aquí);
  • servidor local con recarga en caliente;
  • métricas de rendimiento de ensamblaje;
  • comprobación de dependencias cíclicas;
  • análisis de la estructura y el tamaño del archivo resultante;
  • optimización y minificación para montaje de producción;
  • interpretación de archivos modulares * .scss y la capacidad de eliminar archivos terminados * .css de un paquete;
  • archivos de inserción en línea * .svg ;
  • prefijos polyfill / style para navegadores de destino;
  • resolviendo el problema de almacenamiento en caché de archivos en producción.

También se configurará convenientemente. Resolveré este problema con la ayuda de dos archivos de muestra * .env :


.frontend.env.ejemplo
 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 

Por lo tanto, para iniciar el ensamblaje, debe crear un archivo con el nombre .frontend.env y la presencia obligatoria de todos los parámetros. Este enfoque resolverá varios problemas a la vez: no es necesario crear archivos de configuración separados para Webpack y mantener su coherencia; localmente, puede configurar cuánto lo necesita un desarrollador en particular; los desarrolladores de implementación solo copiarán el archivo para el ensamblado de producción ( cp .frontend.env.prod.example .frontend.env ), enriqueciendo los valores del repositorio, en consecuencia, los desarrolladores frontend tienen la capacidad de administrar la receta a través de variables sin usar administradores. Además, será posible hacer una configuración de ejemplo para los stands (por ejemplo, con mapas fuente).


Para separar los estilos en archivos con CSS_EXTRACT habilitado, usaré mini-css-extract-plugin , que le permite usar Hot Reloading. Es decir, si habilita HOT_RELOAD y CSS_EXTRACT para el desarrollo local, entonces con
solo los estilos se volverán a cargar al cambiar los archivos de estilos, pero, desafortunadamente, todo, no solo el archivo modificado. Con CSS_EXTRACT desactivado, solo se actualizará el módulo de estilo modificado.


HMR para trabajar con React Hooks se incluye de manera bastante estándar:


  • webpack.HotModuleReplacementPlugin en complementos;
  • hot: true en los parámetros webpack-dev-server ;
  • react-hot-loader/babel en complementos de babel-loader ;
  • options.hmr: true en mini-css-extract-plugin ;
  • export default hot(App) en el componente principal de la aplicación;
  • @ hot-loader / react-dom en lugar del usual react-dom (convenientemente a través de resolve.alias: { 'react-dom': '@hot-loader/react-dom' } );

La versión actual de react-hot-loader no admite la memorización de componentes con React.memo , por lo que al escribir decoradores para MobX, deberá tener esto en cuenta para la conveniencia del desarrollo local. Otro inconveniente causado por esto es que cuando Highlight Updates está habilitado en React Developer Tools, todos los componentes se actualizan durante cualquier interacción con la aplicación. Por lo tanto, cuando se trabaja localmente en la optimización del rendimiento, la configuración HOT_RELOAD debe estar deshabilitada.


La optimización de compilación en Webpack 4 se realiza automáticamente cuando el mode : 'development' | 'production' mode : 'development' | 'production' . En este caso, confío en la optimización estándar (+ inclusión del parámetro keep_fnames: true en el plugin terser-webpack para guardar el nombre de los componentes), ya que está bien ajustado.


La separación de los trozos y el control del almacenamiento en caché del cliente merece una atención especial. Para un correcto funcionamiento necesitas:


  • en output.filename para archivos js y css especifique isProduction ? '[name].[contenthash].js' : '[name].js' isProduction ? '[name].[contenthash].js' : '[name].js' (con la extensión .css respectivamente) para que el nombre del archivo se base en su contenido;
  • en optimización, cambie los parámetros a chunkIds: 'named', moduleIds: 'hashed' para que el contador interno del módulo en el paquete web no cambie;
  • poner el tiempo de ejecución en un trozo separado;
  • mueva los grupos de caché a splitChunks (cuatro puntos son suficientes para esta aplicación: lodash, centinela, highcharts y proveedor para otras dependencias de node_modules ). Como los tres primeros rara vez se actualizan, permanecerán en la memoria caché del navegador del cliente el mayor tiempo posible.

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

Para acelerar el ensamblaje en este proyecto, utilizo el cargador de subprocesos : cuando está en paralelo a 4 procesos, le dio al acelerador del ensamblaje en un 90%, que es mejor que happypack con la misma configuración.


La configuración para cargadores, incluso para babel, en archivos separados (como .babelrc ) para publicar , creo, es innecesaria. Pero la configuración entre navegadores es más conveniente para mantenerla en el parámetro de la lista de browserslist del paquete principal.json, ya que también se usa para estilos de corrección automática.


Para la conveniencia de trabajar con Prettier, creé el parámetro AGGREGATION_TIMEOUT , que le permite establecer un retraso entre la detección de cambios en los archivos y la reconstrucción de la aplicación en modo servidor de desarrollo. Como configuré el formateo de los archivos al guardarlos en el IDE, esto provoca 2 reconstrucciones: la primera para guardar el archivo original y la segunda para completar el formateo. 2000 milisegundos suelen ser suficientes para que webpack espere la versión final del archivo.


El resto de la configuración no merece atención especial, ya que se revela en cientos de materiales de capacitación para principiantes, por lo que puede proceder al diseño de la arquitectura de la aplicación.


Temas de estilo


Anteriormente, para crear temas, tenía que hacer varias versiones de archivos * .css y volver a cargar la página al cambiar los temas, cargando el conjunto de estilos deseado. Ahora todo se resuelve fácilmente utilizando las propiedades CSS personalizadas . Esta tecnología es compatible con todos los navegadores de destino de la aplicación actual, pero también hay polyfills para IE.


Digamos que hay 2 temas: claro y oscuro, cuyos conjuntos de colores estarán en


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

Para que estas variables se apliquen globalmente, deben escribirse en document.documentElement , respectivamente, se necesita un pequeño analizador para convertir este archivo en un objeto javascript. Más adelante te diré por qué es más conveniente que almacenarlo en JavaScript de inmediato.


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

Aquí, la consistencia se verifica por eso, es decir, la correspondencia completa del conjunto de variables, con la diferencia de que cae el ensamblaje.


Al usar este cargador, se obtiene un objeto bastante hermoso con parámetros, y un par de líneas para la utilidad de cambio de tema son suficientes:


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

Prefiero traducir estas variables CSS a las estándares para * .scss :


src / styles / constants.scss


El IDE de WebStorm, como se ve en la captura de pantalla, muestra los colores en el panel de la izquierda y al hacer clic en el color se abre una paleta donde puede cambiarlo. El nuevo color se sustituye automáticamente en themes.scss , Hot Reload funcionará y la aplicación se transformará instantáneamente. Este es exactamente el nivel de conveniencia de desarrollo que se espera en 2019.


Principios de organización del código


En este proyecto, me limitaré a duplicar los nombres de carpetas para componentes, archivos y estilos, por ejemplo:

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

En consecuencia, package.json tendrá el contenido { "main": "Chart.js" } . Para componentes con múltiples exportaciones con nombre (por ejemplo, utilidades), el nombre del archivo principal comenzará con un guión bajo:


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

Y el resto de los archivos se exportarán como:


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

Esto le permitirá deshacerse de los nombres de archivos duplicados para no perderse entre los diez primeros abiertos index.js / style.scss . Puede resolver esto con los complementos IDE, pero ¿por qué no de manera universal?


Agruparé los componentes página por página, excepto los generales como Mensaje / Enlace, y también, si es posible, usaré exportaciones con nombre (sin export default ) para mantener la uniformidad de los nombres, la facilidad de refactorización y la búsqueda de proyectos.


Configurar el procesamiento y el almacenamiento de MobX


El archivo que sirve como punto de entrada para Webpack se verá así:


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

Dado que cuando se trabaja con observables, la consola muestra algo así como Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration} , en polyfills Haré una utilidad para la estandarización:


src / polyfill.js
 import { toJS } from 'mobx'; console.js = function consoleJsCustom(...args) { console.log(...args.map(arg => toJS(arg))); }; 

Además, los estilos globales y la normalización de estilos para diferentes navegadores están conectados en el archivo principal, si hay una clave para Sentry en .env.frontend los errores comienzan a registrarse, se crea el almacenamiento MobX, se inicia el seguimiento de los cambios de parámetros con la ejecución automática y se monta el componente envuelto en react-hot-loader en el DOM.


El repositorio en sí será una clase no observable cuyos parámetros son clases no observables con parámetros observables. Por lo tanto, se entiende que el conjunto de parámetros no será dinámico, por lo tanto, la aplicación será más predecible. Este es uno de los pocos lugares donde JSDoc es útil para permitir la finalización automática en el IDE.


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

Un ejemplo de la tienda MobX se puede analizar utilizando el ejemplo de GlobalStore, que tendrá el único propósito en este momento: almacenar y establecer el tema de estilo actual.


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

A veces, los parámetros y el método de la clase establecen manualmente el tipo usando decoradores, por ejemplo:


 export class GlobalStore { @observable currentTheme = ''; @action.bound setTheme(theme) { this.currentTheme = theme; setTheme(theme); } } 

Pero no veo ningún punto en esto, ya que los antiguos decoradores de la clase Propuesta admiten su transformación automática, por lo que la siguiente utilidad es suficiente:


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

Para usarlo, debe ajustar los complementos en loaderBabel.js : ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] request ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] , y en la configuración de ESLint, establezca parserOptions.ecmaFeatures.legacyDecorators: true consecuencia. Sin esta configuración, solo el descriptor de clase sin un prototipo se pasa al decorador de destino, y a pesar de una cuidadosa investigación de la versión actual de la Propuesta , no he encontrado una manera de envolver métodos y propiedades estáticas.


En general, la configuración de almacenamiento ha finalizado, pero sería bueno liberar el potencial de la ejecución automática de MobX. Para este propósito, tareas como "esperar una respuesta del servidor de autorización" o "descargar traducciones del servidor", luego escribir las respuestas en el servidor y renderizar directamente la aplicación en el DOM, son las más adecuadas. Por lo tanto, correré un poco hacia el futuro y crearé una tienda con localización:


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

Como puede ver, hay algunos archivos * .json con traducciones, y la carga asincrónica usando setTimeout se emula en el constructor de la clase. Cuando se ejecuta, el GlobalStore creado recientemente se marca con this.rootStore.global.shouldAppRender = true .


Por lo tanto, desde app.js, debe transferir la función de representación al archivo 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); }); } 

En la función initAutorun , puede haber cualquier cantidad de construcciones de ejecución automática con devoluciones de llamada que funcionarán solo cuando ellas mismas inicien y cambien una variable dentro de una devolución de llamada particular. En este caso, autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true; y provocó la representación de la aplicación en el DOM. Una herramienta poderosa que le permite registrar todos los cambios en la tienda y, en consecuencia, responder a ellos.


Ganchos de localización y reacción


La traducción a otros idiomas es una de las tareas más voluminosas, en las pequeñas empresas a menudo se subestima docenas de veces, y en las grandes empresas se complica innecesariamente. Dependiendo de su implementación, cuántos nervios y tiempo no se perderán a la vez en varios departamentos de la empresa. Mencionaré en el artículo solo la parte del cliente con una cartera de pedidos para una futura integración con otros sistemas.


Para la conveniencia del desarrollo frontend, debe ser capaz de:


  • establecer nombres semánticos para constantes;
  • Insertar variables dinámicas
  • indicar singular / plural;
  • — -;
  • ;
  • / ;
  • ;
  • () ;
  • () , .

, , : 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 , . «, », , — .


Métricas


- ( , , , , ) . . .


— , — , . :


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/450360/


All Articles