Сегодня мы публикуем перевод рассказа о том, как переход с React Boilerplate на Next.js, фреймворк для разработки прогрессивных веб-приложений, основанный на React, позволил ускорить загрузку домашней страницы проекта manifold.co в 7.5 раз. Другие изменения в проект не вносили, и этот переход, в общем-то, оказался совершенно незаметным для других частей системы. То, что получилось в итоге, оказалось даже лучшим, чем ожидалось.
Обзор результатов
Фактически, мы можем говорить о том, что переход на Next.js дал нам нечто вроде «взявшегося из ниоткуда увеличения производительности проекта». Вот как выглядит время загрузки проекта при использовании различных аппаратных ресурсов и сетевых соединений.
Соединение | Процессор | До, секунд | После, секунд | Улучшение, % |
Быстрое (200 Мбит/с) | Быстрый | 1.5 | 0.2 | 750 |
Среднее (3G) | Быстрый | 5.6 | 1.1 | 500 |
Среднее (3G) | Средний | 7.5 | 1.3 | 570 |
Медленное (медленное 3G-соединение) | Средний | 22 | 4 | 550 |
При использовании быстрого соединения и устройства с быстрым процессором время загрузки сайта упало с 1.5 с. до 0.2 с., то есть этот показатель улучшился в 7.5 раз. На соединении среднего качества и на устройстве со средней производительностью время загрузки сайта упало с 7.5 с. до 1.3 с.
Что происходит после того, как пользователь переходит по URL?
Для того чтобы понять особенности работы прогрессивных веб-приложений (Progressive Web App, PWA), нужно сначала разобраться с тем, что происходит между моментом, когда пользователь переходит по URL (по адресу нашего сайта), и моментом, когда он видит что-то в окне браузера (в данном случае — наше React-приложение).
Стадии работы с приложением
Рассмотрим 5 стадий работы с приложением, схема которых приведена выше.
- Пользователь переходит по URL, система выясняет адрес сервера с помощью DNS и обращается к серверу. Всё это выполняется чрезвычайно быстро, занимая обычно менее 100 миллисекунд, но этот шаг занимает некоторое время, поэтому он здесь и упомянут.
- Теперь сервер возвращает HTML-код страницы, но страница в браузере остаётся пустой до тех пор, пока не будут загружены ресурсы, необходимые для её отображения (если только ресурсы не загружаются асинхронно). На самом деле, на этом этапе происходит больше действий, чем показано на схеме, но нас устроит и совместное рассмотрение всех этих процессов.
- После загрузки HTML-кода и важнейших ресурсов браузер начинает выводить на экран то, что он может вывести, продолжая загружать всё остальное (рисунки, например) в фоне. Вы когда-нибудь задавались вопросом о том, почему изображения иногда вдруг «выскакивают» на странице явно быстрее, чем нужно, а иногда грузятся слишком долго? Происходит это именно поэтому. Такой подход позволяет быстрее сформировать готовую страницу.
- JavaScript-код можно распарсить и выполнить только после того, как он будет загружен. В зависимости от объёма используемого на странице JS-кода (а этот объём может быть, у типичного React-приложения, довольно большим, если код упакован в единственный файл) это может занять несколько секунд или даже больше (обратите внимание на то, что JS-коду не нужно, для того, чтобы начать выполняться, ждать загрузки всех остальных ресурсов, несмотря на то, что на схеме это выглядит именно так).
- В случае React-приложения теперь наступает момент, когда код модифицирует DOM, что приводит к тому, что браузер перерисовывает уже выведенную страницу. Тут же начинается ещё один цикл загрузки ресурсов. Время, которое займёт этот шаг, будет зависеть от сложности страницы.
Чем быстрее — тем лучше
Так как прогрессивное веб-приложение берёт React-код и выдаёт статический HTML и CSS-код, это означает, что пользователь видит React-приложение уже на шаге 3 вышеприведённой схемы, а не на шаге 5. В наших тестах это занимает 0.2-4 секунды, что зависит от скорости соединения пользователя с интернетом и от его устройства. Это куда лучше, чем предыдущие 1.5-22 секунды. Прогрессивные веб-приложения — это надёжный способ быстрее доставлять пользователю React-приложения.
Причина, по которой прогрессивные веб-приложения и соответствующие фреймворки наподобие Next.js всё ещё не пользуются широчайшей популярностью, заключается в том, что, традиционно, JS-фреймворки не особенно преуспевают в генерировании статического HTML-кода. Сегодня же всё очень сильно изменилось благодаря тому, что такие фреймворки, как React, Vue и Angular, да и другие, обладают отличной поддержкой средств серверного рендеринга. Однако для того, чтобы этими средствами воспользоваться, всё ещё нужно глубокое понимание особенностей работы бандлеров и средств сборки проектов. Работа со всем этим не лишена проблемных моментов.
Недавнее появление PWA-фреймворков, вроде Next.js и Gatsby (оба появились в конце 2016 — начале 2017) стало серьёзным шагом в сторону широкого принятия PWA благодаря снижению входных барьеров и благодаря тому, что это сделало использование подобных фреймворков простым и приятным занятием.
Хотя не каждое приложение можно перевести на Next.js, для многих React-приложений такой переход означает ту самую «производительность из ниоткуда», о которой мы тут говорим, дополненную ещё и более эффективным использованием сетевых ресурсов.
Насколько сложно осуществить переход на Next.js?
В целом можно отметить, что перевод нашей домашней страницы на Next.js не был очень уж тяжёлым. Однако мы столкнулись с некоторыми сложностями, которые были вызваны особенностями архитектуры нашего приложения.
▍Отказ от маршрутизатора React
Нам пришлось отказаться от маршрутизатора React так как в Next.js имеется собственный встроенный маршрутизатор, который лучше сочетается с оптимизациями, касающимися разделения кода, выполняемыми поверх архитектуры PWA. Это позволяет данному маршрутизатору обеспечить гораздо более быструю загрузку страниц, чем можно ожидать от любого маршрутизатора, работающего на стороне клиента.
Маршрутизатор Next.js — это нечто вроде высокоскоростного маршрутизатора React, но это, всё таки, не маршрутизатор React.
На практике, так как мы не пользовались особенно продвинутыми возможностями, которые предлагает маршрутизатор React, для нас переход на маршрутизатор Next.js заключался в простой замене стандартного компонента маршрутизатора React на соответствующий компонент Next.js:
/* Старый код (Маршрутизатор React) */
<Link to="/my/page">
A link
</Link>
/* Новый код (Маршрутизатор Next.js) */
<Link href="/my/page" passHref>
<a>
A link
</a>
</Link>
В общем-то, всё оказалось не так уж и плохо. Нам пришлось переименовать свойство и добавить тег для целей серверного рендеринга. Так как мы, кроме того, пользовались библиотекой styled-components
, оказалось, что нам, в большинстве экземпляров, понадобилось добавить свойство passHref
для того, чтобы обеспечить такое поведение системы, при котором href
всегда указывает на сгенерированный тег.
Сетевые запросы для manifold.co
Для того чтобы своими глазами увидеть оптимизации маршрутизатора Next.js в действии, откройте вкладку Network инструментов разработчика браузера, просматривая страницу manifold.co, и щёлкните по какой-нибудь ссылке. На предыдущем рисунке показан результат щелчка по ссылке /services
. Как видно, он приводит к выполнению запроса на загрузку services.js
вместо выполнения обычного запроса.
Я не говорю только о маршрутизации на стороне клиента, для решения этой задачи подходит и маршрутизатор React. Я говорю о реальном фрагменте JavaScript-кода, который был выделен из остального кода и загружен по запросу. Это делается стандартными средствами Next.js. И это — гораздо лучше, чем то, что у нас было раньше. А именно, речь идёт о большом пакете JS-кода размером в 1.7 Мб, который клиенту, прежде чем он мог что-то увидеть, нужно было загрузить и обработать.
Хотя представленное здесь решение и не идеально, оно гораздо ближе, чем предыдущее, к идее, в соответствии с которой пользователи загружают код только для тех страниц, которые они просматривают.
▍Особенности использования Redux
Продолжая тему сложностей, связанных с переходом на Next.js, можно отметить, что все те интересные оптимизации, которым Next.js подвергает приложение, оказывают определённое влияние на это приложение. А именно, так как Next.js выполняет разделение кода на уровне страниц, он не позволяет разработчику обращаться к корневому компоненту React
или к методу render()
библиотеки react-dom
. Если вы уже занимались настройкой Redux, то вы можете отметить, что всё это говорит нам о том, что для нормальной работы с Redux нам нужно решить проблему, которая заключается в том, что неясно, где именно нужно искать Redux.
В Next.js предусмотрена специальный компонент высшего порядка, withRedux
, играющий роль обёртки для всех компонентов верхнего уровня на каждой странице:
export default withRedux(HomePage);
Хотя всё это не так уж и плохо, но если вам нужны методы createStore()
, как, например, при использовании redux-reducer-injectors, рассчитывайте на то, что вам понадобится дополнительное время на отладку обёртки (и, кстати, постарайтесь никогда не пользоваться чем-то вроде redux-reducer-injectors
).
Кроме того, из-за того, что теперь Redux представляет собой «чёрный ящик», использование с ним библиотеки Immutable становится проблематичным. Хотя то, что Immutable будет работать с Redux, кажется вполне очевидным, я столкнулся с проблемой. Так, либо состояние верхнего уровня не было иммутабельным (ошибка get is not a function
), либо компонент-обёртка пытался использовать точечную нотацию для работы с JS-объектами вместо метода .get()
(ошибка Can’t get catalog of undefined
). Для отладки этой проблемы мне пришлось обращаться к исходному коду. В конце концов, Next.js заставляет разработчика пользоваться его собственными механизмами не без причины.
В целом же можно отметить, что главной проблемой, связанной с Next.js является то, что очень немногое в этом фреймворке хорошо документировано. В документации имеется множество примеров, на основе которых можно создать что-то своё, но если среди них нет такого, который отражает особенности вашего проекта, вам можно лишь пожелать удачи.
▍Отказ от fetch
Мы использовали библиотеку react-inlinesvg, которая предлагает возможности по стилизации встроенных SVG-изображений и по кэшированию запросов. Но тут у нас возникла одна проблема: при выполнении серверного рендеринга нет такого понятия, как XHR-запросы (по крайней мере, не в смысле URL, генерируемых Webpack, как того можно было бы ожидать). Попытки выполнения таких запросов препятствуют серверному рендерингу.
Хотя существуют и другие библиотеки для работы со встроенными SVG-данными, которые поддерживают SSR, я решил отказаться от этой возможности, так как SVG-файлы, всё равно, использовались редко. Я либо заменил их обычными изображениями, тегами <img>
, в том случае, если при выводе соответствующих изображений не нужна была стилизация, либо встроил их в код в виде React JSX. Вероятно, так всё стало только лучше, так как JSX-иллюстрации теперь попадали в браузер при первоначальной загрузке страницы и в JS-бандле, отправляемом клиенту, было на 1 библиотеку меньше.
Если же вам необходимо пользоваться механизмами загрузки данных (мне эта возможность понадобилась для другой библиотеки), то вы можете это настроить с помощью next.config.js
, используя whatwg-fetch
и node-fetch
:
module.exports = {
webpack: (config, options) =>
Object.assign(config, {
plugins: config.plugins.concat([
new webpack.ProvidePlugin(
config.isServer
? {}
: { fetch: 'imports-loader?this=>global!exports-loader?global.fetch!whatwg-fetch' }
),
]),
resolve: Object.assign(config.resolve, {
alias: Object.assign(
config.resolve.alias,
config.isServer ? {} : { fetch: 'node-fetch' }
),
}),
}),
};
▍Клиентский и серверный JS
Последняя особенность Next.js, о которой тут хотелось бы упомянуть, заключается в том, что этот фреймворк запускается дважды — один раз для сервера, и ещё раз — для клиента. Это немного размывает границу между клиентским JavaScript и Node.js-кодом в одной и той же кодовой базе, вызывая необычные ошибки, наподобие fs is undefined
при попытке воспользоваться возможностями Node.js на клиенте.
В результате приходится сооружать такие вот конструкции в next.js.config
:
module.exports = {
webpack: (config, options) =>
Object.assign(config, {
node: config.isServer ? undefined : { fs: 'empty' },
}),
};
Флаг config.isServer
в Webpack станет вашим лучшим другом в том случае, если один и тот же код нужно запускать в разных окружениях.
Кроме того, Next.js поддерживает, в дополнение к стандартным методам жизненного цикла компонентов React, метод getInitialProps()
, который вызывается только при работе кода в серверном режиме:
class HomePage extends React.Component {
static getInitialProps() {
// Это вызывается только при первом проходе серверного рендеринга
}
componentDidMount() {
// Это вызывается только на клиенте, при монтировании компонента
}
…
}
Да, и не будем забывать о том, что наш хороший друг, объект window
, необходимый для организации прослушивания событий, для определения размеров окна браузера и дающий доступ к множеству полезных функций, в Node.js недоступен:
if (typeof window !== 'undefined') {
// Пожалуйста, позволь мне работать с `window` не устроив тут полный беспорядок
}
Надо отметить, что даже Next.js не способен избавить разработчика от необходимости решения проблем, связанных с выполнением одного и того же кода и на сервере и на клиенте. Но при решении подобных задач весьма полезными оказываются config.isServer
и getInitialProps()
.
Итоги: что будет после Next.js?
В краткосрочной перспективе фреймворк Next.js отлично соответствует, в плане производительности, нашим требованиям к серверному рендерингу и к возможности просматривать наш сайт на устройствах, на которых отключён JavaScript. К тому же, теперь он позволяет использовать расширенные (rich) мета-теги.
Возможно, в будущем мы рассмотрим другие варианты, в том случае, если наше приложение будет нуждаться и в серверном рендеринге и в более сложной серверной логике (например, мы присматриваемся к возможности реализовать технологию единого входа на сайтах manifold.co и dashboard.manifold.co). Но до тех пор мы будем пользоваться Next.js, так как этот фреймворк, при небольших временных затратах, принёс нам огромные выгоды.
Уважаемые читатели! Используете ли вы Next.js в своих проектах?
Автор: ru_vds