Arquitetura de aplicativos do Exchange SPA em 2019

Saudações, Khabrovites!


Eu tenho lido esse recurso desde a sua fundação, mas o momento de escrever um artigo apareceu apenas agora, o que significa que é hora de compartilhar nossa experiência com a comunidade. Para os desenvolvedores iniciantes, espero que este artigo ajude a melhorar a qualidade do design e, para os experientes, ele funcionará como uma lista de verificação para não esquecer elementos importantes no estágio da arquitetura. Para os impacientes, o repositório final e a demonstração .



Suponha que você esteja em uma "empresa dos sonhos" - uma das trocas com livre escolha de tecnologia e recursos para fazer tudo "como deveria". No momento, tudo o que a empresa tem é


Atribuição comercial


Desenvolva um aplicativo SPA para a interface de negociação, na qual você pode:


  • veja uma lista de pares de negociação agrupados por moeda;
  • quando você clica em um par de negociação para ver informações no preço atual, uma alteração em 24 horas, um "copo de pedidos";
  • alterar o idioma do aplicativo para inglês / russo;
  • mude o tema para escuro / claro.

A tarefa é bastante curta, o que permitirá que você se concentre na arquitetura, em vez de escrever grandes volumes de funcionalidade de negócios. O resultado dos esforços iniciais deve ser um código lógico e ponderado que permita prosseguir diretamente para a implementação da lógica de negócios.


Como não há requisitos técnicos na declaração de trabalho do cliente, deixe-os confortáveis ​​para o desenvolvimento:


  • compatibilidade entre navegadores : 2 versões mais recentes de navegadores populares (sem IE);
  • largura da tela :> = 1240px;
  • design : por analogia com outras trocas, como O designer ainda não foi contratado.

Agora é a hora de determinar quais ferramentas e bibliotecas estão sendo usadas. Serei guiado pelos princípios de desenvolvimento "chave na mão" e no KISS , ou seja, levarei apenas as bibliotecas de código-fonte aberto que exigiriam uma quantidade de tempo inadequada para implementar independentemente, incluindo o tempo para treinar futuros colegas desenvolvedores.


  • sistema de controle de versão : Git + Github;
  • back - end : API CoinGecko;
  • montagem / transporte : Webpack + Babel;
  • instalador do pacote : Yarn (npm 6 dependências atualizadas incorretamente);
  • controle de qualidade do código : ESLint + Prettier + Stylelint;
  • view : Reagir (vamos ver como os Hooks são convenientes);
  • loja : MobX;
  • autotests : Cypress.io (uma solução javascript completa em vez de uma montagem modular como Mocha / Karma + Chai + Sinon + Selenium + Webdriver / Transferidor);
  • estilos : SCSS via PostCSS (flexibilidade de configuração, amigos do Stylelint);
  • charts : HighStock (a configuração é muito mais fácil do que o TradingView , mas para um aplicativo real eu aceitaria o último);
  • relatório de erros : Sentinela;
  • Utilitários : Lodash (economia de tempo);
  • roteamento : chave na mão;
  • localização : chave na mão;
  • trabalhar com pedidos : chave na mão;
  • métricas de desempenho : chave na mão;
  • tipificação : não no meu turno.

Portanto, apenas React, MobX, HighStock, Lodash e Sentry serão das bibliotecas do arquivo final do aplicativo. Eu acho que isso se justifica, pois eles têm excelente documentação, desempenho e são familiares a muitos desenvolvedores.


Controle de qualidade do código


Eu prefiro dividir as dependências em package.json em partes semânticas, portanto, o primeiro passo após iniciar o repositório git é agrupar tudo o que diz respeito ao estilo de código na pasta ./eslint-custom , especificando em package.json :


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

A yarn install normal do yarn install não verificará se as dependências do eslint-custom foram alteradas, portanto, utilizarei o yarn upd . Em geral, essa prática parece mais universal, pois os desenvolvedores não precisarão alterar a receita de implantação se os desenvolvedores precisarem alterar o método de instalação de pacotes.


Não faz sentido usar o arquivo yarn.lock, já que todas as dependências estarão sem sempre "covers" (na forma de "react": "16.8.6" ). A experiência mostrou que é melhor atualizar versões manualmente e testá-las cuidadosamente como parte de tarefas individuais do que confiar em um arquivo de bloqueio, dando aos autores do pacote a oportunidade de interromper o aplicativo com uma pequena atualização a qualquer momento (sortudos que não o encontraram).


