Pertukaran arsitektur aplikasi SPA pada 2019

Salam, Khabrovit!


Saya telah membaca sumber ini sejak didirikan, tetapi waktu untuk menulis artikel baru muncul sekarang, yang berarti saatnya untuk berbagi pengalaman kami dengan masyarakat. Untuk pengembang pemula, saya harap artikel ini akan membantu meningkatkan kualitas desain, dan bagi yang berpengalaman akan bertindak sebagai daftar periksa agar tidak melupakan elemen penting pada tahap arsitektur. Untuk yang tidak sabar, repositori akhir dan demo .



Misalkan Anda berada dalam "perusahaan impian" - salah satu pertukaran dengan pilihan teknologi dan sumber daya gratis untuk melakukan segalanya "sebagaimana mestinya". Saat ini, yang dimiliki perusahaan hanyalah


Penugasan bisnis


Kembangkan aplikasi SPA untuk antarmuka perdagangan, di mana Anda dapat:


  • lihat daftar pasangan perdagangan yang dikelompokkan berdasarkan mata uang;
  • ketika Anda mengklik pada pasangan perdagangan untuk melihat informasi pada harga saat ini, perubahan dalam 24 jam, "segelas pesanan";
  • ubah bahasa aplikasi ke Bahasa Inggris / Rusia;
  • ubah tema menjadi gelap / terang.

Tugasnya cukup singkat, yang akan memungkinkan Anda untuk fokus pada arsitektur, daripada menulis fungsionalitas bisnis dalam volume besar. Hasil dari upaya awal harus menjadi kode logis dan bijaksana yang memungkinkan Anda untuk melanjutkan langsung ke implementasi logika bisnis.


Karena tidak ada persyaratan teknis dalam pernyataan kerja dari pelanggan, biarkan mereka merasa nyaman untuk pengembangan:


  • kompatibilitas lintas-browser : 2 versi terbaru dari browser populer (tanpa IE);
  • lebar layar :> = 1240px;
  • desain : dengan analogi dengan pertukaran lainnya, seperti Desainer belum dipekerjakan.

Sekarang adalah waktu untuk menentukan alat dan perpustakaan mana yang sedang digunakan. Saya akan dipandu oleh prinsip-prinsip pengembangan "turnkey" dan KISS , yaitu, saya hanya akan mengambil pustaka sumber terbuka yang akan membutuhkan waktu yang tidak memadai untuk diterapkan secara mandiri, termasuk waktu untuk melatih sesama pengembang di masa depan.


  • sistem kontrol versi : Git + Github;
  • backend : API CoinGecko;
  • perakitan / transportasi : Webpack + Babel;
  • penginstal paket : Benang (npm 6 dependensi yang salah dimutakhirkan);
  • kontrol kualitas kode : ESLint + Prettier + Stylelint;
  • view : React (mari kita lihat betapa nyamannya Hooks);
  • toko : MobX;
  • autotests : Cypress.io (solusi javascript lengkap alih-alih perakitan modular seperti Mocha / Karma + Chai + Sinon + Selenium + Webdriver / Protractor);
  • gaya : SCSS via PostCSS (fleksibilitas konfigurasi, teman-teman dengan Stylelint);
  • grafik : HighStock (setup jauh lebih mudah daripada TradingView , tetapi untuk aplikasi nyata saya akan mengambil yang terakhir);
  • pelaporan kesalahan : Sentry;
  • Utilitas : Lodash (hemat waktu);
  • routing : turnkey;
  • lokalisasi : turnkey;
  • bekerja dengan permintaan : turnkey;
  • metrik kinerja : turnkey;
  • tipifikasi : bukan pada shift saya.

Dengan demikian, dari perpustakaan di file aplikasi final, hanya React, MobX, HighStock, Lodash dan Sentry akan muncul. Saya pikir ini dibenarkan, karena mereka memiliki dokumentasi yang sangat baik, kinerja dan akrab bagi banyak pengembang.


Kontrol kualitas kode


Saya lebih suka memecah dependensi dalam package.json menjadi bagian semantik, jadi langkah pertama setelah memulai repositori git adalah mengelompokkan segala sesuatu yang berkaitan dengan gaya kode dalam folder ./eslint-custom , yang ditentukan dalam package.json :


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

yarn install normal tidak akan memeriksa apakah dependensi di dalam eslint-custom telah berubah, jadi saya akan menggunakan yarn upd . Secara umum, praktik ini terlihat lebih universal, karena devs tidak perlu mengubah resep penempatan jika pengembang perlu mengubah metode pemasangan paket.


Tidak ada gunanya menggunakan file yarn.lock, karena semua dependensi akan tanpa "penutup" semver (dalam bentuk "react": "16.8.6" ). Pengalaman telah menunjukkan bahwa lebih baik untuk memperbarui versi secara manual dan mengujinya dengan hati-hati untuk tugas-tugas individual daripada mengandalkan file kunci, memberikan penulis paket kesempatan untuk memutus aplikasi dengan pembaruan kecil kapan saja (beruntung yang belum menemukan ini).


Dalam paket eslint-custom , dependensinya adalah sebagai berikut:


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

Untuk menghubungkan ketiga alat ini, dibutuhkan 5 paket tambahan ( eslint-plugin-prettier, eslint-config-prettier, stylelint-prettier, stylelint-config-prettier, prettier-eslint ) - Anda harus membayar harga seperti itu hari ini. Untuk kenyamanan maksimal, hanya penyortiran otomatis impor tidak cukup, tetapi, sayangnya, plugin ini kehilangan garis ketika memformat ulang file.


File konfigurasi untuk semua alat akan berada dalam format * .js ( eslint.config.js , stylelint.config.js ) sehingga pemformatan kode akan berfungsi pada mereka. Aturan mungkin dalam format * .yaml , dipecah oleh modul semantik. Versi lengkap konfigurasi dan aturan ada di repositori .


Masih menambahkan perintah di paket utama. 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" } } 

... dan konfigurasikan IDE Anda untuk menerapkan pemformatan saat menyimpan file saat ini. Untuk menjamin ini, saat membuat komit, Anda harus menggunakan git hook yang akan memeriksa dan memformat semua file proyek. Mengapa tidak hanya mereka yang hadir di komit? Untuk prinsip tanggung jawab kolektif untuk seluruh basis kode, sehingga tidak ada yang tergoda untuk menghindari validasi. Untuk melakukan ini, ketika membuat komit, semua peringatan linter akan dianggap kesalahan menggunakan --max-warnings=0 .


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

Perakitan / Transportasi


Sekali lagi, saya akan menggunakan pendekatan modular dan mengambil semua pengaturan Webpack dan Babel di folder ./webpack-custom. Konfigurasi akan bergantung pada struktur file berikut:


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

Pembuat yang dikonfigurasikan dengan benar akan menyediakan:


  • kemampuan untuk menulis kode menggunakan sintaks dan kemampuan spesifikasi EcmaScript terbaru, termasuk proposal yang nyaman (dekorator kelas dan properti mereka untuk MobX pasti berguna di sini);
  • server lokal dengan Hot Reload;
  • metrik kinerja perakitan;
  • memeriksa dependensi siklus;
  • analisis struktur dan ukuran file yang dihasilkan;
  • optimisasi dan minifikasi untuk perakitan produksi;
  • interpretasi file * .scss modular dan kemampuan untuk menghapus file * .css yang sudah jadi dari sebuah bundel;
  • sisipkan inline * .svg ;
  • awalan polyfill / style untuk browser target;
  • memecahkan masalah caching file pada produksi.

Ini juga akan mudah dikonfigurasi. Saya akan memecahkan masalah ini dengan bantuan dua file sampel * .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 

