---
title: "SPA и динамический контент"
description: "chromedp spa-страницы на React и Vue: почему HTML пустой, как дождаться рендера предикатом Poll, скелетоны, бесконечная лента и состояние — примеры на Go."
course: "Chromedp в Go: автоматизация Chrome без Selenium"
url: "https://golangmaster.ru/courses/chromedp/lessons/11-dinamicheskij-kontent/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# SPA и динамический контент

*Курс «Chromedp в Go: автоматизация Chrome без Selenium», урок 11 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/chromedp/lessons/11-dinamicheskij-kontent/*

Витрина на React отдала HTML с одним пустым div — и это не поломка, а её нормальный первый кадр: на таких страницах chromedp spa-задачи решает теми же действиями, что и статические сайты, но ожидания переключаются с HTML на рендер — ждут данные, а не завершение загрузки. Разберём динамический контент go-программой: предикаты Poll, скелетоны, бесконечная лента и короткий путь через состояние страницы.

Первые десять уроков курса строились вокруг страниц, которые сервер отдаёт готовыми: навигация завершилась — разметка на месте. Витрины на фреймворках ломают эту цепочку в самом начале. Navigate честно дожидается загрузки, но загружается только каркас: корневой div, бандл скриптов и spinning-логотип. Данные приедут позже, отдельными запросами, и фреймворк соберёт из них разметку уже в браузере. Для программы, которая после Navigate сразу идёт читать узлы, это выглядит как загадочный сбой: страница есть, селектор не находит ничего. На самом деле сбоя нет — есть разрыв между «загрузка завершена» и «контент отрисован», и весь урок про то, как этот разрыв измерять и ждать. На фреймворковых витринах особенно ценен приём «перечитать селектор»: узлы в DOM появляются и исчезают по воле рендера, и действие, которое переспрашивает документ, переживает пересборку дерева без перезапуска, тогда как единственный прогон поиска мог попасть в момент, когда контейнер вынут и собирается заново. Мы возьмём инструменты из девятого урока про [ожидания](/courses/chromedp/lessons/09-ozhidaniya-elementov/) и десятого про оценку JavaScript и соберём из них дисциплину для живых страниц: что ждёт Navigate, почему фреймворк опаздывает, чем предикат лучше сна и где у SPA прячутся данные до рендера.

### chromedp spa: почему HTML пустой

chromedp spa-страницу начинает читать в момент, который кажется подходящим, но им не является. Navigate относится к действиям, которые всегда запускают переход и дожидаются загрузки страницы — для статического сайта этого достаточно, потому что вместе с загрузкой приезжает и контент. Для React или Vue событие загрузки означает другое: браузер получил документ и скачал скрипты. В этот момент разметки данных ещё не существует: она появится, когда скрипты запустятся, отправят запросы за данными, ответы вернутся и фреймворк прогонит их через виртуальный DOM. Задержка складывается из двух частей — сети и рендера — и ни одну из них программа не контролирует. Поэтому первое правило работы с фреймворковыми витринами звучит так: после Navigate ждут узел, которого на пустом каркасе не было. Контейнер карточек, шапка результатов, список позиций — что угодно, что рендерится только из данных. chromedp.WaitVisible по такому селектору возвращает управление, когда узел не просто найден, а имеет ненулевую рамку, то есть реально нарисован. Это уже половина дела, но не всё: контейнер может отрисоваться пустым, и тогда чтение даст ноль элементов без всякой ошибки. Оставшуюся половину закрывает предикат — условие на содержимое, а не на наличие узла.

### Дождаться данных, а не контейнера

```go
err := chromedp.Run(ctx,
        chromedp.Navigate("https://example.com/catalog"),
        chromedp.WaitVisible(".cards"),
        chromedp.Poll(
                "document.querySelectorAll('.card').length >= 20",
                nil,
                chromedp.WithPollingTimeout(15*time.Second),
        ),
)
```

Navigate дожидается загрузки каркаса, WaitVisible — контейнера с ненулевой рамкой, а Poll — двадцати карточек внутри него. Предикат переоценивается браузером постоянно, программа спит в действии и просыпается только на факт. nil вместо приёмника означает «только ждать»; таймаут ограничивает ожидание пятнадцатью секундами и при истечении вернёт ErrPollingTimeout.

### Предикат вместо сна: Poll под капотом

chromedp.Poll строит из выражения предикат — в исходниках он оборачивается в return (выражение); — и передаёт браузеру на постоянную переоценку. Режимов три. Дефолтный raf исполняет предикат на каждом кадре requestAnimationFrame — самый тугой опрос, подходит для наблюдения за стилями и быстрыми изменениями. WithPollingInterval переключает на таймер с указанным интервалом — разумный выбор для данных, которые едут по сети и не меняются чаще раза в секунду. WithPollingMutation запускает переоценку на каждую мутацию DOM — идеален для лент, где элементы появляются событиями. Три опции настраивают ожидание: WithPollingTimeout ставит потолок, по умолчанию тридцать секунд, ноль отключает его совсем; при истечении действие возвращает ошибку waiting for function failed: timeout. Приёмник получает значение предиката, когда тот наконец вернул истину, — так одним действием можно и дождаться, и забрать результат; тип приёмника подбирается по форме значения: логическое выражение — nil или *bool, число — int, строка — string. Две границы стоит помнить заранее. Предикат не переживает навигацию: если страница переехала, Poll вернёт ошибку уничтоженного контекста исполнения, а не продолжит ждать. И предикат, который кидает исключение — например, обращается к свойству null-контейнера, — приносит эту ошибку сразу, не дожидаясь таймаута.

> **Живое место: предикат на null-контейнере.** Типичная авария: в предикат пишут document.querySelector('.cards').innerText.length > 0, не подумав, что контейнер рендерится самим фреймворком и на пустом каркасе его нет. querySelector возвращает null, обращение к innerText кидает TypeError, и Poll падает с JS-исключением на первом же кадре — программа узнаёт об этом мгновенно, а не через тридцать секунд таймаута. Лечение — защитное выражение: document.querySelector('.cards') && ... возвращает false, пока контейнера нет, и Poll спокойно ждёт. Спорное мнение: проверку на null стоит писать всегда, даже когда контейнер есть в статике — переезд страницы на новый каркас не обязан предупреждать ваш парсер.

### PollFunction: функция и мутации

```go
var cards int
err := chromedp.Run(ctx,
        chromedp.WaitVisible(".cards"),
        chromedp.PollFunction(
                "() => document.querySelectorAll('.card').length",
                &cards,
                chromedp.WithPollingMutation(),
        ),
)
fmt.Println("карточек:", cards)
```

PollFunction оборачивает функцию: пока она возвращает ложное значение, ожидание продолжается, первое истинное значение уходит в приёмник. Ноль карточек — ложь, поэтому действие дождётся первой же отрисованной позиции и вернёт её количество. Режим мутаций переоценивает функцию на каждом изменении DOM — без таймеров и лишних кадров.

### Инструменты ожидания рендера

| Инструмент | Что ждёт | Когда брать |
| --- | --- | --- |
| WaitVisible | узел с ненулевой рамкой | контейнер данных уже отрисован |
| Poll с выражением | истинность выражения | количество узлов, текст, флаг готовности |
| PollFunction + Mutation | вызов функции на каждой мутации DOM | ленты и подгрузки, где элементы приходят событиями |
| Evaluate | ничего — разовый снимок | чтение состояния после того, как готовность доказана |

### Динамический контент go-программой: три источника задержки

Динамический контент go-программа ждёт тем точнее, чем яснее, откуда берётся пауза. Первый источник — сеть: фреймворк отправляет запросы за данными после запуска, и ответы могут ехать секунды на тяжёлых витринах; этот этап — территория таймерного или интервального опроса. Второй — рендер: фреймворк собирает разметку постепенно, и на её месте часто стоит скелетон — серые прямоугольники, изображающие будущие карточки. Скелетон опасен тем, что он узел реальный: WaitVisible на нём проходит мгновенно, а данных внутри ноль, поэтому для витрин со скелетонами ожидание обязано смотреть на содержимое, а не на контейнер. Третий источник — гидрация: разметка уже в документе, а скрипты ещё навешивают поведение; для чтения текстов это не мешает, но клики до гидрации уходят в никуда. Режим опроса подбирается под источник: данные из сети — интервал, рост списка — мутации, стили и анимации — кадры. Когда источник неясен, начинают с мутаций: любой рендер фреймворка — это мутация DOM, и предикат сработает на первом же изменении, сделавшем условие истинным. Разобраться, куда уходит время, помогает DevTools на той же странице: длинная пауза перед первым XHR — это сеть или бэкенд, долгий рендер после ответа — фреймворк; тот же разбор работает и в обратную сторону — если данные отрисовались до вашего WaitVisible, все ожидания прошли мгновенно, и программа просто не заметила, что могла бы гонять.

> **Скелетон — не данные.** Если на странице видели скелетоны, значит они будут и в парсере. Проверяйте готовность по содержимому: количество узлов внутри контейнера, непустой текст первого элемента, исчезновение класса загрузки — Poll-предикатом любое из условий выражается в одну строку. Ждать фиксированное время «на всякий случай» здесь хуже, чем где-либо: скелетон живёт ровно столько, сколько едет сеть, и это не константа.

### Бесконечная лента и «показать ещё»

Каталоги с бесконечным скроллом — самый частый SPA-сценарий после первого экрана. Схема программы: доехать до низа страницы прокруткой, дождаться новой порции узлов, повторить. Прокрутка делается Evaluate с window.scrollTo до document.body.scrollHeight, а ожидание порции — PollFunction с предикатом, сравнивающим текущее количество карточек с зафиксированным раньше. Конец ленты распознаётся по остановке роста: после очередного скролла счётчик не сдвинулся N попыток подряд — данные кончились, и цикл выходит. Здесь живёт одна тонкость виртуализированных списков: экономные фреймворки удаляют узлы, уехавшие за экран, поэтому собирать тексты «в конце, разом» нельзя — данные читают на каждом шаге цикла, пока они в DOM, либо берут их из состояния страницы, о чём ниже. Для витрин с кнопкой «показать ещё» всё проще: тот же цикл, но вместо скролла — Click по кнопке и предикат на рост счётчика. Если сайт позволяет, проверьте и альтернативу: у многих лент есть пагинация с адресами страниц, и она надёжнее скролла — этой теме целиком посвящён двадцать первый урок про таблицы и пагинации.

### Порядок работы со SPA-страницей

- Navigate дожидается загрузки каркаса — дальше вы одни с фреймворком
- WaitVisible на узел, которого нет в статике: контейнер, шапка, список
- Poll или PollFunction на содержимое: количество узлов, текст, отсутствие индикатора загрузки
- режим опроса под источник задержки: сеть — интервал, лента — мутации, стили — кадры
- для лент — цикл скролл-ожидание-чтение с проверкой остановки роста
- результат предиката забирайте в приёмник — готовность и данные одним действием
- ни одного Sleep: скелетон и сеть не живут по расписанию

### Состояние вместо DOM: короткий путь

У SPA есть задняя дверь, мимо которой проходят половина парсеров: данные существуют до того, как стали разметкой. Фреймворк держит их в памяти — в состоянии приложения, в глобальных объектах, которые сайт кладёт для себя: window.__INITIAL_STATE__, объект конфигурации, кэш запросов. Evaluate из десятого урока читает это состояние напрямую и возвращает его в Go JSON-ом — без ожидания рендера, без скелетонов, одним обменом. Для тяжёлых витрин это не просто ускорение: сериализованное состояние стабильнее разметки, которую дизайнер завтра перестроит. Имена глобалов у фреймворков свои: __NEXT_DATA__ у Next.js, __NUXT__ у Nuxt, у самописных приложений чаще всего __INITIAL_STATE__ — подсмотреть их можно в консоли DevTools, начав печатать window.__ и глядя на автодополнение. Границы пути: состояние не документировано, его структура меняется без предупреждения, и у части сайтов оно не глобальное — тогда остаётся классический путь с ожиданием рендера. Второй короткий путь — вообще не ждать рендер, а слушать сеть: если данные едут XHR-ом, их можно перехватывать ответами до того, как фреймворк их распакует, — механика семнадцатого урока про [перехват запросов](/courses/chromedp/lessons/17-perehvat-zaprosov/). Выбирайте по задаче: разовый сбор — DOM с предикатом, промышленный обход — состояние или сеть. А для статических сайтов без скриптов всё это лишнее: [смежный курс](/courses/colly/) разбирает чистый HTTP-путь, который там дешевле.

### Проверка: что дожидается Navigate

**Вопрос:** Navigate вернулся без ошибки, но селектор карточек не находит ничего. Что произошло?

- ✅ Событие загрузки сработало, а фреймворк ещё не отрисовал данные
- Navigate сломался, но не сообщил об этом
- chromedp не работает с React-страницами
- Селектор написан с ошибкой в любом случае

Navigate дожидается загрузки страницы: для SPA это документ и скрипты, а не отрисованные данные. Фреймворк запустится, съездает за данными и соберёт разметку позже — поэтому после Navigate ждут узел рендера, а лучше — предикат на содержимое.

### Проверка: режимы и таймаут Poll

**Вопрос:** Какой режим опроса у Poll по умолчанию и какой потолок ожидания?

- ✅ Каждый кадр requestAnimationFrame, таймаут 30 секунд
- Раз в секунду, таймаута нет
- На каждой мутации DOM, таймаут 60 секунд
- Один вызов без повторов, таймаут 30 секунд

Дефолт — режим raf: предикат исполняется на каждом кадре requestAnimationFrame. Потолок задаёт WithPollingTimeout, по умолчанию 30 секунд, ноль отключает; при истечении возвращается ErrPollingTimeout. Интервал и мутации включаются явно, отдельными опциями.

### Что окажется в cards

```go
// на странице сейчас 0 карточек, через 3 секунды фреймворк отрисует 12
var cards int
// err := chromedp.Run(ctx,
//   chromedp.PollFunction(
//     "() => document.querySelectorAll('.card').length",
//     &cards,
//   ),
// )
```

**Что выведет программа?**

- ✅ 12 — PollFunction дождался первого истинного значения
- 0 — функция вернула ноль на первом вызове
- Ошибка: ноль не является истинным значением
- 30 — сработал дефолтный таймаут

PollFunction ждёт истинное значение предиката: ноль карточек — ложь, ожидание продолжается, и в приёмник уходит первое истинное количество — 12. Ошибкой ноль не является, а таймаут сработал бы только если бы карточки не появились за 30 секунд.

### Соберите ожидание рендера

Восстановите цепочку: контейнер, предикат на количество и ограничение ожидания.

```go
err := chromedp.Run(ctx,
        chromedp.Navigate(url),
        chromedp.___(".cards"),
        chromedp.Poll(
                "document.querySelectorAll('.card').length >= 20",
                ___,
                chromedp.WithPollingTimeout(15*time.Second),
        ),
)
```

Подсказка: Контейнер дожидается WaitVisible, а nil вместо приёмника означает «только ждать, ничего не забирать».

Правильные ответы в пропусках: `WaitVisible`, `nil`

### Расставьте SPA-сценарий

Соберите по порядку: контекст, навигация, ожидание контейнера, предикат на данные, обработка ошибки.

Правильный порядок строк:

1. `ctx, cancel := chromedp.NewContext(context.Background())`
2. `defer cancel()`
3. `err := chromedp.Run(ctx,`
4. `  chromedp.Navigate("https://example.com/catalog"),`
5. `  chromedp.WaitVisible(".cards"),`
6. `  chromedp.Poll("document.querySelectorAll('.card').length >= 20", nil),`
7. `)`
8. `if err != nil {`
9. `  panic(err)`
10. `}`

### Карточки: SPA и ожидание рендера

- **Что дожидается Navigate на SPA** — Событие загрузки: документ и скрипты на месте. Данных фреймворка ещё нет — их приезд и рендер ждут отдельными действиями.
- **chromedp.Poll** — Строит из выражения предикат return (expr); и переоценивает его до первой истины. Значение уходит в приёмник; предикат не переживает навигацию.
- **Режимы Poll** — raf — на каждом кадре (дефолт), WithPollingInterval — таймер, WithPollingMutation — на каждую мутацию DOM.
- **WithPollingTimeout** — Потолок ожидания, по умолчанию 30 секунд, 0 отключает. Истечение даёт ошибку waiting for function failed: timeout.
- **Скелетон** — Реальный узел вместо данных: WaitVisible на нём проходит мгновенно. Готовность проверяют содержимым — количеством узлов или текстом.
- **Состояние страницы** — Данные фреймворка до рендера: window.__INITIAL_STATE__, __NEXT_DATA__, __NUXT__ и кэши. Читаются Evaluate одним обменом, но структура не документирована.

Что дальше: с ожиданием рендера витрина становится обычной страницей курса — дальше по ней работают селекторы, клики и чтение. Двенадцатый урок доводит до конца [формы: заполнение и отправку](/courses/chromedp/lessons/12-formy/) — на SPA-витринах формы почти всегда с JS-валидацией, поэтому без этого урока логин не соберётся. Если данные не нужны в DOM, семнадцатый урок покажет перехват запросов, а двенадцатый же — как дождаться подтверждения отправки. Практика — локально: нужен установленный Go и Chrome, песочница сайта исполняет тематическую программу на чистом Go без браузера, поэтому предикаты и мутации вы увидите только в своём терминале. Домашняя проверка на честность: откройте любую витрину, выключите сеть и посмотрите, как выглядит каркас, — именно его видит Navigate.

## Коротко: что запомнить

- Navigate на SPA дожидается загрузки каркаса, а не данных: разметку собирает фреймворк после загрузки.
- После Navigate ждут узел, которого нет в статике, — контейнер с ненулевой рамкой.
- Poll строит предикат из выражения и ждёт первую истину; значение прилетает в приёмник.
- Режимы опроса: raf по умолчанию, WithPollingInterval для сетевых данных, WithPollingMutation для лент.
- WithPollingTimeout — 30 секунд по умолчанию, ноль отключает; истечение даёт ErrPollingTimeout.
- Предикат не переживает навигацию и падает на исключении — защита на null обязательна.
- Скелетон — реальный узел без данных: готовность доказывает содержимое, а не контейнер.
- Состояние страницы читается Evaluate до рендера; перехват XHR — альтернатива без ожидания разметки.
- Имена глобального состояния у фреймворков свои — __NEXT_DATA__, __NUXT__; подсматриваются в консоли автодополнением.

## Домашнее задание

Возьмите витрину с фреймворком и соберите программу: Navigate, WaitVisible на контейнер данных, Poll с предикатом на минимальное количество карточек и чтение первых заголовков. Затем добавьте цикл бесконечного скролла с подсчётом карточек до остановки роста и логом по каждой порции. В конце сломайте предикат — уберите защитную проверку на null-контейнер — и прочитайте текст ошибки, чтобы узнать её в проде. Финальный штрих: сравните время до первых данных по DOM-предикату и по чтению состояния страницы — на тяжёлых витринах разница получится в секунды, и её стоит записать в конспект.

## Вопросы и ответы по уроку

### Почему WaitVisible прошёл, а чтение вернуло ноль элементов?

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

### Poll вернул waiting for function failed: timeout — что делать?

Тридцать секунд (или ваш WithPollingTimeout) предикат не стал истинным. Сначала проверьте предикат в консоли DevTools — часто там опечатка или проверяется не то свойство. Если выражение верное, значит данные действительно не приехали: у страницы лимиты, бан или регион. Увеличивать таймаут вслепую — путь к висящему обходу; лучше разбираться, почему условие не наступает.

### Можно ли просто подождать пару секунд после Navigate?

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