← Браузер/Жизненный цикл страницы#141 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: async и сетьТермин: Event LoopТермин: Core Web Vitals

Жизненный цикл страницы

Представь, что ты запускаешь SPA на React или Vue. Один из самых частых багов на старте — обращение к DOM до того, как он готов. Или запуск аналитики после того, как пользователь уже ушёл. Жизненный цикл страницы — это четыре события, которые определяют, в какой момент что можно делать.

Что решает этот механизм

Без понимания жизненного цикла код инициализации запускается «вслепую». Скрипт в <head> пытается обратиться к элементам, которых ещё нет в DOM. Или приложение ждёт события load для запуска роутера, хотя DOM готов уже на этапе DOMContentLoaded — и страница отображается пустой лишние 500ms.

На основе предыдущих уроков

  • события, addEventListener — жизненный цикл строится на тех же механизмах подписки
  • mouse/keyboard events — beforeunload использует тот же паттерн event.preventDefault()
  • Порядок событий при загрузке

    Парсинг HTML
         ↓
    DOMContentLoaded  ← DOM готов, JS может работать с элементами
         ↓
    Загрузка картинок, CSS, шрифтов...
         ↓
    load              ← всё загружено, включая ресурсы

    DOMContentLoaded

    Срабатывает когда браузер полностью разобрал HTML и построил DOM-дерево. Картинки и стили ещё могут загружаться.

    document.addEventListener('DOMContentLoaded', () => {
      // DOM готов — можно безопасно работать с элементами
      const menu = document.getElementById('main-menu')
      initNavigation(menu)
    
      const form = document.querySelector('form')
      initFormValidation(form)
    })

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

    load

    Срабатывает когда загружено всё: картинки, стили, шрифты, iframe.

    window.addEventListener('load', () => {
      // Всё загружено — можно работать с размерами изображений
      const img = document.querySelector('img#banner')
      console.log(img.naturalWidth)   // реальная ширина загруженной картинки
      console.log(img.naturalHeight)  // реальная высота
    
      hideLoadingSpinner()
      initAnimations()
    })

    beforeunload

    Срабатывает когда пользователь пытается покинуть страницу (закрыть вкладку, перейти по ссылке). Позволяет показать диалог подтверждения.

    window.addEventListener('beforeunload', (event) => {
      if (hasUnsavedChanges()) {
        // Стандартный способ показать диалог подтверждения
        event.preventDefault()
        event.returnValue = ''  // для совместимости со старыми браузерами
        // Браузер покажет: "Вы уверены, что хотите покинуть страницу?"
      }
    })

    Текст диалога нельзя изменить — браузеры намеренно игнорируют returnValue как строку из соображений безопасности.

    unload

    Срабатывает когда страница окончательно закрывается. Последний шанс что-то сделать, но с ограничениями:

    window.addEventListener('unload', () => {
      // Успевает выполниться только быстрый синхронный код
      // fetch и XMLHttpRequest здесь ненадёжны!
    
      // Правильный способ отправить аналитику при закрытии:
      navigator.sendBeacon('/api/analytics', JSON.stringify({
        sessionDuration: Date.now() - sessionStart,
        lastPage: location.pathname,
      }))
    })

    document.readyState

    Отражает текущее состояние загрузки документа:

    console.log(document.readyState)
    // 'loading'     — HTML ещё парсится
    // 'interactive' — DOM готов (как DOMContentLoaded), ресурсы ещё грузятся
    // 'complete'    — всё загружено (как load)
    // Универсальная инициализация — работает в любой момент
    function initWhenReady(callback) {
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', callback)
      } else {
        // DOM уже готов — выполняем сразу
        callback()
      }
    }
    
    initWhenReady(() => {
      console.log('Приложение инициализировано')
    })

    Событие readystatechange срабатывает при каждом изменении readyState:

    document.addEventListener('readystatechange', () => {
      console.log('readyState:', document.readyState)
    })
    // loading → interactive → complete

    async и defer — влияние на загрузку

    Без атрибутов:
    HTML ─── стоп ─── скрипт ─── продолжение HTML ───► DOMContentLoaded
    
    defer:
    HTML ──────────────────────────────── DOMContentLoaded
                           └─ скрипт (после парсинга, до DOMContentLoaded)
    
    async:
    HTML ─────────── (продолжает) ──────────────────────
                  └─ скрипт (немедленно после загрузки, порядок не гарантирован)
    // defer — скрипт выполняется после парсинга HTML, сохраняет порядок
    // Лучший вариант для большинства скриптов
    <script defer src="app.js"></script>
    
    // async — скрипт загружается параллельно, выполняется сразу после загрузки
    // Подходит для независимых скриптов (счётчики, чаты)
    <script async src="analytics.js"></script>

    Типичные ошибки

    1. Инициализация вне DOMContentLoaded — DOM ещё не готов

    // ПЛОХО — скрипт в <head>, DOM ещё не построен
    const btn = document.getElementById('submit-btn')  // null!
    btn.addEventListener('click', handleSubmit)        // TypeError: Cannot read properties of null
    
    // ХОРОШО
    document.addEventListener('DOMContentLoaded', () => {
      const btn = document.getElementById('submit-btn')  // элемент найден
      btn.addEventListener('click', handleSubmit)
    })

    2. Использование fetch в unload — запрос не успевает отправиться

    // ПЛОХО — браузер может убить страницу до завершения fetch
    window.addEventListener('unload', () => {
      fetch('/api/session-end', { method: 'POST', body: JSON.stringify(stats) })
      // Этот запрос, скорее всего, не дойдёт до сервера
    })
    
    // ХОРОШО — sendBeacon гарантированно отправляется даже при закрытии
    window.addEventListener('unload', () => {
      navigator.sendBeacon('/api/session-end', JSON.stringify(stats))
    })

    3. Игнорирование readyState при динамическом добавлении скриптов

    // ПЛОХО — если скрипт добавлен после DOMContentLoaded, событие уже не придёт
    document.addEventListener('DOMContentLoaded', init)  // пропустит!
    
    // ХОРОШО — universальная проверка
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init)
    } else {
      init()  // уже готов — запускаем сразу
    }

    В реальных проектах

  • SPA (React/Vue/Angular): точка входа main.js всегда ждёт DOMContentLoaded или монтируется после него через defer
  • Аналитика (GA, Яндекс.Метрика): подключается через async — не блокирует загрузку страницы
  • Формы с несохранёнными данными: Notion, Google Docs используют beforeunload для предупреждения при уходе с несохранёнными изменениями
  • Session tracking: маркетинговые системы отправляют длительность сессии через navigator.sendBeacon в unload
  • Примеры

    Машина состояний document.readyState и паттерн инициализации приложения

    // Симуляция жизненного цикла страницы без DOM
    // Моделируем переходы readyState и порядок событий
    
    class PageLifecycleSimulator {
      constructor() {
        this._readyState = 'loading'
        this._listeners = { DOMContentLoaded: [], load: [], beforeunload: [], unload: [] }
        this._log = []
      }
    
      // Подписка на события (аналог document/window.addEventListener)
      onDocument(event, handler) {
        if (this._listeners[event]) {
          this._listeners[event].push(handler)
        }
      }
    
      _emit(eventName) {
        this._log.push(`[событие] ${eventName}`)
        this._listeners[eventName]?.forEach(fn => {
          try { fn() } catch (e) { this._log.push(`[ошибка] ${e.message}`) }
        })
      }
    
      // Симулируем этапы загрузки страницы
      simulateLoad() {
        this._log.push('[состояние] loading — HTML парсится...')
    
        // HTML разобран → DOM готов
        this._readyState = 'interactive'
        this._log.push('[состояние] interactive — DOM готов')
        this._emit('DOMContentLoaded')
    
        // Ресурсы загружены → страница полностью готова
        this._readyState = 'complete'
        this._log.push('[состояние] complete — все ресурсы загружены')
        this._emit('load')
      }
    
      simulateUnload(hasUnsavedData) {
        this._log.push('[событие] пользователь уходит со страницы...')
    
        let cancelled = false
        const beforeunloadEvent = {
          preventDefault: () => { cancelled = true },
          returnValue: '',
        }
    
        this._listeners.beforeunload.forEach(fn => fn(beforeunloadEvent))
    
        if (cancelled) {
          this._log.push('[диалог] "Вы уверены что хотите покинуть страницу?"')
          if (!hasUnsavedData) {
            this._log.push('[результат] пользователь подтвердил уход')
            this._emit('unload')
          } else {
            this._log.push('[результат] пользователь остался на странице')
            return
          }
        } else {
          this._emit('unload')
        }
      }
    
      getLog() { return this._log }
      get readyState() { return this._readyState }
    }
    
    // === Использование симулятора ===
    const page = new PageLifecycleSimulator()
    
    // Регистрируем обработчики — как в реальном приложении
    const sessionStart = 1700000000000  // мок-время
    
    page.onDocument('DOMContentLoaded', () => {
      console.log('1. DOMContentLoaded: инициализируем маршрутизатор')
      console.log('2. DOMContentLoaded: рендерим навигацию')
      console.log('3. DOMContentLoaded: подключаем обработчики форм')
    })
    
    page.onDocument('load', () => {
      console.log('4. load: инициализируем lazy-загрузку изображений')
      console.log('5. load: запускаем мониторинг производительности')
      const loadTime = Date.now() - sessionStart
      console.log(`6. load: время загрузки страницы ~ ${loadTime > 0 ? loadTime : 800}ms`)
    })
    
    page.onDocument('beforeunload', (event) => {
      const unsaved = true  // симулируем несохранённые данные
      if (unsaved) {
        event.preventDefault()
        event.returnValue = ''
      }
    })
    
    page.onDocument('unload', () => {
      console.log('7. unload: отправляем аналитику через navigator.sendBeacon')
      console.log('   POST /api/session-end { duration: 45000 }')
    })
    
    // Симулируем полный жизненный цикл
    console.log('=== ЗАГРУЗКА СТРАНИЦЫ ===')
    page.simulateLoad()
    
    console.log('\nreadyState после загрузки:', page.readyState)  // 'complete'
    
    console.log('\n=== УХОД СО СТРАНИЦЫ (несохранённые данные) ===')
    page.simulateUnload(true)  // пользователь остаётся
    
    console.log('\n=== ЛОГ ЖИЗНЕННОГО ЦИКЛА ===')
    page.getLog().forEach(entry => console.log(entry))
    
    // Функция initWhenReady — реальный паттерн
    console.log('\n=== Паттерн initWhenReady ===')
    function initWhenReady(currentState, callback) {
      if (currentState === 'loading') {
        console.log('DOM ещё не готов — подписываемся на DOMContentLoaded')
      } else {
        console.log('DOM уже готов — выполняем callback немедленно')
        callback()
      }
    }
    
    initWhenReady('complete', () => {
      console.log('Инициализация выполнена!')
    })

    Жизненный цикл страницы

    Представь, что ты запускаешь SPA на React или Vue. Один из самых частых багов на старте — обращение к DOM до того, как он готов. Или запуск аналитики после того, как пользователь уже ушёл. Жизненный цикл страницы — это четыре события, которые определяют, в какой момент что можно делать.

    Что решает этот механизм

    Без понимания жизненного цикла код инициализации запускается «вслепую». Скрипт в <head> пытается обратиться к элементам, которых ещё нет в DOM. Или приложение ждёт события load для запуска роутера, хотя DOM готов уже на этапе DOMContentLoaded — и страница отображается пустой лишние 500ms.

    На основе предыдущих уроков

  • события, addEventListener — жизненный цикл строится на тех же механизмах подписки
  • mouse/keyboard events — beforeunload использует тот же паттерн event.preventDefault()
  • Порядок событий при загрузке

    Парсинг HTML
         ↓
    DOMContentLoaded  ← DOM готов, JS может работать с элементами
         ↓
    Загрузка картинок, CSS, шрифтов...
         ↓
    load              ← всё загружено, включая ресурсы

    DOMContentLoaded

    Срабатывает когда браузер полностью разобрал HTML и построил DOM-дерево. Картинки и стили ещё могут загружаться.

    document.addEventListener('DOMContentLoaded', () => {
      // DOM готов — можно безопасно работать с элементами
      const menu = document.getElementById('main-menu')
      initNavigation(menu)
    
      const form = document.querySelector('form')
      initFormValidation(form)
    })

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

    load

    Срабатывает когда загружено всё: картинки, стили, шрифты, iframe.

    window.addEventListener('load', () => {
      // Всё загружено — можно работать с размерами изображений
      const img = document.querySelector('img#banner')
      console.log(img.naturalWidth)   // реальная ширина загруженной картинки
      console.log(img.naturalHeight)  // реальная высота
    
      hideLoadingSpinner()
      initAnimations()
    })

    beforeunload

    Срабатывает когда пользователь пытается покинуть страницу (закрыть вкладку, перейти по ссылке). Позволяет показать диалог подтверждения.

    window.addEventListener('beforeunload', (event) => {
      if (hasUnsavedChanges()) {
        // Стандартный способ показать диалог подтверждения
        event.preventDefault()
        event.returnValue = ''  // для совместимости со старыми браузерами
        // Браузер покажет: "Вы уверены, что хотите покинуть страницу?"
      }
    })

    Текст диалога нельзя изменить — браузеры намеренно игнорируют returnValue как строку из соображений безопасности.

    unload

    Срабатывает когда страница окончательно закрывается. Последний шанс что-то сделать, но с ограничениями:

    window.addEventListener('unload', () => {
      // Успевает выполниться только быстрый синхронный код
      // fetch и XMLHttpRequest здесь ненадёжны!
    
      // Правильный способ отправить аналитику при закрытии:
      navigator.sendBeacon('/api/analytics', JSON.stringify({
        sessionDuration: Date.now() - sessionStart,
        lastPage: location.pathname,
      }))
    })

    document.readyState

    Отражает текущее состояние загрузки документа:

    console.log(document.readyState)
    // 'loading'     — HTML ещё парсится
    // 'interactive' — DOM готов (как DOMContentLoaded), ресурсы ещё грузятся
    // 'complete'    — всё загружено (как load)
    // Универсальная инициализация — работает в любой момент
    function initWhenReady(callback) {
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', callback)
      } else {
        // DOM уже готов — выполняем сразу
        callback()
      }
    }
    
    initWhenReady(() => {
      console.log('Приложение инициализировано')
    })

    Событие readystatechange срабатывает при каждом изменении readyState:

    document.addEventListener('readystatechange', () => {
      console.log('readyState:', document.readyState)
    })
    // loading → interactive → complete

    async и defer — влияние на загрузку

    Без атрибутов:
    HTML ─── стоп ─── скрипт ─── продолжение HTML ───► DOMContentLoaded
    
    defer:
    HTML ──────────────────────────────── DOMContentLoaded
                           └─ скрипт (после парсинга, до DOMContentLoaded)
    
    async:
    HTML ─────────── (продолжает) ──────────────────────
                  └─ скрипт (немедленно после загрузки, порядок не гарантирован)
    // defer — скрипт выполняется после парсинга HTML, сохраняет порядок
    // Лучший вариант для большинства скриптов
    <script defer src="app.js"></script>
    
    // async — скрипт загружается параллельно, выполняется сразу после загрузки
    // Подходит для независимых скриптов (счётчики, чаты)
    <script async src="analytics.js"></script>

    Типичные ошибки

    1. Инициализация вне DOMContentLoaded — DOM ещё не готов

    // ПЛОХО — скрипт в <head>, DOM ещё не построен
    const btn = document.getElementById('submit-btn')  // null!
    btn.addEventListener('click', handleSubmit)        // TypeError: Cannot read properties of null
    
    // ХОРОШО
    document.addEventListener('DOMContentLoaded', () => {
      const btn = document.getElementById('submit-btn')  // элемент найден
      btn.addEventListener('click', handleSubmit)
    })

    2. Использование fetch в unload — запрос не успевает отправиться

    // ПЛОХО — браузер может убить страницу до завершения fetch
    window.addEventListener('unload', () => {
      fetch('/api/session-end', { method: 'POST', body: JSON.stringify(stats) })
      // Этот запрос, скорее всего, не дойдёт до сервера
    })
    
    // ХОРОШО — sendBeacon гарантированно отправляется даже при закрытии
    window.addEventListener('unload', () => {
      navigator.sendBeacon('/api/session-end', JSON.stringify(stats))
    })

    3. Игнорирование readyState при динамическом добавлении скриптов

    // ПЛОХО — если скрипт добавлен после DOMContentLoaded, событие уже не придёт
    document.addEventListener('DOMContentLoaded', init)  // пропустит!
    
    // ХОРОШО — universальная проверка
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init)
    } else {
      init()  // уже готов — запускаем сразу
    }

    В реальных проектах

  • SPA (React/Vue/Angular): точка входа main.js всегда ждёт DOMContentLoaded или монтируется после него через defer
  • Аналитика (GA, Яндекс.Метрика): подключается через async — не блокирует загрузку страницы
  • Формы с несохранёнными данными: Notion, Google Docs используют beforeunload для предупреждения при уходе с несохранёнными изменениями
  • Session tracking: маркетинговые системы отправляют длительность сессии через navigator.sendBeacon в unload
  • Примеры

    Машина состояний document.readyState и паттерн инициализации приложения

    // Симуляция жизненного цикла страницы без DOM
    // Моделируем переходы readyState и порядок событий
    
    class PageLifecycleSimulator {
      constructor() {
        this._readyState = 'loading'
        this._listeners = { DOMContentLoaded: [], load: [], beforeunload: [], unload: [] }
        this._log = []
      }
    
      // Подписка на события (аналог document/window.addEventListener)
      onDocument(event, handler) {
        if (this._listeners[event]) {
          this._listeners[event].push(handler)
        }
      }
    
      _emit(eventName) {
        this._log.push(`[событие] ${eventName}`)
        this._listeners[eventName]?.forEach(fn => {
          try { fn() } catch (e) { this._log.push(`[ошибка] ${e.message}`) }
        })
      }
    
      // Симулируем этапы загрузки страницы
      simulateLoad() {
        this._log.push('[состояние] loading — HTML парсится...')
    
        // HTML разобран → DOM готов
        this._readyState = 'interactive'
        this._log.push('[состояние] interactive — DOM готов')
        this._emit('DOMContentLoaded')
    
        // Ресурсы загружены → страница полностью готова
        this._readyState = 'complete'
        this._log.push('[состояние] complete — все ресурсы загружены')
        this._emit('load')
      }
    
      simulateUnload(hasUnsavedData) {
        this._log.push('[событие] пользователь уходит со страницы...')
    
        let cancelled = false
        const beforeunloadEvent = {
          preventDefault: () => { cancelled = true },
          returnValue: '',
        }
    
        this._listeners.beforeunload.forEach(fn => fn(beforeunloadEvent))
    
        if (cancelled) {
          this._log.push('[диалог] "Вы уверены что хотите покинуть страницу?"')
          if (!hasUnsavedData) {
            this._log.push('[результат] пользователь подтвердил уход')
            this._emit('unload')
          } else {
            this._log.push('[результат] пользователь остался на странице')
            return
          }
        } else {
          this._emit('unload')
        }
      }
    
      getLog() { return this._log }
      get readyState() { return this._readyState }
    }
    
    // === Использование симулятора ===
    const page = new PageLifecycleSimulator()
    
    // Регистрируем обработчики — как в реальном приложении
    const sessionStart = 1700000000000  // мок-время
    
    page.onDocument('DOMContentLoaded', () => {
      console.log('1. DOMContentLoaded: инициализируем маршрутизатор')
      console.log('2. DOMContentLoaded: рендерим навигацию')
      console.log('3. DOMContentLoaded: подключаем обработчики форм')
    })
    
    page.onDocument('load', () => {
      console.log('4. load: инициализируем lazy-загрузку изображений')
      console.log('5. load: запускаем мониторинг производительности')
      const loadTime = Date.now() - sessionStart
      console.log(`6. load: время загрузки страницы ~ ${loadTime > 0 ? loadTime : 800}ms`)
    })
    
    page.onDocument('beforeunload', (event) => {
      const unsaved = true  // симулируем несохранённые данные
      if (unsaved) {
        event.preventDefault()
        event.returnValue = ''
      }
    })
    
    page.onDocument('unload', () => {
      console.log('7. unload: отправляем аналитику через navigator.sendBeacon')
      console.log('   POST /api/session-end { duration: 45000 }')
    })
    
    // Симулируем полный жизненный цикл
    console.log('=== ЗАГРУЗКА СТРАНИЦЫ ===')
    page.simulateLoad()
    
    console.log('\nreadyState после загрузки:', page.readyState)  // 'complete'
    
    console.log('\n=== УХОД СО СТРАНИЦЫ (несохранённые данные) ===')
    page.simulateUnload(true)  // пользователь остаётся
    
    console.log('\n=== ЛОГ ЖИЗНЕННОГО ЦИКЛА ===')
    page.getLog().forEach(entry => console.log(entry))
    
    // Функция initWhenReady — реальный паттерн
    console.log('\n=== Паттерн initWhenReady ===')
    function initWhenReady(currentState, callback) {
      if (currentState === 'loading') {
        console.log('DOM ещё не готов — подписываемся на DOMContentLoaded')
      } else {
        console.log('DOM уже готов — выполняем callback немедленно')
        callback()
      }
    }
    
    initWhenReady('complete', () => {
      console.log('Инициализация выполнена!')
    })

    Задание

    Напиши функцию initApp(readyState), которая принимает строку readyState ("loading", "interactive" или "complete") и возвращает объект { actions: string[], canAccessDOM: boolean, canAccessImages: boolean }. Для каждого состояния верни список действий которые можно выполнить и флаги доступности DOM и изображений.

    Подсказка

    case "loading": canAccessDOM = false, canAccessImages = false. case "interactive": canAccessDOM = true, canAccessImages = false. case "complete": canAccessDOM = true, canAccessImages = true. actions — любой разумный массив строк для каждого состояния.

    Загружаем среду выполнения...
    Загружаем AI-помощника...