Dengan demikian, untuk memulai perakitan, Anda perlu membuat file dengan nama .frontend.env dan keberadaan wajib semua parameter. Pendekatan ini akan menyelesaikan beberapa masalah sekaligus: tidak perlu membuat file konfigurasi terpisah untuk Webpack dan mempertahankan konsistensi mereka; secara lokal, Anda dapat mengkonfigurasi berapa banyak pengembang tertentu membutuhkannya; penyebaran devs hanya akan menyalin file untuk perakitan produksi ( cp .frontend.env.prod.example .frontend.env ), memperkaya nilai-nilai dari repositori, sehingga pengembang frontend memiliki kemampuan untuk mengelola resep melalui variabel tanpa menggunakan admin. Selain itu, dimungkinkan untuk membuat contoh konfigurasi untuk tegakan (misalnya, dengan peta sumber).


Untuk memisahkan gaya menjadi file dengan CSS_EXTRACT diaktifkan, saya akan menggunakan mini-css-extract-plugin - memungkinkan Anda menggunakan Hot Reloading. Yaitu, jika Anda mengaktifkan HOT_RELOAD dan CSS_EXTRACT untuk pengembangan lokal, maka dengan
hanya gaya yang akan dimuat ulang saat mengubah file gaya - tetapi, sayangnya, semuanya, bukan hanya file yang diubah. Dengan CSS_EXTRACT dimatikan, hanya modul style yang diubah akan diperbarui.


HMR untuk bekerja dengan React Hooks disertakan dengan cukup standar:


  • webpack.HotModuleReplacementPlugin dalam plugin;
  • hot: true dalam parameter webpack-dev-server ;
  • react-hot-loader/babel dalam plugin babel-loader ;
  • options.hmr: true di mini-css-extract-plugin ;
  • export default hot(App) di komponen utama aplikasi;
  • @ hot-loader / react-dom alih - alih reaksi-dom yang biasa (mudah melalui resolve.alias: { 'react-dom': '@hot-loader/react-dom' } );

Versi react-hot-loader saat ini tidak mendukung komponen memoizing menggunakan React.memo , jadi ketika menulis dekorator untuk MobX, Anda perlu mempertimbangkan ini untuk kenyamanan pengembangan lokal. Ketidaknyamanan lain yang disebabkan oleh hal ini adalah bahwa ketika Pembaruan Sorotan diaktifkan di Alat Bereaksi Pengembang, semua komponen diperbarui selama interaksi dengan aplikasi. Oleh karena itu, ketika bekerja secara lokal pada optimasi kinerja, pengaturan HOT_RELOAD harus dinonaktifkan.


Optimasi build di Webpack 4 dilakukan secara otomatis ketika mode : 'development' | 'production' mode : 'development' | 'production' . Dalam hal ini, saya mengandalkan optimasi standar (+ dimasukkannya keep_fnames: true parameter di terser-webpack-plugin untuk menyimpan nama komponen), karena sudah disetel dengan baik.


Pemisahan potongan dan kontrol caching klien perlu perhatian khusus. Untuk operasi yang benar, Anda perlu:


  • dalam output.filename untuk file js dan css tentukan isProduction ? '[name].[contenthash].js' : '[name].js' isProduction ? '[name].[contenthash].js' : '[name].js' (masing-masing dengan ekstensi .css) sehingga nama file didasarkan pada isinya;
  • dalam optimasi, ubah parameter menjadi chunkIds: 'named', moduleIds: 'hashed' sehingga penghitung modul internal di webpack tidak berubah;
  • letakkan runtime di chunk yang terpisah;
  • pindahkan grup cache ke splitChunks (empat poin sudah cukup untuk aplikasi ini - lodash, sentry, highcharts, dan vendor untuk dependensi lain dari node_modules ). Karena tiga yang pertama jarang diperbarui, mereka akan tetap berada di cache browser klien selama mungkin.

