تبادل تطبيق الهندسة المعمارية SPA في عام 2019

تحياتي ، خبروفيتيس!


لقد قرأت هذا المورد منذ تأسيسه ، ولكن لم يظهر وقت كتابة المقالة إلا الآن ، مما يعني أن الوقت قد حان لتبادل تجربتنا مع المجتمع. بالنسبة لبداية المطورين ، آمل أن تساعد هذه المقالة في تحسين جودة التصميم ، وبالنسبة للمحترفين ذوي الخبرة ، ستعمل كقائمة تحقق حتى لا تنسى العناصر المهمة في المرحلة المعمارية. عن الصبر ، مستودع النهائي والعرض .



لنفترض أنك في "شركة أحلام" - أحد البورصات مع حرية اختيار التكنولوجيا والموارد للقيام بكل شيء "كما ينبغي". في الوقت الحالي ، كل ما لدى الشركة هو


مهمة العمل


قم بتطوير تطبيق SPA لواجهة التداول ، حيث يمكنك:


  • راجع قائمة أزواج التداول المجمعة حسب العملة ؛
  • عند النقر فوق زوج تداول لمعرفة المعلومات بالسعر الحالي ، تغيير في غضون 24 ساعة ، "كوب من الطلبات"
  • تغيير لغة التطبيق إلى الإنجليزية / الروسية ؛
  • تغيير الموضوع إلى الظلام / النور.

المهمة قصيرة جدًا ، مما سيتيح لك التركيز على الهندسة المعمارية ، بدلاً من كتابة كميات كبيرة من وظائف الأعمال. يجب أن تكون نتيجة الجهود الأولية رمزًا منطقيًا ومدروسًا يتيح لك المتابعة مباشرةً إلى تنفيذ منطق العمل.


نظرًا لعدم وجود متطلبات فنية في بيان العمل من العميل ، دعهم يشعرون بالراحة للتطوير:


  • التوافق عبر المستعرض : أحدث إصدارين من المتصفحات الشائعة (بدون IE) ؛
  • عرض الشاشة :> = 1240 بكسل ؛
  • تصميم : عن طريق القياس مع التبادلات الأخرى ، كما لم يتم التعاقد مع المصمم.

الآن هو الوقت المناسب لتحديد الأدوات والمكتبات التي يتم استخدامها. سأسترشد بمبادئ تطوير "تسليم المفتاح" و KISS ، أي أنني سأستغرق فقط مكتبات المصادر المفتوحة التي تتطلب قدراً غير كافٍ من الوقت لتنفيذها بشكل مستقل ، بما في ذلك الوقت لتدريب زملائي المطورين في المستقبل.


  • نظام التحكم في الإصدار : Git + Github؛
  • الخلفية : API CoinGecko.
  • التجمع / النقل : Webpack + بابل.
  • حزمة المثبت : الغزل (npm 6 تبعيات تحديثها بشكل غير صحيح) ؛
  • مراقبة جودة الرمز : ESLint + Prettier + Stylelint؛
  • عرض : التفاعل (دعنا نرى كيف هي السنانير مريحة) ؛
  • متجر : موبكس
  • autotests : Cypress.io (حل جافا سكريبت كامل بدلاً من تجميع وحدات مثل Mocha / Karma + Chai + Sinon + Selenium + Webdriver / Protractor) ؛
  • الأنماط : SCSS عبر PostCSS (مرونة التكوين ، الأصدقاء مع Stylelint) ؛
  • الرسوم البيانية : HighStock (الإعداد أسهل بكثير من TradingView ، ولكن بالنسبة للتطبيق الحقيقي كنت آخذ الأخير) ؛
  • الإبلاغ عن الخطأ : ترقب ؛
  • المرافق : Lodash (توفير الوقت) ؛
  • التوجيه : تسليم المفتاح ؛
  • التعريب : تسليم المفتاح ؛
  • العمل مع الطلبات : تسليم المفتاح ؛
  • مقاييس الأداء : تسليم المفتاح.
  • الكتابة : ليس في نوبة بلدي.

وبالتالي ، فقط React و MobX و HighStock و Lodash و Sentry سيكونون من المكتبات في ملف التطبيق النهائي. أعتقد أن هذا له ما يبرره ، لأن لديهم وثائق وأداءً ممتازًا ومألوفين لدى العديد من المطورين.


كود مراقبة الجودة


أفضل تقسيم التبعيات في package.json إلى أجزاء الدلالية ، لذا فإن الخطوة الأولى بعد بدء مستودع git هي تجميع كل ما يتعلق بنمط الكود في مجلد ./eslint-custom ، مع تحديد في package.json :


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

