Эволюция плеера RUTUBE: от монолита к гибким модулям
- пятница, 19 декабря 2025 г. в 00:00:04
Принимая архитектурные решения, часто так или иначе приходится идти на компромисс: между качеством и скоростью разработки, сложностью реализации и удобством поддержки, быстротой решения бизнес-задачи и гибкостью. Со временем небольшие уступки накапливаются, и проект покрывается легаси. Даже если исправно разгребать техдолг, то на достаточно длинной дистанции решения и технологии в любом случае устареют, и станет невозможно обойтись «генеральной уборкой» — потребуется смена архитектуры.
В статье расскажу, как мы столкнулись с неизбежной необходимостью переделки веб-плеера RUTUBE — сервиса, который существует с 2006 года, пережил несколько смен команд и парадигм разработки и при этом достаточно большой и высоконагруженный, чтобы нельзя было «просто так взять и всё переписать».

Меня зовут Паша Фомин, я лид платформенной команды плеера RUTUBE. Я пришёл в компанию в 2022-м году, тогда код веб-плеера представлял из себя кучу легаси, а его архитектура была в таком состоянии, что мешала развивать продукт и реализовывать бизнес-цели.
Основными легаси-проблемами в коде плеера состояли в следующем:
2500 строк кода в главном управляющем компоненте;
дублирование состояний в Redux и в теге video;
анти-паттерн props drilling через 5+ компонентов;
огромное количество связных useEffect'ов — более 50.
Но прежде, чем обсуждать, как мы с этими проблемами боролись, давайте коротко разберём, как работает видео в вебе и зачем вообще нужна какая-то сложная архитектура.
Кажется, есть же <video>, берешь его и готово — в браузере можно воспроизводить видеофайлы:
<video
Id="player"
src="video.mp4"
controls
autoplay
poster="preview.jpg"
></video>Основные атрибуты тега <video>:
src — источник видео;
controls — показать или спрятать элементы управления;
autoplay — включить или выключить автоматическое воспроизведение;
poster — постер, который будет показываться до воспроизведения видео.
Также у тега <video> довольно развитый API, в котором, например, есть такие полезные методы и свойства:
play() — начать воспроизведение;
pause() — приостановить;
currentTime — текущая позиция, которую можно получить или выставить;
volume — управление громкостью;
duration — длительность видео;
requestFullscreen() — переход в фуллскрин.
Естественно, это неполный список, и на первый взгляд, этого в целом достаточно, чтобы работать с видео в вебе. Однако у тега <video> есть существенные ограничения, главные из которых для нас состоят в следующем:
отсутствует гибкое управление буфером, не получится контролировать расход трафика;
невозможно добиться бесшовного переключения качеств, поскольку <video> подгружает статический файл и для смены разрешения надо напрямую поменять ссылку на видео и перезагрузить страницу;
невозможно проигрывать прямые трансляции;
нет гибкой настройки UI.
Решение большинства из этих проблем — потоковое видео.
В нашем контексте воспроизведение потокового видео означает, что вместо скачивания всего файла клиент получает его потоком (на самом деле, конечно, небольшими фрагментами — чанками).
Нативная поддержка потокового видео есть только у Safari, а для воспроизведения потокового видео в остальных случаях есть два основных решения: DASH (технология разработана в рамках группы MPEG) и HLS (протокол, созданный Apple). Для обоих есть соответствующие JS-библиотеки: HLS.js и dash.js.
Рассмотрим на примере HLS, что из себя представляет поток и как с ним работает плеер.
Клиент, вместо того чтобы загрузить статический видеофайл, получает манифест с информацией о доступных качествах видео и расположении файлов/чанков. В стандарте HLS для организации потоковой передачи требуется мастер-манифест и контентный манифест. Ниже фрагмент мастер-манифеста:

В мастер-манифесте содержится метаинформация о видео, а также список ссылок на доступные потоки в разных качествах — контентные манифесты, в которых уже содержится информация о местоположении нужных чанков.
Это фрагмент контентного HLS-манифеста с метаинформацией и ссылками на чанки с их длительностью:

DASH устроен похожим образом, но там манифест один и в нём сразу есть ссылки на качества.
Библиотеки HLS.js и dash.js, кроме того, что позволяют делать запросы за манифестами и чанками, занимаются следующим:
работают с буфером с помощью MediaSource API;
позволяют реализовать контроль ошибок (воспроизведения, сетевых и других);
ABR — Adoptive Bitrate, то есть подстройкой качества в зависимости от пропускной способности сети;
контролируют загрузку фрагментов;
реализуют DRM Module — модуль для работы с лицензионным контентом, который находится под защитой Digital Right Management и другое.
Кроме собственно воспроизведения видео плеер RUTUBE (и плеер любой другой платформы) имеет довольно богатую функциональность и развитый UI. В частности, плеер должен уметь:
Воспроизводить рекламу, для чего нужно остановить показ видео, инициализировать рекламный движок, скачать рекламный креатив, проиграть его и вернуться к видео.
Логировать события статистики и отправлять соответствующие сообщения, если произошла ошибка.
Работать через внешний API, то есть обрабатывать колбеки для внешних пользователей нашего плеера, который может эмбедироваться через iframe.
Показывать субтитры, что кажется простым, но тоже скрывает за собой разветвлённую логику, потому что у нас может быть несколько файлов с субтитрами (например, русские и английские) — нужно организовать выбор языка. Файл субтитров может быть в формате .srt или .vtt, но браузер умеет нативно работать только со вторым. А в плане UI нативной стилизации нам недостаточно, поэтому надо самим рендерить и стилизовать субтитры.
Применять настройки, например, язык цвет и размер упомянутых выше субтитров, то есть модуль субтитров должен быть взаимосвязан с модулем настроек.
Виджеты рекомендаций или следующих серий в серийном контенте: во время паузы, в конце видео и т.д. Чтобы пользователю было удобно продолжить просмотр.

Получается, что несмотря на то что у плеера, конечно, есть специфика работы с видео, плеер — это тоже фронтенд. И мы сталкиваемся с теми же самыми проблемами и задачами, которые есть в любом сложном фронтенде. Поэтому давайте вернёмся к проблемам, которые были у нас в старом плеере, и обсудим способы с ними справиться.
Проблема номер один — ≈ 2500 строчек кода в одном файле. И это не шаблонный код, он действительно реализует бизнес-функциональность. Вот скриншот для подтверждения:

Следующая связанная проблема — props drilling или пробрасывание пропсов. Например, обработчик HandlePlay путешествовал сквозь компоненты на пять уровней вложенности: raichu-wrapper.tsx → raichu-player-ui.tsx → raichu-video-player.tsx → raichu-player-controls.tsx → raichu-player-left-controls.tsx.
Чем это плохо? Тем, что увеличивается связность кода и становится всё сложнее вносить изменения. На приведённом примере: внося изменения в какой-то из этих пяти компонентов, надо иметь в виду, что обработчик HandlePlay может понадобиться где-то на следующих уровнях, нигде не потерять эту взаимосвязь и не допустить нарушения работы функции. Согласитесь, звучит как место для потенциальной ошибки и усложнение работы.
Огромное количество useEffect’ов также не помогало поддержанию порядка. Ниже пример нескольких useEffect’ов, которые по цепочке запускают друг друга: меняется id видео → запрашиваем информацию по этому видео → получили из playOptions информацию про видео →… → еще что-то происходит, например, запускается реклама и т.д.

Это приводит к двум видам проблем. Во-первых, все связанные useEffect’ы находятся рядом только на скриншоте для статьи, а на самом деле они разбросаны по разным частям кода, и отлаживать что-то с их участием очень неудобно. Во-вторых, в таких сценариях сложно отслеживать состояние плеера. Плюс ко всему в таком клубке из useEffect’ов могут возникнуть циклические зависимости.
Но на самом деле мы справлялись! Мы пытались писать хороший изолированный модульный код и потихоньку где-то что-то рефакторить. Пока не появилась задача на плеер вертикальных видео.
Казалось бы: возьми горизонтальный плеер и переверни. К сожалению, горизонтальный и вертикальный плеер отличаются не только ориентацией, есть и другие различия:
Слишком большая разница в UI, контролы и бизнес-функциональность отличается (плеер вертикальных видео обычно проще).
Зато вертикальный плеер должен поддерживать не только потоковое видео, но и MP4.
Необходимо поддерживать несколько независимых инстансов плеера в ленте коротких видео, с чем Redux довольно плохо справился бы из-за глобального стора.
Необходимость интеграции с контентом от Yappy — сервисом коротких видео, который тоже входит в «Газпром-Медиа Холдинг».
Мы приняли решение писать отдельный плеер для вертикальных видео и сделали несколько архитектурных изменений, которые я далее раскрою подробнее:
переехали с Redux на MobX;
перешли с функционального подхода на ООП;
разбили архитектуру на «слои» (грубо говоря, это разбиение на папки по бизнес-функциональности, например, слой UI, слой статистики, слой playback и т.д.).
Протестировав решения на вертикальном плеере, мы поняли, что пора переписывать горизонтальный плеер, чтобы развязать себе руки в плане бизнесового развития и не утыкаться каждый раз в устаревшую архитектуру. Тем более, что набор интеграций должен был расширяться и с новой архитектурой мы смогли бы обеспечить доставку плеера различными способами: эмбедом, через iframe, с помощью npm-пакета или микрофронтендом.
Для новой архитектуры горизонтального плеера мы взяли за основу плеер вертикальных видео, но столкнулись с определенными нюансами. Не все решения, которые хорошо работали там, были оптимальными для основного более сложного и нагруженного плеера.
Отказались от слоёв в пользу плагинов. «Слои» не подходили для масштабирования. Они хорошо работали на не очень сложном проекте, но с ростом объема функций и кода деление стало бы менее логически однозначным и удобным.
Entrypoint'ы для кастомных сборок стали решением вопроса с быстрыми сборками и конфигурациями для разных проектов.
Внедрение dependency injection стало ответом на то, что с ростом количества классов стало сложнее поддерживать зависимости.
Мы переехали на MobX с Redux в первую очередь, чтобы избавиться от глобального стора и получить возможность работать с множественными инстансами. MobX это поддерживает из коробки, поскольку каждый экземпляр store в MobX представляет из себя просто обычный js-класс.
Во-вторых, с MobX мы получили декларативную реактивность вместо императивного диспатча. В Redux изменение состояний обрабатывается через action, reducer и dispatch. При этом всегда создается новый корневой state-объект, изменения пишутся в store, React пересчитывает все подключенные компоненты, чтобы затем выяснить, что изменилось, и перерисовать это. Это создает лишнюю работу и приходится гораздо больше следить за правильным использованием селекторов, эффектов и т.д.
MobX же точечно отслеживает зависимости. Если компонент использует только поле player.currentTime, он будет перерисован, только когда изменится именно это поле, даже если в store изменилось что-то еще. Для плеера, где обновления интерфейса идут постоянно (таймлайн, буферизация), это дало заметный прирост к производительности.
В-третьих, MobX идеально совместим с ООП. Наша новая архитектура — это мир классов: PlaybackPlugin, AdvertPlugin и т.д. MobX отлично вписывается в парадигму ООП. Не нужны дополнительные библиотеки или сложные паттерны, достаточно просто пометить поля классов декоратором как observable, а методы — как action, и получишь полноценное реактивное состояние, тесно связанное с бизнес-логикой самого класса.
Короче говоря, мы поменяли не столько библиотеку, сколько парадигму управления состоянием:
Было (Redux): глобальное, иммутабельное, централизованное состояние. Идеально для данных приложения, но не для изолированных инстансов сложных модулей.
Стало (MobX): локализованное, мутабельное, реактивное состояние. Идеально для нашего случая, где каждый плеер — это самостоятельный «мир» со своей сложной внутренней логикой.
Естественно, выбор ООП не был самоцелью. Мы пришли к нему потому, что его принципы отлично легли на решение наших конкретных проблем — тех самых, с которых мы начали: props drilling, дублирование состояния и монолитность.
Инкапсуляция упростила работу за счёт скрытия внутренней сложности. Раньше у нас было 50+ useEffect — состояние и логика были размазаны по всей кодовой базе. Теперь вся логика, скажем, работы с субтитрами, инкапсулирована внутри одного класса — CaptionPlugin. Он сам управляет своим состоянием, сам обрабатывает события. Извне мы просто вызываем его методы. Больше никто не может случайно сломать его внутреннее состояние.
Принцип единственной ответственности (SRP) по сути является прямым воплощением идеи плагинов. Наш монолит на 2500 строк делал всё: и UI, и воспроизведение, и рекламу. Теперь же AdvertPlugin отвечает только за рекламу, StatsPlugin — только за статистику и т.д. Если нужно изменить логику показа рекламы, мы идём только в один файл и знаем, что не сломаем ничего другого.
Наследование и полиморфизм были критически важны для поддержки разных форматов видео и интеграций.
Рассмотрим на примере. Есть базовый класс class PlaybackPlugin, который отвечает за инициализацию движка и методы работы с воспроизведением видео: load(), play(), pause().
Когда теперь нам нужно работать с DRM (мы строим единую платформу с онлайн-кинотеатром PREMIER), мы просто наследуемся от базового класса, добавляем в него логику работы с DRM и подтягиваем кроме видеопотока ещё и лицензию, чтобы расшифровывать поток. Вот так: class PremierPlaybackPlugin extends PlaybackPlugin.
Также поступим и для поддержки других форматов и других интеграций. Например, VideoPlaybackPlugin для MP4 или HlsPlaybackPlugin для HLS по-своему реализуют метод load(). Но для всего остального кода плеера это неважно — он работает с ними через общий интерфейс. Это и есть полиморфизм.
Внедрение зависимостей (Dependency Injection) — это то, что связало всё вместе. Раньше зависимости прокидывались в конструктор вручную, это было больно. Теперь плагин просто декларативно заявляет, что ему нужно для работы, и DI-контейнер автоматически это предоставляет. Так мы теперь можем делать различные сборки.

