React и Code Splitting

    С 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 для всего что шевелится.


    Идея очень простая


    • исходные коды сканируются, находятся все imports и копируются в отдельный файл
    • используя 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 упрощая и ускоряя некоторые процессы, и опять же, не ломается.


    Ссылки на библиотеки:



    Статьи (на английском)


    • +11
    • 2,4k
    • 2
    Поделиться публикацией

    Похожие публикации

    Комментарии 2

      0
      А вы пользовались next.js? Ну или смотрели ли их способы решения вышеописанных проблем?
      Есть из коробки code-splitting, у них есть своя реализация `React.lazy()`. Большой упор на SSR и нюансы, связанные с ним.

      Допустим, есть новый проект (нужен SSR, хорошие показатели загрузки), в чем ваши решения отличаются от `next.js`? Можно ли объединить подходы в некоторых местах?

        0
        `next.js` использует внутри модифицированный `react-loadable` и контролируют процесс разбиения приложения на чанки.

        Я давно перестал следить за react-loadable(да он и не меняется), и особо не смотрел в эту часть next.js, но у них должны быть проблемы с stream rendering static CSS, описанные тут.

        А насчет обьединения — prerendered-component можно запихнуть в любое приложение, и совместить с любым загрузчиком. Например с React.lazy

        const PrefetchLazy = lazy => {
           let value = PrefetchMap.get(lazy) || lazy._ctor();
           PrefetchMap.set(lazy, value);
           return value;
         );
        
         // using React.lazy
         const AsyncLoadedComponent = React.lazy(() => import('./deferredComponent'));
        
         <PrerenderedComponent
           live={PrefetchLazy(AsyncLoadedComponent)} 
         >
           <AsyncLoadedComponent />
         </PrerenderedComponent>

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое