Туры для SPA-приложений

Одностраничные приложения (Single Page Application, SPA) имеют принципиально иную архитектуру по сравнению с традиционными многостраничными сайтами. Контент в них динамически обновляется без полной перезагрузки страницы, что напрямую влияет на логику работы интерактивных туров в Intro.js.

Ключевые сложности:

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

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


Инициализация тура с учётом маршрутизации

В SPA переход между страницами реализуется через роутер (например, в React Router, Vue Router). Это означает, что:

  • шаги тура могут относиться к разным “страницам”;
  • необходимо синхронизировать шаги с текущим маршрутом.

Пример конфигурации:

const intro = introJs();

intro.setOptions({
  steps: [
    {
      element: '#home-title',
      intro: 'Главная страница'
    },
    {
      element: '#profile-button',
      intro: 'Переход к профилю'
    },
    {
      element: '#profile-info',
      intro: 'Информация о пользователе'
    }
  ]
});

Проблема: элемент #profile-info отсутствует в DOM, пока не выполнен переход на соответствующий маршрут.


Управление переходами между шагами

Для решения используется обработчик события смены шага:

intro.onbeforechange(function(targetElement) {
  const step = intro._currentStep;

  if (step === 2) {
    router.push('/profile');
  }
});

Особенности:

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

Ожидание появления элементов

В SPA элементы часто появляются с задержкой (например, после запроса к API). Intro.js не умеет автоматически ожидать DOM-элементы, поэтому требуется дополнительная логика.

Подход 1: polling (периодическая проверка)

function waitForElement(selector, callback) {
  const interval = setInterval(() => {
    const element = document.querySelector(selector);
    if (element) {
      clearInterval(interval);
      callback(element);
    }
  }, 100);
}

Использование:

intro.onbeforechange(function() {
  if (intro._currentStep === 2) {
    router.push('/profile');

    waitForElement('#profile-info', () => {
      intro.refresh();
    });
  }
});

Подход 2: MutationObserver

Более эффективный способ — отслеживание изменений DOM:

function observeElement(selector, callback) {
  const observer = new MutationObserver(() => {
    const element = document.querySelector(selector);
    if (element) {
      observer.disconnect();
      callback(element);
    }
  });

  observer.observe(document.body, {
    childList: true,
    subtree: true
  });
}

Обновление позиции элементов

После динамического изменения DOM необходимо пересчитать позицию подсказки:

intro.refresh();

Это критично в следующих случаях:

  • загрузка данных изменила размер блока;
  • элемент появился позже;
  • произошёл переход между маршрутами.

Разделение тура на логические части

В SPA часто удобнее разбивать тур на несколько независимых:

Причины:

  • разные разделы приложения;
  • разные роли пользователей;
  • сложная навигация между шагами.

Пример:

const homeTour = introJs().setOptions({
  steps: [
    { element: '#home-title', intro: 'Главная' }
  ]
});

const profileTour = introJs().setOptions({
  steps: [
    { element: '#profile-info', intro: 'Профиль' }
  ]
});

Запуск:

homeTour.start();

// позже
profileTour.start();

Синхронизация с состоянием приложения

В SPA важна связь тура с состоянием (state management):

  • Redux
  • Vuex
  • Zustand
  • MobX

Пример с глобальным состоянием:

store.subscribe(() => {
  const state = store.getState();

  if (state.userLoaded) {
    intro.refresh();
  }
});

Управление доступностью шагов

В SPA некоторые элементы могут быть:

  • скрыты;
  • отключены;
  • не отрисованы.

Intro.js не пропускает шаги автоматически, поэтому требуется ручная проверка:

function isElementVisible(selector) {
  const el = document.querySelector(selector);
  return el && el.offsetParent !== null;
}

Фильтрация шагов:

const steps = [
  { element: '#a', intro: 'A' },
  { element: '#b', intro: 'B' }
];

const filteredSteps = steps.filter(step =>
  isElementVisible(step.element)
);

intro.setOptions({ steps: filteredSteps });

Работа с асинхронными данными

Частая ситуация — шаг зависит от данных API.

