Главная / Статьи

Место, где записана функция, определяет, что она видит: лексический диапазон в JavaScript

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

Издатель: REACTAPP.TOP11 мин чтения2586 слов

Источники: How JavaScript Finds Variables: Scope, Lexical Environment & Scope Chain

Каждый раз, когда JavaScript обрабатывает имя вроде user, движок должен определить, к какому связыванию оно относится. Самая распространённая ошибка — предположение, что функция-получатель может видеть переменные своей функции-звонка, — встречается как на собеседованиях, так и при работе с багами в продакшене. В этом руководстве рассматриваются от контекста к лексическим средам и процессу разрешения идентификаторов, уделяя особое внимание различию между цепочкой контекстов и стеком вызовов, чтобы вы могли предсказывать любые сложные ситуации с вложенными функциями и чётко мыслить при работе с обработчиками в React.

Вопрос: как вложенная функция находит переменные внешнего уровня?

Начнём с трёх уровней вложенности:

const appName = "HealthApp";

function dashboard() {
  const user = "Jashan";
  function showProfile() {
    const status = "Active";
    console.log(appName);
    console.log(user);
    console.log(status);
  }
  showProfile();
}

dashboard();

showProfile() объявляет только status, но все три вызова console.log выполняются успешно. Каждое имя находится в разном месте:

status  → showProfile()
user    → dashboard()
appName → global

Чтобы это объяснить, достаточно краткой последовательности понятий:

Scope
   ↓
Lexical Scope
   ↓
Lexical Environment
   ↓
Scope Chain
   ↓
Identifier Resolution

Область видимости: где можно использовать имя

Область видимости — это участок кода, в котором можно обращаться к идентификатору: она отвечает на вопрос «откуда я могу использовать это имя?»

function dashboard() {
  const user = "Jashan";
  console.log(user); // ✅
}

dashboard();
console.log(user); // ❌ ReferenceError

user принадлежит функции dashboard(); код вне неё вызывает ошибку ReferenceError. В виде дерева:

GLOBAL
│
└── dashboard()
      └── user

Это приводит к следующему правилу:

Inside dashboard  → accessible ✅
Outside dashboard → inaccessible ❌

Зачем нужна изоляция

Каждая из этих двух функций объявляет переменную data:

function loadPatients() {
  const data = ["Patient A"];
}
function loadAppointments() {
  const data = ["Appointment A"];
}

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

Четыре вида областей видимости

В JavaScript существует четыре основных типа областей видимости:

Scope
├── Global Scope
├── Function Scope
├── Block Scope
└── Module Scope

Глобальная область видимости

В классическом (немодульном) скрипте объявления верхнего уровня принадлежат к глобальной области видимости и доступны из любой точки:

var appName = "HealthApp";

function showAppName() {
  console.log(appName);
}

showAppName() находит appName, поскольку путь его поиска заканчивается в глобальной среде. Есть нюанс: объявления верхнего уровня с ключевым словом var также становятся свойствами глобального объекта (window в браузерах), тогда как объявления с let и const — нет. Старайтесь минимизировать глобальное состояние; оно связывает удаленные фрагменты кода и усложняет тестирование.

Область видимости функции

Все, что объявлено внутри функции, является локальным для нее:

function calculateTotal() {
  const price = 100;
  const tax = 20;
  return price + tax;
}
console.log(price); // ❌

price и tax являются локальными переменными, поэтому последующий вывод вызывает ошибку.

var игнорирует блоки

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

function example() {
  if (true) {
    var message = "Hello";
  }
  console.log(message); // ✅ Hello
}

Блок if не содержит переменной message, поэтому он остается читаемым после завершения блока.

Область видимости блока с использованием let и const

Переменные let и const ограничены блоком, в котором они объявлены:

if (true) {
  const message = "Payment successful";
  let status = "completed";
}

console.log(message); // ❌
console.log(status);  // ❌

Оба логирования не сработали. Циклы ведут себя одинаково, причем при каждой итерации цикла for (let ...) создается новая ссылка на переменную i:

for (let i = 0; i < 3; i++) {
  console.log(i);
}
console.log(i); // ❌

