Привет, Хабр! Меня зовут Дима, я фронтенд‑разработчик Яндекса. В этой статье я расскажу о том, как мы переписали наш ридер для электронных книг, создав универсальное решение для веба и нативных приложений.

Статья будет интересна фронтенд‑разработчикам. Из неё вы узнаете, как создать универсальное ядро для веба и натива, получить вместо запутанных асинхронных вызовов чёткие последовательности действий и убрать визуальные артефакты при одновременных пользовательских действиях.

База: EPUB и CFI

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

EPUB (Electronic Publication) — это ZIP‑архив, содержащий HTML, CSS, изображения и метаданные, организованные по определённым правилам. По сути, это миниатюрный веб‑сайт.

CFI (Canonical Fragment Identifier) — это стандарт, который решает проблему, как точно указать на конкретное место в тексте.

Проблемы старой архитектуры

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

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

У нативного и веб‑приложения с ростом функциональности возникала проблема поддержания консистентности состояния при одновременных пользовательских действиях. Например, пользователь может быстро пролистать 10 страниц, затем вернуться назад, открыть оглавление, перейти к закладке — и всё это должно работать без задержек и сбоев.

Старый ридер

Новый ридер

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

Требования к новой архитектуре приложения

Анализируя проблемы старого приложения, мы сформулировали ключевые требования к новому решению:

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

  • Также нужно было объединить логику, которая раньше была распределена между нативным кодом и фронтенд‑приложением, что создавало проблемы в разработке нативного ридера.

Отдельно стоит отметить, почему мы решили отказаться от React в ядре ридера. Несмотря на то, что React с его виртуальным DOM отлично подходит для большинства интерфейсов, для ридера он создаёт определённые трудности. Главная проблема — в отсутствии точного контроля над DOM. В React нет возможности сразу после отрисовки DOM‑элемента вызвать колбэк, что необходимо для таких операций, как восстановление скролла в вертикальном режиме без визуальных артефактов.

Вместо React мы решили использовать Web Components для рендеринга контента. Он позволяет нативно работать с DOM, сохраняя при этом преимущества компонентного подхода с жизненными циклами, необходимыми для отображения состояния контента, загрузки и обработки ошибок. React по‑прежнему используется на веб‑платформе, но весь движок ридера работает на чистом JavaScript с Web Components.

Пересобираем архитектуру приложения

Универсальное ядро для веб-версии и нативных приложений

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

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

Почему мы выбрали WebView для нативных приложений? Браузерные движки изначально оптимизированы для работы с текстом и стилями. К тому же сам формат EPUB представляет собой ZIP‑архив с HTML, CSS и другими веб‑ресурсами — фактически, это маленький веб‑сайт. Аналогичный подход используют и другие популярные ридеры: Kindle, Google Play Books и Apple Books.

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

Декларативные последствия вместо хаоса 

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

Пример:

typescript
[Sequence.VERTICAL_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
	ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
	ActionsUniversal.START_PREVENT_SAVE_CFI,
	ActionsUniversal.CLEAR,
	ActionsVertical.LOAD_FILES_WITH_CFI,
	ActionsVertical.APPEND_MULTIPLE_CONTENT,
	ActionsVertical.SCROLL_TO_CFI,
	ActionsVertical.UPDATE_PROGRESS,
	ActionsUniversal.END_PREVENT_SAVE_CFI,
	ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
	ActionsUniversal.SYNC_READING_PROGRESS,
	ActionsUniversal.SAVE_NAVIGATION_CFI,
	ActionsUniversal.FIND_ACTIVE_TOC,
	ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
],
finally: [ActionsUniversal.RENDER_END],
}

Каждая последовательность включает три этапа: before, actions и final. Это обеспечивает стабильную работу приложения и предотвращает его краши даже при ошибках.

Этот подход даёт нам несколько критических преимуществ:

  1. Предсказуемость выполнения. Мы точно знаем, в каком порядке будут выполняться операции.

  2. Отмену устаревших операций. Если пользователь быстро листает книгу, мы можем отменить ненужные загрузки.

  3. Изоляцию ошибок. Проблема в одном действии не приводит к краху всей системы.

Очереди и предотвращение конфликтов

Третья задача — сохранить консистентность состояния при одновременных пользовательских действиях.

Что происходит, если пользователь быстро нажимает «вперёд», а затем «назад», пока предыдущая операция ещё не завершилась?

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

Пример работы: вертикальный режим чтения

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

Разберём пошагово, как работает отрисовка.

[Sequence.VERTICAL_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
	ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
	ActionsUniversal.START_PREVENT_SAVE_CFI,
	ActionsUniversal.CLEAR,
	ActionsVertical.LOAD_FILES_WITH_CFI,
	ActionsVertical.APPEND_MULTIPLE_CONTENT,
	ActionsVertical.SCROLL_TO_CFI,
	ActionsVertical.UPDATE_PROGRESS,
	ActionsUniversal.END_PREVENT_SAVE_CFI,
	ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
	ActionsUniversal.SYNC_READING_PROGRESS,
	ActionsUniversal.SAVE_NAVIGATION_CFI,
	ActionsUniversal.FIND_ACTIVE_TOC,
	ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
],
finally: [ActionsUniversal.RENDER_END],
}

Шаг 1. Подготовка к отрисовке

[Sequence.VERTICAL_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
	ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
	ActionsUniversal.START_PREVENT_SAVE_CFI,
	ActionsUniversal.CLEAR,
	...
],
}

