SuperOleg dev notes
1.87K subscribers
56 photos
149 links
Обзоры новостей и статей из мира frontend, интересные кейсы, исследования и мысли вслух

https://github.com/SuperOleg39

https://twitter.com/ODrapeza

@SuperOleg39
Download Telegram
Про паттерн Circuit Breaker можно почитать у Мартина Фаулера - https://martinfowler.com/bliki/CircuitBreaker.html

Мы в ряде фронтовых приложений Тинькофф используем CB уровня приложения, реализацию можно посмотреть на гитхабе - https://github.com/TinkoffCreditSystems/tinkoff-request/blob/master/packages/plugin-circuit-breaker/src/CircuitBreaker.ts

Можно считать это продвинутым механизмом таймаутов, который позволяет при проблемах с API незамедлительно на проблемные запросы отдавать ответы из кэша, либо последнюю ошибку, не дожидаясь таймаутов.
Дополнительно, это облегчает нагрузку на проблемный API
Отдельно ряд ссылочке про протокол QUIC - его активное распространение просто невероятно вдохновляет!

Реализация и успешное внедрение QUIC протокола от Facebook - https://engineering.fb.com/2020/10/21/networking-traffic/how-facebook-is-bringing-quic-to-billions/

Обзорная статья про QUIC от Cloudfire, внутри есть много ссылок с подробной информацией - https://blog.cloudflare.com/last-call-for-quic/

Кстати, рекомендую блоги инженеров Cloudfire и Fastly, много интересного контента.

https://www.fastly.com/blog
К разговору об отказоустойчивости, хочу поделиться частным случаем, как повысить надежность загрузки ресурсов фронтовых приложений.
Рецепт узнал у коллег в Тинькофф, также используем в продакшене.

Допустим, у нас есть несколько CDN для загрузки ресурсов - основной, и дополнительный.
Ошибку загрузки css или js ресурса можно отловить с помощью обработчика window.onerror.
Основная сложность в том, что бы запросить упавший ресурс с дополнительного CDN - это необходимость делать повторный запрос синхронно - например, повторная попытка загрузить vendor скрипт, содержищий React, должен завершиться до выполнения остальных скриптов приложения.

Оказывается, что обработчик window.onerror выполняется синхронно, и блокирует выполнение JavaScript кода.
Таким образом, остается только синхронно повторно загрузить упавший ресурс.
На помощь приходит старый товарищ XMLHttpRequest, который позволяет нам загрузить код скрипта с fallback CDN, и вставить его на страницу как inline скрипт.
Минимальный пример кода:

// этот inline скрипт должен быть расположен перед всеми загружаемыми скриптами и стилями
const primaryDomain = 'https://cdn-first.com/';
const fallbackDomain = 'https://cdn-second.com/';
const retriedResourcesList = {};
const maxRetries = 1;

function retry(event) {
// тут будет ссылка на DOM элемент script или link
const tag = event.target;
// если упал файл стилей, получим `LINK` в `tagName`, если скрипт - то `SCRIPT`
const tagName = tag.tagName && tag.tagName.toLowerCase();

if (tagName !== 'link' && tagName !== 'script') {
return;
}

const isLink = tagName === 'link';
const failedUrl = isLink ? tag.href : tag.src;
const fallbackUrl = getFallbackUrl(failedUrl);

// простой вариант не уйти в цикл повторных запросов
if (!retriedResourcesList[fallbackUrl]) {
retriedResourcesList[fallbackUrl] = 1;
} else if (retriedResourcesList[fallbackUrl] < maxRetries) {
retriedResourcesList[fallbackUrl] = retriedResourcesList[fallbackUrl] + 1;
} else {
return;
}

const newTag = isLink ? createLink(tag, fallbackUrl) : createScript(tag, fallbackUrl);

// вставляем новый тег рядом с упавшим ресурсом
tag.parentNode.insertBefore(newTag, tag);
}

function getFallbackUrl(url) {
if (url.indexOf(primaryDomain) === 0) {
return url.replace(primaryDomain, fallbackDomain);
}
return url;
}

function createLink(tag, fallbackUrl) {
const newTag = document.createElement('link');

newTag.href = fallbackUrl;
newTag.rel = 'stylesheet';

return newTag;
}

function createScript(tag, fallbackUrl) {
const newTag = document.createElement('script');

// тут можно обработать и "обогатить" ошибку
const xhr = new XMLHttpRequest();
xhr.open('GET', fallbackUrl, false);
xhr.send();
newTag.text = xhr.responseText;

return newTag;
}

// пока не выполнится обработчик retry, выполнение JS будет заблокировано!
window.addEventListener('error', retry, true);


В этот код можно добавить health check запросы к CDN, добавить подробностей в ошибки, например информацию из window.navigator.connection

Отдельно порекомендую механизм NEL - Network Error Logging, который поддерживается в Chromium браузерах, и позволяет отправить невероятно подробные отчеты о сетевых ошибках.
Статья в блоге Fastly про NEL - https://www.fastly.com/blog/network-error-logging

Демка на github с повторными запросами:

https://superoleg39.github.io/retry-resources/
https://github.com/SuperOleg39/retry-resources
Привет!

Рецепт ретраев загрузки статических ресурсов будет не полным, если не рассмотреть альтернативы.

На самом деле, самый простой и логичный механизм для этого - Service Worker.
Мы можем перехватить ошибку любого запроса, убедиться что это был запрос на статику на наш CDN, и продолжить цепочку промисов запросом на тот же ресурс с другого CDN.
Код в воркере может выглядеть примерно так:


self.addEventListener('fetch', event => {
return fetch(event.request).catch((error) => {
const requestToCDN = event.request.url.startsWith(primaryCDN);
const requestToAsset = event.request.url.endsWith('.css') || event.request.url.endsWith('.js'));

if (requestToCDN && requestToAsset) {
const nextUrl = event.request.url.replace(primaryCDN, fallbackCDN);

return fetch(new Request(nextUrl, event.request));
}

throw error;
});
});

Service Worker позволяет сделать нам гораздо более гибкие стратегии для повторных запросов, сам процесс ретраев будет полностью прозрачным.

Есть и минусы такого подхода:

- SW может быть еще не зарегистрирован, т.е. для первых пользователей сайта ретраи будут не доступны
- Если на домене доступны несколько приложений, но общий Service Worker, в теории эти приложения могут использовать разные CDN, и будет чуть труднее сделать логику ретраев подходящей для всех
- Используется workbox , и вклиниться в существующий процесс кэширования статики будет сложнее

Но даже с учетом этих нюансов, использование Service Worker кажется более перспективным решением для механизма ретраев.

Альтернативный вариант реализации ретраев - интеграция этого механизма в Webpack.
Мне не нравится тем, что вариант самый не прозрачный и не очевидный, но возможно это дело вкуса)

Как я понимаю, основной вариант реализации ретраев - это расширение возможностей методов webpack_require , например плагин webpack-retry-chunk-load-plugin расширяет webpack_require.e (это require.ensure, который используется для динамических импортов), и в обработчике ошибок загрузки чанка продолжает цепочку промисов повторной загрузкой.

Ссылка на плагин - https://github.com/mattlewis92/webpack-retry-chunk-load-plugin

Никогда не помешает ссылка на великолепный инструмент workbox - https://developers.google.com/web/tools/workbox
Давно не заходил к ним на сайт, появился раздел с рецептами, в том числе пример offline fallback - несколько лет назад мне очень не хватало этого рецепта) https://developers.google.com/web/tools/workbox/modules/workbox-recipes?hl=en#offline_fallback
👍1
Channel photo updated
Привет!

Несколько дней назад на Github состоялся релиз фреймворка tramvai - https://github.com/TinkoffCreditSystems/tramvai

tramvai - это фреймворк для создания SSR приложений на React, внутренняя разработка Тинькофф, и последние полтора года я работаю в небольшой технической команде, которая занимается поддержкой этого инструмента.

На самом деле, это нельзя назвать новым релизом - во-первых, tramvai развивается уже много лет, во-вторых, в рамках постепенной миграции фреймворка в open source, мы настроили зеркалирование всех изменений кодовой базы на Github, но продолжаем вести разработку внутри компании.

В open source уже существует несколько интересных SSR фреймворков (если говорить про React экосистему): Next.js, Fusion.js от Uber, Umi.js, Remix от создателей react-router.
Отдельно хочу отметить Next.js, как самый активно развивающийся и передовой инструмент.
Мейнтейнеры Next, к примеру, в данный момент портируют используемые babel плагины на Rust, для интеграции swc и радикального улучшения Developer Experience!

Я бы хотел в серии постов рассказать об особенностях tramvai , которые считаю уникальными, и которыми продолжаю вдохновляться, и возможно немного о наших планах на будущее.

И конечно ссылочки:
- Next.js - https://github.com/vercel/next.js/
- Fusion.js - https://github.com/fusionjs/fusionjs
- Umi.js - https://github.com/umijs/umi
- Remix - https://remix.run/features
Одна из ключевых особенностей
tramvai
- Dependency Injection.
Встроенный механизм внедрения зависимостей позволяет собирать приложений из модулей как конструктор из кубиков, и при хорошем проектировании модулей, открывает потрясающие возможности для расширения или замены возможностей в нашем приложении.

Dependency Injection все активнее используется в мире фронтенда, но как мне кажется, ассоциируется только с Angular или Nest.js.
При этом увидеть использование DI контейнеров в React приложениях можно гораздо реже.
Учитывая активное развитие SSR приложений, меня даже немного удивляет, что этот паттерн не получил широкое распространение, далее постараюсь объяснить почему.

Несколько лет назад я занимался разработкой базовой инфраструктуры для миграции PHP приложения (с классической связкой - шаблонизатор на сервере и React на клиенте) на универсальное React приложения (SSR на NodeJS), в онлайн кинотеатре ivi.ru.
Начиная запускать код существующего клиентского JS приложения на сервере, сталкиваешься с рядом проблем, основная из которых - "универсальность" кода, т.е. насколько код сильно завязан на окружение, в котором будет выполняться.
Т.е. все обращения к
location
, работа с
localStorage
, чтение
cookies
, не должны происходить на сервере, либо должны содержать проверки на окружение, и например читать куки не из
document.cookies
, а из
req.cookies
, если мы используем
express
на сервере.

