ReferenceError: window is not defined и document is not defined — как исправить

JavaScript Автор: Среда и версия: Браузеры, Node.js, SSR, сборщики и тестовые среды
содержание

ReferenceError: window is not defined и ReferenceError: document is not defined означают одно: код обратился к браузерному глобальному объекту там, где его нет. Чаще всего файл выполняется в Node.js во время SSR, сборки или теста. Та же ошибка возможна в Web Worker: это браузерная среда, но у воркера нет ни window, ни DOM.

window и document не входят в сам язык JavaScript. Их предоставляет браузер для кода страницы: window представляет окно и служит его глобальным объектом, а window.document указывает на загруженный DOM-документ. Node.js предоставляет другой набор глобальных объектов. Поэтому один и тот же синтаксически верный модуль может работать в странице и падать на сервере.

JavaScript / 01

document существует там, где создан DOM

document существует там, где создан DOM01 Node / SSR / build document → ReferenceError Код выполняется на сервере или при импорте. DOM не создан. 02 Окно браузера Window + Document Обращайся к DOM в клиентском событии или эффекте.01Node / SSR / builddocument→ ReferenceErrorКод выполняется на сервере или приимпорте. DOM не создан.02Окно браузераWindow + DocumentОбращайся к DOM в клиентском событииили эффекте.
Имя файла не определяет среду: важны процесс, который выполняет модуль, и фаза выполнения. Браузерный вызов безопасен только в контексте страницы после перехода к клиентской работе.

Сначала выясните, где выполняется код

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

Типичные среды различаются так:

  • в главном окне браузера доступны window и document;
  • в Web Worker нет окна страницы и DOM, хотя код выполняет браузер;
  • в Node.js есть globalThis и набор глобальных API Node.js, но DOM-документ сам по себе не появляется;
  • при SSR компонент сначала вычисляется на сервере, а затем страница подключает клиентский JavaScript;
  • сборщик может выполнять конфигурацию, плагины, генерацию страниц или серверную часть приложения в Node.js;
  • тестовый раннер использует выбранную среду: часто Node.js по умолчанию, а DOM-эмулятор — только по настройке.

Наличие отдельного Web API ещё не доказывает, что код работает в браузере. Например, Node.js поддерживает fetch, URL и AbortController, но это не добавляет к процессу окно страницы или DOM.

Для первичной проверки достаточно typeof: этот оператор не выбрасывает ReferenceError для необъявленного имени.

const runtime = {
  hasWindow: typeof window !== 'undefined',
  hasDocument: typeof document !== 'undefined',
}

console.log(runtime)

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

globalThis не создаёт единый набор API для всех сред. Это общее имя глобального объекта, а не полифил DOM. В обычном Node.js выражение globalThis.document не даёт настоящий документ: свойства может не быть. Замена document на globalThis.document лишь меняет ранний ReferenceError на undefined и часто переносит сбой к следующему обращению к свойству.

Проверьте фазу: импорт, рендер или клиентское событие

Верхнеуровневый код модуля выполняется при первом импорте. Поэтому безобидный на вид импорт может уронить SSR или тест ещё до вызова вашей функции:

// shortcuts.js
document.addEventListener('keydown', handleShortcut)

export function handleShortcut(event) {
  // ...
}
// server-entry.js
import './shortcuts.js'

export function renderPage() {
  // До этой функции выполнение не дойдёт.
}

Статические зависимости выполняются до тела импортирующего модуля. Поэтому поздняя проверка в server-entry.js не защитит побочный эффект, который уже произошёл внутри shortcuts.js.

Та же граница важна в компонентах. Обращение к window или document во время вычисления компонента выполняется и при серверном рендере. Обработчик клика, клиентский эффект или функция инициализации запускаются позже, уже в странице. Сначала определите, нужен ли браузерный результат для исходного HTML. Если нет, перенесите действие на клиентскую фазу. Если нужен, серверу требуется отдельный источник данных, а не фиктивный DOM.

Используйте typeof window, когда отсутствие браузера допустимо

Безопасная проверка необъявленного глобального имени выглядит так:

export function getViewportWidth() {
  if (typeof window === 'undefined') {
    return null
  }

  return window.innerWidth
}

Проверка if (window) не работает: чтобы вычислить условие, движок сначала должен разрешить имя window и сразу выбрасывает ReferenceError. typeof window возвращает строку 'undefined', если имени нет.

Такая проверка уместна, только когда контракт допускает отсутствие браузерного значения. Например, сервер может вернуть null, а клиент измерит область просмотра после подключения. Если функция обязана менять DOM, молча пропускать работу опасно: вызывающий код решит, что операция прошла. Для такой функции лучше явно ограничить место вызова или передать зависимость параметром.

Не проверяйте только window, если дальше нужен document. В некоторых средах и тестовых заглушках набор глобальных свойств неполон. Проверяйте именно используемую возможность:

