В середине 2018 года в Sports.ru задумались о переезде на новый WYSIWYG-редактор текста для пользовательских постов. С июня 2019 года редактор работает в режиме бета-версии. За это время мы решили множество проблем, связанных как с проектированием архитектуры всего сервиса, так и с реализацией самого редактора в браузере на основе библиотеки ProseMirror, и решили поделиться своим опытом.
Sports.ru – медиа о спорте с аудиторией 20 млн пользователей в месяц. Наши главные отличия от классических СМИ – сообщество и UGC. Пользовательский контент – оценки, комментарии, чаты, посты – не только дополняет ценность редакционного, но создает платформу для взаимодействия пользователей друг с другом. Каждый месяц наши пользователи пишут почти 10 тысяч постов. Лучшие из них выносятся на главную страницу сайта вместе с редакционными, отправляются в мобильные приложения, социальные сети. На пользовательский контент приходится около 40% всех прочтений страниц Sports.ru.
Мы хотим быть самой удобной платформой для авторов о спорте, помогать создавать контент и доставлять его заинтересованной аудитории. 10 лет мы использовали редактор TinyMCE – и в конце концов он устарел, перестал устраивать и команду, и пользователей, привыкших к современным редакторам.
Рис. 1. Интерфейс старого редактора на основе TinyMCE
От авторов блогов регулярно приходили примерно такие жалобы:
долго писал, потом случайно закрыл вкладку и все пропало;
писать длинные тексты очень неудобно;
раздражает, что для вставки каждой картинки ее надо сначала загрузить на хостинг изображений.
У команды тоже были свои претензии:
в TinyMCE нельзя загружать картинки напрямую из файла, можно только прикреплять ссылки на изображения, а из-за того, что пользователи не имели возможности загружать картинки в наше хранилище, то если ссылки на них умирали, мы ничего не могли с этим сделать;
возможности для редактирования и форматирования текста не сбалансированы. С одной стороны, не хватало каких-то стилей, например, для внутренних заголовков в тексте. С другой – использовать имеющиеся инструменты можно было в каких угодно сочетаниях. В результате посты выглядели неединообразно (в Sports.ru началась работа по внедрению дизайн-системы и пользовательские посты должны выглядеть в соответствии с ней);
контент создается и хранится в HTML, поэтому сложно управлять стилями в постах на разных клиентах, да и просто вносить изменения в верстку постов.
Дальше будет рассказ о том, как мы решали задачу создания нового редактора на стороне фронтенда. Правда, кое-что про продуктовую, дизайнерскую и бэкенд-части там тоже будет, потому что без этого сложно будет понять, почему на фронтенде было принято то или иное решение.
1.2. Описание задачи, которая стояла перед разработчиками
Если коротко, то тезис, от которого мы изначально отталкивались: ведение блога на Sports.ru – это боль. В принципе, можно было бы не делать новый редактор, а просто добавить автосохранение и возможность загрузки картинок в собственное хранилище – и большая часть жалоб пользователей и сотрудников отпала бы. Но хотелось все же не поддерживать инструмент на старых технологиях, а создать новый современный редактор, который мы сможем легко развивать и масштабировать.
Помимо неудобного интерфейса одна из основных технических проблем старого редактора заключалась в том, что контент поста сохранялся сразу в виде HTML-строки, и изменения во внешний вид поста либо требовали вмешательства бэкенд-разработчиков, либо реализовывались в рантайме на клиенте (например, расстановка рекламных блоков в теле поста). Нашей задачей в том числе было отделить данные от их представления и, соответственно, верстку и интерфейс оставить в клиентском коде, а работу с данными — в серверном.
В качестве ролевой модели мы взяли Medium, иногда подглядывая идеи у Google Doc. Помимо решения уже выявленных проблем, мы решили добавить несколько новых фичей, которые сделали бы использование редактора более комфортным:
сама концепция WYSIWYG, т.е. what you see is what you get (англ. «что видишь, то и получишь», подробнее можно посмотреть в Википедии), когда пользователи в процессе создания поста буквально видят его точно таким, каким он будет после публикации. И не только самого поста, а также его превью в ленте;
вставка эмбедов (эмбедом мы называем мультимедийный элемент или виджет, не зависимый от остального контента на странице, с которым можно взаимодействовать так же, как на ресурсе, где он был создан; например, эмбед поста в инстаграме позволяет пролистать карусель фотографий или поставить лайк) постов из соцсетей и видеохостингов.
При этом сам редактор не должен был быть завязан на особенности Sports.ru, потому что Sports.ru хоть и флагманский проект у нас в компании, но все же не единственный. В компании также разрабатываются международное спортивное медиа Tribuna, социальная сеть для любителей ставок Betting Insider, а недавно начала работу собственная продакшн-студия, занимающаяся рекламными проектами. Разработка онлайн-редактора – это достаточно дорогая штука, чтобы не захотеть переиспользовать этот код на другом сайте с другими версткой и стилями, со своим набором инструментов для редактирования и форматирования.
Текстового контента у нас очень много и перед тем, как начинать работы по созданию нового редактора постов, мы задумались, в каком виде этот контент нужно хранить. TinyMCE не давал нам выбора и контент приходилось хранить только в HTML, что, как уже было сказано выше, не устраивало команду. В итоге мы придумали свой собственный формат для хранения текстовых данных, отвечающий нашим требованиям, и назвали его structured body.
Structured body – это массив объектов, отражающий структуру контента. При этом контент разбивается на элементы, представляющие собой независимые блоки, например, paragraph, list, picture. Элемент хранит информацию о том, какого он типа и какие у него свойства. Например, блок subtitle описывает заголовок в тексте, он обязательно должен содержать поля text и level. Соответственно, в text содержится текст этого заголовка, а в level — уровень (от 1 до 4). Structured body, состоящий из одного заголовка второго уровня, может выглядеть, например, так:
const structuredBody = [
{
type: 'subtitle',
value: {
text: 'Мой новый заголовок второго уровня',
level: 2,
},
},
];
Переход на structured body позволил нам начать процесс разделения бизнес-логики, данных и их представления. В конечном итоге мы хотим, чтобы сервер и клиенты обменивались только данными. А как и для чего отобразить конечному пользователю эти данные, каждый клиент будет определять самостоятельно.
Контент в формате structured body хранится в JSON и для валидации его содержимого мы создали JSON-схему, которую назвали structured body schema. В этой схеме описаны все допустимые элементы и их свойства. Таким образом мы можем быть уверены, что везде, где нужен structured body, используется один набор ключей и значений.
Более того, это позволяет разным командам использовать одни и те же сервисы для обработки контента в этом формате. Например, сервис для генерации HTML из structured body для отображения контента или редактор для создания контента. Это заметно сокращает затраты на разработку и поддержку всего ядра сервисов, связанных с созданием и отображением контента.
Предполагалось, что новый редактор должен принимать на вход и отдавать на выход контент исключительно в формате structured body. И тут надо было учесть тонкий момент: поскольку раньше посты сразу сохранялись в HTML, то для отображения на клиент передавалась эта HTML-строка из базы данных (здесь и далее под клиентом мы имеем в виду исключительно браузер, если не указано иное). Теперь мы хотим хранить контент всех постов в structured body, но клиенты умеют обрабатывать только HTML. Значит, вместе с задачей переезда на новый редактор одновременно идет задача реализации нового способа отображения клиентами постов для чтения напрямую из structured body. Мы решили, что слона лучше есть по частям, поэтому для начала надо полностью отказаться от TinyMCE, а уже потом браться за логику отображения постов для чтения. Более того, не для всех старых постов удалось перевести контент в новый формат, а значит эти посты всегда будут храниться только в HTML и необходимо для них тоже сохранить возможность чтения.
Итого: часть постов (все новые и старые, которые были успешно переведены на новый формат) будет храниться в двух форматах – HTML и structured body – пока не будет реализована новая логика отображения для чтения, а остальные (большая часть старых и очень-очень старых постов) так и останутся только в HTML.
2. Как выбирали инструмент
Нам предстояло реализовать возможность редактирования и создания поста на клиенте с учетом перечисленных ранее особенностей и ограничений. Как всегда, можно взять готовое решение, а можно придумать свое.
Для начала мы изучили, какие есть уже готовые библиотеки для создания WYSIWYG-редакторов и подходят ли они нам. Мы остановились на Slate, Draft.js и ProseMirror.
Кроме хранения контента в структуре данных, критичным моментом для нас также была возможность работы с Vue или чистым JS, потому что мы уже успели начать переводить сайт на новый технологический стек с использованием Vue+Vuex. Кроме того, хотелось бы при необходимости расширять возможности готовой библиотеки с помощью новых модулей (сторонних или самописных).
Табл. 1. Сравнение рассмотренных библиотек по наиболее важным для Sports.ru параметрам
Как видно из таблицы, ProseMirror вполне соответствовал нашим требованиям, поэтому идею написать свою библиотеку для редактирования текстового контента мы уже не рассматривали, а начали изучать эту библиотеку подробнее. Есть еще довольно популярный Quill, который не попал в наше сравнение только потому, что мы честно забыли про него на этапе выбора инструмента. По нашим ключевым требованиям он тоже проходит, но так уж сложилось. О том, что такое ProseMirror и как с ним работать, мы уже рассказали в другой статье.
3. Что получилось
3.1. Архитектура сервиса
Сам редактор контента на клиенте — это еще далеко не все. Редактор нужно поместить в уже существующий проект, отобразить его где-то на веб-странице, а также продумать взаимодействие с бэкендом, решить проблему одновременной поддержки двух редакторов (от старого тоже нельзя было мгновенно отказаться) и хранения контента в двух форматах (HTML и structured body).
Все эти задачи можно условно разделить на те, которые относятся к фронтенду, бэкенду и их интеграции. Нас тут волнуют в первую очередь вопросы фронтенда и интеграции, хотя про пару важных аспектов задач бэкенда тоже упомянем.
Сервисы фронтенда для редактора можно разбить на несколько уровней:
web-страница для создания и редактирования поста;
Vue-app, которое запускается на клиенте. По сути, это форма, написанная на Vue, помимо самого контента поста она сабмитит еще множество мета-данных, например, название, аннотацию, привязку к разделу и подразделам и т.д., а также валидирует данные, выводит различные сообщения для пользователя и делает прочие вещи, которые делают все уважающие себя формы;
WYSIWYG-редактор на основе ProseMirror, который инициализируется внутри формы на Vue. Это и есть наш редактор текста, работающий примерно так, как описано в статье;
SB2HTML – сервис для рендера HTML из structured body, живущий на отдельном сервере. Как мы уже упоминали, structured body – это наше внутреннее соглашение о формате данных, в котором хранится контент. Необходимость такого сервиса родилась из идеи, что между сервером, имеющим доступ к базе данных, и клиентом нужно передавать только данные. Но пока для Sports.ru клиенты не умеют генерировать HTML из structured body, придется по-прежнему хранить в базе актуальный HTML для отображения. Именно для этого мы реализовали генерацию HTML на сервере на Node.Js, чтобы в будущем можно было переиспользовать этот JS-код для отображения постов.
Процесс сохранения поста изображен на рис. 2. На бэкенд передается контент самого поста в формате structured body и его мета-данные. Бэкенд отправляет контент в сервис SB2HTML, получает в ответе готовый HTML, кладет все это в базу и отвечает клиенту, что пост успешно сохранен, или сообщает об ошибке.
Рис. 2. Схема сохранения поста при создании или редактировании в WYSIWYG-редакторе
3.2. С какими сложностями столкнулись
Сложностей было много, возникали они постоянно и часто в самые неожиданные моменты.
Как мы уже говорили, редактор контента находится внутри формы, которая позволяет вводить дополнительные данные, необходимые для создания поста, такие как название, аннотация и т.д. Для аннотации должна быть возможность загрузки изображений из файла и по ссылке из интернета. Но ведь для контента мы тоже хотим загружать изображения из файла и по ссылке, причем по одним и тем же правилам. И тут мы оказались перед дилеммой: с одной стороны, контент поста при редактировании изолирован от внешней формы и обслуживается средствами ProseMirror, но с другой, хочется соблюсти принцип DRY и не дублировать один и тот же код. Решили это следующим образом: описали загрузку изображений как набор методов в объекте на уровне Vue-формы и передали этот объект как один из параметров в конструктор WYSIWYG-редактора.
В модели ProseMirror определены сущности, описывающие контент — Node и Fragment. Однако для транзакции используются исключительно индексы для определения диапазона символов, к которым эта транзакция применяется (отсчет индексов ведется как от начала документа, так и от начала родительской ноды). Индексирование символов — это одна из центральных концепций ProseMirror, но при редактировании и форматировании текста гораздо удобнее думать о сущностях из модели ProseMirror. В итоге для комфортной работы с контентом мы написали свои хелперы, упрощающие взаимодействие с документом для транзакций. Уже после начала нашей работы появилась библиотека tiptap, которая представляет собой набор похожих хелперов.
Следующая проблема заключалась в том, что на этапе создания схемы мы поняли, что у нас уже есть утвержденный внутренний формат хранения контента – structured body, отвечающий нашим потребностям, а ProseMirror хранит в стейте контент в своем собственном формате. Переходить на формат ProseMirror было сложно и нецелесообразно. Мы оказались в ситуации, когда на клиент по API приходят данные в одном формате, а для их отображения нужен другой. Аналогичная ситуация у нас возникает при необходимости сохранить измененный или созданный контент. Для этого мы реализовали конвертер, который преобразует форматы туда и обратно. Написали для него несложный тест, который берет контент одного поста в формате structured body, переводит его в формат ProseMirror, потом обратно и уже сравнивает исходный вариант с полученным. Получилось быстро и просто.
В дальнейшем по мере изменения и, как правило, усложнения схемы документа, стало понятно, что малейшее изменение может приводить к ошибкам в работе редактора, и такой тест, похоже, дает очень плохое покрытие. В итоге на два небольших метода-конвертера пришлось написать тесты практически на все сочетания нод и марок. Сейчас без этих тестов невозможно определить, поломает ли что-нибудь очередное изменение в схеме или нет.
Следующая проблема снова связана с необходимостью обратной совместимости старых и новых технологий. Наш WYSIWYG-редактор реализован только в браузерах (десктопных и, в скором времени, мобильных). Соответственно, для редактирования контент на клиент отдается в JSON в формате structured body, однако чтение постов в браузерах осуществляется только из HTML. При этом большая часть мобильных приложений уже перешла на отображение пользовательских постов непосредственно из structured body.
Для мобильных приложений необходимо было предусмотреть случай, когда клиент не может обработать какой-то элемент из structured body. Например, если в structured body добавлен новый элемент, отображение которого реализовано только в более новой версии приложения. Так как не все пользователи одновременно обновляют свои приложения, то необходимо было предусмотреть план «Б» для более старых версий: вместо создания HTML из structured body для нужного элемента вставлять готовый фрагмент HTML. Наличие фрагментов HTML для каждого элемента не было предусмотрено в схеме structured body, потому что сама идея этой структуры заключалась в том, чтобы отказаться от хранения данных в HTML. Но в итоге мы пришли к выводу, что нам нужно две схемы structured body — одна для отображения и одна для редактирования. Различия между схемами заключаются в том, что structured body для редактирования содержит исключительно контент статьи, а для отображения мы добавляем некоторые дополнительные элементы. В частности, фрагмент HTML для каждого элемента создается при сохранении поста в сервисе SB2HTML и добавляется только в structured body для отображения поста. Кроме того, в structured body для отображения также размечаются места для рекламы в контенте.
При открытии контента для редактирования в браузере мы пока в принципе не можем столкнуться с неизвестным элементом, потому что все посты создаются и отображаются по одной схеме. Но решили на будущее предусмотреть такой случай тоже. Для этого мы добавили в схему ProseMirror дефолтный элемент-заглушку. Мы назвали этот элемент unsupportedBlock. Заглушка отображается на месте неподдерживаемого элемента. Мы стилизовали ее под серый прямоугольник с текстом о том, что этот элемент не поддерживается и его нельзя редактировать. При сохранении поста такой элемент в неизменном виде остается в structured body. Пользователь может поменять его расположение относительно других элементов, но внутреннее содержание неизвестного элемента изменить или отредактировать нельзя. Однако пользователь может удалить такой элемент, тогда, конечно, в итоговом документе он не сохранится.
Все описанные проблемы касались сложностей реализации самого WYSIWYG-редактора. Но пока он существовал в бета-режиме, мы не могли отказаться от старого редактора на TinyMCE и вынуждены были поддерживать оба редактора, обеспечивая между ними обратную совместимость. Например, можно было создать пост в WYSIWYG-редакторе, сохранить, потом отредактировать его в TinyMCE, сохранить, снова открыть в WYSIWYG, и так до бесконечности. В итоге при открытии в WYSIWYG мы видели тот же контент, что и при предыдущем сохранении в TinyMCE. Для реализации обратной совместимости необходимо было отдавать в TinyMCE контент в HTML, который мы уже научились создавать из structured body и сохранять в базу данных при сохранении поста. А при сохранении поста через TinyMCE созданный контент на сервере прогоняется через сервис HTML2SB, в результате чего мы можем сохранить как свежий HTML, так и structured body.
HTML2SB – это сервис, обратный тому, что делает SB2HTML, то есть преобразует контент из HTML в structured body. Хронологически этот сервис появился раньше всего, потому что до создания WYSIWYG-редактора единственной возможностью получить контент поста в формате structured body был прямой парсинг из HTML. HTML2SB являлся частью бэкендной инфраструктуры вокруг редактора постов, но после отказа от TinyMCE необходимость в нем отпала.
4. Результаты бета-тестирования
Сейчас WYSIWYG-редактор доступен для всех пользователей в бета-версии, а скоро станет основным редактором постов Sports.ru. Мы уже получили инструмент для создания и редактирования постов, который отвечает большинству наших требований:
интерфейс редактора стал понятным, лаконичным и современным, писать длинные посты стало намного проще;
появилась возможность загрузки изображений из файла и по ссылке, которые сразу размещаются в нашем хранилище;
добавили опцию вставки эмбедов из основных соцсетей и видеохостингов;
навели порядок со стилями форматирования текста;
мобильные приложения уже перешли на отображение постов из structured body и могут задавать собственные стили для контента.
Безусловно, редактор еще пока не до конца отлажен, мы периодически обнаруживаем новые баги. На подходе следующие обновления:
автосохранение;
версия WYSIWYG для пользователей с расширенными правами (администраторы, штатные редакторы);
создание и редактирование постов из мобильных браузеров;
сообщения о параллельном редактировании документа несколькими пользователями;
подсказки и онбординг;
статистические виджеты спортивных команд, матчей и расстановок.
На момент написания этой статьи через бета-версию редактора было опубликовано уже более 13000 постов, это около 20% от общего числа пользовательских текстов на Sports.ru за период с июня 2019-го по февраль 2020-го включительно. Доля как созданных, так и опубликованных через новый редактор постов, стабильно растет.
Рис. 3. Доля пользовательских постов, созданных и опубликованных в новом редакторе
Похоже, что органический рост доли пользовательских постов, созданных и опубликованных через новый редактор – это сигнал о том, что пользователи довольны обновлением, что также подтверждается отзывами в анонсе его запуска в бета-тестирование (некоторые из них представлены на рис. 4). Поэтому в ближайшие месяцы мы планируем полностью перевести создание постов на новый редактор, чтобы сосредоточиться только на его поддержке и развитии.
Кстати, какой функционал в наш WYSIWYG-редактор добавили бы вы?
Рис. 4. Комментарии пользователей в посте с анонсом обновления WYSIWYG-редактора