Как раз в это время я читал замечательную книгу "Шаблоны проектирования Node.JS", в которой познакомился с Dependency Injection и его реализациями в виде DI container или Service Locator. К слову, раньше мне очень трудно давалось понимание этого паттерна)
В какой-то момент в голове сложились все паззлы, и пришло понимание что DI дает нам очень удобную абстракцию для универсального кода, и лучше всего рассмотреть это на примере работы с
cookies
.

Для начала, пример итогового использования сервиса:

// создадим общий контейнер зависимостей
const di = createContainer();

// опишем интерфейс сервиса для работы с куками
interface CookieService {
get(key): string;
set(key, value, options): void;
}

// React компонент, который завязан только на интерфейс, но не на реализацию
const Component = ({ cookieKey }) => {
// не важно, на сервере или на клиенте будет отрендерен компонент,
// главное что бы в di была подходящая реализация сервиса
const cookieService = useDI<CookieService>('cookie service');

const cookieValue = cookieService.get(cookieKey);

return ...;
}

Далее, напишем реализации интерфейса CookieService:

// серверная реализация, работает с объектом Request
class ServerCookieService implements CookieService {
constructor(request: Request) {}
get(key) { ... }
set(key, value, options) { ... }
}

// серверная реализация, работает с document.cookie
class ClientCookieService implements CookieService {
constructor() {}
get(key) { ... }
set(key, value, options) { ... }
}

И остается только зарегистрировать нужные реализации в подходящем окружении:

// server.js
// получаем зависимость
const request = di.get('request');
// регистрируем зависимость на сервере
di.provide('cookie service', new ServerCookieService(request));

// client.js
// регистрируем зависимость на клиенте
di.provide('cookie service', new ClientCookieService());

Таким образом мы можем практически полностью избавиться от постоянных проверок в коде на текущее окружение.
Также, мы можем изолировать DI на разные запросы в приложение, и использовать уникальные Request и Response, создавая отдельный DI контейнер на каждый запрос.

Другая фича, которую дает DI, и которая реализована в Angular, Nest.js и tramvai - это возможность расширения приложения отдельными модулями, дополняющими приложение конкретным функционалом с помощью набора токенов и провайдеров (интерфейсы и реализации зависимостей).
Если посмотреть на другие фронтенд фреймворки, можно увидеть разные механизмы для расширения кода - системы плагинов, огромные файлы конфигураци, или паттерн middleware.
На мой взгляд, модульный подход дает максимальную гибкость, и позволяет сделать минимальное зацепление кода у различных модулей.

Подробнее про реализацию DI в tramvai можно почитать в документации - https://tramvai.dev/docs/concepts/overview

И ссылочки на различные DI контейнеры:
- https://github.com/inversify/InversifyJS
- https://github.com/mgechev/injection-js
- https://github.com/microsoft/tsyringe
- https://github.com/typestack/typedi

Многие из этих библиотек используют механизм рефлексии, позволяющий делать магию с DI - https://www.npmjs.com/package/reflect-metadata

Рефлексия имеет ряд плюсов и недостатков, и на данный момент не используется в tramvai.
Привет!

Хочу рассказать еще про одну особенность tramvai, связанную с жизненным циклом приложения, и запросами в это приложение.

Для начала рассмотрим несколько популярных вариантов, как запускаются и конфигурируются стандартные приложения, написанные на Express или React.

Если это express сервер, обычно создается одна точка входа, где создается приложение, к нему добавляются роуты и middleware, и запускается сервер через app.listen

Если это React приложение, мы так же в большинстве случаев увидим одну точку входа, где происходит инициализация стейт-менеджера, композиция провайдеров, и создание App компонента. Если это SSR, то в серверной реализации App дополнительно будет логика по загрузке всех данных для приложения, а в клиентской реализации - гидрация этих данных.

И это подходит для многих приложений, но при этом несет ряд проблем, при росте кодовой базы:

- Добавление новой независимой фичи - со своими страницами, сторами, провайдерами - потребует вносить изменения в несколько разных мест
- Не связанный код зацеплен друг с другом
- Если мы говорим про middleware, не связанный код может еще и ломать друг друга, подробнее в классном докладе - https://www.youtube.com/watch?v=RS8x73z4csI
- Сайд-эффекты могут быть размазаны по всему приложению, и для серверного рендеринга, нужно постараться, что бы выполнить все асинхронные действия до renderToString нашего приложения

А если в нашем SSR приложении мы хотим рендерить страницу как можно быстрее?
Для этого нужно запускать асинхронные действия максимально параллельно, и иметь возможность исключить действия, выходящие за определенный таймаут.
Затем, было бы неплохо выполнить эти сайд-эффекты, которые не уложились в тайминги, повторно на клиенте.
Звучит как не тривиальная задача, и некоторые из фреймворков пытаются решить ее тем или иным способом, например Appolo просто рендерит наше приложение на сервере несколько раз, пока не выполнит все query - и это очень не эффективно, т.к. renderToString это самая медленная часть нашего SSR, к тому же синхронно блокирующий event loop.

Если обобщить, помимо производительности, страдает расширяемость и модульность приложения.
Что интересного предлагает tramvai, для решения этих кейсов?