لن يتحقق yarn install العادي إذا كانت التبعيات داخل eslint-custom قد تغيرت ، لذلك سأستخدم yarn upd . بشكل عام ، تبدو هذه الممارسة أكثر عالمية ، حيث لن يتعين على devs تغيير وصفة النشر إذا كان المطورون بحاجة إلى تغيير طريقة تثبيت الحزم.


ليس هناك فائدة من استخدام ملف yarn.lock ، حيث أن جميع التبعيات ستكون بدون "أغطية" تفرز (في شكل "react": "16.8.6" ). أظهرت التجربة أنه من الأفضل تحديث الإصدارات يدويًا واختبارها بعناية كجزء من المهام الفردية بدلاً من الاعتماد على ملف القفل ، مما يتيح لمؤلفي الحزمة الفرصة لكسر التطبيق مع تحديث بسيط في أي وقت (المحظوظون الذين لم يواجهوا هذا).


في الحزمة المخصصة لـ eslint ، ستكون التبعيات كما يلي:


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

لتوصيل الأدوات الثلاثة ، استغرق الأمر 5 حزم إضافية ( eslint-plugin-prettier ، eslint-config-prettier ، stylelint-prettier، stylelint-configtier، prettier، prettier، prettier ) - يجب عليك دفع هذا السعر اليوم. لتحقيق أقصى قدر من الراحة ، يكفي الافتقار إلى الفرز التلقائي للواردات ، لكن للأسف ، يفقد هذا البرنامج المساعد خطوطًا عند إعادة تهيئة الملف.


ستكون ملفات التهيئة لجميع الأدوات بتنسيق * .js ( eslint.config.js ، stylelint.config.js ) بحيث يعمل تنسيق التعليمات البرمجية عليها. قد تكون القواعد بتنسيق * .yaml ، موزعة حسب الوحدات الدلالية. توجد إصدارات كاملة من التكوينات والقواعد في المستودع .


يبقى لإضافة الأوامر في الحزمة الرئيسية. 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" } } 

... وتكوين IDE الخاص بك لتطبيق التنسيق مع حفظ الملف الحالي. لضمان ذلك ، عند إنشاء التزام ، يجب عليك استخدام ربط بوابة يقوم بفحص وتنسيق جميع ملفات المشروع. لماذا ليس فقط تلك الموجودة في ارتكاب؟ من أجل مبدأ المسؤولية الجماعية عن قاعدة الشفرة بأكملها ، بحيث لم يتم إغراء أحد بالتحايل على التحقق من الصحة. للقيام بذلك ، عند إنشاء التزام ، سيتم اعتبار جميع تحذيرات --max-warnings=0 أخطاء باستخدام --max-warnings=0 .


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

الجمعية / النقل


مرة أخرى ، سأستخدم النهج المعياري وسأخرج كل إعدادات Webpack و Babel في مجلد ./webpack-custom. سيعتمد التكوين على بنية الملف التالية:


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

يوفر المنشئ الذي تم تكوينه بشكل صحيح:


  • القدرة على كتابة التعليمات البرمجية باستخدام بناء الجملة وقدرات أحدث مواصفات EcmaScript ، بما في ذلك الاقتراحات المريحة (الديكورات الصفية وخصائصها لـ MobX مفيدة هنا بالتأكيد) ؛
  • خادم محلي مع إعادة تحميل الساخن ؛
  • مقاييس الأداء التجمع.
  • التحقق من التبعيات الدورية ؛
  • تحليل هيكل وحجم الملف الناتج ؛
  • الأمثل والتقليل لتجميع الإنتاج ؛
  • تفسير ملفات * .scss المعيارية والقدرة على إزالة ملفات * .css منتهية من حزمة ؛
  • inline-insert * .svg files؛
  • بادئات polyfill / style للمتصفحات المستهدفة ؛
  • حل مشكلة التخزين المؤقت للملفات على الإنتاج.

كما سيتم تكوين مريح. سوف أقوم بحل هذه المشكلة بمساعدة ملفين عينة * .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 