webpack-custom / config / configOptimization.js
 /** * @docs: https://webpack.js.org/configuration/optimization * */ const TerserPlugin = require('terser-webpack-plugin'); module.exports = { runtimeChunk: { name: 'runtime', }, chunkIds: 'named', moduleIds: 'hashed', mergeDuplicateChunks: true, splitChunks: { cacheGroups: { lodash: { test: module => module.context.indexOf('node_modules\\lodash') !== -1, name: 'lodash', chunks: 'all', enforce: true, }, sentry: { test: module => module.context.indexOf('node_modules\\@sentry') !== -1, name: 'sentry', chunks: 'all', enforce: true, }, highcharts: { test: module => module.context.indexOf('node_modules\\highcharts') !== -1, name: 'highcharts', chunks: 'all', enforce: true, }, vendor: { test: module => module.context.indexOf('node_modules') !== -1, priority: -1, name: 'vendor', chunks: 'all', enforce: true, }, }, }, minimizer: [ new TerserPlugin({ terserOptions: { keep_fnames: true, }, }), ], }; 

Untuk mempercepat perakitan dalam proyek ini, saya menggunakan thread-loader - ketika diparalelkan dengan 4 proses, itu memberikan akselerasi perakitan sebesar 90%, yang lebih baik daripada happypack dengan pengaturan yang sama.


Pengaturan untuk loader, termasuk untuk babel, dalam file terpisah (seperti .babelrc ) untuk dipadamkan , saya pikir, tidak perlu. Tetapi konfigurasi lintas-browser lebih nyaman untuk disimpan dalam parameter daftar browserslist dari paket utama. Json , karena ini juga digunakan untuk gaya autoprefixer.


Untuk kenyamanan bekerja dengan Prettier, saya membuat parameter AGGREGATION_TIMEOUT , yang memungkinkan Anda untuk mengatur penundaan antara mendeteksi perubahan dalam file dan membangun kembali aplikasi dalam mode dev-server. Karena saya mengkonfigurasi pemformatan ulang file saat menyimpan ke IDE, ini menyebabkan 2 membangun kembali - yang pertama untuk menyimpan file asli, yang kedua untuk menyelesaikan pemformatan. 2000 milidetik biasanya cukup untuk webpack untuk menunggu versi final file.


Sisa konfigurasi tidak pantas mendapat perhatian khusus, seperti yang diungkapkan dalam ratusan materi pelatihan untuk pemula, sehingga Anda dapat melanjutkan ke desain arsitektur aplikasi.


Tema Gaya


Sebelumnya, untuk membuat tema, Anda harus membuat beberapa versi file * .css dan memuat ulang halaman saat mengubah tema, memuat kumpulan gaya yang diinginkan. Sekarang semuanya mudah diselesaikan menggunakan Properti CSS Kustom . Teknologi ini didukung oleh semua browser target aplikasi saat ini, tetapi ada juga polyfills untuk IE.


Katakanlah ada 2 tema - terang dan gelap, set warna yang akan digunakan


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

Agar variabel-variabel ini dapat diterapkan secara global, masing-masing harus ditulis ke document.documentElement , masing-masing, pengurai kecil diperlukan untuk mengonversi file ini ke objek javascript. Nanti saya akan memberi tahu mengapa lebih nyaman daripada menyimpannya di javascript segera.


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

Di sini, konsistensi diperiksa oleh itu - yaitu, kepatuhan penuh dari set variabel, dengan perbedaan yang jatuh perakitan.


Saat menggunakan loader ini, objek yang cukup indah dengan parameter diperoleh, dan beberapa baris untuk utilitas perubahan tema sudah cukup:


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

Saya lebih suka menerjemahkan variabel css ini menjadi yang standar untuk * .scss :


src / styles / constants.scss


IDE WebStorm, seperti yang terlihat pada tangkapan layar, menunjukkan warna pada panel di sebelah kiri dan dengan mengklik pada warna membuka palet di mana Anda dapat mengubahnya. Warna baru secara otomatis diganti menjadi themes.scss , Hot Reload akan berfungsi dan aplikasi akan langsung diubah. Ini persis tingkat kenyamanan pengembangan yang diharapkan pada 2019.