С помощью Dependency Injection и механизма Command Line Chain, фреймворк дает возможность вклиниться на ряд этапов жизненного цикла приложения:

- Различные этапы инициализации веб-сервера (т.к. мы используем express под капотом, можно добавить express middleware или роуты)
- Различные этапы запуска listen сервера (можно добавить обработчики uncaughtException и unhandledRejection , запустить на другом порту мокер, или static сервер)
- Этапы для загрузки данных, необходимых для рендеринга страницы (один из них как раз создан для сайд-эффектов)
- Этап генерации страницы (на нем сервер делает renderToString, а клиент hydrate)
- Этапы для загрузки данных при SPA-переходах

Механизм Command Line Chain во-первых, предоставляет наглядный флоу жизни приложения и запросов.
Во-вторых, выполняет все действия параллельно, на каждом этапе.
Во-вторых, вместе с механизмом Actions, позволяет установить таймауты на выполнение действий, отдать ответ на клиент не дожидаясь этих действий, и выполнить их повторно на клиенте.

Пример добавления нового действия на этап получения информации о пользователе, и документация - https://coretech-frontend.pages.devplatform.tcsbank.ru/tramvai/docs/concepts/command-line-runner#resolve_user_deps


// загружаем в store данные, необходимые для последующего рендеринга страницы
{
provide: commandLineListTokens.resolveUserDeps,
multi: true,
useFactory: ({ store }) => {
return async function updateCounterStore() {
await someAsyncAction();
store.dispatch(incrementEvent());
};
},
deps: {
store: STORE_TOKEN,
},
}


При необходимости, с помощью провайдеров можно предоставлять хуки и guard'ы для переходов роутера, например для проверки доступов пользователя к конкретной странице - https://tramvai.dev/docs/references/libs/router#router-guards

Также, есть провайдеры, позволяющие добавить скрипты, стили или meta-теги в HTML разметку итоговой страницы.

Пример такого провайдера, и документация - https://coretech-frontend.pages.devplatform.tcsbank.ru/tramvai/docs/references/modules/render#как-добавить-загрузку-ассетов-на-странице


// регистрируем meta viewport, который будет добавлятся на каждую страницу
{
provide: RENDER_SLOTS,
multi: true,
useValue: {
type: ResourceType.asIs,
slot: ResourceSlot.HEAD_META,
payload:
'<meta name="viewport" content="width=device-width, initial-scale=1">',
},
}


Такая гибкость дает нам возможность создавать отдельные модули, которые по сути являются списком провайдеров, вместе реализующих определенную фичу, и подключать эти модули простым добавлением в список modules при создании нашего приложения.
Привет!

В ближайших постах хочу рассказать про устройство монорепозитория tramvai, и про решение зеркалировать публичный код на Github, вместо полной миграции.

Наш приватный репозиторий содержит около 150 npm библиотек, инфраструктуру для сборки, тестирования и публикации этих пакетов, сайт документации и различные утилиты.

Версиями пакетов, публикациями и генерацией ченджлогов управляет внутренняя разработка Тинькофф - библиотека pvm.
На каждый мерж в master ветку, pvm определяет какие пакеты были изменены, поднимает их версии согласно conventional commits, и создает релизный тег, где и хранятся все версии.
На каждый релизный тег запускается пайплайн публикации пакетов в приватный npm registry, и одновременно зеркалирование публичного кода на Github.

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

Сквозное версионирование используется для всех пакетов, имеющий непосредственное отношение к фреймворку (cli, ядро фреймворка, многочисленные модули), этот термин означает что все unified пакеты общую версию, и должны обновляться одновременно.
Такой подход вы можете увидеть у Angular , и с некоторыми ограничениями, в монорепозиториях использующих Lerna - https://github.com/lerna/lerna#fixedlocked-mode-default

Основной плюс unified версионирования - гарантируется совместимость между пакетами одной версии. Это очень крутая возможность, т.к. раньше у пользователя был только один простой вариант поднять версию фреймворка, не потеряв совместимость - устанавливать все пакеты latest версии.
Один из минусов подхода - любое обновление пакета из списка unified, требует поднять версии и опубликовать все эти пакеты из списка, что значительно замедляет CI.

Для удобной работы со сквозными версиями tramvai предлагает два механизма:

- cli команда tramvai update для легкого обновления версий
- утилита @tramvai/tools-check-versions, которая поставляется вместе с core пакетом, и на этапе postinstall проверяет версии установленных зависимостей в приложении.

Хранение версий в релизных тегах само по себе не дает преимуществ, и у нас используется вместе со stub версиями пакетов в исходных package.json файлах.

Допустим, у нас был пакет с зависимостями:

{
"name": "@tramvai/foo",
"version": "0.1.0",
"dependencies": {
"@tramvai/bar": "^1.1.0",
"@tramvai/baz": "^2.0.0"
}
}

Раньше, каждый крупный Merge Request сопровождался конфликтами, если в master ветке обновлялась версии пакетов , а затронутые библиотеки в MR содержали изменения в dependencies.

Теперь, наш пакет выглядит так:
 
{
"name": "@tramvai/foo",
"version": "0.0.0-stub",
"dependencies": {
"@tramvai/bar": "0.0.0-stub",
"@tramvai/baz": "0.0.0-stub"
}
}