Обратите внимание, что фигурные скобки объектного литерала описывают значение, а не блок, поэтому они не создают области видимости.

Область видимости модуля

В ES-модулях объявления верхнего уровня являются приватными для данного файла:

// auth.js
const token = "secret";
export function getUser() {}

Другой модуль не может видеть переменную token:

// dashboard.js
console.log(token); // ❌

Только то, что модуль явно экспортирует, может быть импортировано в другом месте:

import { getUser } from "./auth.js";

Это стандартный диапазон видимости в проектах React, Next.js, Vite и современных проектах Node.js.

Лексический диапазон видимости: определяется положением в исходном коде

В JavaScript действует лексический диапазон видимости: функция имеет доступ к переменным всех уровней диапазона, которые находятся выше её в исходном коде. Эта структура фиксируется при парсинге кода, ещё до его выполнения.

const name = "Global";

function outer() {
  const name = "Outer";
  function inner() {
    console.log(name);
  }
  inner();
}

outer();

Программа выводит:

Outer

inner() находится внутри outer() в исходном коде, поэтому переменная name из outer является наиболее близкой:

GLOBAL
│
└── outer()
      ├── name = "Outer"
      │
      └── inner()

Поиск переменной name происходит от inner наружу и останавливается у outer:

inner
  ↓
not found locally
  ↓
outer
  ↓
name = "Outer" ✅

Лексический диапазон видимости определяется местом определения функции, а не местом её вызова.

Ловушка на собеседовании: определение против вызова

Теперь вынесите inner() из outer(), при этом outer() должен по-прежнему вызывать его:

const name = "Global";
function outer() {
  const name = "Outer";
  inner();
}
function inner() {
  console.log(name);
}
outer();

Часто делают такой предположение:

Outer

Фактический результат будет таким:

Global

inner() определен на верхнем уровне, рядом с outer(), а не внутри него.

GLOBAL
├── name = "Global"
├── outer()
└── inner()

Поэтому путь поиска к нему идет напрямую в глобальную среду:

inner
  ↓
Global

Он не проходит через outer:

inner
  ↓
outer ❌

То, что outer() вызывал inner(), ничего не меняет. Место вызова формирует стек вызовов; место определения определяет лексическую область видимости.

Лексические среды: механизм области видимости