No pacote eslint-custom , as dependências serão as seguintes:


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 as três ferramentas, foram necessários 5 pacotes auxiliares ( eslint-plugin-mais bonito, eslint-config-mais bonito, stylelint-mais bonito, stylelint-configuração-mais bonito, stylelint-configuração-mais bonito, mais bonito-eslint ) - você tem que pagar esse preço hoje. Para maior comodidade, a falta de classificação automática de importações é suficiente , mas infelizmente esse plug-in perde linhas ao reformatar um arquivo.


Os arquivos de configuração de todas as ferramentas estarão no formato * .js ( eslint.config.js , stylelint.config.js ) para que a formatação do código funcione neles. As regras podem estar no formato * .yaml , discriminadas por módulos semânticos. Versões completas de configurações e regras estão no repositório .


Resta adicionar os comandos no pacote 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" } } 

... e configure seu IDE para aplicar a formatação ao salvar o arquivo atual. Para garantir isso, ao criar um commit, você deve usar um gancho git que verifique e formate todos os arquivos do projeto. Por que não apenas aqueles que estão presentes no commit? Pelo princípio da responsabilidade coletiva por toda a base de código, para que ninguém fosse tentado a burlar a validação. Para fazer isso, ao criar uma confirmação, todos os avisos do linter serão considerados erros usando --max-warnings=0 .


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

Montagem / Transporte


Novamente, usarei a abordagem modular e removerei todas as configurações do Webpack e Babel na pasta ./webpack-custom. A configuração dependerá da seguinte estrutura de arquivos:


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

Um construtor configurado corretamente fornecerá:


  • a capacidade de escrever código usando a sintaxe e os recursos da mais recente especificação EcmaScript, incluindo propostas convenientes (decoradores de classe e suas propriedades para o MobX são definitivamente úteis aqui);
  • servidor local com recarga a quente;
  • métricas de desempenho de montagem;
  • verificação de dependências cíclicas;
  • análise da estrutura e tamanho do arquivo resultante;
  • otimização e minificação para montagem da produção;
  • interpretação de arquivos * .scss modulares e a capacidade de remover arquivos * .css concluídos de um pacote;
  • arquivos embutidos * .svg de inserção;
  • prefixos polyfill / style para navegadores de destino;
  • resolvendo o problema de armazenar arquivos em cache na produção.

Também será configurado convenientemente. Vou resolver esse problema com a ajuda de dois arquivos de exemplo * .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 

Portanto, para iniciar a montagem, você precisa criar um arquivo com o nome .frontend.env e a presença obrigatória de todos os parâmetros. Essa abordagem resolverá vários problemas ao mesmo tempo: não há necessidade de criar arquivos de configuração separados para o Webpack e manter sua consistência; localmente, você pode configurar quanto um desenvolvedor específico precisa; Os desenvolvedores de implantação copiarão apenas o arquivo para o assembly de produção ( cp .frontend.env.prod.example .frontend.env ), enriquecendo os valores do repositório, de modo que os desenvolvedores de front-end podem gerenciar a receita por meio de variáveis ​​sem usar administradores. Além disso, será possível fazer um exemplo de configuração para stands (por exemplo, com mapas de origem).


Para separar estilos em arquivos com CSS_EXTRACT ativado, usarei o mini-css-extract-plugin - ele permite que você use o Hot Reloading. Ou seja, se você ativar HOT_RELOAD e CSS_EXTRACT para desenvolvimento local, então com
somente os estilos serão recarregados ao alterar os arquivos de estilos - mas, infelizmente, tudo, não apenas o arquivo alterado. Com CSS_EXTRACT desativado, apenas o módulo de estilo alterado será atualizado.


O HMR para trabalhar com ganchos de reação é incluído de maneira bastante padrão:


  • webpack.HotModuleReplacementPlugin em plugins;
  • hot: true nos parâmetros webpack-dev-server ;
  • react-hot-loader/babel em plugins babel-loader ;
  • options.hmr: true no mini-css-extract-plugin ;
  • export default hot(App) no componente principal do aplicativo;
  • @ hot-loader / react-dom em vez do usual resolve.alias: { 'react-dom': '@hot-loader/react-dom' } -dom (convenientemente via resolve.alias: { 'react-dom': '@hot-loader/react-dom' } );