Версия 0.0.0-stub никогда не вызовет конфликтов слияния, а вычисление реальных версий происходит только в CI - при создании нового релизного тега и публикации, внутри библиотеки pvm.
Раньше я не встречался с таким подходом, и в целом не работал с монорепозиториями, но на своем опыте увидел, как это упростило разработку.

Также, для монорепы мы используем yarn workspaces , и воркспейсы замечательно работают со stub версиями.

На Github репозитории, для публикации кода в npm, версии пакетов поставляются в файле https://github.com/TinkoffCreditSystems/tramvai/blob/main/packages-versions.json
Про development и production сборку пакетов.

Все tramvai библиотеки написаны на Typescript, и комфортная разработка требует тщательной настройки инфраструктуры монорепозитория.

Для локальной разработки, мы используем tsc и Project References - https://www.typescriptlang.org/docs/handbook/project-references.html, в этой связке watch режим имеет минимальное время старта, и очень быструю пересборку.
Project References дают возможность одной командой запускать сборку всех пакетов в репозитории, и при этом делать пересборку только измененного кода.
Также, при пересборке учитываются, кто зависит от изменившегося пакета, и выполняется пересборка итогового графа зависимостей - это позволяет сразу увидеть, как изменения одного пакета могут поломать другие.
Минимальное время старта команды tsc --build --watch достигается за счет кэширования информации о сборке каждого пакета.

К сожалению, Typescript не дает инструмента для автоматической генерации Project References и поддержки их в актуальном состоянии - поэтому в tramvai монорепе используется самописная утилита @tramvai-monorepo/fix-ts-references

Вторая проблема Project References - это отсутствие сгенерированных .d.ts файлов при первой сборке, и возможные ошибки этой сборки.
Мы решаем эту проблему так:

- В package.json каждого пакета поле typings смотрит на исходные .ts файлы в папке src
- Перед публикацией, все поля typings заменяются на собранные .d.ts файлы из папки lib

Для эффективной production сборки библиотек мы используем rollup, поверх которого написана утилита @tramvai/build - https://tramvai.dev/docs/references/tools/build

@tramvai/build собирает код каждого пакета в несколько общих бандлов:

- es2019 код с CommonJS модулями для запуска через старые версии NodeJS, бандл будет указан в "main" поле в package.json
- es2019 код с ES modules для современных бандлеров (@tramvai/cli использует webpack@5), бандл будет указан в "module" поле в package.json
- И если для пакета указано поле browser в package.json, собирается отдельный es2019 код с ES modules, содержащий код для браузерного окружения

Таким образом, на пакет, который указывает отдельные точки входа для серверной и клиентской сборки, сгенерируется три бандла - server.js, server.es.js и browser.js

Спецификацию поля browser можно посмотреть тут - https://github.com/defunctzombie/package-browser-field-spec

Почему мы публикуем код в es2019, вместо es5?

Фронтенд библиотеки в Тинькофф (в части проектов, в основном это React экосистема) следуют принципу, когда публикуются только современный JavaScript код, а разработчики приложений транспилируют зависимости в подходящий стандарт кода.

Немного мотивации к публикации modern кода:

- https://web.dev/publish-modern-javascript/
- https://github.com/sindresorhus/ama/issues/446
- https://dev.to/garylchew/bringing-modern-javascript-to-libraries-432c
- https://babeljs.io/blog/2018/06/26/on-consuming-and-publishing-es2015+-packages

@tramvai/cli собирает бандлы приложения по такой логике:

- Общий server.js бандл с серверным кодом, содержащий все зависимости, и код стандарта es5.
И
нтересный факт, эксперименты с использованием es2019/es2020 кода на сервере показали ухудшение производительности примерно на 10%!

- Для клиентского кода у нас есть два набора browserslist конфигов - default и modern, и es2019 код библиотек транспилируется в формат для целевых браузеров с помощью babel, генерируются отдельные чанки с default и modern кодом, и затем для браузера выбираются нужные через проверку User-Agent
Вт
орой интересный факт, modern сборка может весить значительно меньше default, но как и в случае с серверным кодом, может работать медленнее.
Кажется это должно улучшаться в лучшую сторону с каждым обновлением существующих JS движков.

В итоге, мы получили неплохой Developer Experience при разработке в монорепе, и поставляем код библиотек минимального размера, с поддержкой tree-shaking и различных target окружений, для наших пользователей.
Добавлю парочку полезных ссылок:

Топовые советы в Wiki Typescript по ускорению сборки, об этой страничке я узнал в канале @wild_wild_web - https://github.com/microsoft/TypeScript/wiki/Performance

Например, совет по ограничению поля "types" почти в два раза ускорил нашу production сборку - https://github.com/microsoft/TypeScript/wiki/Performance#controlling-types-inclusion
Проблема была в том, что монорепозиторий содержит огромное количество тайпингов в зависимостях, и для транспиляции даже файла с одной строчкой кода, TS тратил очень много времени на резолв этих тайпингов, из которых реально нужные, без прямого импорта - это node и jest

Утилита для сборки пакетов microbundle, наш референс при разработке @tramvai/build - https://github.com/developit/microbundle