وبالتالي ، لبدء التجميع ، تحتاج إلى إنشاء ملف باسم .frontend.env والحضور الإلزامي لجميع المعلمات. سيؤدي هذا النهج إلى حل العديد من المشكلات في وقت واحد: لا حاجة لإنشاء ملفات تكوين منفصلة لـ Webpack والحفاظ على تناسقها ؛ محليًا ، يمكنك تكوين مقدار احتياج مطور معين ؛ أثناء النشر ، لن تقوم devs إلا بنسخ الملف الخاص بتجميع الإنتاج ( cp .frontend.env.prod.example .frontend.env ) ، مما يؤدي إلى إثراء القيم من المخزون ، وبالتالي يكون لدى مطوري الواجهة الأمامية القدرة على إدارة الوصفة من خلال المتغيرات دون استخدام مدراء. بالإضافة إلى ذلك ، سيكون من الممكن إجراء تكوين مثال للحوامل (على سبيل المثال ، مع خرائط المصدر).


لفصل الأنماط في ملفات مع تمكين CSS_EXTRACT ، سأستخدم mini-css-extract-plugin - يتيح لك استخدام Hot Reloading. هذا هو ، إذا قمت بتمكين HOT_RELOAD و CSS_EXTRACT للتنمية المحلية ، ثم باستخدام
سيتم إعادة تحميل الأنماط فقط عند تغيير ملفات الأنماط - ولكن لسوء الحظ ، كل شيء ، وليس فقط الملف الذي تم تغييره. مع إيقاف تشغيل CSS_EXTRACT ، سيتم تحديث وحدة النمط التي تم تغييرها فقط.


يتم تضمين HMR للعمل مع React Hooks بشكل قياسي تمامًا:


  • webpack.HotModuleReplacementPlugin في الإضافات ؛
  • hot: true في المعلمات webpack-dev-server ؛
  • react-hot-loader/babel في الإضافات بابل محمل .
  • options.hmr: true في mini-css-extract-plugin ؛
  • export default hot(App) في المكون الرئيسي للتطبيق ؛
  • @ hot-loader / react-dom بدلاً من react-dom المعتادة (بشكل ملائم عبر resolve.alias: { 'react-dom': '@hot-loader/react-dom' } ) ؛

لا يدعم الإصدار الحالي من أداة رد الفعل الساخنة مكونات تحفيظ باستخدام React.memo ، لذلك عند كتابة أدوات تزيين لـ MobX ، ستحتاج إلى مراعاة ذلك من أجل راحة التطوير المحلي. من المضايقات الأخرى الناجمة عن ذلك أنه عندما يتم تمكين Highlight Updates في React Developer Tools ، يتم تحديث جميع المكونات أثناء أي تفاعل مع التطبيق. لذلك ، عند العمل محليًا على تحسين الأداء ، يجب تعطيل إعداد HOT_RELOAD .


يتم تنفيذ تحسين البنية في Webpack 4 تلقائيًا عندما يكون mode : 'development' | 'production' mode : 'development' | 'production' . في هذه الحالة ، فأنا أعتمد على التحسين القياسي (+ تضمين أسماء keep_fnames: true المعلمة keep_fnames: true في البرنامج المساعد terser-webpack لحفظ اسم المكونات) ، نظرًا لأنه مضبوط بالفعل.


الفصل بين القطع والتحكم في التخزين المؤقت للعميل يستحق عناية خاصة. للتشغيل الصحيح تحتاج:


  • في output.filename لملفات JS و CSS تحديد isProduction ? '[name].[contenthash].js' : '[name].js' isProduction ? '[name].[contenthash].js' : '[name].js' (بالملحق .css على التوالي) بحيث يستند اسم الملف إلى محتوياته ؛
  • في التحسين ، قم بتغيير المعلمات إلى chunkIds: 'named', moduleIds: 'hashed' بحيث لا يتغير عداد الوحدة الداخلية في webpack ؛
  • وضع وقت التشغيل في جزء منفصل ؛
  • نقل مجموعات ذاكرة التخزين المؤقت إلى splitChunks (أربع نقاط كافية لهذا التطبيق - lodash ، خفير ، highcharts والبائع عن التبعيات الأخرى من node_modules ). نظرًا لأن الثلاثة الأولى سيتم تحديثها نادرًا ، فسيظلون في ذاكرة التخزين المؤقت لمتصفح العميل لأطول فترة ممكنة.

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

لتسريع التجميع في هذا المشروع ، أستخدم لودر ترابط - عند موازنته لـ 4 عمليات ، أعطى تسريع التجميع بنسبة 90٪ ، وهو أفضل من happypack مع نفس الإعدادات.


إعدادات اللوادر ، بما في ذلك بابل ، في ملفات منفصلة (مثل .babelrc ) لإخمادها ، على ما أعتقد ، ليست ضرورية. لكن التكوين عبر المستعرض أكثر ملاءمة للإبقاء عليه في معلمة قائمة browserslist من الحزمة الرئيسية. json ، لأنه يستخدم أيضًا لأنماط الإصلاح التلقائي.


لتوفير الراحة للعمل مع Prettier ، قمت بعمل المعلمة AGGREGATION_TIMEOUT ، والتي تتيح لك ضبط التأخير بين اكتشاف التغييرات في الملفات وإعادة بناء التطبيق في وضع dev-server. منذ أن قمت بتهيئة إعادة تهيئة الملفات عند الحفظ في IDE ، يتسبب ذلك في إعادة إنشاء 2 - الأول لحفظ الملف الأصلي ، والثاني لإكمال التنسيق. 2000 مللي ثانية عادةً ما يكفي ل webpack لانتظار الإصدار النهائي من الملف.


لا تستحق بقية التكوين اهتمامًا خاصًا ، حيث يتم الكشف عنها في مئات المواد التدريبية للمبتدئين ، بحيث يمكنك متابعة تصميم بنية التطبيق.


ثيمات ستايل


في السابق ، لإنشاء سمات ، كان عليك إنشاء عدة إصدارات من ملفات * .css وإعادة تحميل الصفحة عند تغيير السمات ، وتحميل مجموعة الأنماط المطلوبة. الآن يتم حل كل شيء بسهولة باستخدام Custom CSS Properties . هذه التكنولوجيا مدعومة من قبل جميع المتصفحات المستهدفة للتطبيق الحالي ، ولكن هناك أيضًا ملفات متعددة لـ IE.


دعنا نقول أن هناك موضوعين - الضوء والظلام ، مجموعات الألوان التي ستكون في


الأنماط / الموضوعات
 .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); } 

لكي يتم تطبيق هذه المتغيرات على المستوى العالمي ، يجب كتابتها إلى document.documentElement ، على التوالي ، هناك حاجة إلى محلل صغير لتحويل هذا الملف إلى كائن javascript. في وقت لاحق ، سوف أخبركم لماذا هو أكثر ملاءمة من تخزينه في جافا سكريبت على الفور.


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

هنا ، يتم التحقق من الاتساق من خلال ذلك - أي الامتثال التام لمجموعة المتغيرات ، مع اختلاف الاختلاف الذي يقع فيه التجميع.


عند استخدام هذا المُحمل ، يتم الحصول على كائن جميل جدًا به معلمات ، ويكفي بضعة أسطر لأداة تغيير السمة:


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

أفضل ترجمة هذه المتغيرات css إلى متغيرات قياسية لـ * .scss :


src / styles / constants.scss


يُظهر WebStorm IDE ، كما يظهر في لقطة الشاشة ، الألوان الموجودة في اللوحة الموجودة على اليسار ومن خلال النقر فوق اللون يفتح لوحة حيث يمكنك تغييرها. يتم استبدال اللون الجديد تلقائيًا إلى themes.scss ، وسيعمل Hot Reload وسيتم تحويل التطبيق على الفور. هذا هو بالضبط مستوى راحة التطوير المتوقع في 2019.


مبادئ تنظيم الكود


في هذا المشروع ، سألتزم بتكرار أسماء المجلدات للمكونات والملفات والأنماط ، على سبيل المثال:

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

وفقًا لذلك ، ستحتوي package.json على المحتوى { "main": "Chart.js" } . بالنسبة للمكونات ذات الصادرات المحددة المسماة (على سبيل المثال ، الأدوات المساعدة) ، سيبدأ اسم الملف الرئيسي بتسطير أسفل السطر:


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

وسيتم تصدير بقية الملفات على النحو التالي:


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

سيسمح لك ذلك بالتخلص من أسماء الملفات المكررة حتى لا تضيع في العشرة الأوائل index.js / style.scss المفتوحة . يمكنك حل هذا باستخدام الإضافات IDE ، ولكن لماذا لا بطريقة عالمية.


سأقوم بتجميع المكونات في كل صفحة ، باستثناء العناصر العامة مثل Message / Link ، وكذلك ، إذا أمكن ، استخدم الصادرات المسماة (بدون export default ) للحفاظ على توحيد الأسماء وسهولة إعادة التجهيز والبحث عن المشروع.


تكوين تقديم MobX والتخزين


سيبدو الملف الذي يمثل نقطة الدخول لـ Webpack كما يلي:


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

منذ العمل مع الملاحظات ، تعرض وحدة التحكم شيئًا مثل Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration} ، في polyfills سأقدم أداة توحيد:


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

