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:
. |
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 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:
. |
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:
. |
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'; 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 { 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) { const classPrototype = target.prototype; const methodsAndGetters = Object.getOwnPropertyNames(classPrototype).filter( methodName => methodName !== 'constructor' ); for (const methodName of methodsAndGetters) { const descriptor = Object.getOwnPropertyDescriptor( classPrototype, methodName ); descriptor.value = decorate(classPrototype, { [methodName]: typeof descriptor.value === 'function' ? action.bound : computed, }); } return (...constructorArguments) => { const store = new target(...constructorArguments); const staticProperties = Object.keys(store); staticProperties.forEach(propName => { if (propName === 'rootStore') { return false; } const descriptor = Object.getOwnPropertyDescriptor(store, propName); Object.defineProperty( store, propName, observable(store, propName, descriptor) ); }); return store; }; }
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 { 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 import { autorun } from 'mobx'; import { renderToDOM } from 'utils'; import App from 'components/App'; const loggingEnabled = true; function logReason(autorunName, reaction) { if (!loggingEnabled || reaction.observing.length === 0) { return false; } const logString = reaction.observing.reduce( (str, { name, value }) => `${str}${name} changed to ${value}; `, '' ); console.log(`autorun-${autorunName}`, logString); } export function initAutorun(store) { autorun(reaction => { if (store.global.shouldAppRender) { renderToDOM(App); } logReason('shouldAppRender', reaction); }); }
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); export function useStore() { return React.useContext(storeContext); }
, observer:
import React from 'react'; import { observer } from 'utils'; import { useStore } from 'hooks'; function TestComponent() { const store = useStore(); return <div>{store.i18n.currentLanguage}</div>; } export const TestComponentConnected = observer(TestComponent);
TestLocalization — useLocalization:
src/hooks/useLocalization.js import _ from 'lodash'; import { declOfNum } from 'utils'; import { useStore } from './useStore'; const showNoTextMessage = false; function replaceDynamicParams(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithValues = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { messageWithValues = formattedMessage.replace(`{${paramName}}`, value); }); return messageWithValues; } function replacePlurals(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithPlurals = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { const pluralPattern = new RegExp(`{${paramName}:\\s([^}]*)}`); const pluralMatch = formattedMessage.match(pluralPattern); if (pluralMatch && pluralMatch[1]) { messageWithPlurals = formattedMessage.replace( pluralPattern, declOfNum(value, pluralMatch[1].split(',')) ); } }); return messageWithPlurals; } export function useLocalization(filename, messages) { const { i18n: { i18n, currentLanguage }, } = useStore(); return function getLn(text, values) { const key = _.findKey(messages, message => message === text); const localizedText = _.get(i18n, [filename, key]); if (!localizedText && showNoTextMessage) { console.error( `useLocalization: no localization for lang '${currentLanguage}' in ${filename} ${key}` ); } let formattedMessage = localizedText || text; formattedMessage = replaceDynamicParams(values, formattedMessage); formattedMessage = replacePlurals(values, formattedMessage); return formattedMessage; }; }
replaceDynamicParams replacePlurals — , , , , , ..
Webpack — __filename — , , . , — , , . , :
useLocalization: no localization for lang 'ru' in src\components\TestLocalization\TestLocalization.js hello
ru.json :
src/localization/ru.json { "src\\components\\TestLocalization\\TestLocalization.js": { "hello": " {count} {count: ,,}" } }
, . src/localization/en.json « » setLocalization I18nStore.
«» React Message:
src/components/Message/Message.js import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; function Message(props) { const { filename, messages, text, values } = props; const getLn = useLocalization(filename, messages); return getLn(text, values); } const ConnectedMessage = observer(Message); export function init(filename, messages) { return function MessageHoc(props) { const fullProps = { filename, messages, ...props }; return <ConnectedMessage {...fullProps} />; }; }
__filename ( id ), , :
const Message = require('components/Message').init( __filename, messages ); <Message text={messages.hello} values={{ count: 1 }} />
— useLocalization ( currentLanguage , Message — . , , .
, ( , , , / production). id , messages.js *.json , . ( / ), production. , , .
MobX + Hooks . , backend, , , .
API
( backend, ) — , , . , . :
src/stores/CurrentTPStore.js import _ from 'lodash'; import { makeObservable } from 'utils'; import { apiRoutes, request } from 'api'; @makeObservable export class CurrentTPStore { constructor(rootStore) { this.rootStore = rootStore; } id = ''; symbol = ''; fullName = ''; currency = ''; tradedCurrency = ''; low24h = 0; high24h = 0; lastPrice = 0; marketCap = 0; change24h = 0; change24hPercentage = 0; fetchSymbol(params) { const { tradedCurrency, id } = params; const { marketsList } = this.rootStore; const requestParams = { id, localization: false, community_data: false, developer_data: false, tickers: false, }; return request(apiRoutes.symbolInfo, requestParams) .then(data => this.fetchSymbolSuccess(data, tradedCurrency)) .catch(this.fetchSymbolError); } fetchSymbolSuccess(data, tradedCurrency) { const { id, symbol, name, market_data: { high_24h, low_24h, price_change_24h_in_currency, price_change_percentage_24h_in_currency, market_cap, current_price, }, } = data; this.id = id; this.symbol = symbol; this.fullName = name; this.currency = symbol; this.tradedCurrency = tradedCurrency; this.lastPrice = current_price[tradedCurrency]; this.high24h = high_24h[tradedCurrency]; this.low24h = low_24h[tradedCurrency]; this.change24h = price_change_24h_in_currency[tradedCurrency]; this.change24hPercentage = price_change_percentage_24h_in_currency[tradedCurrency]; this.marketCap = market_cap[tradedCurrency]; return Promise.resolve(); } fetchSymbolError(error) { console.error(error); } }
, , . fetchSymbol , id , . , — ( @action.bound
), Sentry :
src/utils/initSentry.js import * as Sentry from '@sentry/browser'; export function initSentry() { if (SENTRY_URL !== 'false') { Sentry.init({ dsn: SENTRY_URL, }); const originalErrorLogger = console.error; console.error = function consoleErrorCustom(...args) { Sentry.captureException(...args); return originalErrorLogger(...args); }; } }
, :
src/api/_api.js import _ from 'lodash'; import { omitParam, validateRequestParams, makeRequestUrl, makeRequest, validateResponse, } from 'api/utils'; export function request(route, params) { return Promise.resolve() .then(validateRequestParams(route, params)) .then(makeRequestUrl(route, params)) .then(makeRequest) .then(validateResponse(route, params)); } export const apiRoutes = { symbolInfo: { url: params => `https://api.coingecko.com/api/v3/coins/${params.id}`, params: { id: omitParam, localization: _.isBoolean, community_data: _.isBoolean, developer_data: _.isBoolean, tickers: _.isBoolean, }, responseObject: { id: _.isString, name: _.isString, symbol: _.isString, genesis_date: v => _.isString(v) || _.isNil(v), last_updated: _.isString, country_origin: _.isString, coingecko_rank: _.isNumber, coingecko_score: _.isNumber, community_score: _.isNumber, developer_score: _.isNumber, liquidity_score: _.isNumber, market_cap_rank: _.isNumber, block_time_in_minutes: _.isNumber, public_interest_score: _.isNumber, image: _.isPlainObject, links: _.isPlainObject, description: _.isPlainObject, market_data: _.isPlainObject, localization(value, requestParams) { if (requestParams.localization === false) { return true; } return _.isPlainObject(value); }, community_data(value, requestParams) { if (requestParams.community_data === false) { return true; } return _.isPlainObject(value); }, developer_data(value, requestParams) { if (requestParams.developer_data === false) { return true; } return _.isPlainObject(value); }, public_interest_stats: _.isPlainObject, tickers(value, requestParams) { if (requestParams.tickers === false) { return true; } return _.isArray(value); }, categories: _.isArray, status_updates: _.isArray, }, }, };
request :
- apiRoutes ;
- , route.params, , omitParam ;
- URL
route.url
— , , — get- URL; - fetch, JSON;
- ,
route.responseObject
route.responseArray
( ). , — , ; - / / / , ( fetchSymbolError ) .
. , Sentry, response:

— ( ), .
, , . , :
- ;
- pathname search;
- / ;
- location ;
- beforeEnter, isLoading, ;
- : , , , beforeEnter, ;
- / ;
- / ;
- .
«», -, — . :
src/routes.js export const routes = { marketDetailed: { name: 'marketDetailed', path: '/market/:market/:pair', masks: { pair: /^[a-zA-Z]{3,5}-[a-zA-Z]{3}$/, market: /^[a-zA-Z]{3,4}$/, }, beforeEnter(route, store) { const { params: { pair, market }, } = route; const [symbol, tradedCurrency] = pair.split('-'); const prevMarket = store.marketsList.currentMarket; function optimisticallyUpdate() { store.marketsList.currentMarket = market; } return Promise.resolve() .then(optimisticallyUpdate) .then(store.marketsList.fetchSymbolsList) .then(store.rates.fetchRates) .then(() => store.marketsList.fetchMarketList(market, prevMarket)) .then(() => store.currentTP.fetchSymbol({ symbol, tradedCurrency, }) ) .catch(error => { console.error(error); }); }, }, error404: { name: 'error404', path: '/error404', }, };
src/routeComponents.js import { MarketDetailed } from 'pages/MarketDetailed'; import { Error404 } from 'pages/Error404'; export const routeComponents = { marketDetailed: MarketDetailed, error404: Error404, };
, , — <Link route={routes.marketDetailed}>
, . Webpack , .
, location .
src/stores/RouterStore.js import _ from 'lodash'; import { makeObservable } from 'utils'; import { routes } from 'routes'; @makeObservable export class RouterStore { constructor(rootStore) { this.rootStore = rootStore; this.currentRoute = this._fillRouteSchemaFromUrl(); window.addEventListener('popstate', () => { this.currentRoute = this._fillRouteSchemaFromUrl(); }); } currentRoute = null; _fillRouteSchemaFromUrl() { const pathnameArray = window.location.pathname.split('/'); const routeName = this._getRouteNameMatchingUrl(pathnameArray); if (!routeName) { const currentRoute = routes.error404; window.history.pushState(null, null, currentRoute.path); return currentRoute; } const route = routes[routeName]; const routePathnameArray = route.path.split('/'); const params = {}; routePathnameArray.forEach((pathParam, i) => { const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') === 0) { const paramName = pathParam.replace(':', ''); params[paramName] = urlParam; } }); return Object.assign({}, route, { params, isLoading: true }); } _getRouteNameMatchingUrl(pathnameArray) { return _.findKey(routes, route => { const routePathnameArray = route.path.split('/'); if (routePathnameArray.length !== pathnameArray.length) { return false; } for (let i = 0; i < routePathnameArray.length; i++) { const pathParam = routePathnameArray[i]; const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') !== 0) { if (pathParam !== urlParam) { return false; } } else { const paramName = pathParam.replace(':', ''); const paramMask = _.get(route.masks, paramName); if (paramMask && !paramMask.test(urlParam)) { return false; } } } return true; }); } replaceDynamicParams(route, params) { return Object.entries(params).reduce((pathname, [paramName, value]) => { return pathname.replace(`:${paramName}`, value); }, route.path); } goTo(route, params) { if (route.name === this.currentRoute.name) { if (_.isEqual(this.currentRoute.params, params)) { return false; } this.currentRoute.isLoading = true; this.currentRoute.params = params; const newPathname = this.replaceDynamicParams(this.currentRoute, params); window.history.pushState(null, null, newPathname); return false; } const newPathname = this.replaceDynamicParams(route, params); window.history.pushState(null, null, newPathname); this.currentRoute = this._fillRouteSchemaFromUrl(); } }
— routes.js . — 404. , « », , , — , 'test-test'.
currentRoute , params ( URL) isLoading: true
. React- Router:
src/components/Router.js import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; import { routeComponents } from 'routeComponents'; function getRouteComponent(route, isLoading) { const Component = routeComponents[route.name]; if (!Component) { console.error( `getRouteComponent: component for ${ route.name } is not defined in routeComponents` ); return null; } return <Component isLoading={isLoading} />; } function useBeforeEnter() { const store = useStore(); const { currentRoute } = store.router; React.useEffect(() => { if (currentRoute.isLoading) { const beforeEnter = _.get(currentRoute, 'beforeEnter'); if (_.isFunction(beforeEnter)) { Promise.resolve() .then(() => beforeEnter(currentRoute, store)) .then(() => { currentRoute.isLoading = false; }) .catch(error => console.error(error)); } else { currentRoute.isLoading = false; } } }); return currentRoute.isLoading; } function Router() { const { router: { currentRoute }, } = useStore(); const isLoading = useBeforeEnter(); return getRouteComponent(currentRoute, isLoading); } export const RouterConnected = observer(Router);
, , currentRoute == null
. — isLoading === true
, false , route.beforeEnter
( ). console.error
, , .
, — , . React- 2 :
- componentWillMount / componentDidMount / useEffect , , . — , «». — — ;
- ( ) , . — — , . — / — real-time , / .
, — ( , , ..), .
beforeEnter , : « », ( , , ), — ( — 500 ; ; , ; ..). «» , MVP .
:
src/components/Link.js import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; function checkRouteParamsWithMasks(route, params) { if (route.masks) { Object.entries(route.masks).forEach(([paramName, paramMask]) => { const value = _.get(params, paramName); if (paramMask && !paramMask.test(value)) { console.error( `checkRouteParamsWithMasks: wrong param for ${paramName} in Link to ${ route.name }: ${value}` ); } }); } } function Link(props) { const store = useStore(); const { currentRoute } = store.router; const { route, params, children, onClick, ...otherProps } = props; checkRouteParamsWithMasks(route, params); const filledPath = store.router.replaceDynamicParams(route, params); return ( <a href={filledPath} onClick={e => { e.preventDefault(); if (currentRoute.isLoading) { return false; } store.router.goTo(route, params); if (onClick) { onClick(); } }} {...otherProps} > {children} </a> ); } export const LinkConnected = observer(Link);
route , params ( ) ( ) href . , beforeEnter , . «, », , — .
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');
Cypress fetch, , :
tests/cypress/support/index.js import { apiRoutes } from 'api'; let polyfill = null; before(() => { const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js'; cy.request(polyfillUrl).then(response => { polyfill = response.body; }); }); Cypress.on('window:before:load', window => { delete window.fetch; window.eval(polyfill); window.fetch = window.unfetch; }); before(() => { cy.server(); cy.route(`${apiRoutes.symbolsList.url}**`).as('symbolsList'); cy.route(`${apiRoutes.rates.url}**`).as('rates'); cy.route(`${apiRoutes.marketsList.url}**`).as('marketsList'); cy.route(`${apiRoutes.symbolInfo.url({ id: 'bitcoin-cash' })}**`).as( 'symbolInfo' ); cy.route(`${apiRoutes.chartData.url}**`).as('chartData'); });
, .
, ?
, - . , , - / - / .
, , , - , - , (real-time , serviceWorker, CI, , , -, , ..).
( Gzip) :

React Developer Tools :

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