Выше укороченный пример для иллюстрации принципа: регистрируем глобальные переменные, которые нужны внутри инстанса плеера, и регистрируем нужные плагины. Соответственно, для другого плеера, который инстанцируется в других условиях, сборка может выглядеть по-другому: какие-то плагины добавим, какие-то наоборот уберём.
Следующий пример показывает инициализацию контроллов. Они также лежат внутри класса: UI, который относится к end screen, лежит в плагине end screen; элементы управления, относящиеся к рекламе, — в плагине рекламы.

Когда понадобится аудиоплеер без UI — просто не будем регистрировать ненужные плагины.
Ниже пример того, как мы, собственно, регистрируем плагин: оборачиваем в декоратор @Plugin и прописываем список зависимостей. При сборке плеера экземпляр плагина добавляется в массив, а уже при инициализации механизм dependency injection проверяет зависимости и запускает конструкторы классов.

Подход с плагинами делает приложение удобно тестируемым — любой плагин легко подменить на mock-объект.
Как вы видите из примеров, у нас получилась архитектура, которая не просто написана на классах — она спроектирована на принципах ООП.
Быстрая интеграция с кастомными сборками.
Небольшая связность кода, изолированная функциональность — меньше вероятность нечаянно что-то сломать.
Лучше Developer experience — с кодом в текущей архитектуре гораздо приятнее работать.
Как следствие можем гораздо быстрее вносить изменения.
Несмотря на то, что ООП во фронтенде используется редко, оно классно работает тогда, когда очень много бизнес-логики и не так много UI. Полная смена концепции — это не так страшно, как может показаться. Иногда полезно смотреть по сторонам и выходить за рамки привычных шаблонов. Но при этом, конечно, в выборе инструментов нужно ориентироваться на задачи, а не гнаться за хайпом.
Также о том, как мы перестраиваем архитектуру фронтенда RUTUBE, читайте в статье «Универсальный BFF для всех платформ». И подписывайтесь на канал Смотри за IT: там рассказываем о создании медиасервисов в Цифровых активах «Газпром-Медиа Холдинга» таких, как RUTUBE, PREMIER, Yappy.