Мы продолжаем рассказывать о разработке недавно вышедшего продукта.

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

За два года создания Squadus мы столкнулись с массой непростых технических задач. О том, как решали их при разработке фронтенда, читайте под катом.


Привет, Хабр! Меня зовут Виталий Матвиевич, я руководитель группы разработки в МойОфис. Вместе с командой мы создаем Squadus — цифровое рабочее пространство, которое позволяет компаниям гибко структурировать коммуникации. Ранее мой коллега @zasonnikрассказал о бэкенде продукта. Я же, в свою очередь, остановлюсь на пользовательской части Squadus: нюансах разработки фронтенда на базе open-source, сопутствующих проблемах и способах их решения.

С чем предстояло справиться

Как мы уже писали, разработка Squadus начиналась на базе СПО. Пару лет назад мы взяли основную функциональность из Rocket.Chat, но быстро поняли, что нашим потенциальным заказчикам — компаниям разной численности — этих возможностей будет недостаточно. Так мы начали переписывать решение. В процессе выявили массу проблем и последние полтора года активно с ними разбирались

Вот три главные категории трудностей, с которыми мы столкнулись при разработке фронтенда:

  1. Технический долг. Rocket.Chat использует fullstack-фреймворк Meteor.js, в чем-то удобный, но вместе с тем привносящий в проект ряд проблем. Наиболее острая из них: шаблонизатор Blaze, на котором в Rocket.Chat были реализованы ключевые компоненты, включая комнаты, чаты, сообщения и т.д. А это — jQuery, отсутствие TypeScript и уступающая React-компонентам производительность. Справедливости ради отмечу, что команда Rocket.Chat также активно уходит от Blaze и переводит компонентную базу на React и TypeScript.

    Еще один комплекс проблем: вместо библиотек, рекомендуемых разработчиками Meteor.js для работы с React-компонентами, создатели Rocket.Chat использовали собственные решения. Как показала практика, это стало причиной некоторых плавающих багов.

  2. Проблемы с UI-kit. React-компоненты в Rocket.Chat реализованы на основе собственной библиотеки (RocketChat Fuselage). С одной стороны, она дает большой набор готовых компонентов и предоставляет инструменты для гибкой разработки новых компонентов, с другой стороны — привносит ряд ограничений. Наиболее проблемными компонентами были поля ввода и саджестеры.

    В частности, саджестеры демонстрировали крайне непривычное поведение с точки зрения UX, при этом технической возможности скорректировать поведение компонента разумными трудозатратами не было. А с учетом того, что саджестеры, поля ввода и иные элементы форм — это компоненты, с которыми пользователи взаимодействуют регулярно, для нас было важно решить эти проблемы.

    Ну и наконец, документация по компонентам практически отсутствовала, Storybook не запускался, что особенно больно било по новым разработчикам, с учетом размера кодовой базы проекта. В этой ситуации также осложнялась разработка новых фич: не было источника данных о доступных компонентах, приходилось буквально рыскать по проекту в поисках подходящих элементов, чтобы избежать дублирования компонентов.

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

Что мы предприняли

Исходя из указанных сложностей, мы выработали несколько направлений разработки на фронтенде (помимо, разумеется, добавления новых фич):

  1. Рефакторинг компонентов с Blaze на React и TypeScript

  2. Перевод UI-компонентов с Fuselage на собственный UI-kit

  3. Оптимизация приложения

Расскажем немного о каждом направлении.

Рефакторинг компонентов с Blaze на React и TypeScript

Уход с Blaze на React и TypeScript помогает нам достичь сразу нескольких целей:

  • Удалить из проекта лишние зависимости — в частности, сам Blaze и jQuery, некоторые другие пакеты.

  • Снять ограничения с разработки новых фич в legacy-компонентах. Очевидно, что добавлять новые возможности в legacy — это больно, дорого и только отдаляет перспективы перехода на актуальный стек, демотивируя разработчиков. Поэтому, за редким исключением, мы считаем заблокированными те фичи, которые требуют доработок в legacy-компонентах, до момента их рефакторинга.

  • Повысить производительность приложения и исправить ситуацию с утечками памяти. React-компоненты показывают лучшую производительность, при этом реализованный в Rocket.Chat механизм для рендера Blaze-шаблонов в React (и наоборот) — течет.

  • Устранить сопутствующие баги в legacy-компонентах.

