GrĂĽĂźe, Chabrowiten!
Ich habe diese Ressource seit ihrer Gründung gelesen, aber die Zeit für das Schreiben eines Artikels ist erst jetzt gekommen, was bedeutet, dass es Zeit ist, unsere Erfahrungen mit der Community zu teilen. Für Anfänger hoffe ich, dass dieser Artikel dazu beiträgt, die Qualität des Designs zu verbessern, und für erfahrene Entwickler als Checkliste dient, um wichtige Elemente in der Architekturphase nicht zu vergessen. Für die Ungeduldigen das endgültige Repository und die Demo .
Angenommen, Sie befinden sich in einem „Traumunternehmen“ - einem der Börsen mit einer freien Auswahl an Technologie und Ressourcen, um alles „so zu machen, wie es sollte“. Im Moment ist alles, was das Unternehmen hat,
Geschäftsauftrag
Entwickeln Sie eine SPA-Anwendung fĂĽr die Handelsschnittstelle, in der Sie:
- eine Liste der nach Währung gruppierten Handelspaare anzeigen;
- Wenn Sie auf ein Handelspaar klicken, um Informationen zum aktuellen Preis anzuzeigen, eine Änderung innerhalb von 24 Stunden, ein "Glas Bestellungen".
- Bewerbungssprache in Englisch / Russisch ändern;
- Ändern Sie das Thema in dunkel / hell.
Die Aufgabe ist recht kurz, sodass Sie sich auf die Architektur konzentrieren können, anstatt große Mengen an Geschäftsfunktionen zu schreiben. Das Ergebnis der ersten Bemühungen sollte ein logischer und durchdachter Code sein, mit dem Sie direkt mit der Implementierung der Geschäftslogik fortfahren können.
Da die Leistungsbeschreibung des Kunden keine technischen Anforderungen enthält, sollten diese für die Entwicklung angenehm sein:
- Cross-Browser-Kompatibilität : 2 neueste Versionen gängiger Browser (ohne IE);
- Bildschirmbreite :> = 1240px;
- Design : in Analogie zu anderen Börsen, as Der Designer wurde noch nicht eingestellt.
Jetzt ist es an der Zeit, die verwendeten Tools und Bibliotheken zu identifizieren. Ich werde mich von den Prinzipien der "schlüsselfertigen" Entwicklung und von KISS leiten lassen, dh ich werde nur die OpenSource-Bibliotheken verwenden, deren unabhängige Implementierung nicht genügend Zeit erfordert, einschließlich der Zeit, um zukünftige Entwicklerkollegen zu schulen.
- Versionskontrollsystem : Git + Github;
- Backend : API CoinGecko;
- Montage / Transport : Webpack + Babel;
- Paketinstallationsprogramm : Yarn (npm 6 hat die Abhängigkeiten falsch aktualisiert);
- Code-Qualitätskontrolle : ESLint + Prettier + Stylelint;
- Ansicht : Reagieren (mal sehen, wie bequem Hooks sind);
- Geschäft : MobX;
- Autotests : Cypress.io (eine vollständige Javascript-Lösung anstelle einer modularen Baugruppe wie Mokka / Karma + Chai + Sinon + Selen + Webdriver / Winkelmesser);
- Stile : SCSS über PostCSS (Konfigurationsflexibilität, Freunde mit Stylelint);
- Diagramme : HighStock (Setup ist viel einfacher als TradingView , aber fĂĽr eine echte Anwendung wĂĽrde ich letzteres nehmen);
- Fehlerberichterstattung : Sentry;
- Dienstprogramme : Lodash (zeitsparend);
- Routing : schlĂĽsselfertig;
- Lokalisierung : schlĂĽsselfertig;
- Arbeit mit Anfragen : schlĂĽsselfertig;
- Leistungsmetriken : schlĂĽsselfertig;
- Typisierung : nicht in meiner Schicht.
Aus den Bibliotheken in der endgĂĽltigen Anwendungsdatei werden daher nur React, MobX, HighStock, Lodash und Sentry angezeigt. Ich halte dies fĂĽr gerechtfertigt, da sie ĂĽber eine hervorragende Dokumentation und Leistung verfĂĽgen und vielen Entwicklern bekannt sind.
Code-Qualitätskontrolle
Ich ziehe es vor, die Abhängigkeiten in package.json in semantische Teile zu zerlegen. Der erste Schritt nach dem Initiieren des Git-Repositorys besteht darin, alles, was den Codestil betrifft, im Ordner ./eslint-custom zu gruppieren und in package.json anzugeben:
{ "scripts": { "upd": "yarn install --no-lockfile" }, "dependencies": { "eslint-custom": "file:./eslint-custom" } }
Bei der normalen Garninstallation wird nicht überprüft, ob sich die Abhängigkeiten in eslint-custom geändert haben. Daher verwende ich die yarn upd
. Im Allgemeinen sieht diese Vorgehensweise universeller aus, da Entwickler das Bereitstellungsrezept nicht ändern müssen, wenn Entwickler die Methode zur Installation von Paketen ändern müssen.
Es macht keinen Sinn, die Datei yarn.lock zu verwenden , da alle Abhängigkeiten ohne semver "cover" sind (in Form von "react": "16.8.6"
). Die Erfahrung hat gezeigt, dass es besser ist, Versionen manuell zu aktualisieren und im Rahmen einzelner Aufgaben sorgfältig zu testen, als sich auf eine Sperrdatei zu verlassen, damit Paketautoren die Möglichkeit haben, die Anwendung jederzeit mit einem geringfügigen Update zu unterbrechen (glückliche, denen dies nicht begegnet ist).
Im eslint-custom- Paket lauten die Abhängigkeiten wie folgt:
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" } }
Um die drei Tools zu verbinden, waren 5 Hilfspakete erforderlich ( eslint-plugin-prettier, eslint-config-prettier, stylelint-prettier, stylelint-config-prettier, prettier-eslint ) - Sie mĂĽssen heute einen solchen Preis zahlen. FĂĽr maximalen Komfort reicht nicht nur das automatische Sortieren von Importen aus, aber leider verliert dieses Plugin beim Neuformatieren einer Datei Zeilen.
Die Konfigurationsdateien für alle Tools haben das Format * .js ( eslint.config.js , stylelint.config.js ), sodass die Code-Formatierung auf ihnen funktioniert. Regeln können im * .yaml- Format vorliegen und nach semantischen Modulen unterteilt sein. Vollversionen von Konfigurationen und Regeln befinden sich im Repository .
Es bleibt, die Befehle im Hauptpaket hinzuzufĂĽgen.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" } }
... und konfigurieren Sie Ihre IDE so, dass beim Speichern der aktuellen Datei die Formatierung angewendet wird. Um dies zu gewährleisten, müssen Sie beim Erstellen eines Commits einen Git-Hook verwenden, der alle Projektdateien überprüft und formatiert. Warum nicht nur diejenigen, die im Commit vorhanden sind? Für das Prinzip der kollektiven Verantwortung für die gesamte Codebasis, so dass niemand versucht war, die Validierung zu umgehen. Zu diesem --max-warnings=0
beim Erstellen eines Commits alle Linter-Warnungen mit --max-warnings=0
als Fehler --max-warnings=0
.
{ "husky": { "hooks": { "pre-commit": "npm run format:js -- --max-warnings=0 ./ && npm run format:style ./**/*.scss" } } }
Montage / Transport
Ich werde wieder den modularen Ansatz verwenden und alle Einstellungen von Webpack und Babel im Ordner ./webpack-custom herausnehmen. Die Konfiguration basiert auf der folgenden Dateistruktur:
. |
Ein ordnungsgemäß konfigurierter Builder bietet:
- die Fähigkeit, Code unter Verwendung der Syntax und der Fähigkeiten der neuesten EcmaScript-Spezifikation zu schreiben, einschließlich praktischer Vorschläge (Klassendekoratoren und ihre Eigenschaften für MobX sind hier definitiv nützlich);
- lokaler Server mit Hot Reloading;
- Leistungsmetriken fĂĽr Baugruppen;
- Überprüfung auf zyklische Abhängigkeiten;
- Analyse der Struktur und Größe der resultierenden Datei;
- Optimierung und Minimierung fĂĽr die Serienmontage;
- Interpretation modularer * .scss- Dateien und die Möglichkeit , fertige * .css- Dateien aus einem Bundle zu entfernen ;
- Inline-Insert * .svg- Dateien;
- Polyfill / Style-Präfixe für Zielbrowser;
- Lösen des Problems des Zwischenspeicherns von Dateien in der Produktion.
Es wird auch bequem konfiguriert. Ich werde dieses Problem mit Hilfe von zwei * .env-Beispieldateien lösen:
.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
Um die Assembly zu starten, müssen Sie daher eine Datei mit dem Namen .frontend.env und dem obligatorischen Vorhandensein aller Parameter erstellen. Dieser Ansatz löst mehrere Probleme gleichzeitig: Es müssen keine separaten Konfigurationsdateien für Webpack erstellt und deren Konsistenz beibehalten werden. lokal können Sie konfigurieren, wie viel ein bestimmter Entwickler benötigt; Deployment-Entwickler kopieren nur die Datei für die Produktionsassembly ( cp .frontend.env.prod.example .frontend.env
), cp .frontend.env.prod.example .frontend.env
die Werte aus dem Repository cp .frontend.env.prod.example .frontend.env
Frontend-Entwickler das Rezept über Variablen verwalten, ohne Administratoren zu verwenden. Zusätzlich ist es möglich, eine Beispielkonfiguration für Stände vorzunehmen (z. B. mit Quellkarten).
Um Stile in Dateien mit aktiviertem CSS_EXTRACT zu trennen, verwende ich das Mini-CSS-Extrakt-Plugin - es ermöglicht Ihnen, Hot Reloading zu verwenden. Das heißt, wenn Sie HOT_RELOAD und CSS_EXTRACT für die lokale Entwicklung aktivieren , dann mit
Beim Ändern von Stildateien werden nur Stile neu geladen - aber leider alles, nicht nur die geänderte Datei. Wenn CSS_EXTRACT deaktiviert ist, wird nur das geänderte Stilmodul aktualisiert.
HMR für die Arbeit mit React Hooks ist standardmäßig enthalten:
webpack.HotModuleReplacementPlugin
in Plugins;hot: true
in den Parametern webpack-dev-server ;react-hot-loader/babel
in Babel-Loader- Plugins;options.hmr: true
im Mini-CSS-Extract-Plugin ;export default hot(App)
in die Hauptkomponente der Anwendung.- @ hot-loader / react-dom anstelle des ĂĽblichen
resolve.alias: { 'react-dom': '@hot-loader/react-dom' }
-dom (bequem ĂĽber resolve.alias: { 'react-dom': '@hot-loader/react-dom' }
);
Die aktuelle Version von React -Hot-Loader unterstĂĽtzt das Speichern von Komponenten mit React.memo
nicht. Wenn React.memo
also Dekoratoren für MobX schreiben, müssen Sie dies berücksichtigen, um die lokale Entwicklung zu vereinfachen. Eine weitere Unannehmlichkeit, die dadurch verursacht wird, besteht darin, dass bei aktivierter Aktivierung von Highlight-Updates in den React Developer Tools alle Komponenten während einer Interaktion mit der Anwendung aktualisiert werden. Wenn Sie lokal an der Leistungsoptimierung arbeiten , sollte daher die Einstellung HOT_RELOAD deaktiviert sein.
Die Build-Optimierung in Webpack 4 wird automatisch ausgefĂĽhrt, wenn der mode : 'development' | 'production'
mode : 'development' | 'production'
. In diesem Fall verlasse ich mich auf die Standardoptimierung (+ Aufnahme des Parameters keep_fnames: true
in das Terser-Webpack-Plugin , um den Namen der Komponenten zu speichern), da dieser bereits gut abgestimmt ist.
Die Trennung von Chunks und die Kontrolle des Client-Caching verdienen besondere Aufmerksamkeit. Für den korrekten Betrieb benötigen Sie:
- Geben Sie in output.filename fĂĽr js- und css-Dateien
isProduction ? '[name].[contenthash].js' : '[name].js'
isProduction ? '[name].[contenthash].js' : '[name].js'
(mit der Erweiterung .css), sodass der Name der Datei auf ihrem Inhalt basiert; - Ändern Sie bei der Optimierung die Parameter in
chunkIds: 'named', moduleIds: 'hashed'
damit sich der interne Modulzähler im Webpack nicht ändert. - Legen Sie die Laufzeit in einem separaten Block ab.
- Verschieben Sie Cache-Gruppen in splitChunks (vier Punkte reichen für diese Anwendung aus - lodash, Sentry, Highcharts und Vendor für andere Abhängigkeiten von node_modules ). Da die ersten drei nur selten aktualisiert werden, verbleiben sie so lange wie möglich im Browser-Cache des Clients.
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, }, }), ], };
Um die Baugruppe in diesem Projekt zu beschleunigen, verwende ich den Thread-Loader. Bei Parallelisierung zu 4 Prozessen wurde die Baugruppe um 90% beschleunigt, was besser ist als bei HappyPack mit denselben Einstellungen.
Einstellungen für Lader, einschließlich für babel, in separaten Dateien (wie .babelrc ) zum Löschen sind meines Erachtens nicht erforderlich . Die browserübergreifende Konfiguration ist jedoch bequemer im Browserlistenparameter des Hauptpakets.json zu speichern , da sie auch für Autoprefixer-Stile verwendet wird.
Um die Arbeit mit Prettier zu vereinfachen , habe ich den Parameter AGGREGATION_TIMEOUT erstellt , mit dem Sie die Verzögerung zwischen dem Erkennen von Änderungen in Dateien und dem erneuten Erstellen der Anwendung im Dev-Server-Modus festlegen können. Da ich die Neuformatierung der Dateien beim Speichern in der IDE konfiguriert habe, werden zwei Neuerstellungen durchgeführt - die erste zum Speichern der Originaldatei und die zweite zum Abschließen der Formatierung. 2000 Millisekunden reichen normalerweise aus, damit das Webpack auf die endgültige Version der Datei wartet.
Der Rest der Konfiguration verdient keine besondere Aufmerksamkeit, da er in Hunderten von Schulungsmaterialien für Anfänger beschrieben wird, sodass Sie mit dem Entwurf der Anwendungsarchitektur fortfahren können.
Stil-Themen
Zuvor mussten Sie zum Erstellen von Themen mehrere Versionen von * .css- Dateien erstellen und die Seite beim Ändern von Themen neu laden und die gewünschten Stile laden. Jetzt ist alles einfach mit benutzerdefinierten CSS-Eigenschaften zu lösen. Diese Technologie wird von allen Zielbrowsern der aktuellen Anwendung unterstützt, es gibt jedoch auch Polyfills für den IE.
Nehmen wir an, es gibt zwei Themen - hell und dunkel, deren Farbsets verwendet werden
styles / theme.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); }
Damit diese Variablen global angewendet werden können, müssen sie in document.documentElement
geschrieben werden. Zum Konvertieren dieser Datei in ein Javascript-Objekt ist jeweils ein kleiner Parser erforderlich. Später werde ich Ihnen sagen, warum es bequemer ist, es sofort in Javascript zu speichern.
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)}`; };
Hier wird die Konsistenz dadurch überprüft - das heißt, die vollständige Entsprechung des Satzes von Variablen, mit deren Differenz die Baugruppe fällt.
Wenn Sie diesen Loader verwenden, erhalten Sie ein sehr schönes Objekt mit Parametern, und ein paar Zeilen für das Dienstprogramm zum Ändern von Themen reichen aus:
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); }); }
Ich bevorzuge es, diese CSS-Variablen in Standardvariablen fĂĽr * .scss zu ĂĽbersetzen :
src / styles / constants.scss Die WebStorm-IDE (siehe Screenshot) zeigt die Farben im linken Bereich an. Durch Klicken auf die Farbe wird eine Palette geöffnet, in der Sie sie ändern können. Die neue Farbe wird automatisch in theme.scss ersetzt , Hot Reload funktioniert und die Anwendung wird sofort transformiert. Dies ist genau das Maß an Entwicklungskomfort, das 2019 erwartet wird.
Grundsätze der Code-Organisation
In diesem Projekt bleibe ich bei doppelten Ordnernamen fĂĽr Komponenten, Dateien und Stile, zum Beispiel:
. |
Dementsprechend hat package.json den Inhalt { "main": "Chart.js" }
. Bei Komponenten mit mehreren benannten Exporten (z. B. Dienstprogrammen) beginnt der Name der Hauptdatei mit einem Unterstrich:
. |
Der Rest der Dateien wird exportiert als:
export * from './someUtil'; export * from './anotherUtil';
Auf diese Weise können Sie doppelte Dateinamen entfernen , um nicht in den Top Ten der offenen index.js / style.scss verloren zu gehen . Sie können dies mit IDE-Plugins lösen, aber warum nicht auf universelle Weise.
Ich werde die Komponenten Seite für Seite gruppieren, mit Ausnahme allgemeiner Komponenten wie Nachricht / Link, und wenn möglich auch benannte Exporte (ohne export default
) verwenden, um die Einheitlichkeit der Namen, die einfache Umgestaltung und die Projektsuche aufrechtzuerhalten.
Konfigurieren Sie MobX-Rendering und -Speicher
Die Datei, die als Einstiegspunkt fĂĽr Webpack dient, sieht folgendermaĂźen aus:
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);
Da bei der Arbeit mit Observablen die Konsole in Polyfills so etwas wie Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration}
anzeigt Ich werde ein Dienstprogramm zur Standardisierung erstellen:
src / polyfill.js import { toJS } from 'mobx'; console.js = function consoleJsCustom(...args) { console.log(...args.map(arg => toJS(arg))); };
Außerdem sind globale Stile und Stilnormalisierung für verschiedene Browser in der Hauptdatei verbunden. Wenn in .env.frontend ein Schlüssel für Sentry vorhanden ist, werden Fehler protokolliert, MobX-Speicher erstellt, die Verfolgung von Parameteränderungen mit Autorun initiiert und die in den React-Hot-Loader eingeschlossene Komponente bereitgestellt im DOM.
Das Repository selbst ist eine nicht beobachtbare Klasse, deren Parameter nicht beobachtbare Klassen mit beobachtbaren Parametern sind. Es versteht sich daher, dass der Parametersatz nicht dynamisch ist - daher ist die Anwendung vorhersehbarer. Dies ist einer der wenigen Orte, an denen JSDoc nützlich ist, um die automatische Vervollständigung in der IDE zu ermöglichen.
src / store / 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); } }
Ein Beispiel fĂĽr einen MobX-Speicher kann am Beispiel eines GlobalStore analysiert werden, der derzeit nur den Zweck hat, das aktuelle Stilthema zu speichern und festzulegen.
src / store / 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); } }
Manchmal legen die Parameter und Methoden der Klasse den Typ manuell mithilfe von Dekoratoren fest, zum Beispiel:
export class GlobalStore { @observable currentTheme = ''; @action.bound setTheme(theme) { this.currentTheme = theme; setTheme(theme); } }
Aber ich sehe keinen Grund darin, da die alten Dekorateure der Proposal-Klasse ihre automatische Transformation unterstĂĽtzen, sodass das folgende Dienstprogramm ausreicht:
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; }; }
Um es zu verwenden, mĂĽssen Sie die Plugins in loaderBabel.js anpassen : ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }]
, und setzen Sie in den ESLint-Einstellungen parserOptions.ecmaFeatures.legacyDecorators: true
entsprechend. Ohne diese Einstellungen wird nur der Klassendeskriptor ohne Prototyp an den Zieldekorateur übergeben, und trotz sorgfältiger Recherche der aktuellen Version von Proposal habe ich keine Möglichkeit gefunden, Methoden und statische Eigenschaften zu verpacken.
Im Allgemeinen ist das Speicher-Setup abgeschlossen, aber es wäre schön, das Potenzial von MobX-Autorun freizuschalten. Zu diesem Zweck sind Aufgaben wie "Warten auf eine Antwort vom Autorisierungsserver" oder "Herunterladen von Übersetzungen vom Server", dann Schreiben der Antworten auf den Server und direktes Rendern der Anwendung im DOM am besten geeignet. Daher werde ich ein wenig in die Zukunft laufen und ein Geschäft mit Lokalisierung erstellen:
src / store / 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; } }
Wie Sie sehen können, gibt es einige * .json- Dateien mit Übersetzungen, und das asynchrone Laden mit setTimeout wird im Klassenkonstruktor emuliert. Bei der Ausführung wird der kürzlich erstellte GlobalStore mit this.rootStore.global.shouldAppRender = true
markiert.
Daher mĂĽssen Sie von app.js aus die Rendering-Funktion in die Datei autorun.js ĂĽbertragen :
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); }); }
Die initAutorun- Funktion kann eine beliebige Anzahl von Autorun- Konstruktionen mit Rückrufen enthalten, die nur funktionieren, wenn sie sich selbst initiieren und eine Variable innerhalb eines bestimmten Rückrufs ändern. In diesem Fall wurde autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true;
und verursachte das Rendern der Anwendung im DOM. Ein leistungsstarkes Tool, mit dem Sie alle Änderungen im Geschäft protokollieren und entsprechend reagieren können.
Lokalisierung und React Hooks
Die Übersetzung in andere Sprachen ist eine der umfangreichsten Aufgaben, in kleinen Unternehmen wird sie oft dutzende Male unterschätzt und in großen Unternehmen ist sie unnötig überkompliziert. Abhängig von der Implementierung, wie viele Nerven und Zeit nicht in mehreren Abteilungen des Unternehmens gleichzeitig verschwendet werden. Ich werde in dem Artikel nur den Client-Teil mit einem Rückstand für die zukünftige Integration mit anderen Systemen erwähnen.
FĂĽr die Bequemlichkeit der Frontend-Entwicklung mĂĽssen Sie in der Lage sein:
- setze semantische Namen fĂĽr Konstanten;
- FĂĽgen Sie dynamische Variablen ein
- Singular / Plural angeben;
- — -;
- ;
- / ;
- ;
- () ;
- () , .
, , : 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 , . «, », , — .
Metriken
- ( , , , , ) . . .
— , — , . :
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);
, .
, , . — , , , , - .