Решение:

  1. дождаться загрузки данных;
  2. только после этого запускать тур.
fetch('/api/user')
  .then(res => res.json())
  .then(data => {
    renderUser(data);
    intro.start();
  });

Управление жизненным циклом тура

В SPA важно корректно завершать тур:

intro.onexit(() => {
  console.log('Тур завершён');
});

Также полезно сохранять прогресс:

intro.oncomplete(() => {
  localStorage.setItem('tourCompleted', 'true');
});

Проверка перед запуском:

if (!localStorage.getItem('tourCompleted')) {
  intro.start();
}

Повторная инициализация тура

В SPA компоненты могут размонтироваться и монтироваться заново. Поэтому:

  • нельзя полагаться на один экземпляр Intro.js;
  • иногда требуется пересоздание тура.
function createTour() {
  return introJs().setOptions({
    steps: [...]
  });
}

let intro = createTour();

Интеграция с популярными фреймворками

React

Особенности:

  • компоненты могут перерендериваться;
  • важно использовать useEffect.
useEffect(() => {
  const intro = introJs();

  intro.setOptions({
    steps: [...]
  });

  intro.start();

  return () => intro.exit();
}, []);

Vue

Использование в lifecycle:

mounted() {
  this.$nextTick(() => {
    const intro = introJs();
    intro.start();
  });
}

Angular

Использование в ngAfterViewInit:

ngAfterViewInit() {
  const intro = introJs();
  intro.start();
}

Обработка ошибок

Типичные проблемы:

  • элемент не найден;
  • элемент скрыт;
  • шаг зависает.

Защита:

intro.onbeforechange(function(targetElement) {
  if (!targetElement) {
    intro.nextStep();
  }
});

Производительность

В SPA с большим количеством компонентов:

  • избегать частых refresh();
  • не использовать polling без необходимости;
  • минимизировать количество шагов.

Рекомендации:

  • объединять шаги;
  • использовать ленивую загрузку туров;
  • запускать тур только при необходимости.

UX-подходы для SPA-туров

Особенности взаимодействия:

  • пользователь может изменить состояние приложения во время тура;
  • возможна потеря контекста.

Практики:

  • блокировка интерфейса при необходимости:
intro.setOptions({
  disableInteraction: true
});
  • подсветка только ключевых элементов;
  • короткие, логичные шаги;
  • минимизация переходов между маршрутами.

Отладка туров

Полезные методы:

intro.goToStep(2);
intro.exit();
intro.refresh();

Логирование:

intro.onchange(function(element) {
  console.log('Текущий шаг:', intro._currentStep);
});

Расширение возможностей

Intro.js можно комбинировать с:

  • кастомными tooltip-компонентами;
  • аналитикой (отслеживание прохождения тура);
  • системой обучения пользователей.

Пример интеграции с аналитикой:

intro.onchange(() => {
  analytics.track('Tour Step', {
    step: intro._currentStep
  });
});

Управление состоянием тура между сессиями

Сохранение текущего шага:

intro.onchange(() => {
  localStorage.setItem('tourStep', intro._currentStep);
});

Восстановление:

const savedStep = localStorage.getItem('tourStep');

if (savedStep) {
  intro.goToStepNumber(parseInt(savedStep) + 1).start();
}

Кастомизация поведения в SPA

Дополнительные настройки:

intro.setOptions({
  scrollToElement: true,
  scrollPadding: 50,
  showStepNumbers: true,
  exitOnOverlayClick: false
});

Обработка сложных сценариев

Сценарий: шаг требует открытия модального окна

intro.onbeforechange(() => {
  if (intro._currentStep === 1) {
    openModal();
  }
});

После открытия:

setTimeout(() => {
  intro.refresh();
}, 300);

Сценарий: вкладки (tabs)

intro.onbeforechange(() => {
  if (intro._currentStep === 2) {
    activateTab('settings');
  }
});

Архитектурный подход

Для крупных SPA рекомендуется:

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

Пример структуры:

/tours
  homeTour.js
  profileTour.js
/services
  tourService.js

Итоговые принципы

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