A versão atual do react -hot-loader não suporta componentes de memorização usando React.memo ; portanto, ao escrever decoradores para o MobX, você precisará levar isso em consideração para a conveniência do desenvolvimento local. Outro inconveniente causado por isso é que, quando as atualizações de destaque são ativadas no React Developer Tools, todos os componentes são atualizados durante qualquer interação com o aplicativo. Portanto, ao trabalhar localmente na otimização de desempenho, a configuração HOT_RELOAD deve ser desativada.


A otimização de compilação no Webpack 4 é executada automaticamente quando mode : 'development' | 'production' mode : 'development' | 'production' . Nesse caso, confio na otimização padrão (+ inclusão do parâmetro keep_fnames: true no plugin terser-webpack- para salvar o nome dos componentes), pois ele já está bem ajustado.


A separação de partes e o controle do cache do cliente merece atenção especial. Para uma operação correta, você precisa de:


  • em output.filename para arquivos js e css, especifique isProduction ? '[name].[contenthash].js' : '[name].js' isProduction ? '[name].[contenthash].js' : '[name].js' (com a extensão .css respectivamente) para que o nome do arquivo seja baseado no seu conteúdo;
  • na otimização, altere os parâmetros para chunkIds: 'named', moduleIds: 'hashed' para que o contador do módulo interno no webpack não seja alterado;
  • colocar o tempo de execução em um pedaço separado;
  • mova grupos de cache para splitChunks (quatro pontos são suficientes para este aplicativo - lodash, sentry, highcharts e vendor para outras dependências do node_modules ). Como os três primeiros raramente são atualizados, eles permanecem no cache do navegador do cliente pelo maior tempo possível.

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 a montagem neste projeto, eu uso o thread-loader - quando paralelo a 4 processos, ele acelerou a montagem em 90%, o que é melhor do que o happypack com as mesmas configurações.


Configurações para carregadores, inclusive para babel, em arquivos separados (como .babelrc ), que eu acho que são desnecessárias. Mas a configuração entre navegadores é mais conveniente para manter o parâmetro browserslist do pacote principal.json, pois também é usado para estilos de autoprefixer.


Para facilitar o trabalho com Prettier, criei o parâmetro AGGREGATION_TIMEOUT , que permite definir o atraso entre a detecção de alterações nos arquivos e a reconstrução do aplicativo no modo dev-server. Desde que eu configurei a reformatação dos arquivos ao salvar no IDE, isso causa duas reconstruções - a primeira para salvar o arquivo original e a segunda para concluir a formatação. 2000 milissegundos geralmente são suficientes para o webpack aguardar a versão final do arquivo.


O restante da configuração não merece atenção especial, pois é divulgado em centenas de materiais de treinamento para iniciantes, para que você possa prosseguir com o design da arquitetura do aplicativo.


Temas de Estilo


Anteriormente, para criar temas, era necessário criar várias versões dos arquivos * .css e recarregar a página ao alterar temas, carregando o conjunto de estilos desejado. Agora tudo é facilmente resolvido usando as propriedades CSS personalizadas . Essa tecnologia é suportada por todos os navegadores de destino do aplicativo atual, mas também existem polyfills para o IE.


Digamos que existam 2 temas - claro e escuro, cujos conjuntos de cores estarão em


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 essas variáveis ​​sejam aplicadas globalmente, elas precisam ser gravadas em document.documentElement , respectivamente, um pequeno analisador é necessário para converter esse arquivo em um objeto javascript. Mais tarde, explicarei por que é mais conveniente do que armazená-lo em javascript imediatamente.


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

Aqui, a consistência é verificada por isso - ou seja, a correspondência completa do conjunto de variáveis, com a diferença da qual a montagem cai.


Ao usar esse carregador, um objeto bastante bonito com parâmetros é obtido e algumas linhas para o utilitário de mudança de tema são 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); }); } 

Eu prefiro traduzir essas variáveis ​​css em padrão para * .scss :


src / styles / constants.scss


O IDE WebStorm, como visto na captura de tela, mostra as cores no painel à esquerda e, ao clicar na cor, abre uma paleta na qual você pode alterá-lo. A nova cor é automaticamente substituída pelo themes.scss , o Hot Reload funcionará e o aplicativo será transformado instantaneamente. Esse é exatamente o nível de conveniência de desenvolvimento esperado em 2019.