Здесь мы показываем спиннер (RENDER_START), блокируем подгрузку новых глав книги при скролле (START_PREVENT_INFINITY_SCROLL), блокируем сохранение CFI(START_PREVENT_SAVE_CFI) и очищаем страницу (CLEAR).

Шаг 2. Загрузка нужного файла

[Sequence.VERTICAL_TO_CFI]: {
actions: [
	...
	ActionsVertical.LOAD_FILES_WITH_CFI,
	...
]
}

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

Шаг 3. Отрисовка контента и цитат

[Sequence.VERTICAL_TO_CFI]: {
actions: [
	...
	ActionsVertical.APPEND_MULTIPLE_CONTENT,
	...
]
}

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

  • выставляем нужные размеры для изображений;

  • обрезаем главы для бесплатного фрагмента, если у пользователя нет подписки;

  • обрабатываем специальные элементы (сноски, цитаты).

Шаг 4. Прокрутка к позиции CFI

[Sequence.VERTICAL_TO_CFI]: {
actions: [
	...
	ActionsVertical.SCROLL_TO_CFI,
	...
]
}

Декодим CFI и получаем элемент, к которому нужно проскроллить.

Шаг 5. Актуализируем состояние

[Sequence.VERTICAL_TO_CFI]: {
actions: [
	...
	ActionsVertical.UPDATE_PROGRESS,
	ActionsUniversal.END_PREVENT_SAVE_CFI,
	ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
	ActionsUniversal.SYNC_READING_PROGRESS,
	...
],
}

Актуализируем прогресс в процентах по текущему CFI(UPDATE_PROGRESS), убираем блокировки, сохраняем CFI и включаем подгрузку контента по скроллу.

Шаг 6. Завершаем рендеринг

[Sequence.VERTICAL_TO_CFI]: {
actions: [
	...
	ActionsUniversal.SAVE_NAVIGATION_CFI,
	ActionsUniversal.FIND_ACTIVE_TOC,
	ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
],
finally: [ActionsUniversal.RENDER_END],
}

Сохраняем состояние навигации (SAVE_NAVIGATION_CFI) для истории переходов (навигация позволяет отменять и восстанавливать действия пользователя — Undo/Redo), ищем заголовок, который соответствует текущей главе. Также проверяем, есть ли закладки на текущем экране, на RENDER_END убираем лоадер.

По аналогии работает отрисовка по CFI в горизонтальном режиме.

[Sequence.HORIZONTAL_INIT_TO_CFI]: {
before: [ActionsUniversal.RENDER_START],
actions: [
	ActionsUniversal.START_PREVENT_SAVE_CFI,
	ActionsUniversal.CLEAR,
	ActionsHorizontal.LOAD_FILE_WITH_CFI,
	ActionsHorizontal.APPEND_SINGLE_CONTENT_CFI,
	ActionsHorizontal.SCROLL_TO_CFI,
	ActionsHorizontal.RENDER_PAGE,
	ActionsHorizontal.UPDATE_PROGRESS,
	ActionsUniversal.END_PREVENT_SAVE_CFI,
	ActionsUniversal.FIND_ACTIVE_TOC,
	ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
	ActionsUniversal.SAVE_NAVIGATION_INIT_CFI,
],
finally: [ActionsUniversal.RENDER_END],
}

Выводы

Как в итоге нам удалось улучшить ридер:

  • Универсальность — мы пришли к единой архитектуре, которая одинаково хорошо работает и в вебе, и в нативных приложениях. Это избавило нас от дублирования и повысило переиспользуемость кода.

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

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

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


  1. grokinn
    17.06.2025 07:37

    Привет, Дима. Скажи, есть ли какая то причина, по которой в мобильном приложении есть поиск по тексту книги, а в веб-версии его нет?


  1. IgDem
    17.06.2025 07:37

    Скажите, у меня одного в Я.Книгах на iPad периодически:
    - перестают перелистываться страницы. Т.е. анимация перелистывания есть, но страница остается старой
    - сбивается верстка и книга показывается в 4 колонки
    От той и другой проблемы спасает перезагрузка приложения. Происходит примерно раз в день.


  1. Jijiki
    17.06.2025 07:37

    у вас оч дизайн красивый


  1. ganzmavag
    17.06.2025 07:37

    О, у вас, кстати, очень хорошее приложение. Я когда его ставил, ожидал увидеть самый минимум, как у некоторых других. Обычно если сервис дает книги, то функционал приложения стоит на втором месте и сильно уступает специализированным читалкам. А у вас оказалась полноценная читалка с довольно большим количеством функций. Был приятно удивлён.


  1. Vitaly_js
    17.06.2025 07:37

    actions: [
    	ActionsUniversal.START_PREVENT_INFINITY_SCROLL,
    	ActionsUniversal.START_PREVENT_SAVE_CFI,
    	ActionsUniversal.CLEAR,
    	ActionsVertical.LOAD_FILES_WITH_CFI,
    	ActionsVertical.APPEND_MULTIPLE_CONTENT,
    	ActionsVertical.SCROLL_TO_CFI,
    	ActionsVertical.UPDATE_PROGRESS,
    	ActionsUniversal.END_PREVENT_SAVE_CFI,
    	ActionsUniversal.END_PREVENT_INFINITY_SCROLL,
    	ActionsUniversal.SYNC_READING_PROGRESS,
    	ActionsUniversal.SAVE_NAVIGATION_CFI,
    	ActionsUniversal.FIND_ACTIVE_TOC,
    	ActionsUniversal.FIND_ACTIVE_BOOKMARK_UUIDS,
    ],

    А как вы во всем этом не путаетесь? Иными словами как проверяется корректность порядка действий?