С code splitting я познакомился очень давно, в году так 2008, когда Яндекс немного подвис, и скрипты Яндекс.Директа, синхронно подключенные на сайте, просто этот сайт убили. Вообще в те времена было нормой, если ваши "скрипты" это 10 файлов которые вы подключаете в единственно правильном порядке, что и до сих пор (с defer) работает просто на ура.
Потом я начал активно работать с картами, а они до сих пор подключаются как внешние скрипты, конечно же lazy-load. Потом уже, как член команды Яндекс.Карт, я активно использовал ymodules возможность tree-shaking на клиенте, что обеспечивало просто идеальный code splitting.
А потом я переметнулся в webpack
и React
, в страну непуганных идиотов, которые смотрели на require.ensure
как баран на новые ворота, да и до сих пор так делают.
Code splitting — это не "вау-фича", это "маст хэв". Еще бы SSR
не мешался...
Маленькое введение
В наше время, когда бандлы полнеют с каждым днем, code splitting становится как никогда важен. В самом начале люди выходили из данной ситуации просто, создавая отдельные entrypoint, для каждой страницы своего приложения, что вообще хорошо, но для SPA работать не будет.
Потом появилась функция require.ensure
, сегодня известная как dynamic import
(просто import), посредством которой можно просто запросить модуль, который чуть позже и "получите".
Первой библиотекой "про это дело" для React был react-loadable, хайп вокруг которой мне до сих пор не очень понятен, и которая уже сдохла (просто перестала нравиться автору).
Сейчас более менее "официальным" выбором будут React.lazy
и loadable-components(просто @loadable
), и выбор между ними очевиден:
- React.lazy совсем никак не может SSR(Server Side Rendering), от слова вообще. Даже в тестах упадет без особых плясок с бубном, типа "синхронных промисов".
- Loadable SSR может, и при этом поддерживает Suspense, те ничем не хуже React.Lazy.
В том числе loadable поддерживает красивые обертки над загрузкой библиотек (loadable.lib, можно увести moment.js в React renderProp), и помогает webpack на стороне сервера собрать список использованных скриптов, стилей и ресурсов на префетч (чего сам webpack не очень умеет). В общем, читайте официальную документацию.
SSR
В общем — все проблема в SSR. Для CSR(Client Side Render) сгодиться или React.lazy или маленький скрипт в 10 строчек — этого точно будет вполне достаточно, и подключать большую внешнюю библиотеку смысла не имеет. Но на сервере этого будет совсем не достаточно. И если вам SSR особо не нужен — дальше можно не читать. У вас нет проблем, которые надо долго и упорно решать.
SSR – это боль. Я (в неком роде) один из маинтейнеров loadable-components и это просто жуть сколько багов вылезает из разных мест. И с каждым обновлением webpack прилетает еще больше.
SSR + CSS
Еще больший источник проблем при SSR — это CSS.
Если у вас Styled-components — это не так чтобы больно — они поставляются с transform-stream
который сам добавит в конечный код что надо. Главное — должна быть одна версия SC везде, иначе фокус не получится — одна версия SC не сможет ничего рассказать о себе другой, а SC любит плодиться (проверьте свой бандл). Буду честен — именно из-за этого ограничения фокус обычно и не получается.
C emotion попроще — их styled
адаптер просто выплюнет <style>
перед самом компонентом, и проблема решена. Просто, дешево и сердито. В принципе очень mobile-friendly, и сильно оптимизирует самый-первый-view. Но немного портит второй. Да и лично мне совесть не позволяет так стили инлайнить.
С обычным CSS (в том числе полученым из CSS-in-JS библиотек различной магией) все еще проще — информация о них есть в графе webpack, и "известно" какие CSS нужно подключить.
Порядок подключения
Вот тут собака и зарылась. Когда что надо подключать?
Смысл SSR friendly code splitting в том и заключается, что перед тем как звать ReactDOM.hydrate
надо загрузить все "составные части", которые уже присуствуют в ответе сервера, но не по силам скриптам загруженным на клиент в данный момент.
Посему все библиотеки предлагают некий callback который будет вызван когда все-все-все что надо загрузилось, и можно стартовать мозги. В этом и есть смысл работы SSR codesplitting библиотек.
JS можно загружать когда угодно, и обычно их список добавляют в конец HTML, а вот CSS, чтобы не было FOUC, надо добавлять в начало.
Все библиотеки умеют это делать для старого renderToString
, и все библиотеки не умеют это делать для renderToNodeStream
.
Это не беда если у вас только JS(такого не бывает), или SC/Emotion(которые сами себя добавят). Но — ежели у вас "просто CSS" — все. Или они будут в конце, или прийдется использовать renderToString
, или другую буферизацию, что обеспечит задержку TTFB(Time To First Byte) и чуток уменшит смысл вообще этот SSR иметь.
Ну и конечно — все это завязано на webpack и вообще никак иначе. Посему, при всем уважению к Грегу, автору loadable-components — предлагаю рассмотреть другие варианты.
Далее идет повестнование из трех частей, основная идея которого сделать что-то не убиваемое и не зависимое от бандлера.
1. React-Imported-Component
React-Imported-Component — не плохой "loader", с более менее станданым интерфейсом, крайне схожим с loadable-components, который может SSR для всего что шевелится.
Идея очень простая
- исходные коды сканируются, находятся все
import
s и копируются в отдельный файл - используя
babel plugin
каждый вызов кimport
оборачивается в некий сахар
const AsyncComponent1 = imported(() => import('./MyComponent')); ///// const AsyncComponent1 = imported(() => importedWrapper("imported_18g2v0c_component", import('./MyComponent')));
- при вызове "импорта" просто делается function.toString и магический номер вытаскивается. Таким образом, становиться понятно что было вызвано. (Да — это накладывает некие ограничения на код, но меньше чем другие загрузчики, которые вообще не могут "не их" импорты)
- на клиенте у нас есть файл, где собраны все возможные импорты, и любой импорт можно повторить.
Не надо парсить stats.json
, адаптируюсь под оптимизации webpack(concatenation, или common code) — надо просто сопоставить "метку" одного импорта в ключем в массиве и выполнить импорт еще раз. Как он будет выполнен в рамках конкретного бандлера, сколько файлов на самом деле будут загружены и от куда — не его проблема.
Минус — начало загрузки "использованных" чанков происходит после загрузки основного бандла, который хранит маппинг, что немного "позже" чем в случае loadable-components, который эту информацию добавит прямо в HTML.
Да с CCS это не работает от слова никак.
2. used-styles
А вот used-styles работает только с CSS, но примерно так же как react-imported-components.
- сканирует все css (в директории билда)
- запоминает где какой класс определен
- сканирует выходной renderToNodeStream (или ответ
renderToString
) - находит class='XXX', сопоставляет файл и выплевывает его в ответ сервера.
- (ну и потом телепортирует все такие стили в head, чтобы не сломать hydrate). Style Components работают точно также.
Задержки TTBT нет, к бандлеру не привязно — сказка. Работает как часики, если стили нормально написаны.
Пример работы react-import-component+used-styles+parcel.
Не самый очевидный бонус — на сервере обе библиотеки сделают "все что нужно" во время стартапа, до того момента как express server сможет принять первого клиента, и будут полностью синхроны и просто на сервере, и во время тестов.
3. react-prerendered-component
И замыкает тройку библиотека, которая делает "partial rehydration", и делает это настолько дедовским способом, что я прям диву даюсь. Она реально добавляет "дивы".
- на сервере:
- оборачивает некий кусок дерева в див с "известным id"
- на клиенте:
- конструкторе компонента находит "свой" див
- копирует его innerHTML, до того как "React" его пережует.
- использует этот HTML до тех пор, пока клиент не готов его
hydrate
- технически это позволяет использовать Hybrid SSR(Rendertron)
const AsyncLoadedComponent = loadable(() => import('./deferredComponent'));
const AsyncLoadedComponent = imported(() => import('./deferredComponent'));
<PrerenderedComponent
live={AsyncLoadedComponent.preload()} // when Promise got resolve - component will go "live"
>
<AsyncLoadedComponent />
// meanwhile you will see "preexisting" content
</PrerenderedComponent>
С loadable-components этот фокус не работает, так как он не возвращает из preload promise. Особо это важно для библиотек типа react-snap(и других "пререндеров"), которые имеют "контент", но не прошли через "настоящий" SSR.
С точки зрения кода — это 10 строк, плюс еще немного чтобы получить стабильные SSR-CSR UID с учетом рандомного порядка загрузки и испольнения кода.
Бонусы:
- можно не ждать "загрузки всех скриптом" перед стартом мозгов — мозги будут запускаться по мере готовности
- можно вообще не загружать мозги, так и оставляя данные SSR-ed (если SSR версии нет то мозги будут таки загружены). Прям как во времена jQuery.
- можно еще потоковое кеширование крупных рендер блоков реализовать (теоритически Suspence-compatible) — опять же средствами transform stream.
- и сериализацию/десериализацию стейта в/из HTML, как во время jQuery
В принципе сериализация и десериализация и были основной идеей создания библиотеки, чтобы решить проблему дублирования стейта (картинка из статьи про SSR). Кеширование прилетело потом.
Итого
Итого — три подхода, которые могут изменить ваш взгляд на SSR и code splitting. Первый работает с JS codesplitting, и не ломается. Второй работает с CSS codesplitting, и не ломается. Третий работает на уровне HTML упрощая и ускоряя некоторые процессы, и опять же, не ломается.
Ссылки на библиотеки:
- https://github.com/theKashey/react-imported-component/
- https://github.com/theKashey/react-prerendered-component
- https://github.com/theKashey/used-styles
- https://github.com/smooth-code/loadable-components/
- (для тех кто в танке) https://reactjs.org/blog/2018/10/23/react-v-16-6.html#reactlazy-code-splitting-with-suspense
Статьи (на английском)