Princípios de organização de código


Neste projeto, manterei a duplicação de nomes de pastas para componentes, arquivos e estilos, por exemplo:

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

Assim, package.json terá o conteúdo { "main": "Chart.js" } . Para componentes com várias exportações nomeadas (por exemplo, utilitários), o nome do arquivo principal começará com um sublinhado:


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

E o restante dos arquivos será exportado como:


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

Isso permitirá que você se livre de nomes de arquivos duplicados para não se perder nos dez principais index.js / style.scss . Você pode resolver isso com plugins IDE, mas por que não de uma maneira universal.


Vou agrupar os componentes página por página, exceto os gerais, como Message / Link, e também, se possível, usar exportações nomeadas (sem export default ) para manter a uniformidade dos nomes, facilitar a refatoração e a pesquisa de projetos.


Configurar renderização e armazenamento MobX


O arquivo que serve como ponto de entrada para o Webpack terá a seguinte aparência:


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

Como ao trabalhar com observáveis, o console exibe algo como Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration} , em polyfills Vou criar um utilitário para padronização:


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

Além disso, estilos globais e normalização de estilos para diferentes navegadores são conectados no arquivo principal. Se houver uma chave para os erros do Sentry nos arquivos .env.frontend começarem a registrar, o armazenamento MobX é criado, o rastreamento das alterações de parâmetros com a execução automática é iniciado e o componente envolvido no react-hot-loader é montado no DOM.


O próprio repositório será uma classe não observável cujos parâmetros são classes não observáveis ​​com parâmetros observáveis. Assim, entende-se que o conjunto de parâmetros não será dinâmico - portanto, a aplicação será mais previsível. Este é um dos poucos lugares em que o JSDoc é útil para permitir o preenchimento automático no 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); } } 

Um exemplo de loja MobX pode ser analisado usando o exemplo da GlobalStore, que terá o único objetivo no momento - armazenar e definir o tema do estilo atual.


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

Às vezes, os parâmetros e o método da classe definem manualmente o tipo usando decoradores, por exemplo:


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

Mas não vejo sentido nisso, uma vez que os antigos decoradores da classe Proposal suportam sua transformação automática, portanto, o seguinte utilitário é 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 usar, você precisa ajustar os plugins em loaderBabel.js : ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] , e nas configurações ESLint, defina parserOptions.ecmaFeatures.legacyDecorators: true acordo. Sem essas configurações, apenas o descritor de classe sem um protótipo é transferido para o decorador de destino e, apesar de um estudo cuidadoso da versão atual da Proposta , não encontrei uma maneira de agrupar métodos e propriedades estáticas.


Em geral, a configuração do armazenamento é concluída, mas seria interessante desbloquear o potencial da execução automática do MobX. Para esse fim, tarefas como “aguardar uma resposta do servidor de autorização” ou “baixar traduções do servidor”, gravar as respostas no servidor e renderizar diretamente o aplicativo no DOM são as mais adequadas. Portanto, vou correr um pouco para o futuro e criar uma loja com localização:


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 você pode ver, existem alguns arquivos * .json com traduções e o carregamento assíncrono usando setTimeout é emulado no construtor da classe. Quando é executado, o GlobalStore criado recentemente é marcado com this.rootStore.global.shouldAppRender = true .


Portanto, no app.js, você precisa transferir a função de renderização para o arquivo 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); }); } 

Na função initAutorun , pode haver qualquer número de construções de execução automática com retornos de chamada que funcionarão apenas quando elas próprias iniciarem e alterarem uma variável dentro de um retorno de chamada específico. Nesse caso, autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true; e causou a renderização do aplicativo no DOM. Uma ferramenta poderosa que permite registrar todas as alterações na loja e responder a elas.


Ganchos de localização e reação


A tradução para outras línguas é uma das tarefas mais volumosas; em pequenas empresas, muitas vezes é subestimada dezenas de vezes, e em grandes empresas é excessivamente complicada. Depende de sua implementação, quantos nervos e tempo não serão desperdiçados imediatamente em vários departamentos da empresa. Mencionarei no artigo apenas a parte do cliente com um backlog para futura integração com outros sistemas.


Para a conveniência do desenvolvimento de front-end, você deve ser capaz de:


  • definir nomes semânticos para constantes;
  • Inserir variáveis ​​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/pt450360/


All Articles