Обработка асинхронно загружаемых элементов

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

Основная сложность заключается в том, что Intro.js ожидает наличие всех элементов на момент инициализации. При асинхронной загрузке это условие не выполняется.


Механизм работы Intro.js с DOM

Intro.js формирует шаги на основе:

  • селекторов (element)
  • порядка шагов (steps)
  • текущего состояния DOM

При запуске:

introJs().start();

библиотека:

  1. Сканирует DOM
  2. Привязывает шаги к элементам
  3. Вычисляет координаты
  4. Отрисовывает подсказки

Если элемент отсутствует — шаг игнорируется или вызывает некорректное поведение.


Подходы к решению

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

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

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

setTimeout(() => {
  introJs().start();
}, 1000);

Недостатки:

  • фиксированная задержка
  • нестабильность при медленной сети

2. Проверка наличия элемента

Более надёжный способ — проверять DOM до тех пор, пока элемент не появится.

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

waitForElement('#async-element', () => {
  introJs().start();
});

Преимущества:

  • запуск строго после появления элемента
  • независимость от скорости загрузки

3. Использование MutationObserver

Современный и эффективный способ отслеживания изменений DOM.

const observer = new MutationObserver((mutations, obs) => {
  if (document.querySelector('#async-element')) {
    introJs().start();
    obs.disconnect();
  }
});

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

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

  • реагирует на реальные изменения
  • минимальная нагрузка по сравнению с polling

Управление шагами при динамическом DOM

Динамическое формирование steps

Если элементы создаются асинхронно, шаги лучше формировать после их появления:

function buildSteps() {
  return [
    {
      element: document.querySelector('#step1'),
      intro: 'Первый шаг'
    },
    {
      element: document.querySelector('#step2'),
      intro: 'Второй шаг'
    }
  ];
}

introJs().setOptions({
  steps: buildSteps()
}).start();

Пересоздание тура

При изменении DOM во время прохождения тура:

const intro = introJs();

intro.onbeforechange(() => {
  if (!document.querySelector('#dynamic-element')) {
    intro.exit();
    waitForElement('#dynamic-element', () => {
      intro.start();
    });
  }
});

Асинхронная загрузка внутри шага

Иногда элемент существует, но его содержимое подгружается позже.

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

introJs().onafterchange(function(targetElement) {
  if (targetElement.id === 'data-container') {
    loadData().then(() => {
      introJs().refresh();
    });
  }
});

Где:

function loadData() {
  return fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      document.querySelector('#data-container').innerHTML = data.content;
    });
}

Метод refresh():

  • пересчитывает позицию
  • обновляет размеры подсказки

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

React

Проблема: компоненты рендерятся после жизненного цикла.

Решение:

useEffect(() => {
  if (dataLoaded) {
    introJs().start();
  }
}, [dataLoaded]);

Vue

watch(() => isReady, (val) => {
  if (val) {
    nextTick(() => {
      introJs().start();
    });
  }
});

Angular

ngAfterViewInit() {
  this.dataService.getData().subscribe(() => {
    setTimeout(() => {
      introJs().start();
    });
  });
}

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

Если следующий шаг зависит от асинхронного действия:

introJs().onbeforechange(function(targetElement) {
  if (targetElement.id === 'step-async') {
    return new Promise((resolve) => {
      fetchData().then(() => {
        resolve();
      });
    });
  }
});

Важно: стандартный Intro.js не поддерживает Promise напрямую, поэтому требуется кастомная обёртка или контроль через exit() и start().


Паттерн: “ленивый шаг”

Шаг создаётся только в момент необходимости:

introJs().onbeforechange(function(targetElement) {
  if (targetElement.id === 'placeholder') {
    waitForElement('#real-element', () => {
      introJs().addStep({
        element: '#real-element',
        intro: 'Динамический шаг'
      });
      introJs().nextStep();
    });
  }
});

Ошибки и типичные проблемы

1. Шаг не отображается

Причины:

  • элемент отсутствует в DOM
  • селектор неверный
  • элемент скрыт (display: none)

2. Неправильное позиционирование

Причины:

  • элемент ещё не получил размеры
  • CSS ещё не применился

Решение:

setTimeout(() => {
  introJs().refresh();
}, 0);

3. Тур запускается слишком рано

Решение:

  • использовать события загрузки данных
  • избегать window.onload как единственной точки запуска

Комбинированный подход

На практике используется сочетание методов:

function initTour() {
  waitForElement('#step1', () => {
    waitForElement('#step2', () => {
      introJs().setOptions({
        steps: [
          { element: '#step1', intro: 'Шаг 1' },
          { element: '#step2', intro: 'Шаг 2' }
        ]
      }).start();
    });
  });
}

Или через MutationObserver + refresh:

const intro = introJs();

const observer = new MutationObserver(() => {
  intro.refresh();
});

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

intro.start();

Рекомендации по архитектуре

  • отделять загрузку данных от логики тура
  • запускать Intro.js только после готовности UI
  • использовать явные сигналы готовности (state, флаги)
  • избегать жёстких таймеров
  • минимизировать зависимость шагов от динамики DOM

Расширенный контроль через события

Основные хуки:

  • onbeforechange
  • onafterchange
  • oncomplete
  • onexit

Пример:

introJs()
  .onbeforechange((el) => {
    if (!el) return false;
  })
  .onafterchange((el) => {
    introJs().refresh();
  })
  .start();

Работа с удаляемыми элементами

Если элемент может исчезнуть:

introJs().onbeforechange(function(targetElement) {
  if (!document.body.contains(targetElement)) {
    introJs().nextStep();
  }
});

Итоговая стратегия

  1. Определение всех асинхронных точек
  2. Ожидание появления элементов
  3. Динамическая генерация шагов
  4. Обновление позиции через refresh()
  5. Использование наблюдателей DOM
  6. Интеграция с жизненным циклом приложения

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