export function findAppRoot() {
  if (typeof document === 'undefined') {
    return null
  }

  return document.querySelector('[data-app-root]')
}

Уберите браузерные побочные эффекты из импорта

Модуль, который лишь объявляет функции, безопаснее импортировать на сервере, в сборщике и в тесте. Браузерную работу запускает отдельная клиентская точка входа:

// shortcuts.js
export function startShortcuts(doc) {
  function handleShortcut(event) {
    // ...
  }

  doc.addEventListener('keydown', handleShortcut)

  return function stopShortcuts() {
    doc.removeEventListener('keydown', handleShortcut)
  }
}
// client-entry.js
import { startShortcuts } from './shortcuts.js'

if (typeof document !== 'undefined') {
  const stopShortcuts = startShortcuts(document)
  window.addEventListener('pagehide', (event) => {
    if (!event.persisted) stopShortcuts()
  })
}

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

Например, эффекты React выполняются только на клиенте, поэтому подписку можно поставить в useEffect. Рендер компонента при этом остаётся чистым и не читает DOM:

import { useEffect } from 'react'
import { startShortcuts } from './shortcuts.js'

export function Editor() {
  useEffect(() => startShortcuts(document), [])

  return null
}

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

Если сторонняя библиотека обращается к DOM прямо при импорте, вынесите и сам импорт на клиентскую фазу:

export async function startBrowserWidget() {
  if (typeof window === 'undefined') {
    return null
  }

  const { mountWidget } = await import('./browser-widget.js')
  return mountWidget(document.querySelector('[data-widget]'))
}

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

Передавайте DOM-зависимости параметрами

Код легче тестировать, когда его требования видны в сигнатуре. Функции форматирования, расчёта и валидации обычно вообще не должны знать о document. Считайте данные отдельно, а DOM обновляйте в тонком браузерном адаптере:

export function formatCartCount(items) {
  return `Товаров: ${items.length}`
}

export function renderCartCount(doc, items) {
  const output = doc.querySelector('[data-cart-count]')

  if (!output) {
    throw new Error('Не найден элемент [data-cart-count]')
  }

  output.textContent = formatCartCount(items)
}

Тест formatCartCount() не требует DOM. Для адаптера можно передать документ из браузера или из специализированной тестовой среды. Такой параметр полезнее глобальной заглушки: зависимость видна вызывающему коду и не влияет на соседние тесты.

Для API, который используется и на сервере, и в браузере, передавайте небольшой объект с нужными операциями. Не копируйте весь window:

export function saveDraft(storage, value) {
  storage.setItem('draft', value)
}

saveDraft(window.localStorage, editorValue)

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

Не включайте DOM-эмулятор для каждого теста

Среда Node.js полезна тем, что обнаруживает неявную зависимость от браузера. Если тест проверяет расчёт, парсер, запрос к API или серверный рендер, оставьте его без window и document. Случайное обращение к DOM тогда завершит тест именно в месте архитектурной ошибки.

DOM-эмулятор нужен, когда предмет теста — поведение DOM: поиск и создание элементов, изменение атрибутов, распространение событий. В Vitest такую среду можно выбрать для отдельного файла:

// @vitest-environment jsdom

import { describe, expect, it } from 'vitest'
import { renderCartCount } from './cart.js'

describe('renderCartCount', () => {
  it('записывает количество в найденный элемент', () => {
    document.body.innerHTML = '<output data-cart-count></output>'

    renderCartCount(document, [{ id: 1 }, { id: 2 }])

    expect(document.querySelector('[data-cart-count]').textContent).toBe('Товаров: 2')
  })
})

Эмулятор не превращает тест в настоящий браузер. Геометрия, отрисовка, часть навигации и некоторые Web API могут отличаться или отсутствовать. Подключайте его ради проверяемого DOM-контракта, а не чтобы ошибка исчезла во всём наборе тестов.

Что не исправляет причину

Не добавляйте пустой объект в глобальную область:

globalThis.window = {}
globalThis.document = {}

У таких объектов нет контрактов Window и Document. Код упадёт позже на addEventListener, querySelector, location или другом свойстве, а стек станет дальше от причины. Полный DOM-эмулятор оправдан в DOM-тесте; в продакшен-сервере он обычно маскирует неверно выбранную среду и добавляет лишнюю работу.

Не лечит ошибку и замена системы модулей сама по себе. Если рядом появляется require is not defined, это ещё один признак, что смешались ожидания разных сред или форматов модулей; порядок диагностики описан в статье require is not defined. Но переход с CommonJS на ESM не создаёт DOM в Node.js.

Итоговая проверка короткая: найдите первое обращение к браузерному API, установите фактическую среду и фазу выполнения, затем выберите одну границу. Необязательное чтение защищайте через typeof, побочный эффект запускайте в клиентском жизненном цикле, а обязательную браузерную зависимость передавайте явно. Тогда серверный код не притворяется браузером, а браузерный код выполняется там, где действительно существуют Window и Document.

Источники