И мощный фреймворк для организации монорепозиториев NX - https://nx.dev/
Это достаточно сложный для освоения и интеграции инструмент, но потенциально может в разы ускорять CI процессы в больших монорепозиториях.
Пример работы инструмента Nx Cloud, с распределенным выполнением задач и кэшированием - https://www.youtube.com/watch?v=Exs64pscwxA&ab_channel=Nrwl-NarwhalTechnologiesInc.
👍1
Привет!

Про выход tramvai в open source.

Зачем это нужна для команды разработчиков, и для Тинкофф в целом?

Одна из важнейших вещей, это еще одно громкое заявление - "В Тинькофф крутой фронтенд!"
Разработчики Тинькофф делают все больше отличных open source проектов, пишут все больше интересных статей, выступают на конференциях - из таких активностей формируется образ компании, привлекательный для новых разработчиков.
Мы активно нанимаем коллег, и у нас классные интервьюеры 😉

Для мейнтейнеров tramvai, это новый источник вдохновения для развития фреймворка, возможность получить свежий взгляд со стороны.

И один из плюсов, это быстрое прототипирование с помощью JS песочниц, недавно мы добавили базовый шаблон трамвая на Codesandbox - https://codesandbox.io/s/tramvai-new-qgk90

Далее, опишу ряд проблем, которые могут стоять на пути у проприетарной разработки, усложняющие переход в OSS:

- Код, документация и история VCS могут содержать чувствительную информацию - самый простой пример, это ссылки на внутренние ресурсы
- Часть пакетов подходит для OSS, часть предназначена только для внутреннего использования
- Пакеты публикуются в приватный регистр пакетов
- Проект использует приватный CI
- Отсутствут локализация документации

Таким образом, полный переход Github требует отделить публичную часть проекта, перенести код без сохранения истории коммитов, настроить в Github Actions линтинг, unit и интеграционное тестирование, версионирование и публикацию пакетов, перевод, сборку и деплой документации.

Например, такой переход осуществил огненный Angular ui-kit от наших коллег - https://taiga-ui.dev/
Taiga содержит ряд проприетарных компонентов в приватном репозитории внутри Тинькофф, и приватный репозитрий использует публичный с помощью git submodules.

Почему этот путь не подошел для tramvai:

- Теряется история коммитов - это важный источник информации для разработчика
- Проект имеет достаточно сложный CI, и в версионировании и публикации пакетов участвует другая внутренняя разработка Тинькофф, о которой я писал в одном из предыдущих постов
- Все tramvai пакеты объединены сквозным версионированием, отделение публичных означает, что два набора пакетов разойдутся в версиях, и вернутся проблемы, связанные с обновлением зависимостей
- Не удобно делать глобальный рефакторинг, который затрагивает все пакеты

Каждая из этих проблем кажется неизбежным злом, но вместе порождают очень много сложностей, которые мы постарались решить инкрементальным выходом в OSS.
Первый этап, на котором сейчас находится проект - это зеркалирование публичного кода на Github, при этом вся разработка ведется в приватном репозитории.

На этапе исследования, мы нашли мощный инструмент, созданный для аналогичных целей внутри Google, copybara - https://github.com/google/copybara.
При чем, кажется это вторая или третья итерация по созданию такого инструмента от разработчиков Google.

Кстати, для copybara есть гибкая обвязка под Github Actions - https://github.com/Olivr/copybara-action и публичный Docker образ - https://github.com/anipos/copybara-docker-image

Каждый релиз трамвая, запускается джоба с генерацией конфига и запуском copybara.
Конфиг содержит список публичных файлов и трансформации для кода - например, замена ссылок приватной документации на https://tramvai.dev

Copybara проверяет изменения с последнего коммита в Github репе, схлопывает все новые коммиты в один релизный коммит, и пушить его на Github.

Затем в Github Actions запускается сборка и деплой документации, и сборки и публикация пакетов в npm.

Я несколько раз накосячил с публикацией пакетов и откатывал их, оказалось, что npm запрещает публикацию удаленных пакетов в течение 24 часов, и запрещает публиковать ту же версию, что была удалена 😥

По итогу, мы всегда имеем актуальный код на Github, наш фреймворк доступен для публичного использования, и при этом наши процессы разработки не поменялись.

Следующий шаг миграции в OSS - это зеркалирование MR от контрибьюторов из Github в наш приватный репозиторий, на данный момент мы можем делать это только вручную.

Двусторонняя синхронизация репозиториев это потенциальный источник проблем, поэтому мы не спешим с переходом на этот этап.

В будущем хочется полностью перейти на Github, отказавшись от синхронизации репозиториев.
😁1
Привет!

Хочу рассказать про одно не законченное исследование, и возможно таким образом дать себе инерцию завершить это дело)

Фронтенд экосистема Тинькофф состоит из двух мощных экосистем - Angular и React, примерно 100+ разработчиков на каждый фреймворк.

Для каждой экосистемы разработан и поддерживается свой ui-kit - один для React, и второй для Angular.

Целью исследования было проверить, можно ли создать функциональный фреймворк-агностик ui-kit на одной технологии, и эффективно переиспользовать его в каждом нашем фреймворке.

Спойлер - реализации прототипа только на одной технологии мне хватило для вывода, что реализовать такой kit, не потеряв при его использовании developer experience, сравнительно с нативным китом, невозможно, или крайне сложно, но мне по прежнему очень интересно, чего можно добиться с различными технологиями.

