Галоўная / Артыкулы

Там, дзе запісана функцыя, вялічыць тое, што яна бачыць: лексычны дыялект 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, прыемнікаў, таймераў, promises і hooks. Кожны рендер стварае новую среду з значэннямі гэтага рендера, а прыемнікі, заданыя ў яго час, вжываюць саме гэтыя значэння. Гэта є база клозураў, а таксама «старых» клозураў, калі прыемнік з паканальнага рендера продовжвае чытаць стары 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 — да блаковых.
    • Рэгламент вялікасці ў JavaScript являе сабою лексычны прынцып: зовнішняе сераўака функцыі зафіксавана там, дзе яна была адзначана, таму ўвокаліванне яе з іншага месца ніколі не змянюе таго, што яна можа бачыць.
    • Процес аднавлення ідэнтыфікатора выкананы з ныявечнага сераўака на зовнішнія, зупыняецца на першам падабенстве (затэніванні), а якщо дасягне канца, выклікаецца ReferenceError.
    • Внутранія функцыі зберагаюць канкрэтныя аднасуненні да зовнішняго сераўака, а не копіі, і гэта являецца адной з базаў клозураў, а таксама прычыной старых клозураў у React.