За поисковыми интерфейсами Яндекса скрывается крупный проект со сложной инфраструктурой.
У нас есть десятки мегабайт кода, который должен быстро работать и быстро собираться.
Когда нам нужно было перевести проект на React и TypeScript, мы начали с Create React App, CRA. И мы быстро поняли, что многое нужно улучшить.
В своем докладе на Я.
Субботник Про я вспомнил, чем и как мы выполнили сборку и архитектуру «стандартного современного проекта» и какие результаты получили.
— Последние полтора года я работаю в архитектурной команде Serpa. Там мы разрабатываем рантайм и сборку нового кода на React и TypeScript. Давайте поговорим о нашей общей боли, которой и будет посвящен этот доклад. Когда вы хотите сделать небольшой проект на React, вам просто нужно использовать стандартный набор инструментов под названием три буквы — CRA. Сюда входят скрипты сборки, скрипты для запуска тестов, настройки среды разработки и всё уже сделано для производства.
Делается все очень просто через NPM-скрипты, и об этом наверняка знает каждый, кто имел опыт работы с React. Но предположим, что проект становится большим, в нем много кода, много разработчиков, появляются продакшен-фичи, например, переводы, о которых Create React App ничего не знает. Или у вас какой-то сложный конвейер CI/CD. Дальше начинаются мысли, взять за основу Create React App и настроить его под свой проект. Но совершенно непонятно, что ждет там, за этим выбросом.
Потому что когда делаешь катапульт, там написано, что это очень опасная операция, вернуть его обратно не получится и так далее, очень страшно.
Те, кто нажимал eject, знают, что там вываливается куча конфигов, в которых нужно разобраться.
В общем, рисков много, и что делать непонятно.
Расскажу, как было у нас.
Сначала о нашем проекте.
Наш фронтенд-проект — это Серп, Страницы результатов поиска, страницы результатов поиска Яндекса, которые все видели.
С 2018 года мы уходим от React и TypeScript. На Serp в прошлом году уже было написано около 12 мегабайт кода.
Есть несколько стилей и много кода TS и SCSS. Я не писала, сколько было вначале, в 2018 году было очень мало, был очень резкий скачок.
Давайте разберемся, много это кода или нет. По сравнению с исходным кодом webpack-4, в webpack-4 гораздо меньше кода.
Даже в репозитории TypeScript меньше кода.
Но у vs-code больше кода, хороший проект с целых 30 мегабайтами кода TypeScript. Да, он тоже написан на TypeScript, и Sickle кажется меньше.
Мы стартовали в 2018 году, в 2019 году было 12 мегабайт, и работало 70 наших разработчиков, делающих 100 вливаемых пул-реквестов в неделю.
За год они утроили этот размер, получив ровно 30 мегабайт. Замеры я проводил в этом месяце, всего у нас теперь 30 мегабайт кода, а это уже больше, чем в vs-code.
Примерно то же самое, но немного больше.
Это порядок нашего проекта.
И мы сделали выброс в самом начале, потому что сразу знали, что кода у нас будет много и, скорее всего, оригинальные конфиги, которые есть в Create React App, нас не устроят. Но мы начали точно так же, с создания приложения React.
Вот о чем пойдет речь.
Хотим поделиться опытом, рассказать, что нам пришлось сделать с Create React App, чтобы Яндекс Серп на нем корректно работал.
То есть как мы добились быстрой загрузки и инициализации в браузере и как старались не тормозить сборку, какие настройки, плагины и прочее для этого использовали.
И естественно, в конечном итоге будут те результаты, которых нам удалось достичь.
Как мы рассуждали? Первоначальная идея заключалась в том, что наш Sickle — это страница, которая должна рендериться очень быстро, потому что, по сути, там очень простые текстовые результаты, поэтому нам нужны шаблоны на стороне сервера, потому что это единственный способ получить быстрый рендеринг.
То есть еще до того, как что-то начнет инициализироваться на клиенте, нам уже надо что-то нарисовать.
При этом статический размер хотелось сделать минимальным, чтобы не загружать ничего лишнего и инициализация тоже происходила быстро.
То есть мы хотим, чтобы и первый рендеринг был быстрым, и инициализация была быстрой.
Что нам предлагает Create React App? К сожалению, он ничего не предлагает нам по поводу серверного рендеринга.
Там же написано, что рендеринг на стороне сервера не поддерживается в приложении Create React. Кроме того, Create React App имеет только одну запись для всего приложения.
То есть по умолчанию для всего вашего огромного разнообразия страниц собирается один большой пакет. Это много.
Понятно, что из 30 мегабайт примерно половина — это TS-типы, но все равно очень много кода пойдет прямо в браузер.
В то же время Create React App имеет несколько хороших настроек, например, среда выполнения веб-пакета запускается там в отдельном чане.
Загружается отдельно, может быть кэширован, поскольку обычно не меняется.
Кроме того, модули из node_modules также собираются в отдельные чанки.
Они тоже редко меняются, а потому еще и кэшируются браузером, это здорово, это надо сохранять.
Но в то же время в Create React App нет ничего о переводах.
Давайте соберем наш список, как в нашем случае должен выглядеть список возможностей нашей платформы.
Во-первых, как я уже сказал, нам нужен северный рендеринг, чтобы сделать рендеринг быстрым.
Кроме того, нам хотелось бы иметь отдельный файл записей для каждого результата поиска.
Если, например, на Серпе есть калькулятор, то нам бы хотелось, чтобы комплект с калькулятором доставили, но комплект с переводчиком не нужно доставлять быстро.
Если собрать все это в одну большую связку, то все всегда будет работать, даже если половины этих вещей нет в наличии по конкретному выпуску.
Далее хотелось бы доставлять общие модули отдельными кусками, чтобы не загружать то, что уже загружено.
Вот еще пример с Серпом.
В нем есть калькулятор, есть комплект калькулятора.
Есть общие компоненты.
Они были доставлены клиенту.
Потом появилась еще одна функция — карта.
Прибыла связка карт, и прибыли другие общие комплектующие, за вычетом тех, что уже были доставлены.
Если общие компоненты собирать отдельно, то появляется такая чудесная возможность для оптимизации и ставится только то, что нужно, только дифф.
А самые популярные модули, которые всегда есть на странице, например, runtime webpack, который всегда нужен всей этой инфраструктуре, его всегда надо загружать.
Поэтому имеет смысл собрать в отдельный чанк.
То есть эти общие компоненты также можно разделить на те компоненты, которые не всегда нужны, и компоненты, которые нужны всегда.
Их можно собрать в отдельный файл и всегда подгружать, а также кэшировать, поскольку эти общие компоненты, такие как кнопки/ссылки, меняются не очень часто, в общем, на кэшировании можно получить прибыль.
И в то же время вам необходимо принять решение о сборке переводов.
Здесь все совершенно ясно.
Если мы зайдем на турецкий серп, нам хотелось бы загружать только турецкие переводы, а не все остальные переводы, потому что это лишний код. Что мы сделали? Сначала о серверном коде.
По этому поводу у нас будет два направления — сборка для производства и запуск для разработки.
Вообще про TypeScript нужно сначала сделать отдельное заявление.
Обычно в проектах, как я слышал, используют Babel. Но мы сразу решили использовать стандартный компилятор TypeScript, так как считали, что в нем новые возможности TypeScript появятся быстрее.
Поэтому мы сразу отказались от Babel и использовали tsc. Итак, это наш текущий размер кода, эти 30 мегабайт компилируются на ноутбуке за три минуты.
Совсем немного.
Если отказаться от проверки типов и использовать форк tsc при каждой компиляции (к сожалению, в TSC нет настройки, отключающей проверку типов, пришлось использовать форк), то можно сэкономить вдвое больше времени.
Компиляция нашего кода займет всего полторы минуты.
Почему мы не можем проверять типы при компиляции? Потому что мы, например, можем проверить их в хуках pre-commit. Сделать линтер, который будет выполнять только проверку типов, а саму сборку можно делать и без проверки типов.
Мы приняли это решение.
Как запустить в разработке? В разработке мы обычно также используем связку Babel с веб-пакетом, но используем такой инструмент, как ts-node.
Это очень простой инструмент. Чтобы запустить его, просто напишите следующий код require(ts-node) во входном файле JavaScript, и он переопределит требования всего кода TS на более позднем этапе процесса.
И если в этот процесс попутно загрузить TS-код, то он будет скомпилирован на лету.
Очень простая вещь.
Естественно, есть небольшой накладной расход, связанный с тем, что если файл еще не был загружен в этом процессе, то его необходимо перекомпилировать заново.
Но на самом деле эти накладные расходы минимальны и в целом приемлемы.
Кроме того, в этом листинге есть еще несколько интересных строк.
Первый — это игнорирование стилей, поскольку нам не нужны стили для серверных шаблонов.
Нам нужно только получить HTML. Поэтому мы тоже используем этот модуль — ignore-styles. И, кроме того, отключаем проверку типов точно так же (transpile-only), как мы это делали в TSC, чтобы ускорить ts-node. Перейдем к клиентскому коду.
Как нам создать код ts в веб-пакете? Мы используем ts-loader и опцию transpileOnly, то есть примерно один и тот же бандл.
Вместо Babel-Loader есть более-менее стандартные инструменты ts-loader и transpileOnly. Но, к сожалению, инкрементные сборки в ts-loader не работают. То есть все-таки ts-loader — это не совсем стандартный инструмент, и его делают не те же ребята, что делают TypeScript. Поэтому там поддерживаются не все параметры компилятора.
Например, инкрементные сборки не поддерживаются.
Инкрементная сборка — это вещь, которая может быть очень полезна во время разработки.
Таким же образом вы можете добавить эти кэши в конвейер.
В общем, когда у тебя изменения небольшие, ты не можешь полностью перекомпилировать всё, весь TypeScript, а только то, что изменилось.
Это работает довольно эффективно.
В общем, чтобы избежать инкрементных сборок, мы используем кэш-загрузчик.
Это стандартное решение от вебпака.
Все совершенно ясно.
Когда код TypeScript пытается подключиться во время сборки вебпака, он обрабатывается компилятором, добавляется в кеш, и в следующий раз, если в исходных файлах не было изменений, кэш-загрузчик не запустит ts-loader и возьмет его из кэша.
То есть здесь все достаточно просто.
Его можно использовать для чего угодно, но конкретно для TypeScript это удобная штука, потому что ts-loader — достаточно тяжелый загрузчик, поэтому кэш-загрузчик здесь очень уместен.
Но у кэш-загрузчика есть один недостаток — он работает с учетом времени модификации файлов.
Здесь вы можете увидеть фрагмент исходного кода.
И это не сработало для нас.
Нам пришлось форкнуть и переделать алгоритм кеширования на основе хеша содержимого файла, поскольку он нас не устраивал для использования в конвейере кэш-загрузчика.
Дело в том, что когда вы хотите повторно использовать результаты сборки между несколькими пул-реквестами, этот механизм не сработает. Потому что если сборка была, например, давно.
Затем вы пытаетесь сделать новый запрос на включение, который не меняет файлы, созданные в предыдущий раз.
Но их время уже более недавнее.
Соответственно, кэш-загрузчик будет думать, что файлы обновились, но на самом деле это не так, поскольку это не время модификации, а время извлечения.
А если сделать так, то будут сравниваться хеши от контента.
Содержимое не изменилось, будет использован старый результат. Здесь следует отметить, что если мы использовали Babel, то Babel-Loader по умолчанию имеет внутри механизм кэширования, и он уже сделан на хешах от контента, а не на mtime. Поэтому, возможно, подумаем еще раз и посмотрим в сторону Бабеля.
Теперь о сборке кусков.
Давайте немного поговорим о том, как вебпак все это делает по умолчанию.
Если у нас есть входной индексный файл, в него включаются компоненты.
Также они содержат компоненты и т. д. Кроме того, подключаются общие модули: React, React-dom и lodash, например.
Итак, по умолчанию вебпак, как, наверное, все знают, но на всякий случай повторюсь, собирает все зависимости в один большой бандл.
При этом все, что подключено через node_modules, можно собирать как внешние, загружая отдельными скриптами, так и в отдельный чанк, настроив в вебпаке специальную настройку оптимизации.
splitChunks. По-моему, даже по умолчанию эти вендорские модули собраны в отдельный чанк.
У CRA есть слегка измененная версия этого SplitChunks.
Давайте также вспомним, что такое runtimeChunks. Я упомянул его.
Это код, содержащий «заголовок» загрузочных скриптов и функций, обеспечивающих работу модульной системы на клиенте.
А дальше массив (или кеш), который, собственно, и содержит модули.
Зачем я тебе об этом рассказал? Потому что Create React App также использует настройку, которая собирает эти runtimeChunks в отдельный файл.
Этот файл будет прикреплен не к исходному исправному пакету, а к отдельному файлу.
Его можно кэшировать в браузере и все такое.
Итак, что же нас не устраивает в Create React App?
Этот SplitChunks, который там используется по умолчанию, собирает в отдельные чанки только то, что node_modules. Но на самом деле есть общие компоненты, общие библиотеки, которые есть на уровне проекта.
Еще хотелось бы собрать их в отдельные куски, потому что они, наверное, тоже редко меняются.
Почему мы ограничиваемся только тем, что есть в node_modules? Кроме того, по поводу runtimeChunks тоже можно сказать, что было бы здорово, как мы изначально обсуждали, помимо самого runtime, еще и собирать там, в одном чанке, модули, которые всегда нужны.
Те же кнопки/ссылки.
На Serpa всегда есть ссылки.
Мне всегда хотелось собирать ссылки.
То есть не только среда выполнения веб-пакета, но и некоторые суперпопулярные компоненты.
Этого нет в приложении Create React. Как мы это сделали здесь?
Далее мы настроили SplitChunks таким образом, что отключили все стандартное поведение и попросили собрать в общий код не только то, что есть в node_modules, но и то, что есть общие компоненты нашего проекта и библиотечный код нашего проекта, что находится в src/lib находится в src/comComponents.
Кроме того, мы собираем в отдельные чанки то, что связано посредством динамического импорта и то, что обычно называют асинхронными чанками.
Здесь нужно обратить внимание на два варианта.
Один из них — принудительный, а второй — начальный.
В целом, Enforces — довольно удобная настройка, отключающая всякие сложные эвристики в SplitChunks. По умолчанию SplitChunks пытается понять, насколько модули востребованы, и учитывать эту статистику при разделении.
Но за этим сложно уследить, да и спрос на модуль может время от времени меняться, и модуль будет «прыгать» между чанками.
Из общего блока попадаем в пакет функций и обратно.
То есть это очень непредсказуемое поведение, поэтому отключаем его.
То есть мы всегда говорим, что все, что удовлетворяет условиям тестового поля, попадает в наши общие чанки.
Нам не нужна никакая эвристика.
А вот chunks:initial — это тоже хорошая вещь, это значит, что эти синхронные модули, модули, которые подключаются через динамический импорт, их можно подключать в разных местах по-разному.
То есть подключить один и тот же модуль можно как динамическим импортом, так и обычным импортом.
А начальное значение позволяет собрать один и тот же модуль в двух вариантах.
То есть он собирается как асинхронно, так и синхронно, что позволяет использовать его обоими способами.
Достаточно удобно.
Это немного увеличивает размер собираемой статики, но позволяет использовать любой импорт. Кстати, это довольно сложно понять из документации.
Недавно перечитал документацию по вебпаку и там ничего нормального про начальный не написано.
Это то, что мы сделали с помощью SplitChunks. Теперь, что мы сделали с runtimeChunks. Вместо того, чтобы собирать в runtimeChunks только время выполнения, мы хотим добавить туда больше суперпопулярных компонентов.
Поэтому мы написали собственный плагин, который называется MainChunkPlugin. И у него очень тривиальная установка.
Там просто список модулей, которые нужно собрать, которые мы сочли популярными.
Проще говоря, с помощью наших инструментов A/B-тестирования и различных оффлайн-инструментов мы поняли, какие компоненты чаще всего возвращают. Там они были записаны в виде плоского списка.
И в результате наш плагин собирает эти компоненты из списка, а также библиотеки, а также веб-пакет времени выполнения, который собирает этот стандартный оптимизация.
splitChunks.
Вот, кстати, кусок кода, который склеивает рантайм.
Это тоже нетривиально, показать, что писать плагины не так уж и просто, но дальше посмотрим, что это дало.
Также следует отметить, что вообще говоря, в вебпаке есть стандартный механизм для таких вещей, который называется DLLPlugin. Он также позволяет собрать отдельный чанк на основе списка зависимостей.
Но у него есть ряд недостатков.
Например, он не включает runtimeChunks. То есть runtimeChunks у вас всегда будет отдельный чанк, и будет чанк, собранный DLLPlugin. Это не очень удобно.
Также для DLLLPlugin требуется отдельная сборка.
То есть, если бы мы хотели собрать этот отдельный чанк с наиболее важными компонентами с помощью DLLPlugin, нам пришлось бы запускать две сборки.
То есть можно было бы собрать этот отдельный чанк с файлом манифеста, а остальная сборка собрала бы все остальное, просто вычитая через файл манифеста, она бы не собирала то, что уже включено в чанк с популярными компонентами.
А это замедляет сборку, потому что реализация DLLPlugin локально у нас заняла семь секунд. Это довольно много.
А это нельзя оптимизировать, потому что там строгая последовательность исполнения.
К тому же в определенный момент нам понадобилось собрать этот наш основной кусок из популярных компонентов без CSS, только JS. Но DLLLPlugin не может этого сделать.
Он всегда собирает все, что доступно через require через импорт. То есть, если вы включите CSS, он тоже всегда войдет. Для нас это было неудобно.
Но если для вас это не проблема, и вы не хотите писать такой хитрый код, то DLLPlugin вполне нормальное решение.
Это решает главную проблему.
То есть самые популярные компоненты он выносит в отдельный файл.
Его можно использовать.
Итак, что мы получили? Наша фича может использовать суперпопулярные компоненты из нашего MainChunk, которые собираются специальным одноименным плагином.
Кроме того, есть общие чанки, включающие в себя всевозможные общие компоненты, а есть асинхронные чанки, загружаемые посредством динамического импорта.
Весь остальной код находится в пакетах функций.
По сути, это структура вашего фрагмента.
О сборке переводов.
Наши переводы — это просто ts-файлы, расположенные рядом с компонентами, требующими перевода.
Вот у нас девять языков, вот девять файлов.
Переводы выглядят так.
Это просто объект, содержащий ключевую фразу и значение переведенной фразы.
Так к компоненту подключаются переводы, а затем используется специальный помощник.
Как можно собирать эти переводы? Мы думаем: надо собрать переводы, посмотрим в Интернете, что пишут, как можно это сделать.
В Интернете говорят: используйте мультикомпиляцию.
То есть вместо запуска одной сборки веб-пакета просто запустите отдельную сборку веб-пакета для каждого языка.
Но, мол, все будет хорошо, потому что есть кеш-загрузчик, который будет кешировать всю эту общую работу с TypeScript, или чем там у вас есть, а значит долго это не протянет. Не расстраивайтесь, не думайте, что это будут девять реальных прогонов вебпака.
Так не будет, будет хорошо.
Единственное, что нужно немного подправить, это добавить модуль replacePlugin, который вместо индексного файла, связывающего все языки, заменит его конкретной языковой связью.
Все достаточно тривиально, и да, вывод нужно подправить.
Теперь, оказывается, нам нужно собрать отдельный бандл для каждого языка.
Схема этого рецепта такая.
Был переводчик.
Он подключил переводы переводчика.
Он соединил языки, и вместо того, чтобы собирать эту одну структуру, мы продублировали ее для каждого языка, получили отдельную и каждую собрали в отдельную компиляцию.
Но, к сожалению, это не работает. Я попытался запустить этот вариант мультикомпиляции для нашего текущего кода размером 30 МБ, подождал полтора часа и получил эту ошибку.
Это очень долго и невозможно.
Что мы с этим сделали? Мы сделали еще один плагин.
Берем ту же структуру и вклиниваемся в работу вебпака, когда он собирается сохранить выходные файлы на диск.
Мы копируем эту структуру столько раз, сколько у нас языков, и приклеиваем к каждому по одному языку.
И только потом создаем файлы.
При этом основная работа, которую выполняет вебпак по обходу зависимостей компиляции, не повторяется.
То есть мы врезаемся на самом последнем этапе, а потому можем надеяться, что это будет быстро.
Но код плагина оказался сложным.
Это буквально восьмая часть нашего плагина.
Просто демонстрирую, насколько это сложно.
И там у нас регулярно возникают мелкие неприятные баги.
Но проще реализовать это не удалось.
Но это работает очень хорошо.
То есть вместо полутора часов с ошибкой мы получаем пять минут сборки с этим нашим плагином.
Теперь доставка и инициализация.
Все, что касается доставки и инициализации, просто.
То, что мы загружаем в отдельные ресурсы, мы используем preload, как и все, наверное.
Потом подключаем CSS, JS, собственно HTML для наших компонентов, и загружаем эти свои ресурсы, но без асинхронности.
Мы экспериментировали.
Если вы используете асинхронный режим, время интерактивности задерживается, а это не то, чего мы хотим.
Поэтому мы просто используем предварительную загрузку и загрузку в конце страницы.
В общем, ничего особенного.
В то же время мы встроим все остальное.
То есть это наш MainChunk, его CSS мы встроим.
Встраиваем общие компоненты, стили, в общем все, что написано на слайде.
Это также была серия экспериментов, которые показали, что «инлайн» дает лучший результат при первом рендеринге и наступлении интерактивности.
А теперь к цифрам.
Чтобы поговорить о цифрах, нужно сказать два слова о метриках.
У нас есть специальная команда по скорости, цель которой — обеспечить эффективную работу всего кода внешнего интерфейса.
Это касается и шаблонизации сервера, и загрузки ресурсов, и инициализации на клиенте, в общем, всего этого.
У нас есть целая куча метрик, которые отправляются из производства в нашу специальную систему журналов.
Мы можем контролировать это в экспериментах A/B. У нас есть офлайн-инструменты, в целом мы очень активно за этим следим.
И мы использовали эти инструменты, когда реализовывали наш новый код на React и TypeScript.
Давайте теперь проследим это с помощью офлайн-инструментов (потому что я не смог провести честный онлайн-эксперимент, в котором использовались бы все наши показатели).
Давайте посмотрим, что произойдет, если мы откатимся от нашего текущего решения к созданию приложения React на основе этих ключевых показателей.
В Теги: #JavaScript #interfaces #react.js #typescript #react #webpack #создать приложение реагирования
-
Разбитое Стекло И Пиксель
19 Oct, 24 -
«Сытая Мышка» — Концепция Glide Keyboard
19 Oct, 24 -
Вы Видели Млечный Путь?
19 Oct, 24 -
Оптимизация «Тяжелых» Вычислений Javascript
19 Oct, 24