أيضًا ، يتم ربط الأنماط العامة وتطبيع الأنماط لمتصفحات مختلفة في الملف الرئيسي ، إذا كان هناك مفتاح لـ Sentry في أخطاء .env.frontend تبدأ في التسجيل ، يتم إنشاء تخزين MobX ، يتم بدء تعقب تغييرات المعلمات مع التشغيل التلقائي ويتم تثبيت المكون المُلف في محمل رد الفعل الساخن في DOM.


سيكون المستودع نفسه فئة غير قابلة للملاحظة وتكون معلماتها عبارة عن فصول غير قابلة للملاحظة ذات معلمات يمكن ملاحظتها. وبالتالي ، من المفهوم أن مجموعة المعلمات لن تكون ديناميكية - وبالتالي ، سيكون التطبيق أكثر قابلية للتنبؤ. هذا هو واحد من الأماكن القليلة التي يكون JSDoc في متناول اليد لتمكين الإكمال التلقائي في IDE.


src / مخازن / 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); } } 

يمكن تحليل مثال على متجر MobX باستخدام مثال GlobalStore ، والذي سيكون له الغرض الوحيد في الوقت الحالي - لتخزين وتعيين سمة النمط الحالي.


src / مخازن / 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); } } 

في بعض الأحيان تقوم معلمات الفصل وطريقة تعيينه يدويًا بالكتابة باستخدام أدوات الديكور ، على سبيل المثال:


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

لكنني لا أرى أي نقطة في هذا ، لأن مصممي فصول Proposal القديمة يدعمون عملية التحويل التلقائي ، وبالتالي فإن الأداة التالية كافية:


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

لاستخدامها ، تحتاج إلى ضبط المكونات الإضافية في loaderBabel.js : ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] plugin-المقترح- ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }] ، وفي إعدادات ESLint ، قم بتعيين parserOptions.ecmaFeatures.legacyDecorators: true وفقًا لذلك. بدون هذه الإعدادات ، يتم تمرير واصف الفئة فقط بدون نموذج أولي إلى الديكور المستهدف ، وعلى الرغم من البحث الدقيق في الإصدار الحالي من Proposal ، لم أجد طريقة للالتفاف على الأساليب والخصائص الثابتة.


بشكل عام ، انتهى إعداد التخزين ، ولكن سيكون من الجيد إطلاق إمكانات التشغيل التلقائي لـ MobX. للقيام بذلك ، فإن المهام مثل "انتظار استجابة من خادم التخويل" أو "تنزيل الترجمات من الخادم" ، ثم كتابة الردود على الخادم وتقديم التطبيق مباشرة في DOM ، هي الأنسب. لذلك ، سأعمل قليلاً في المستقبل وأنشئ متجرًا مع الترجمة:


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 { /** * @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; } } 

كما ترون ، هناك بعض ملفات * .json مع ترجمات ، ويتم محاكاة التحميل غير المتزامن باستخدام setTimeout في مُنشئ الفصل الدراسي. عند تنفيذه ، يتم وضع علامة على GlobalStore التي تم إنشاؤها مؤخرًا باستخدام this.rootStore.global.shouldAppRender = true .


وبالتالي ، من app.js ، تحتاج إلى نقل وظيفة التقديم إلى ملف 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); }); } 

يمكن أن تحتوي وظيفة initAutorun على أي عدد من إنشاءات التشغيل التلقائي مع عمليات الاسترجاعات التي لن تعمل إلا إذا كانت قد بدأت بنفسها وتغيير متغير داخل رد اتصال معين. في هذه الحالة ، autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true; وتسبب تقديم التطبيق في DOM. أداة قوية تتيح لك تسجيل جميع التغييرات في المتجر والرد عليها وفقًا لذلك.


توطين ورد فعل السنانير


تعتبر الترجمة إلى اللغات الأخرى واحدة من أكثر المهام الضخمة ، وفي كثير من الأحيان يتم الاستهانة بها في المرات الصغيرة ، وفي الشركات الكبيرة يتم التعقيد بشكل غير ضروري. يعتمد على تنفيذه ، كم من الأعصاب والوقت لن تضيع في وقت واحد في العديد من الإدارات في الشركة. سأذكر في المقال فقط الجزء الخاص بالعميل مع تراكم للتكامل في المستقبل مع الأنظمة الأخرى.


من أجل راحة تطوير الواجهة الأمامية ، يجب أن تكون قادرًا على:


  • تعيين الأسماء الدلالية للثوابت.
  • إدراج المتغيرات الديناميكية
  • تشير إلى صيغة المفرد / الجمع ؛
  • — -;
  • ;
  • / ;
  • ;
  • () ;
  • () , .

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


المقاييس


- ( , , , , ) . . .


— , — , . :


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


All Articles