Начнем со списка возможностей, который дает нам React UI-kit в React приложении:

- Можно передавать свойства в компонент
- Обновление свойств приведет к обновлению DOM
- В компонент можно вкладывать другие компоненты (композиция с помощью children и render props)
- Доступ к context приложения
- Поддержка SSR

Начать исследования я решил со знакомых, и на первый взгляд максимально близкий друг к другу технологий - React приложение и Preact UI-kit.

Другие потенциальные технологии - Web Components (плюс один из фреймворков с поддержкой SSR), Svelte.

Для интеграции Preact компонентов (да и любых других в будущем) в React приложение я решил использовать концепцию адаптеров - такой адаптер принимает Preact компонент из кита, и возвращает React компонент, готовый к использованию в приложении.
Код адаптера доступен тут - https://github.com/SuperOleg39/agnostik-kit/blob/master/adapters/preact-to-react/src/index.js

Для проверки возможностей, заранее создал адаптер-пустышку, обернул в него компоненты, и написал в приложении основные кейсы - с передачей пропсов, с коллбэками, пробросом в children текста, React компонентов, и других Preact компонентов, обернутых в адаптер.
Кейсы можно посмотреть тут - https://github.com/SuperOleg39/agnostik-kit/blob/master/apps/react-app/src/App.js

Рассмотрим подробнее реализацию адаптера.

На серверной стороне, мы должны отрендерить Preact компонент в строку c помощью preact-render-to-string, до рендеринга в строку нашего React приложения.

Вспомним требование про композицию - в children могут быть React компоненты, которые заранее надо отрендерить в строку, и так же вставить в китовый компонент.
React компоненты могут содержать другие PreactAdapter компоненты, так что по сути это рекурсивный процесс.

Оказывается, что механизм добавления произвольной разметки в React и Preact одинаковый - dangerouslySetInnerHTML: { __html: string }, правда нам потребуется создавать лишний тег - враппер, который и будет содержать эту разметку.

Это дает нам возможность композиции, в несколько этапов:

1. Делаем ReactDOM.renderToString текущих children
2. Результат передаем в dangerouslySetInnerHTML Preact компонента
3. Делаем Preact.renderToString этого компонента
4. Результат передаем в dangerouslySetInnerHTML враппера

В клиентском коде, в этот враппер необходимо отрендерить Preact компонент, а при наличии children, сделать их гидрацию (помним, что там будут React компоненты), используя ref на китовый компонент в качестве root.

Также, необходимо делать повторный рендеринг Preact компонента, при каждом изменении props.

Такой небольшой адаптер покрывает большую часть полноценной интеграции, и мы уже можем увидеть ряд недостатков:

- Теряем react context для вложенных React компонентов
- DOM дерево разрастается из-за врапперов для dangerouslySetInnerHTML
- Нарушаем флоу работы React - вместо стандартного рендеринга дерева компонентов от самого вложенного, к родителю, и общего маунта, мы рендерим компоненты в адаптере сверху вниз, и маунтим по цепочке.
В теории, при больших поддеревьях компонентов, можно увидеть значительные задержки рендеринга.
- Нужно писать надежный механизм перерендеринга китового компонента
Взвесив все эти минусы интеграции таких близких на первый взгляд технологий, решили не продолжать исследования, но идея попробовать другие технологии осталась.

Через несколько месяцев, мне попалась статья на habr про создание общего UI-kit для Vue и React с помощью Stencil - https://habr.com/ru/company/uchi_ru/blog/543308/

Веб-компоненты в связке со Stencil кажутся перспективными для фреймворк агностик кита, из-за флоу инициализации и работы на сервере и клиенте - рендеринг web components происходит поверх уже отрендеренного приложения, что в теории позволяет сохранить общее дерево нашего React приложения.
А внешняя схожесть React и Preact на самом деле никак не упрощает интеграцию, из-за завязки рендеринга фреймворка на конкретный DOM узел.

Также, у Stencil существует интеграция для React - https://stenciljs.com/docs/react

Про не завершенную, но интересную попытку интегрировать Stencil постараюсь рассказать в следующем посте.
Привет!

Продолжаю тему создания фреймворк-агностик UI-kit, с помощью веб-компонентов и Stencil - https://stenciljs.com/

Почему нам вообще нужно использовать какой-то фреймворк для веб-компонентов?
Для этого есть ряд причин, вот несколько самых важных:

- web components не поддерживают server-side rendering
- механизм обновления DOM должен реализовывать разработчик
- много шаблонного кода

Stencil решает эти проблемы, предоставляя поддержку SSR, vDOM + реактивные биндинги, и возможность использовать JSX

Фреймворк Stencil дает много возможностей, хотя оказался для меня достаточно не комфортным в интеграции - дело в том, что аналогично Svelte, Stencil это компилируемый фреймворк, и например создавая библиотеку компонентов, перед публикацией мы должны генерировать код для рендеринга этих компонентов, который будет использовать наше приложение.

