问候,哈布罗夫派!
自创建以来,我一直在阅读此资源,但是写文章的时间才刚刚出现,这意味着该是时候与社区分享我们的经验了。 对于初学者,我希望本文将有助于提高设计质量,对于有经验的开发人员,本文将作为清单,以免在架构阶段忘记重要的元素。 对于不耐烦的人,请提供最终的存储库和演示 。
假设您在一家“梦想中的公司”中,这是一种可以自由选择技术和资源来“按需”进行所有交易的交易所之一。 目前,公司所拥有的只是
业务分配
为交易界面开发一个SPA应用程序,您可以在其中:
- 查看按货币分组的交易对清单;
- 当您单击交易对以查看当前价格的信息时,将在24小时内发生更改,即为“一杯订单”;
- 将申请语言更改为英语/俄语;
- 将主题更改为暗/亮。
任务很短,这使您可以专注于体系结构,而不是编写大量的业务功能。 初步工作的结果应该是逻辑和周到的代码,使您可以直接进行业务逻辑的实现。
由于客户的工作声明中没有技术要求,因此让他们对开发感到满意:
- 跨浏览器兼容性 :2个最新版本的流行浏览器(不带IE);
- 屏幕宽度 :> = 1240px;
- 设计 :与其他交易所进行类比 该设计师尚未被雇用。
现在是时候确定所使用的工具和库了。 我将以“交钥匙式”开发和KISS的原则为指导,也就是说,我将仅采用那些需要足够的时间来独立实施的开源库,其中包括培训未来的开发人员的时间。
- 版本控制系统 :Git + Github;
- 后端 : API CoinGecko;
- 组装/运输 :Webpack + Babel;
- 软件包安装程序 :Yarn(npm 6错误更新了依赖项);
- 代码质量控制 :ESLint + Prettier + Stylelint;
- view :React(让我们看看Hooks有多方便);
- 商店 :MobX;
- 自动测试 : Cypress.io (完整的javascript解决方案,而不是像Mocha / Karma + Chai + Sinon + Selenium + Webdriver / Protractor等模块化程序集);
- 样式 :通过PostCSS的SCSS(配置灵活,与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
。 通常,这种做法看起来更通用,因为如果开发人员需要更改安装软件包的方法,则开发人员不必更改部署配方。
使用yarn.lock 文件没有意义,因为所有依赖项都没有semver“ covers”(以"react": "16.8.6"
)。 经验表明,手动更新版本并针对单个任务仔细测试它们比依赖锁定文件要好,这使程序包作者有机会随时通过较小的更新来破坏应用程序(幸运的是没有遇到此问题的人)。
eslint-custom软件包将具有以下依赖关系:
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-plug-prettier,eslint-config-prettier,stylelint-prettier,stylelint-config-prettier,prettier-eslint )-您今天必须支付这样的价格。 为了最大的方便,仅自动分类导入是不够的,但是不幸的是,此插件在重新格式化文件时会丢失行。
所有工具的配置文件都将采用* .js格式( eslint.config.js , stylelint.config.js ),以便对它们进行代码格式化。 规则可以采用* .yaml格式, 并按语义模块细分。 完整版本的配置和规则位于存储库中 。
仍然需要在主package.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以在保存当前文件时应用格式。 为了保证这一点,在创建提交时,必须使用git钩子,该钩子将检查并格式化所有项目文件。 为什么不仅是提交中存在的那些? 对于整个代码库的集体负责原则,因此没有人愿意绕过验证。 为此,在创建提交时,使用--max-warnings=0
,所有短绒警告都将被视为错误。
{ "husky": { "hooks": { "pre-commit": "npm run format:js -- --max-warnings=0 ./ && npm run format:style ./**/*.scss" } } }
组装/运输
同样,我将使用模块化方法,并删除./webpack-custom文件夹中Webpack和Babel的所有设置。 配置将依赖于以下文件结构:
. |
正确配置的构建器将提供:
- 使用最新EcmaScript规范的语法和功能(包括方便的建议)编写代码的能力(在这里,类装饰器及其MobX的属性绝对有用);
- 具有热重装功能的本地服务器;
- 装配性能指标;
- 检查周期性依赖关系;
- 分析结果文件的结构和大小;
- 生产装配的优化和最小化;
- 解释模块化* .scss文件,并能够从包中删除已完成的* .css文件;
- 内联插入* .svg文件;
- 目标浏览器的polyfill /样式前缀;
- 解决在生产中缓存文件的问题。
还将方便地进行配置。 我将通过两个* .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创建单独的配置文件并保持其一致性; 在本地,您可以配置特定开发人员的需求量; 部署开发人员将仅复制生产程序集的文件( cp .frontend.env.prod.example .frontend.env
),从而丰富了存储库中的值,因此前端开发人员能够通过变量管理配方,而无需使用管理员。 另外,有可能为展台做一个示例配置(例如,带有源图)。
要将样式分成启用CSS_EXTRACT的文件, 我将使用mini-css-extract- plugin-它允许您使用热重载。 也就是说,如果启用HOT_RELOAD和CSS_EXTRACT进行本地开发,则可以
更改样式文件时,只有样式会重新加载-但很遗憾,所有内容,而不仅仅是更改的文件。 关闭CSS_EXTRACT时,将仅更新更改后的样式模块。
用于React Hooks的HMR非常标准地包括在内:
- 插件中的
webpack.HotModuleReplacementPlugin
; hot: true
参数webpack-dev-server中为 hot: true
;- babel-loader插件中的
react-hot-loader/babel
; options.hmr: true
在mini-css-extract-plugin中为 options.hmr: true
;- 在应用程序的主要组件中
export default hot(App)
; - @ hot-loader / react-dom而不是通常的react-dom (方便地通过
resolve.alias: { 'react-dom': '@hot-loader/react-dom' }
);
当前版本的react-hot-loader不支持使用React.memo
来React.memo
组件,因此在为MobX编写装饰器时,您需要考虑到这一点,以便于本地开发。 由此带来的另一个不便之处是,在React Developer Tools中启用了Highlight Updates时,所有组件在与应用程序进行任何交互时都会更新。 因此,在本地进行性能优化时,应禁用HOT_RELOAD设置。
在以下mode : 'development' | 'production'
自动执行Webpack 4中的构建优化mode : 'development' | 'production'
mode : 'development' | 'production'
。 在这种情况下,我依靠标准优化(在terser-webpack-plugin中 +包含keep_fnames: true
参数来保存组件的名称),因为它已经进行了很好的调整。
块的分离和客户端缓存的控制值得特别注意。 为了正确操作,您需要:
- 在js和CSS文件的output.filename中指定
isProduction ? '[name].[contenthash].js' : '[name].js'
isProduction ? '[name].[contenthash].js' : '[name].js'
(分别带有扩展名.css),以便文件名基于其内容; - 在优化中,将参数更改为
chunkIds: 'named', moduleIds: 'hashed'
以使webpack中的内部模块计数器不变。 - 将运行时放在单独的块中;
- 将缓存组移动到splitChunks(对于此应用程序,四个点就足够了-lodash,sentry,highcharts和供应商,用于来自node_modules的其他依赖项 )。 由于前三个很少更新,因此它们将在客户端的浏览器缓存中保留尽可能长的时间。
webpack-custom / config / configOptimization.js const TerserPlugin = require('terser-webpack-plugin'); module.exports = { runtimeChunk: { name: 'runtime', }, chunkIds: 'named', moduleIds: 'hashed', mergeDuplicateChunks: true, splitChunks: { cacheGroups: { lodash: { test: module => module.context.indexOf('node_modules\\lodash') !== -1, name: 'lodash', chunks: 'all', enforce: true, }, sentry: { test: module => module.context.indexOf('node_modules\\@sentry') !== -1, name: 'sentry', chunks: 'all', enforce: true, }, highcharts: { test: module => module.context.indexOf('node_modules\\highcharts') !== -1, name: 'highcharts', chunks: 'all', enforce: true, }, vendor: { test: module => module.context.indexOf('node_modules') !== -1, priority: -1, name: 'vendor', chunks: 'all', enforce: true, }, }, }, minimizer: [ new TerserPlugin({ terserOptions: { keep_fnames: true, }, }), ], };
为了加快该项目中的组装速度,我使用了线程加载器 -当并行化为4个进程时,它使组装速度提高了90%,这比具有相同设置的happypack更好。
我认为,在单独的文件(如.babelrc )中设置加载程序(包括babel)的设置 ,我认为这是不必要的。 但是,跨浏览器配置保留在主package.json的browserslist
参数中更为方便,因为它也用于自动前缀样式。
为了方便使用Prettier,我设置了AGGREGATION_TIMEOUT参数,该参数允许您设置检测文件更改与在开发服务器模式下重建应用程序之间的延迟。 由于我在保存到IDE时配置了文件的重新格式化,因此会导致2次重建-第一次保存原始文件,第二次完成格式化。 Webpack通常等待2000毫秒就可以等待文件的最终版本。
其余的配置不值得特别注意,因为它已在数百个针对初学者的培训材料中公开,因此您可以继续进行应用程序体系结构的设计。
风格主题
以前,要创建主题,必须制作* .css文件的多个版本,并在更改主题时重新加载页面,以加载所需的样式集。 现在,使用“ 自定义CSS属性”可以轻松解决所有问题。 当前应用程序的所有目标浏览器都支持该技术,但是也有IE的polyfill。
假设有2个主题-浅色和深色,其颜色集将在
样式/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); }
为了将这些变量全局应用,需要将它们分别写入document.documentElement
,需要一个小型解析器将此文件转换为javascript对象。 稍后,我将告诉您为什么它比立即将其存储在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的标准变量:
如屏幕快照所示,WebStorm IDE在左侧面板中显示颜色,单击颜色可打开一个调色板,您可以在其中进行更改。 新颜色将自动替换为themes.scss ,Hot Reload将起作用,并且该应用程序将立即转换。 这正是2019年预期的开发便利水平。
代码组织原则
在此项目中,我将坚持使用重复的文件夹名称来表示组件,文件和样式,例如:
. |
因此, package.json将具有内容{ "main": "Chart.js" }
。 对于具有多个命名导出的组件(例如,实用程序),主文件的名称将以下划线开头:
. |
其余文件将导出为:
export * from './someUtil'; export * from './anotherUtil';
这将使您摆脱重复的文件名,以免丢失在前十个打开的index.js / style.scss中 。 您可以使用IDE插件解决此问题,但为什么不采用通用方式。
除了“消息/链接”之类的常规组件外,我将逐个页面对组件进行分组,并且在可能的情况下,还使用命名出口(不使用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}
时,控制台会在Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration}
显示类似Proxy {0: "btc", 1: "eth", 2: "usd", 3: "test", Symbol(mobx administration): ObservableArrayAdministration}
我将制作一个用于标准化的实用程序:
src / polyfill.js import { toJS } from 'mobx'; console.js = function consoleJsCustom(...args) { console.log(...args.map(arg => toJS(arg))); };
另外,在主文件中连接了不同浏览器的全局样式和样式规范化,如果.env中存在Sentry的密钥,则开始记录前端错误,创建MobX存储, 启动通过自动运行对参数更改的跟踪并装入包装在react-hot-loader中的组件在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'; 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); } }
可以使用GlobalStore的示例来分析MobX商店的示例,该示例目前仅具有用途-存储和设置当前样式主题。
src /商店/ GlobalStore.js import { makeObservable, setTheme } from 'utils'; import themes from 'styles/themes.scss'; const themesList = Object.keys(themes); @makeObservable export class GlobalStore { constructor(rootStore) { this.rootStore = rootStore; setTheme(themesList[0]); } themesList = themesList; currentTheme = ''; setTheme(theme) { this.currentTheme = theme; setTheme(theme); } }
有时,类的参数和方法使用装饰器手动设置类型,例如:
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) { const classPrototype = target.prototype; const methodsAndGetters = Object.getOwnPropertyNames(classPrototype).filter( methodName => methodName !== 'constructor' ); for (const methodName of methodsAndGetters) { const descriptor = Object.getOwnPropertyDescriptor( classPrototype, methodName ); descriptor.value = decorate(classPrototype, { [methodName]: typeof descriptor.value === 'function' ? action.bound : computed, }); } return (...constructorArguments) => { const store = new target(...constructorArguments); const staticProperties = Object.keys(store); staticProperties.forEach(propName => { if (propName === 'rootStore') { return false; } const descriptor = Object.getOwnPropertyDescriptor(store, propName); Object.defineProperty( store, propName, observable(store, propName, descriptor) ); }); return store; }; }
要使用,您需要调整loaderBabel.js中的插件: ['@babel/plugin-proposal-decorators', { legacy: true }], ['@babel/plugin-proposal-class-properties', { loose: true }]
,并在ESLint设置中,相应地设置parserOptions.ecmaFeatures.legacyDecorators: true
。 如果没有这些设置,则仅将没有原型的类描述符传递给目标装饰器,尽管对Proposal的当前版本进行了仔细的研究,但我还没有找到包装方法和静态属性的方法。
通常,存储设置已完成,但是最好释放MobX自动运行的潜力。 为此,最适合的任务是“等待来自授权服务器的响应”或“从服务器下载翻译”,然后将响应写入服务器并直接在DOM中呈现应用程序。 因此,我会花一点时间来创建本地化的商店:
src /存储/ I18nStore.js import { makeObservable } from 'utils'; import ru from 'localization/ru.json'; import en from 'localization/en.json'; const languages = { ru, en, }; const languagesList = Object.keys(languages); @makeObservable export class I18nStore { constructor(rootStore) { this.rootStore = rootStore; setTimeout(() => { this.setLocalization('ru'); }, 500); } i18n = {}; languagesList = languagesList; currentLanguage = ''; setLocalization(language) { this.currentLanguage = language; this.i18n = languages[language]; this.rootStore.global.shouldAppRender = true; } }
如您所见,存在一些带有翻译的* .json文件,并且在类构造函数中模拟了使用setTimeout的异步加载。 执行时,最近创建的GlobalStore标记有this.rootStore.global.shouldAppRender = true
。
因此,您需要从app.js中将呈现功能转移到autorun.js文件中:
src / autorun.js import { autorun } from 'mobx'; import { renderToDOM } from 'utils'; import App from 'components/App'; const loggingEnabled = true; function logReason(autorunName, reaction) { if (!loggingEnabled || reaction.observing.length === 0) { return false; } const logString = reaction.observing.reduce( (str, { name, value }) => `${str}${name} changed to ${value}; `, '' ); console.log(`autorun-${autorunName}`, logString); } export function initAutorun(store) { autorun(reaction => { if (store.global.shouldAppRender) { renderToDOM(App); } logReason('shouldAppRender', reaction); }); }
initAutorun函数可以具有任意数量的带有回调的自动运行构造,这些构造只有在初始化自身并更改特定回调中的变量时才起作用。 在这种情况下, autorun-shouldAppRender GlobalStore@3.shouldAppRender changed to true;
,并导致在DOM中呈现应用程序。 一个功能强大的工具,可让您记录存储中的所有更改并做出相应的响应。
本地化和React挂钩
翻译成其他语言是最繁重的任务之一,在小型公司中,它常常被低估了数十倍,而在大型公司中,则不必要地过于复杂。 根据其实施情况,公司的多个部门不会一次浪费多少神经和时间。 我将在本文中仅提及带有待办事项的客户端部分,以便将来与其他系统集成。
为了方便前端开发,您必须能够:
- 为常量设置语义名称;
- 插入动态变量
- 表示单数/复数;
- — -;
- ;
- / ;
- ;
- () ;
- () , .
, , : messages.js ( ) . . ( / ), . ( , , ) . .
, currentLanguage i18n , , .
src/components/TestLocalization.js import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; const messages = { hello: ' {count} {count: ,,}', }; function TestLocalization() { const getLn = useLocalization(__filename, messages); return <div>{getLn(messages.hello, { count: 1 })}</div>; } export const TestLocalizationConnected = observer(TestLocalization);
, MobX- , , Connected. , ESLint, .
observer mobx-react-lite/useObserver
, HOT_RELOAD React.memo ( PureMixin / PureComponent ), useObserver :
src/utils/observer.js import { useObserver } from 'mobx-react-lite'; import React from 'react'; function copyStaticProperties(base, target) { const hoistBlackList = { $$typeof: true, render: true, compare: true, type: true, }; Object.keys(base).forEach(key => { if (base.hasOwnProperty(key) && !hoistBlackList[key]) { Object.defineProperty( target, key, Object.getOwnPropertyDescriptor(base, key) ); } }); } export function observer(baseComponent, options) { const baseComponentName = baseComponent.displayName || baseComponent.name; function wrappedComponent(props, ref) { return useObserver(function applyObserver() { return baseComponent(props, ref); }, baseComponentName); } wrappedComponent.displayName = baseComponentName; let memoComponent = null; if (HOT_RELOAD === 'true') { memoComponent = wrappedComponent; } else if (options.forwardRef) { memoComponent = React.memo(React.forwardRef(wrappedComponent)); } else { memoComponent = React.memo(wrappedComponent); } copyStaticProperties(baseComponent, memoComponent); memoComponent.displayName = baseComponentName; return memoComponent; }
displayName
, React- ( stack trace ).
RootStore:
src/hooks/useStore.js import React from 'react'; import { store } from 'stores'; const storeContext = React.createContext(store); export function useStore() { return React.useContext(storeContext); }
, observer:
import React from 'react'; import { observer } from 'utils'; import { useStore } from 'hooks'; function TestComponent() { const store = useStore(); return <div>{store.i18n.currentLanguage}</div>; } export const TestComponentConnected = observer(TestComponent);
TestLocalization — useLocalization:
src/hooks/useLocalization.js import _ from 'lodash'; import { declOfNum } from 'utils'; import { useStore } from './useStore'; const showNoTextMessage = false; function replaceDynamicParams(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithValues = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { messageWithValues = formattedMessage.replace(`{${paramName}}`, value); }); return messageWithValues; } function replacePlurals(values, formattedMessage) { if (!_.isPlainObject(values)) { return formattedMessage; } let messageWithPlurals = formattedMessage; Object.entries(values).forEach(([paramName, value]) => { const pluralPattern = new RegExp(`{${paramName}:\\s([^}]*)}`); const pluralMatch = formattedMessage.match(pluralPattern); if (pluralMatch && pluralMatch[1]) { messageWithPlurals = formattedMessage.replace( pluralPattern, declOfNum(value, pluralMatch[1].split(',')) ); } }); return messageWithPlurals; } export function useLocalization(filename, messages) { const { i18n: { i18n, currentLanguage }, } = useStore(); return function getLn(text, values) { const key = _.findKey(messages, message => message === text); const localizedText = _.get(i18n, [filename, key]); if (!localizedText && showNoTextMessage) { console.error( `useLocalization: no localization for lang '${currentLanguage}' in ${filename} ${key}` ); } let formattedMessage = localizedText || text; formattedMessage = replaceDynamicParams(values, formattedMessage); formattedMessage = replacePlurals(values, formattedMessage); return formattedMessage; }; }
replaceDynamicParams replacePlurals — , , , , , ..
Webpack — __filename — , , . , — , , . , :
useLocalization: no localization for lang 'ru' in src\components\TestLocalization\TestLocalization.js hello
ru.json :
src/localization/ru.json { "src\\components\\TestLocalization\\TestLocalization.js": { "hello": " {count} {count: ,,}" } }
, . src/localization/en.json « » setLocalization I18nStore.
«» React Message:
src/components/Message/Message.js import React from 'react'; import { observer } from 'utils'; import { useLocalization } from 'hooks'; function Message(props) { const { filename, messages, text, values } = props; const getLn = useLocalization(filename, messages); return getLn(text, values); } const ConnectedMessage = observer(Message); export function init(filename, messages) { return function MessageHoc(props) { const fullProps = { filename, messages, ...props }; return <ConnectedMessage {...fullProps} />; }; }
__filename ( id ), , :
const Message = require('components/Message').init( __filename, messages ); <Message text={messages.hello} values={{ count: 1 }} />
— useLocalization ( currentLanguage , Message — . , , .
, ( , , , / production). id , messages.js *.json , . ( / ), production. , , .
MobX + Hooks . , backend, , , .
API
( backend, ) — , , . , . :
src/stores/CurrentTPStore.js import _ from 'lodash'; import { makeObservable } from 'utils'; import { apiRoutes, request } from 'api'; @makeObservable export class CurrentTPStore { constructor(rootStore) { this.rootStore = rootStore; } id = ''; symbol = ''; fullName = ''; currency = ''; tradedCurrency = ''; low24h = 0; high24h = 0; lastPrice = 0; marketCap = 0; change24h = 0; change24hPercentage = 0; fetchSymbol(params) { const { tradedCurrency, id } = params; const { marketsList } = this.rootStore; const requestParams = { id, localization: false, community_data: false, developer_data: false, tickers: false, }; return request(apiRoutes.symbolInfo, requestParams) .then(data => this.fetchSymbolSuccess(data, tradedCurrency)) .catch(this.fetchSymbolError); } fetchSymbolSuccess(data, tradedCurrency) { const { id, symbol, name, market_data: { high_24h, low_24h, price_change_24h_in_currency, price_change_percentage_24h_in_currency, market_cap, current_price, }, } = data; this.id = id; this.symbol = symbol; this.fullName = name; this.currency = symbol; this.tradedCurrency = tradedCurrency; this.lastPrice = current_price[tradedCurrency]; this.high24h = high_24h[tradedCurrency]; this.low24h = low_24h[tradedCurrency]; this.change24h = price_change_24h_in_currency[tradedCurrency]; this.change24hPercentage = price_change_percentage_24h_in_currency[tradedCurrency]; this.marketCap = market_cap[tradedCurrency]; return Promise.resolve(); } fetchSymbolError(error) { console.error(error); } }
, , . fetchSymbol , id , . , — ( @action.bound
), Sentry :
src/utils/initSentry.js import * as Sentry from '@sentry/browser'; export function initSentry() { if (SENTRY_URL !== 'false') { Sentry.init({ dsn: SENTRY_URL, }); const originalErrorLogger = console.error; console.error = function consoleErrorCustom(...args) { Sentry.captureException(...args); return originalErrorLogger(...args); }; } }
, :
src/api/_api.js import _ from 'lodash'; import { omitParam, validateRequestParams, makeRequestUrl, makeRequest, validateResponse, } from 'api/utils'; export function request(route, params) { return Promise.resolve() .then(validateRequestParams(route, params)) .then(makeRequestUrl(route, params)) .then(makeRequest) .then(validateResponse(route, params)); } export const apiRoutes = { symbolInfo: { url: params => `https://api.coingecko.com/api/v3/coins/${params.id}`, params: { id: omitParam, localization: _.isBoolean, community_data: _.isBoolean, developer_data: _.isBoolean, tickers: _.isBoolean, }, responseObject: { id: _.isString, name: _.isString, symbol: _.isString, genesis_date: v => _.isString(v) || _.isNil(v), last_updated: _.isString, country_origin: _.isString, coingecko_rank: _.isNumber, coingecko_score: _.isNumber, community_score: _.isNumber, developer_score: _.isNumber, liquidity_score: _.isNumber, market_cap_rank: _.isNumber, block_time_in_minutes: _.isNumber, public_interest_score: _.isNumber, image: _.isPlainObject, links: _.isPlainObject, description: _.isPlainObject, market_data: _.isPlainObject, localization(value, requestParams) { if (requestParams.localization === false) { return true; } return _.isPlainObject(value); }, community_data(value, requestParams) { if (requestParams.community_data === false) { return true; } return _.isPlainObject(value); }, developer_data(value, requestParams) { if (requestParams.developer_data === false) { return true; } return _.isPlainObject(value); }, public_interest_stats: _.isPlainObject, tickers(value, requestParams) { if (requestParams.tickers === false) { return true; } return _.isArray(value); }, categories: _.isArray, status_updates: _.isArray, }, }, };
request :
- apiRoutes ;
- , route.params, , omitParam ;
- URL
route.url
— , , — get- URL; - fetch, JSON;
- ,
route.responseObject
route.responseArray
( ). , — , ; - / / / , ( fetchSymbolError ) .
. , Sentry, response:

— ( ), .
, , . , :
- ;
- pathname search;
- / ;
- location ;
- beforeEnter, isLoading, ;
- : , , , beforeEnter, ;
- / ;
- / ;
- .
«», -, — . :
src/routes.js export const routes = { marketDetailed: { name: 'marketDetailed', path: '/market/:market/:pair', masks: { pair: /^[a-zA-Z]{3,5}-[a-zA-Z]{3}$/, market: /^[a-zA-Z]{3,4}$/, }, beforeEnter(route, store) { const { params: { pair, market }, } = route; const [symbol, tradedCurrency] = pair.split('-'); const prevMarket = store.marketsList.currentMarket; function optimisticallyUpdate() { store.marketsList.currentMarket = market; } return Promise.resolve() .then(optimisticallyUpdate) .then(store.marketsList.fetchSymbolsList) .then(store.rates.fetchRates) .then(() => store.marketsList.fetchMarketList(market, prevMarket)) .then(() => store.currentTP.fetchSymbol({ symbol, tradedCurrency, }) ) .catch(error => { console.error(error); }); }, }, error404: { name: 'error404', path: '/error404', }, };
src/routeComponents.js import { MarketDetailed } from 'pages/MarketDetailed'; import { Error404 } from 'pages/Error404'; export const routeComponents = { marketDetailed: MarketDetailed, error404: Error404, };
, , — <Link route={routes.marketDetailed}>
, . Webpack , .
, location .
src/stores/RouterStore.js import _ from 'lodash'; import { makeObservable } from 'utils'; import { routes } from 'routes'; @makeObservable export class RouterStore { constructor(rootStore) { this.rootStore = rootStore; this.currentRoute = this._fillRouteSchemaFromUrl(); window.addEventListener('popstate', () => { this.currentRoute = this._fillRouteSchemaFromUrl(); }); } currentRoute = null; _fillRouteSchemaFromUrl() { const pathnameArray = window.location.pathname.split('/'); const routeName = this._getRouteNameMatchingUrl(pathnameArray); if (!routeName) { const currentRoute = routes.error404; window.history.pushState(null, null, currentRoute.path); return currentRoute; } const route = routes[routeName]; const routePathnameArray = route.path.split('/'); const params = {}; routePathnameArray.forEach((pathParam, i) => { const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') === 0) { const paramName = pathParam.replace(':', ''); params[paramName] = urlParam; } }); return Object.assign({}, route, { params, isLoading: true }); } _getRouteNameMatchingUrl(pathnameArray) { return _.findKey(routes, route => { const routePathnameArray = route.path.split('/'); if (routePathnameArray.length !== pathnameArray.length) { return false; } for (let i = 0; i < routePathnameArray.length; i++) { const pathParam = routePathnameArray[i]; const urlParam = pathnameArray[i]; if (pathParam.indexOf(':') !== 0) { if (pathParam !== urlParam) { return false; } } else { const paramName = pathParam.replace(':', ''); const paramMask = _.get(route.masks, paramName); if (paramMask && !paramMask.test(urlParam)) { return false; } } } return true; }); } replaceDynamicParams(route, params) { return Object.entries(params).reduce((pathname, [paramName, value]) => { return pathname.replace(`:${paramName}`, value); }, route.path); } goTo(route, params) { if (route.name === this.currentRoute.name) { if (_.isEqual(this.currentRoute.params, params)) { return false; } this.currentRoute.isLoading = true; this.currentRoute.params = params; const newPathname = this.replaceDynamicParams(this.currentRoute, params); window.history.pushState(null, null, newPathname); return false; } const newPathname = this.replaceDynamicParams(route, params); window.history.pushState(null, null, newPathname); this.currentRoute = this._fillRouteSchemaFromUrl(); } }
— routes.js . — 404. , « », , , — , 'test-test'.
currentRoute , params ( URL) isLoading: true
. React- Router:
src/components/Router.js import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; import { routeComponents } from 'routeComponents'; function getRouteComponent(route, isLoading) { const Component = routeComponents[route.name]; if (!Component) { console.error( `getRouteComponent: component for ${ route.name } is not defined in routeComponents` ); return null; } return <Component isLoading={isLoading} />; } function useBeforeEnter() { const store = useStore(); const { currentRoute } = store.router; React.useEffect(() => { if (currentRoute.isLoading) { const beforeEnter = _.get(currentRoute, 'beforeEnter'); if (_.isFunction(beforeEnter)) { Promise.resolve() .then(() => beforeEnter(currentRoute, store)) .then(() => { currentRoute.isLoading = false; }) .catch(error => console.error(error)); } else { currentRoute.isLoading = false; } } }); return currentRoute.isLoading; } function Router() { const { router: { currentRoute }, } = useStore(); const isLoading = useBeforeEnter(); return getRouteComponent(currentRoute, isLoading); } export const RouterConnected = observer(Router);
, , currentRoute == null
. — isLoading === true
, false , route.beforeEnter
( ). console.error
, , .
, — , . React- 2 :
- componentWillMount / componentDidMount / useEffect , , . — , «». — — ;
- ( ) , . — — , . — / — real-time , / .
, — ( , , ..), .
beforeEnter , : « », ( , , ), — ( — 500 ; ; , ; ..). «» , MVP .
:
src/components/Link.js import React from 'react'; import _ from 'lodash'; import { useStore } from 'hooks'; import { observer } from 'utils'; function checkRouteParamsWithMasks(route, params) { if (route.masks) { Object.entries(route.masks).forEach(([paramName, paramMask]) => { const value = _.get(params, paramName); if (paramMask && !paramMask.test(value)) { console.error( `checkRouteParamsWithMasks: wrong param for ${paramName} in Link to ${ route.name }: ${value}` ); } }); } } function Link(props) { const store = useStore(); const { currentRoute } = store.router; const { route, params, children, onClick, ...otherProps } = props; checkRouteParamsWithMasks(route, params); const filledPath = store.router.replaceDynamicParams(route, params); return ( <a href={filledPath} onClick={e => { e.preventDefault(); if (currentRoute.isLoading) { return false; } store.router.goTo(route, params); if (onClick) { onClick(); } }} {...otherProps} > {children} </a> ); } export const LinkConnected = observer(Link);
route , params ( ) ( ) href . , beforeEnter , . «, », , — .
指标
- ( , , , , ) . . .
— , — , . :
src/api/utils/metrics.js import _ from 'lodash'; let metricsArray = []; let sendMetricsCallback = null; export function startMetrics(route, apiRoutes) { return function promiseCallback(data) { clearTimeout(sendMetricsCallback); const apiRouteName = _.findKey(apiRoutes, route); metricsArray.push({ id: apiRouteName, time: new Date().getTime(), }); return data; }; } export function stopMetrics(route, apiRoutes) { return function promiseCallback(data) { const apiRouteName = _.findKey(apiRoutes, route); const metricsData = _.find(metricsArray, ['id', apiRouteName]); metricsData.time = new Date().getTime() - metricsData.time; clearTimeout(sendMetricsCallback); sendMetricsCallback = setTimeout(() => { console.log('Metrics sent:', metricsArray); metricsArray = []; }, 2000); return data; }; }
middleware request :
export function request(route, params) { return Promise.resolve() .then(startMetrics(route, apiRoutes)) .then(validateRequestParams(route, params)) .then(makeRequestUrl(route, params)) .then(makeRequest) .then(validateResponse(route, params)) .then(stopMetrics(route, apiRoutes)) .catch(error => { stopMetrics(route, apiRoutes)(); throw error; }); }
, , 2 , ( ) . — , — , ( ) , .
- — .
end-to-end , Cypress. : ; , ; Continious Integration.
javascript Chai / Sinon , . , , — ./tests, package.json — "dependencies": { "cypress": "3.2.0" }
. Webpack :
tests/cypress/plugins/index.js const webpack = require('../../../node_modules/@cypress/webpack-preprocessor'); const webpackConfig = require('../../../webpack-custom/webpack.config'); module.exports = on => { const options = webpack.defaultOptions; options.webpackOptions.module = webpackConfig.module; options.webpackOptions.resolve = webpackConfig.resolve; on('file:preprocessor', webpack(options)); };
. module ( ) resolve ( ). ESLint ( describe , cy ) eslint-plugin-cypress . , :
tests/cypress/integration/mixed.js describe('Market Listing good scenarios', () => { it('Lots of mixed tests', () => { cy.visit('/market/usd/bch-usd'); cy.location('pathname').should('equal', '/market/usd/bch-usd');
Cypress fetch, , :
tests/cypress/support/index.js import { apiRoutes } from 'api'; let polyfill = null; before(() => { const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js'; cy.request(polyfillUrl).then(response => { polyfill = response.body; }); }); Cypress.on('window:before:load', window => { delete window.fetch; window.eval(polyfill); window.fetch = window.unfetch; }); before(() => { cy.server(); cy.route(`${apiRoutes.symbolsList.url}**`).as('symbolsList'); cy.route(`${apiRoutes.rates.url}**`).as('rates'); cy.route(`${apiRoutes.marketsList.url}**`).as('marketsList'); cy.route(`${apiRoutes.symbolInfo.url({ id: 'bitcoin-cash' })}**`).as( 'symbolInfo' ); cy.route(`${apiRoutes.chartData.url}**`).as('chartData'); });
, .
, ?
, - . , , - / - / .
, , , - , - , (real-time , serviceWorker, CI, , , -, , ..).
( Gzip) :

React Developer Tools :

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