Prinsip Organisasi Kode


Dalam proyek ini, saya akan menempel duplikat nama folder untuk komponen, file, dan gaya, misalnya:

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

Dengan demikian, package.json akan memiliki konten { "main": "Chart.js" } . Untuk komponen dengan beberapa ekspor bernama (misalnya, utilitas), nama file utama akan dimulai dengan garis bawah:


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

Dan sisa file akan diekspor sebagai:


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

Ini akan memungkinkan Anda untuk menyingkirkan nama file duplikat agar tidak tersesat di sepuluh index.js / style.scss terbuka. Anda dapat menyelesaikan ini dengan plugin IDE, tetapi mengapa tidak dengan cara universal.


Saya akan mengelompokkan komponen-komponen halaman demi halaman, kecuali yang umum seperti Message / Link, dan juga, jika mungkin, menggunakan ekspor bernama (tanpa export default ) untuk menjaga keseragaman nama, kemudahan refactoring, dan pencarian proyek.


Konfigurasikan rendering dan penyimpanan MobX


File yang berfungsi sebagai titik masuk untuk Webpack akan terlihat seperti ini:


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

Sejak saat bekerja dengan observable, konsol menampilkan sesuatu seperti Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration} , dalam polyfill Saya akan membuat utilitas untuk standardisasi:


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

Selain itu, gaya global dan normalisasi gaya untuk browser yang berbeda terhubung dalam file utama, jika ada kunci untuk Sentry di .env.frontend kesalahan mulai masuk, penyimpanan MobX dibuat, pelacakan perubahan parameter dengan autorun dimulai, dan komponen yang dibungkus dengan react-hot-loader dipasang. di DOM.


Repositori itu sendiri akan menjadi kelas yang tidak dapat diamati yang parameternya adalah kelas yang tidak dapat diamati dengan parameter yang bisa diamati. Dengan demikian, dipahami bahwa set parameter tidak akan dinamis - karena itu, aplikasi akan lebih mudah diprediksi. Ini adalah salah satu dari sedikit tempat di mana JSDoc sangat berguna untuk mengaktifkan pelengkapan otomatis dalam IDE.


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

Contoh toko MobX dapat dianalisis menggunakan contoh GlobalStore, yang akan memiliki satu-satunya tujuan saat ini - untuk menyimpan dan mengatur tema gaya saat ini.


src / stores / GlobalStore.js
 import { makeObservable, setTheme } from 'utils'; import themes from 'styles/themes.scss'; const themesList = Object.keys(themes); @makeObservable export class GlobalStore { /** * @param rootStore {RootStore} */ constructor(rootStore) { this.rootStore = rootStore; setTheme(themesList[0]); } themesList = themesList; currentTheme = ''; setTheme(theme) { this.currentTheme = theme; setTheme(theme); } } 

Terkadang parameter dan metode kelas secara manual mengatur jenis menggunakan dekorator, misalnya:


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

Tapi saya tidak melihat ada gunanya dalam hal ini, karena dekorator kelas Proposal lama mendukung transformasi otomatis mereka, sehingga utilitas berikut cukup:


src / utils / makeObservable.js
 import { action, computed, decorate, observable } from 'mobx'; export function makeObservable(target) { /** *   -   this +    *     * *   -   computed * */ const classPrototype = target.prototype; const methodsAndGetters = Object.getOwnPropertyNames(classPrototype).filter( methodName => methodName !== 'constructor' ); for (const methodName of methodsAndGetters) { const descriptor = Object.getOwnPropertyDescriptor( classPrototype, methodName ); descriptor.value = decorate(classPrototype, { [methodName]: typeof descriptor.value === 'function' ? action.bound : computed, }); } return (...constructorArguments) => { /** * ,   rootStore,   * observable * */ const store = new target(...constructorArguments); const staticProperties = Object.keys(store); staticProperties.forEach(propName => { if (propName === 'rootStore') { return false; } const descriptor = Object.getOwnPropertyDescriptor(store, propName); Object.defineProperty( store, propName, observable(store, propName, descriptor) ); }); return store; }; } 

