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 предоставляет другой набор глобальных объектов. Поэтому один и тот же синтаксически верный модуль может работать в странице и падать на сервере.
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.