Спецификация ECMAScript моделирует область видимости с помощью лексических сред. Каждая из них хранит две вещи:

  1. идентификаторы, связанные с этой средой
  • ссылка на среду, которая её окружает
  • В качестве ментальной модели:

    Lexical Environment
    │
    ├── Environment Record
    └── Outer Environment Reference
    

    В новых версиях спецификации внешняя ссылка включается в запись среды в качестве поля [[OuterEnv]]; модель остаётся прежней.

    Запись среды хранит связи

    Возьмём функцию с двумя локальными константами:

    function dashboard() {
      const user = "Jashan";
      const role = "Admin";
    }
    

    Концептуально её запись среды соответствует соотнесению имен с значениями:

    dashboard Environment
    user → "Jashan"
    role → "Admin"
    

    Это модель, а не объект, который можно исследовать в коде.

    Внешняя ссылка связывает среды между собой

    Каждая среда указывает на ту, что лексически её окружает:

    const appName = "HealthApp";
    
    function dashboard() {
      const user = "Jashan";
    }
    

    Для функции dashboard() это выглядит так:

    dashboard Environment
    │
    ├── user = "Jashan"
    └── Outer
          ↓
    Global Environment
    

    Этот указатель позволяет осуществлять поиск за пределами локального области видимости. Он устанавливается при создании функции в зависимости от места её определения, поэтому место вызова не имеет значения.

    Прослеживание полной цепочки

    Вернёмся к начальному примеру с объединёнными тремя записями в журнале:

    const appName = "HealthApp";
    
    function dashboard() {
      const user = "Jashan";
      function showProfile() {
        const status = "Active";
        console.log(appName, user, status);
      }
      showProfile();
    }
    
    dashboard();
    

    Когда выполняется функция showProfile(), её среды связаны следующим образом:

    SHOWPROFILE ENVIRONMENT
    │
    ├── status = "Active"
    └── Outer
          ↓
    DASHBOARD ENVIRONMENT
    │
    ├── user = "Jashan"
    └── Outer
          ↓
    GLOBAL ENVIRONMENT
    │
    ├── appName = "HealthApp"
    └── Outer
          ↓
         null
    

    Внешний указатель глобальной среды равен null, что завершает цепочку.

    Цепочка областей видимости и разрешение идентификаторов

    Цепочка областей видимости — это путь, который следует движок для поиска идентификатора: сначала проверяется текущая среда, затем следуется внешний указатель, пока имя не будет найдено или цепочка не закончится.

    Current Environment
            ↓
    Outer Environment
            ↓
    Outer Environment
            ↓
    Global
            ↓
    null
    

    Поиск связи по этому пути называется разрешением идентификаторов. Рассмотрим три операции поиска:

    console.log(status);
    console.log(user);
    console.log(appName);
    

    Решение проблемы с status

    Найдено непосредственно в локальной среде:

    showProfile
         ↓
    status found ✅
    

    Решение проблемы с user

    Отсутствует в локальной среде, найдено на уровень выше:

    showProfile ❌
         ↓
    dashboard
         ↓
    user found ✅
    

    Решение проблемы с appName

    Отсутствует в обеих функциях, найдено в глобальной среде:

    showProfile ❌
         ↓
    dashboard ❌
         ↓
    Global
         ↓
    appName found ✅
    

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

    Когда поиск не удается

    Рассмотрим функцию, которая пытается получить доступ к имени, которое никто не объявлял:

    function showProfile() {
      console.log(age);
    }
    

    и вызов этой функции:

    showProfile();
    

    Если age отсутствует ни в одной из доступных сред, поиск завершается:

    showProfile
         ↓
    Global
         ↓
    null
    

    Энджин не может разрешить имя и выбрасывает ошибку:

    ReferenceError: age is not defined
    

    Это отличается от переменной, содержащей значение undefined, или от связи типа let, которая ещё не инициализирована (так называемая временная зона мертвых значений). Эти три состояния легко путать:

    undefined
    uninitialized
    undeclared
    

    Три правила, охватывающих большинство случаев

    Поиск происходит только внешне

    Inner
      ↓
    Outer
      ↓
    Global
    

    Внутренний код может читать переменные внешнего уровня. Внешний код не может обращаться к переменным, объявленным только в вложенной области видимости.

    Побеждает ближайшая связь

    Когда одно и то же имя существует на нескольких уровнях, используется самая близкая к месту вызова связь:

    const user = "Global User";
    
    function dashboard() {
      const user = "Dashboard User";
      function showProfile() {
        console.log(user);
      }
      showProfile();
    }
    

    Результат:

    Dashboard User
    

    Поиск завершается в разделе dashboard, то есть на уровень выше:

    showProfile ❌
         ↓
    dashboard ✅
         ↓
    STOP
    

    Глобальная переменная user никогда не используется. Такое скрытие внешней переменной внутренней называется затенением.

    В вложенные функции ничего не копируется

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

    showProfile
    ├── copied user
    └── copied appName
    

    На самом деле существует цепочка ссылок:

    showProfile
         ↓
    dashboard
         ↓
    Global
    

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

    Цепочка областей видимости против стека вызовов

    Вот пример ловушки снова:

    const name = "Global";
    
    function outer() {
      const name = "Outer";
      inner();
    }
    function inner() {
      console.log(name);
    }
    
    outer();
    

    Во время выполнения inner() стек вызовов выглядит так:

    ┌──────────────────┐
    │    inner FEC     │ ← Current
    ├──────────────────┤
    │    outer FEC     │
    ├──────────────────┤
    │    Global EC     │
    └──────────────────┘
    

    outer() явно находится в стеке, поскольку он вызвал inner(). Тем не менее inner() всё равно выводит:

    Global
    

    потому что поиск переменных происходит в соответствии с цепочкой областей видимости, а цепочка inner ведёт непосредственно к глобальной среде. Эти две структуры отвечают на разные вопросы:

    • Стек вызовов отслеживает контексты выполнения, которые в настоящее время активны; цепочка областей видимости разрешает идентификаторы.
    • Стек вызовов упорядочен по тому, какая функция вызывает другую; цепочка областей видимости формируется в зависимости от структуры вложенности исходного кода.
    • В стеке вызовов действует принцип «последний добавлен — первым удаляется»; цепочка областей видимости следует по ссылкам на внешнюю среду.
    • Стек вызовов отвечает на вопрос «что выполняется прямо сейчас?»; цепочка областей видимости отвечает на вопрос «где находится этот идентификатор?»

    Контексты выполнения и лексические среды

    Контекст выполнения

    Контекст выполнения хранит состояние, необходимое во время работы кода. Существуют два основных типа:

    Global Execution Context
    Function Execution Context
    

    Лексическая среда

    Лексическая среда хранит связи идентификаторов и ссылку на её внешнюю среду. Контекст выполнения обращается к своей среде примерно таким образом:

    Execution Context
          │
          └── environment information
                   ↓
           Lexical Environment
                   │
                   ├── bindings
                   └── outer environment
    

    Следовательно, это не одно и то же:

    Execution Context ≠ Lexical Environment
    

    Контекст выполнения объясняет, как выполняется код; лексическая среда объясняет, как находятся имена.

    Лексический диапазон в компонентах React

    Код React постоянно зависит от лексического диапазона. Рассмотрим обработчик события, определённый внутри компонента:

    function UserDashboard({ userId }: { userId: string }) {
      const role = "Admin";
    
      function handleProfileClick() {
        console.log(userId);
        console.log(role);
      }
    
      return (
        <button onClick={handleProfileClick}>
          View Profile
        </button>
      );
    }
    

    handleProfileClick() не объявляет ни одного из этих имен:

    userId
    role
    

    Однако он всё равно их использует, потому что написан внутри UserDashboard:

    handleProfileClick
            ↓
    UserDashboard
            ├── userId
            └── role
    

    Тот же механизм применяется к props, state, обработчикам, таймерам, обещаниям и хукам. Каждая перерисовка создаёт новую среду с значениями этой перерисовки, и обработчики, определённые во время неё, оперируют именно этими значениями. В этом заключается основа замыканий, а также проблемы устаревших замыканий, при которых обработчик из предыдущей перерисовки продолжает читать старое состояние.

    Распространённые заблуждения

    «Поиск происходит в соответствии с местом вызова»

    Это не так. Важен сайт определения:

    Where function is called ❌
    Where function is defined ✅
    

    «Стек вызовов и цепочка областей видимости — это одно и то же»

    Они выполняют разные функции:

    Call Stack  → execution
    Scope Chain → identifier lookup
    

    «Внешний код может читать переменные внутреннего блока»

    Поиск однонаправленный:

    Inner → Outer → Global ✅
    Outer → Inner ❌
    

    «Ключевое слово var учитывает блоки»

    Это не так; это делают только let и const:

    var       → function-scoped
    let/const → block-scoped
    

    «Лексическая среда — это обычный объект»

    Это концепция спецификации; движки реализуют её по-своему, и ваш код не может к ней обратиться.

    Модель мышления, которую следует помнить

    Эта тема сводится к цепочке вопросов:

    Scope
       ↓
    Where can I use this variable?
       ↓
    Lexical Scope
       ↓
    Where was this function defined?
    Lexical Environment
       ↓
    What variables exist here?
    Outer Environment
       ↓
    Where do I look next?
    Scope Chain
       ↓
    Current → Outer → Global
    Identifier Resolution
       ↓
    Find the matching variable
    

    Основные выводы

    • Область видимости определяет, где может использоваться имя; в JavaScript существуют глобальная, функциональная, блочная и модульная области видимости, причем var привязан к функциям, а let/const — к блокам.
    • Область видимости является лексической: внешняя среда функции фиксирована там, где она определена, поэтому вызов её из другого места никогда не меняет то, что она может видеть.
    • Резолюция идентификаторов происходит от текущей среды наружу, останавливается при первом совпадении (затенении) и вызывает ошибку ReferenceError, если достигнут конец.
    • Внутренние функции хранят ссылки на внешние среды, а не их копии, что является основой замыканий и проблемы устаревших замыканий в React.