Untuk menggunakan, Anda perlu menyesuaikan plugin di loaderBabel.js : ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] , dan dalam pengaturan ESLint, atur parserOptions.ecmaFeatures.legacyDecorators: true sesuai. Tanpa pengaturan ini, hanya deskriptor kelas tanpa prototipe yang ditransfer ke dekorator target, dan meskipun mempelajari dengan seksama versi Proposal saat ini , saya belum menemukan cara untuk membungkus metode dan properti statis.


Secara umum, pengaturan penyimpanan selesai, tetapi akan lebih baik untuk membuka kunci potensi autorun MobX. Untuk melakukan ini, tugas-tugas seperti "menunggu respons dari server otorisasi" atau "unduh terjemahan dari server", lalu tulis respons ke server dan langsung render aplikasi dalam DOM, adalah yang paling cocok. Karenanya, saya akan menjalankan sedikit ke masa depan dan membuat toko dengan pelokalan:


src / stores / I18nStore.js
 import { makeObservable } from 'utils'; import ru from 'localization/ru.json'; import en from 'localization/en.json'; const languages = { ru, en, }; const languagesList = Object.keys(languages); @makeObservable export class I18nStore { /** * @param rootStore {RootStore} */ constructor(rootStore) { this.rootStore = rootStore; setTimeout(() => { this.setLocalization('ru'); }, 500); } i18n = {}; languagesList = languagesList; currentLanguage = ''; setLocalization(language) { this.currentLanguage = language; this.i18n = languages[language]; this.rootStore.global.shouldAppRender = true; } } 

Seperti yang Anda lihat, ada beberapa file * .json dengan terjemahan, dan memuat asinkron menggunakan setTimeout ditiru dalam konstruktor kelas. Ketika dijalankan, GlobalStore yang baru dibuat ditandai dengan this.rootStore.global.shouldAppRender = true .


Jadi, dari app.js, Anda perlu mentransfer fungsi rendering ke file autorun.js :


src / autorun.js
 /* eslint-disable no-unused-vars */ import { autorun } from 'mobx'; import { renderToDOM } from 'utils'; import App from 'components/App'; const loggingEnabled = true; function logReason(autorunName, reaction) { if (!loggingEnabled || reaction.observing.length === 0) { return false; } const logString = reaction.observing.reduce( (str, { name, value }) => `${str}${name} changed to ${value}; `, '' ); console.log(`autorun-${autorunName}`, logString); } /** * @param store {RootStore} */ export function initAutorun(store) { autorun(reaction => { if (store.global.shouldAppRender) { renderToDOM(App); } logReason('shouldAppRender', reaction); }); } 

Fungsi initAutorun dapat memiliki sejumlah konstruksi autorun dengan callback yang hanya akan berfungsi jika mereka memulai sendiri dan mengubah variabel di dalam callback tertentu. Dalam hal ini, autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true; , dan menyebabkan rendering aplikasi di DOM. Alat yang ampuh yang memungkinkan Anda untuk mencatat semua perubahan di toko dan meresponsnya.


Lokalisasi dan React Hooks


Penerjemahan ke bahasa lain adalah salah satu tugas yang paling banyak, di perusahaan kecil sering diremehkan puluhan kali, dan di perusahaan besar itu terlalu rumit. Bergantung pada implementasinya, berapa banyak saraf dan waktu tidak akan terbuang sekaligus di beberapa departemen di perusahaan. Saya akan menyebutkan dalam artikel ini hanya bagian klien dengan jaminan untuk integrasi masa depan dengan sistem lain.