Хороший пример подобной рефакторинговой задачи — лента чата. С ней был связан большой спектр проблем:

  • Некорректная работа прокрутки истории. После двух-трех корректных подгрузок комната начинала посылать по несколько параллельных запросов к истории, в ленту загружалось сразу несколько её фрагментов, а позиция скролла становилась непредсказуемой. По итогу пользователь просто терялся в чате.

  • Отсутствие виртуализации плюс не работавший механизм подчистки кэша комнат, что при активной работе с чатами приводило к перегруженному DOM-дереву и чрезмерному потреблению памяти.

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

  • Некорректный переход к сообщению по ссылке из-за скачущей высоты компонентов и некорректной обработки этих сценариев.

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

  • Некорректная обработка «склеенных» сообщений и разделителей дат: сначала сообщения рендерились без склейки и дат, затем в комнату поднималось событие, указывающее на завершение рендера, после чего комната проходила циклом по DOM-узлам и на лету расставляла элементам классы для склеивания и отображения дат.

  • Отсутствие пересылки сообщений.

  • Отсутствие черновиков сообщений.

  • Некорректная работа разделителя непрочитанных сообщений, открытие чата в конце истории, а не на последнем непрочитанном сообщении, и другие проблемы.

За последние полгода мы провели большой объем работ над оптимизацией ленты и решили следующие задачи:

  • Реализовали механизм виртуализации сообщений и infinite scroll, тем самым устранили проблемы пользователей при работе с историей чатов.

  • Написали менеджер истории для тредов, также прикрутив механизм виртуализации и infinite scroll.

  • При помощи IntersectionObserver реализовали механизм отложенной «активации» сообщений и вынесли туда все тяжелые операции, включая отображение медиа.

  • Научили ленту открывать чат с первого непрочитанного сообщения, а также при помощи той же механики на IntersectionObserver научились «читать» пользователем только те сообщения, которые попали во вьюпорт.

  • Стали производить склейку и вывод дат до рендера сообщений (в актуальной версии RocketChat эта проблема тоже решена).

  • Добавили пересылку сообщений (включая массовую пересылку).

  • Добавили черновики сообщений. Правда, пока без синхронизации между устройствами.

  • Починили лимиты на кэширование комнат.

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

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

UI-Kit

Теперь поговорим про уход с Fuselage на собственный UI-kit. В решении этой задачи нам помогает опыт коллег из команды разработки Mailion. Совместно мы разрабатываем и поддерживаем так называемый базовый UI-kit, который сделан на основе MUI и предоставляет командам обоих продуктов базовый набор компонентов.

На основе этих общих компонентов мы создаем UI-kit Squadus. Такой подход позволяет нам синхронизировать подходы к разработке базовых компонентов с другими командами, переиспользовать решения друг друга, но не приводит к ограничениям по кастомизации компонентов внутри нашего продукта. На уровне продуктового кита (UI-kit Squadus) мы подставляем в компоненты цветовую схему продукта из Figma.

В рамках этого направления мы уже переработали большинство компонентов форм и ряд прочих компонентов: саджестеры, текстовые поля, чекбоксы, свитчеры, дропдауны, поповеры, модальные окна, тултипы и многое другое.

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

Разработку UI-kit мы ведем в монорепозитории, о котором уже рассказали в статье про микросервисную архитектуру продукта. В клиентской части монорепы пока живет только UI-kit, отсюда же запускается и публикуется Storybook, все компоненты покрываются тестами. В будущем мы планируем переносить в монорепу всю клиентскую часть приложения, но это уже совсем другая история.

Оптимизация приложения

В рамках работ по оптимизации приложения мы наметили следующие задачи:

  • Уменьшение бандла. На момент исследования бандл грузил в страницу больше 10 МБ JavaScript-кода, что чудовищным образом сказывалось на производительности.

  • Внедрение механизмов кэширования: добавление Service Worker, кэширование всей статики, кэширование аватарок пользователей.

  • Планомерная работа по отлавливанию и починке утечек памяти.