Отдельная боль - это не прозрачная конфигурация, и отсутствие рецептов в документации, как интегрировать Stencil, например новый UI-kit, в уже существующую монорепу.
Пока писал этот пост, обнаружил что во время предыдущего исследования сделал не корректную интеграцию, генерируя Stencil код не в библиотеку с компонентами, а в само приложение - https://github.com/SuperOleg39/agnostik-kit/pull/1

Нашел на гитхабе хороший пример интеграции Stencil и NextJS - https://github.com/jagreehal/nextjs-stenciljs-ssr-example, его и буду использовать для нового исследования.

Вот так выглядит рендеринг в строку на сервере:


// под капотом у renderToHTML - ReactDOM.renderToString
const html = await app.renderToHTML(req, res, req.path, req.query);

// рендерим Stencil поверх результата рендеринга ReactDOM
const renderedHtml = await stencil.renderToString(html);


И гидрация на клиенте:


const {
applyPolyfills,
defineCustomElements
} = require("stencil-web-components/loader");

// все это происходит после ReactDOM.hydrate
applyPolyfills().then(() => {
defineCustomElements(window);
});


Пример достаточно старый, и не использует официальные биндинги для React - https://stenciljs.com/docs/react, вместо этого используется хук useCustomElement

Сгенерировал биндинги в этом PR - https://github.com/SuperOleg39/nextjs-stenciljs-ssr-example/pull/1, большая часть кода - генерируется Stencil.

Интеграция React биндингов тоже крайне не привычна - мы должны создать пакет - "пустышку" для будущих React компонентов, но при этом компилировать его содержимое будет наш пакет с web компонентами, везде сложно понять, что надо сохранять в git, а что нужно только для публикации.

Еще заметил минус у биндингов - нет поддержки SSR при генерации React компонентов, и отсутствует нужный рецепт в документации, по итогу надо использовать оба пакета - с web-компонентами и с React биндингами.

Краткое резюме, после базовой интеграции Stencil с React и NextJS:
сама интеграция - достаточно проблемная, но мы получили типизированные React компоненты и возможность передавать в свойста веб-компонентов children, функции и объекты.
children можно передавать, если в веб-компоненте используется <slot />

Далее, я начал проверять, как ведут себя children в веб-компоненте, и обнаружил, что Stencil по умолчанию не использует shadow DOM, и все веб-компоненты, используемые как React биндинги, сломаны на этапе ReactDOM.hydrate - для исправления проблемы нужно включать shadow DOM вручную для каждого Stencil компонента, через shadow: true, немного о проблеме в этой статье - https://leechy.dev/hide-stencil-children

Кстати, в React биндингах Stencil есть хак - defineCustomElements вызывается сразу, т.е. до ReactDOM.hydrate - а React уже получает shadow DOM при гидрации, и не модифицирует его.

Затем, я провел ряд экспериментов, где в web-component вставлял React ноды, другие web-components, PR с тестовыми кейсами можно посмотреть тут (файл `a.tsx`) - https://github.com/SuperOleg39/nextjs-stenciljs-ssr-example/pull/2/files
Я был настроен скептически, но все тесты показали отличный результат:

- Сам механизм интеграции веб-компонентов устроен так, что React context не теряется
- Рендеринг на сервере и гидрация - работают корректно
- React прекрасно перерендеривает children, которые веб-компоненты рендерят через <slot />, не ломая разметку веб-компонента
- Веб-компоненты прекрасно обрабатывают изменение локального состояния и shadow DOM, не ломая управляемую React`ом разметку, которая приходит через children
- Многократная вложенность React и веб-компонентов друг в друга ничего не ломают

Все работает корректно, если я все правильно понял, т.к. React управляет только разметкой в template, а веб-компоненты сразу рендерят эти template в slot, т.е. области ответственности фреймворков не пересекаются.

Завершая исследование, могу сказать что web-components действительно отлично подходят для создания компонентов, которые могут быть использованы в любом фреймворке.
Мейнтейнеры Stencil проделали огромную работу, и фреймворк дает нам возможность использовать компоненты в SSR React приложении, без каких-то серьезных ограничений в developer experience.
Для SPA приложений, интеграция веб-компонентов тривиальна, и может даже не требовать отдельных фреймворков вроде Stencil или lit-element для простых случаев.

Какие вопросы у меня остаются к Stencil:

- Документация и интеграции - очень много открытых вопросов, интеграция с React рассмотрена поверхностно
- Размер генерируемого кода - для нескольких компонентов, Stencil сгенерировал 400кб исходного кода. Сделал анализа бандла, импорт и использование Stencil компонента из нашего UI-kit добавляет примерно 45кб gzip кода.
Определенно, такое маленькое приложение на Svelte сгенерировало бы гораздо меньше кода, это примерно размер react + react-dom
- Производительность рендеринга - сделал поверхностные замеры, похоже и на сервере и на клиенте все быстро - но приложение слишком небольшое
- Необходимость в React биндингах - очень уж не очевидный механизм для их генерации
Интересная статья про минусы Stencil - https://www.abeautifulsite.net/posts/moving-from-stencil-to-lit-element/

Но похоже это единственное хорошее решение с поддержкой SSR, в lit это только в экспериментах/планах - https://www.polymer-project.org/blog/2020-09-22-lit-element-and-lit-html-next-preview и https://github.com/lit/lit/tree/main/packages/labs/ssr