Untuk kenyamanan pengembangan frontend, Anda harus dapat:


  • menetapkan nama semantik untuk konstanta;
  • Masukkan variabel dinamis
  • menunjukkan singular / jamak;
  • โ€” -;
  • ;
  • / ;
  • ;
  • () ;
  • () , .

, , : messages.js ( ) . . ( / ), . ( , , ) . .


, currentLanguage i18n , , .


src/components/TestLocalization.js
 import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; const messages = { hello: '  {count} {count: ,,}', }; function TestLocalization() { const getLn = useLocalization(__filename, messages); return <div>{getLn(messages.hello, { count: 1 })}</div>; } export const TestLocalizationConnected = observer(TestLocalization); 

, MobX- , , Connected. , ESLint, .


observer mobx-react-lite/useObserver , HOT_RELOAD React.memo ( PureMixin / PureComponent ), useObserver :


src/utils/observer.js
 import { useObserver } from 'mobx-react-lite'; import React from 'react'; function copyStaticProperties(base, target) { const hoistBlackList = { $$typeof: true, render: true, compare: true, type: true, }; Object.keys(base).forEach(key => { if (base.hasOwnProperty(key) && !hoistBlackList[key]) { Object.defineProperty( target, key, Object.getOwnPropertyDescriptor(base, key) ); } }); } export function observer(baseComponent, options) { const baseComponentName = baseComponent.displayName || baseComponent.name; function wrappedComponent(props, ref) { return useObserver(function applyObserver() { return baseComponent(props, ref); }, baseComponentName); } wrappedComponent.displayName = baseComponentName; let memoComponent = null; if (HOT_RELOAD === 'true') { memoComponent = wrappedComponent; } else if (options.forwardRef) { memoComponent = React.memo(React.forwardRef(wrappedComponent)); } else { memoComponent = React.memo(wrappedComponent); } copyStaticProperties(baseComponent, memoComponent); memoComponent.displayName = baseComponentName; return memoComponent; } 

displayName , React- ( stack trace ).


RootStore:


src/hooks/useStore.js
 import React from 'react'; import { store } from 'stores'; const storeContext = React.createContext(store); /** * @returns {RootStore} * */ export function useStore() { return React.useContext(storeContext); } 

, observer:


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

TestLocalization โ€” useLocalization:


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

replaceDynamicParams replacePlurals โ€” , , , , , ..


Webpack โ€” __filename โ€” , , . , โ€” , , . , :


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

ru.json :


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

, . src/localization/en.json ยซ ยป setLocalization I18nStore.


ยซยป React Message:


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

__filename ( id ), , :


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

