https://habrahabr.ru/company/piter/blog/343680/- Программирование
- Высокая производительность
- ReactJS
- JavaScript
- Блог компании Издательский дом «Питер»
Здравствуйте, уважаемые читатели! Совсем скоро у нас выйдет
новая книга о технологиях React и Redux, оригинал — O'Reilly, май 2017
Чтобы обрисовать
масштабы бедствия круг проблем, которые могут возникать при создании веб-приложений с использованием таких технологий, предлагаем сокращенный перевод статьи Сэмюэла Менденхолла (от 15 ноября), где рассмотрены тонкости работы с React, Redux, Typescript и рассказано, как устранить и упредить проблемы с производительностью в таких приложениях.
Введение
В прошлом году наша команда переписала одно из внутренних приложений компании с Angular на React. Раньше у нас многие имели дело с React, были и «новички» и «опытные». Но все мы изучили на этом проекте много нового, особенно – когда разбирались с болевыми точками, возникавшими при разработке, боролись с неэффективностью, изучали удачные приемы коллег или методом проб и ошибок выясняли, что же лучше всего подходит для нас. Вот что у нас получилось.
Мы использовали Typescript
Одно из наиболее удачных решений, принятых на этом проекте – использовать Typescript, больше скажу – типизированный JavaScript в широком смысле. Пришлось выбирать между Typescript и Flow; пусть мы и ничего не имели против Flow, остановились на TypeScript, поскольку он лучше вписывался в наш рабочий процесс. Оказалось, что TypeScript в нашем случае – просто находка, и мы стали гораздо увереннее чувствовать себя, разрабатывая базу кода всей командой. При рефакторинге огромной базы кода 3-4 вызова глубиной, причем, вызовы поступают из разных частей приложения… это нервное дело. Работая с Typescript, как только вы типизируете все свои функции, неопределенность почти полностью исчезнет. Нет, конечно, можно написать на TypeScript некорректный или неполный код, который будет вызывать ошибки, но, если строго придерживаться правильной типизации, то некоторые классы ошибок (скажем, передача неверного набора аргументов) практически исчезают.
Если вы плаваете в Typescript, либо хотите серьезно снизить уровень риска в приложении – берите и используйте Typescript.
Кстати, мы работали и с
typestyle.github.io/#, которым остались очень довольны.
Избегайте крупных приложений, в которых не действует строгая стилизация кода и стандарты и/или не применяется какой-нибудь инструмент проверки типов в JavaScript, например, Flow или Typescript. В частности, здесь могут помочь другие инструменты, например, Scala.js.
Напротив, учитывайте, что чем дольше javascript-проект растет без типизации, тем сложнее будет рефакторинг. Проверка типов никогда не устраняет риск до конца, но значительно снижает его.
Отслеживайте ошибки
Еще одно бесценное решение, которое мы приняли всей командой – пользоваться Sentry:
sentry.io/welcome. Убежден, что в природе есть и другие отличные инструменты для отслеживания ошибок, но мы начали именно с Sentry, и он послужил нам просто замечательно. С Sentry словно прозреваешь. А мы на первом этапе двигались в продакшен-окружениях словно вслепую, брат. Сначала полагались на тестировщиков и пользователей, думали, узнаем от них об ошибках в продукте, а пользователи всегда будут находить те ошибки, которые ускользнули от тестировщиков. Вот здесь очень помог Sentry. Если правильно разметить релизы, то можно сконцентрироваться на конкретном релизе, конкретной группе пользователей и фактически упреждать баги и ошибки. Множество ошибок мы выловили еще до попадания в продакшен, анализируя качество кода в Sentry, где очень легко выявляются какие-нибудь неожиданные проблемы с данными или другие неучтенные ситуации.
Старайтесь не выходить в продакшен, пока не реализовали возможность автоматического отлова ошибок.
Лучше работайте с Sentry или каким-нибудь другим инструментом для подготовки отчетов об ошибках.
Оптимизируйте процесс сборки
Да, найдите на это время. Допустим, ваша локальная dev-сборка происходит за 20 секунд. Что, если у вас на проекте 10 разработчиков, и вы перекомпилируете код каждые 12 минут, то есть, 40 раз за день. Это значит, что 800 секунд в день тратится на ожидание. В пересчете на рабочие дни и с учетом ежегодного одномесячного отпуска, получается, что каждый разработчик впустую тратит 50 часов в год, а вся команда – 500 человеко-часов. Это не такая мелочь, если можно без всякого труда сократить длительность сборки и сэкономить время, которое тратится на переключение между контекстами и ожидание.
Наши пересборки занимают не более 2-5 секунд благодаря использованию Webpack DLL и другой оптимизации со стороны разработки. Также мы применяем разделение кода и горячую перезагрузку модулей – то есть, перезагружаются лишь те модули, в которые внесены изменения. У нас даже есть урезанная версия сборки, поэтому, работая над определенными частями приложения, мы компилируем все приложение только в самом начале. Много трюков можно проделать с webpack.
В AirBnB написали замечательный отчет о том, как им удалось оптимизировать процесс сборки:
github.com/webpack/webpack/issues/5718. Многие упомянутые там методы оптимизации применяются и у нас, другие – нет.
Старайтесь не ограничиваться стандартной webpack-сборкой, рекомендуем проводить достаточно глубокую оптимизацию.
Нет, нужно подгонять сборку webpack с учетом специфики конкретного приложения. Например, если вы используете Typescript, то, возможно, прибегнете к awesome-typescript-loader, если нет – употребите благословенный хак.
Пользуйтесь современными конструкциями Javascript, но учитывайте, к чему это приведет. Например, при помощи async/await очень удобно писать совершенно чистый асинхронный код. Но не забывайте, что, если вы ожидаете,
Promise.all
и любой элемент промиса не выполняется, то обвалится весь вызов. Именно в таком расчете выстраивайте и ваши redux-действия, иначе небольшой отказ API может привести к тому, что целые куски вашего приложения не загрузятся.
Еще одна очень приятная конструкция – оператор расширения, но, учтите, что он нарушает правило равенства объектов и таким образом обходит естественный способ использования
PureComponent
.
Старайтесь не использовать конструкций ES6/ES7, если из-за этого страдает производительность веб-приложения. Например, так ли вам нужна эта анонимная внутренняя функция в вашем
onClick
? Если вы не передаете никаких дополнительных аргументов – то, скорее всего, не нужна.
Лучше разберитесь в эффектах разных конструкций и пользуйтесь ими рационально.
Так ли вам нужна babel?
На одном из первых этапов переписывания со старого доброго Javascript на Typescript, у нас в конвейере еще был babel. Но в какой-то момент мы задались вопросом: «а что в этой смеси делает babel?» Babel – неоценимая библиотека, превосходно решающая те задачи, для которых предназначена, но мы же используем Typescript, который также транспилирует для нас код. Babel нам не нужна. Переписав код без нее, мы ускорили процесс сборки, избавившись от одного уровня сложности.
Старайтесь не использовать библиотек и загрузчиков, которые вам не нужны. Когда вы в последний раз пересматривали ваш package.json или конфигурацию webpack и проверяли, нет ли там неиспользуемых загрузчиков и библиотек?
Лучше периодически пересматривайте тулчейн сборки и загружаемые библиотеки – возможно, от чего-то удастся избавиться.
Учитывайте, какие нежелательные библиотеки у вас есть
При обновлении зависимостей всегда существует некоторый риск, снизить его можно при помощи функциональных тестов, Typescript и грамотной сборки. Если не обновляться, то риск может быть даже выше. Взять, к примеру, React 16, где произошли ключевые изменения: в последних версиях React 15 можно было выдавать предупреждения о том, что некоторые зависимости пока не соответствуют новому стандарту PropTypes и в следующем релизе нарушатся. Эти предупреждения выглядели примерно так:
Warning: Accessing PropTypes via the main React package is deprecated. Use the prop-types package from npm instead.
Следовательно, если вы ни разу не обновляли зависимых библиотек, разрешающих такие проблемы, то и до React 16 обновиться не сможете.
Управление зависимыми библиотеками – своеобразная палка о двух концах. Если вы фиксируете зависимости, то поначалу снижаете риск, но в будущем все-таки рискуете упустить возможность что-то пофиксить или (потенциально) оптимизировать. Некоторые библиотечные зависимости могут не соответствовать правилам, а владельцы продукта, возможно, не станут портировать важные фиксы в старые версии.
Другой конец этой «палки» — жесткие зависимости могут помешать, если версии библиотек обновляются слишком часто.
Мы сумели найти баланс между фиксацией и частым обновлением библиотек. В данном случае есть золотая середина: если дать основному релизу стабилизироваться, то на этапе закалки приложения постарайтесь выкроить время на обновление зависимостей.
Старайтесь не фиксировать зависимостей и не отказываться от обновления. Кроме того, не обязательно обновлять каждый крупный релиз сразу же после выхода.
Лучше выработайте последовательность проверки релизов зависимостей, оцените, что сейчас стоит обновить и выполните такие обновления на этапе обкатки приложения.
Знайте слабые стороны вашего стека
Например, мы используем
react-actions
и
react-redux
, а здесь есть недостаток: типы аргументов действий не проверяются между действиями (actions) и преобразователями (reducers). В данном контексте мы сталкивались с некоторыми проблемами, когда обновляли действие, но забывали обновить элементы преобразователя, и возникала нестыковка, не отлавливаемая при проверке типов. Один из вариантов обойти такую проблему – написать единый интерфейс, содержащий все аргументы, и работать с ним. В таком случае мы обновляем именно этот разделяемый интерфейс и можем не сомневаться, что все типы будут как следует проверены.
Не делайте так:
interface IActionProductName { productName: string; }
interface IActionProductVersion { productVersion string; }
const requestUpdateProductVersion = createAction(types.REQUEST_UPDATE_PRODUCT_VERSION,
(productName: string, productVersion: string) => ({productName, productVersion}),
null
);
const receiveUpdateProductVersion = createAction(types.RECEIVE_UPDATE_PRODUCT_VERSION,
(productName: string, productVersion: string) => ({productName, productVersion}),
isXhrError
);
[types.RECEIVE_UPDATE_PRODUCT_VERSION]: (state: ICaseDetailsState, action: ActionMeta): ICaseDetailsState => {
// ...
});
В крупных приложениях такой подход проще, чище и компактнее, но в нем недостает проверки типов на AND-интерфейсах между действием и преобразователем. Строго говоря, подлинной проверки типов между действием и преобразователем все равно не бывает, но из-за отсутствия единого интерфейса для всех аргументов возникает риск ошибок при рефакторинге.
Лучше делайте так:
interface IActionUpdateProductNameVersion {
productName: string;
productVersion: string;
}
const requestUpdateProductVersion = createAction(types.REQUEST_UPDATE_PRODUCT_VERSION,
(productName: string, productVersion: string) => ({productName, productVersion}),
null
);
const receiveUpdateProductVersion = createAction(types.RECEIVE_UPDATE_PRODUCT_VERSION,
(productName: string, productVersion: string) => ({productName, productVersion}),
isXhrError
);
[types.RECEIVE_UPDATE_PRODUCT_VERSION]: (state: ICaseDetailsState, action: ActionMeta): ICaseDetailsState => {
// ...
});
При использовании общего
interfaces.IActionUpdateProductNameVersion
любые изменения в этом интерфейсе будут подхватываться как действием, так и преобразователем.
Профилируйте приложение в браузере
React вам не признается, что у него возникли проблемы с производительностью, и выявить их в самом деле бывает сложно, если не заглянуть в информацию о профилировании javascript.
Я бы сказал, что многие проблемы с производительностью React/Javascript относятся к одной из следующих трех категорий.
Первая: может быть, компонент обновился, а не должен был? И следующая проблема: может быть, обновить компонент затратнее, чем просто отобразить? Ответить на первый вопрос просто, на второй – не так просто. Но, отвечая на первый вопрос, можно воспользоваться чем-то вроде
github.com/MalucoMarinero/react-wastage-monitor, это просто. Инструмент сообщит в консоли о таких случаях, когда компонент обновился, но его свойства не изменились. Для этой конкретной цели он хорош. Мы провели при помощи этой библиотеки оптимизацию, а затем отключили ее, поскольку исключить node_modules как следует не удавалось, и не удавалось потому, что сама она несовершенна: зависит от функций свойств и т.п. Каждый инструмент хорош для того, для чего предназначен.
Вторая категория оптимизаций Javascript достигается при помощи профилирования. Есть ли в коде такие участки, которые работают медленнее, чем вы рассчитывали? Есть ли утечки памяти?
В Google составили отличную справку по этому поводу:
developers.google.com/web/tools/chrome-devtools/evaluate-performance/reference и
developers.google.com/web/tools/chrome-devtools/memory-problems
Третья категория – устранение ненужных вызовов и обновлений. Такая оптимизация отличается от первой; тогда мы проверяли, должен ли компонент обновляться. В данном случае оптимизация начинается с вопроса о том, а нужен ли вызов вообще. Так, ничего не стоит допустить такую ошибку: случайно отправить с бэкенда множество вызовов к одному и тому же компоненту.
Старайтесь не ограничиваться этим:
componentWillReceiveProps(nextProps: IProps) {
if (this.props.id !== nextProps.id) {
this.props.dispatch(fetchFromBackend(id));
}
}
export function fetchFromBackend(id: string) {
return async (dispatch, getState: () => IStateReduced) => {
// ...
}
}
Лучше делайте так:
componentWillReceiveProps(nextProps: IProps) {
if (this.props.id !== nextProps.id && !nextProps.isFetchingFromBackend) {
this.props.dispatch(fetchFromBackend(id));
}
}
И не сомневайтесь, что в действие можно добавить еще одну проверку:
export function fetchFromBackend(id: string) {
return async (dispatch, getState: () => IStateReduced) => {
if (getState().isFetchingFromBackend) return;
...
}
}
Этот пример немного надуманный, но с логикой в нем все в порядке. Проблема возникнет, если в вашем компоненте сработает componentWillReceiveProps, но вы с самого начала не установили проверку, нужен ли вызов с бэкенда – тогда он будет выполняться безусловно.
Проблема тем более осложнится, если придется иметь дело со множеством разных кликов и меняющимися аргументами. Если вы отображаете заказ клиента, и компонент должен повторно отобразиться с новым заказом, но еще до того, как это произойдет, пользователь может щелкнуть где-то в другой точке и сделать новый заказ. Выполнение таких асинхронных вызовов не всегда получается детерминированным. Более того, а что если первый асинхронный вызов выполнится позже второго из-за какой-то непредвиденной задержки на бэкенде? Тогда пользователь увидит не тот заказ. В вышеприведенном примере кода эта проблема даже не затрагивается, но такой код не позволяет сделать множество вызовов, пока один вызов еще выполняется. В конечном счете, чтобы справиться с такой гипотетической ситуацией нам потребовалось бы создать в преобразователе объект с доступом по ключу, вот так:
objectCache: {[id: string]: object};
isFetchingCache: {[id: string]: boolean};
где компонент всегда содержит ссылку на последний
id
, по которому сделан щелчок, а
isFetchingCache
отмечается последним
id
.
Следует отметить, что выше рассмотрены далеко не все проблемы с производительностью, которые могут возникать при работе с React и JavaScript. В частности, однажды нам довелось столкнуться с падением производительности при вызове редьюсеров. Оказалось, что проблема связана с тем, что в отклике, приходящем с API, в redux есть очень глубоко вложенный крупный объект – он и портил производительность при глубоком клонировании. Проблему удалось выловить, профилируя JavaScript в Chrome: когда функция клонирования на некоторое время вышла наверх, мы быстро разобрались, в чем дело.