Уменьшение бандла

Для оценки размера бандла и диагностики проблем мы пользовались инструментом bundle-visualizer, с его помощью мы обнаружили и начали исправлять следующие болезни:

  • Удаление лишних и дублирующихся зависимостей. Здесь мы, в том числе, планируем окончательно избавиться от Fuselage, Blaze, jQuery.

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

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

Устраняем проблемы с кэшированием и соединением

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

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

  • Создали Service Worker и кэшировали всю статику: CSS, JS, статичные изображения, модули динамического импорта.

  • Внедрили механизм кэширования аватарок пользователей.

В сценариях, связанных с потерей и восстановлением соединения, нас тоже ждало немало сюрпризов. В частности мы обнаружили:

  • При восстановлении соединения Rocket.Chat начинает перезагружать истории всех закэшированных чатов. С учетом того, что лимит на количество закэшированных чатов в Rocket.Chat не работает, то в середине рабочего дня при моргнувшем соединении приложение могло пойти собирать историю по сотне чатов — а это сотни запросов к серверу с последующей обработкой их результатов на клиенте.

  • Rocket.Chat не учитывает, что помимо новых сообщений за время потери соединения могли также быть пропущены апдейты по уже загруженным сообщениям. Соответственно, эти данные в принципе не приходили при восстановлении сетевого подключения.

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

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

Что мы сделали:

  • При восстановлении соединения клиент запрашивает историю только того чата, который в данный момент открыт у пользователя и только в том случае, если пользователь находится в конце истории (и, соответственно, нуждается в новых сообщениях). Если пользователь в середине истории, то мы просто сообщаем комнате, что история сообщений обновилась и менеджеру истории будет что загрузить при скролле. Закэшированные чаты считаем неактуальными и вместо перезапроса истории выбрасываем их из кэша. Если пользователь вернется в такой чат, то комната запросит актуальную историю переписки и пользователь ничего не пропустит.

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

Как мы справлялись с утечками памяти

Отдельным направлением работ по улучшению пользовательского опыта в нашем продукте стала борьба с утечками памяти. Честно говоря, никто из команды разработки не обладал «боевым опытом» по поиску утечек. Конечно, у всех было общее понимание: что это, откуда оно берется, но как методологически выстроить работу по планомерному выявлению и починке — с этим нам предстояло разбираться.

Изучив материалы по теме, мы решили выбрать технику трех снапшотов. На первом же сценарии (им стало переключение чатов) мы поймали утечку и поняли, что методология нам подойдет :)

Одним из самых ярких «открытий» стал механизм кэширования чатов, о котором я уже несколько раз упоминал выше. По задумке разработчиков в Rocket.Chat кэшируется ограниченное количество чатов (по умолчанию — пять штук), причем в память залетает вообще все: шаблоны, сообщения, различные переменные состояния и т.п. При достижении лимита по количеству кэшированных чатов вызываются методы подчистки кэша наиболее старых чатов.

Однако снапшоты показали, что в памяти остаются ВСЕ чаты, которые открывал пользователь. Естественно, за день активной работы память выедалась страшными объемами.

С точки зрения кода, на первый взгляд, все было нормально, но в итоге мы обнаружили лишний символ «!» в методе, который удаляет комнату из кэша:

close(rid: IRoom['_id']): void {
    if (!this.rooms.has(rid)) {
      this.rooms.delete(rid);
      this.emit('closed', rid);
    }
    this.emit('changed', this.rid);
  }

Иными словами, комната удаляется из кэша только тогда, когда ее там нет. То есть никогда. Кстати, мы обнаружили, что на момент создания статьи эта проблема все еще актуальна, поэтому создали PR на ее исправление.

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

***

Больше подробностей о Squadus, едином цифровом рабочем пространстве от МойОфис, мы расскажем в следующих хабр-статьях. Если вам интересна тема непосредственно фронтенда, будем рады вашей обратной связи в комментариях. Возможно вы также сталкивались с проблемами, описанными в тексте, и можете рассказать об альтернативных способах их решения.

Комментарии (1)