โ€” useLocalization ( currentLanguage , Message โ€” . , , .


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


MobX + Hooks . , backend, , , .


API


( backend, ) โ€” , , . , . :


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

, , . fetchSymbol , id , . , โ€” ( @action.bound ), Sentry :


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

, :


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

request :


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

. , Sentry, response:



โ€” ( ), .



, , . , :


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

ยซยป, -, โ€” . :


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

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

, , โ€” <Link route={routes.marketDetailed}> , . Webpack , .


, location .


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

โ€” routes.js . โ€” 404. , ยซ ยป, , , โ€” , 'test-test'.


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


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

, , currentRoute == null . โ€” isLoading === true , false , route.beforeEnter ( ). console.error , , .


, โ€” , . React- 2 :


  1. componentWillMount / componentDidMount / useEffect , , . โ€” , ยซยป. โ€” โ€” ;
  2. ( ) , . โ€” โ€” , . โ€” / โ€” real-time , / .

, โ€” ( , , ..), .


beforeEnter , : ยซ ยป, ( , , ), โ€” ( โ€” 500 ; ; , ; ..). ยซยป , MVP .


:


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

route , params ( ) ( ) href . , beforeEnter , . ยซ, ยป, , โ€” .


Metrik


- ( , , , , ) . . .


โ€” , โ€” , . :


src/api/utils/metrics.js
 import _ from 'lodash'; let metricsArray = []; let sendMetricsCallback = null; export function startMetrics(route, apiRoutes) { return function promiseCallback(data) { clearTimeout(sendMetricsCallback); const apiRouteName = _.findKey(apiRoutes, route); metricsArray.push({ id: apiRouteName, time: new Date().getTime(), }); return data; }; } export function stopMetrics(route, apiRoutes) { return function promiseCallback(data) { const apiRouteName = _.findKey(apiRoutes, route); const metricsData = _.find(metricsArray, ['id', apiRouteName]); metricsData.time = new Date().getTime() - metricsData.time; clearTimeout(sendMetricsCallback); sendMetricsCallback = setTimeout(() => { console.log('Metrics sent:', metricsArray); metricsArray = []; }, 2000); return data; }; } 

middleware request :


 export function request(route, params) { return Promise.resolve() .then(startMetrics(route, apiRoutes)) .then(validateRequestParams(route, params)) .then(makeRequestUrl(route, params)) .then(makeRequest) .then(validateResponse(route, params)) .then(stopMetrics(route, apiRoutes)) .catch(error => { stopMetrics(route, apiRoutes)(); throw error; }); } 

, , 2 , ( ) . โ€” , โ€” , ( ) , .


- โ€” .



end-to-end , Cypress. : ; , ; Continious Integration.


javascript Chai / Sinon , . , , โ€” ./tests, package.json โ€” "dependencies": { "cypress": "3.2.0" }


. Webpack :


tests/cypress/plugins/index.js
 const webpack = require('../../../node_modules/@cypress/webpack-preprocessor'); const webpackConfig = require('../../../webpack-custom/webpack.config'); module.exports = on => { const options = webpack.defaultOptions; options.webpackOptions.module = webpackConfig.module; options.webpackOptions.resolve = webpackConfig.resolve; on('file:preprocessor', webpack(options)); }; 

. module ( ) resolve ( ). ESLint ( describe , cy ) eslint-plugin-cypress . , :


tests/cypress/integration/mixed.js
 describe('Market Listing good scenarios', () => { it('Lots of mixed tests', () => { cy.visit('/market/usd/bch-usd'); cy.location('pathname').should('equal', '/market/usd/bch-usd'); //    ,       cy.wait('@symbolsList') .its('response.body') .should(data => { expect(data).to.be.an('array'); }); //    cy.wait('@rates'); cy.wait('@marketsList'); cy.wait('@symbolInfo'); cy.wait('@chartData'); //       cy.get('#marketTab-eth').click(); cy.location('pathname').should('equal', '/market/eth/bch-usd'); cy.wait('@rates'); cy.wait('@marketsList'); //    cy.contains(''); cy.get('#langSwitcher-en').click(); cy.contains('Markets list'); //    cy.get('body').should('have.class', 'light'); cy.get('#themeSwitcher-dark').click(); cy.get('body').should('have.class', 'dark'); }); }); 

Cypress fetch, , :


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

, .


, ?


, - . , , - / - / .


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


( Gzip) :



React Developer Tools :



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




Update 13.07.2019


, , :


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


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


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


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

3. useLocalization(__filename, messages) , โ€”


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

,


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

IDE , Webpack:


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

, messages.js :


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

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


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

,


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

*.json โ€” , , .


3. Lodash someResponseParam: _.isString , . :


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

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


5. , , . โ€” ( body isEntering , isLeaving ) beforeLeave ( Prompt react-router ), false- ,


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

, - . , :


โ€” /some/long/auth/path beforeEnter /auth
โ€” beforeEnter /auth , โ€” /profile
โ€” beforeEnter /profile , , , /profile/edit


, โ€” window.history.pushState location.replace . , . ยซ ยป ยซ componentDidMount ยป, , .


6. (, ) :


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

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

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

, .


, , . โ€” , , , , - .

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


All Articles