Пропонуємо сьогодні поговорити про кругові залежності в JavaScript без довгих передмов. Поїхали!
Імпорти — це посилання, а не значення
Ось приклад імпорту:
import { thing } from './module.js';
У цьому прикладі thing
є тим самим, що й thing
у ./module.js
. Можливо, це звучить очевидно, але як щодо:
const module = await import('./module.js');
const { thing: destructuredThing } = await import('./module.js');
У цьому випадку module.thing
є тим самим, що й thing
у ./module.js
. Водночас destructuredThing
— це новий ідентифікатор, якому присвоєно значення thing
у ./module.js
, поведінка якого інакша.
Припустимо, це ./module.js
:
// module.js
export let thing = 'initial';
setTimeout(() => {
thing = 'changed';
}, 500);
А це ./main.js
:
// main.js
import { thing as importedThing } from './module.js';
const module = await import('./module.js');
let { thing } = await import('./module.js');
setTimeout(() => {
console.log(importedThing); // "changed"
console.log(module.thing); // "changed"
console.log(thing); // "initial"
}, 1000);
Імпорти — це «живі прив'язки (bindings)», або «посилання» в інших мовах. Це означає, що коли для thing
у module.js
присвоюється інше значення, то зміни відбиваються на імпорті у main.js
. Деструктурований імпорт не підхоплює зміни, оскільки деструктуризація призначає поточне значення (а не посилання) до нового ідентифікатора.
Деструктуризація поводиться так не лише з імпортами:
const obj = { foo: 'bar' };
// Це скорочення для:
// let foo = obj.foo;
let { foo } = obj;
obj.foo = 'hello';
console.log(foo); // Досі "bar"
Наведений код виглядає правильним, але помилка полягає в тому, що названий статичний імпорт (import { thing } …
) виглядає, як деструктуризація, але поводиться не так.
Добре, ось до чого ми доходимо:
// Це дає «живі» (live) посилання на експортовані thing:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// Це присвоює поточне значення експорту новому ідентифікатору:
let { thing } = await import('./module.js');
Але «стандартний імпорт» працює інакше
Ось ./module.js
:
// module.js
let thing = 'initial';
export { thing };
export default thing;
setTimeout(() => {
thing = 'changed';
}, 500);
І ось ./main.js
:
// main.js
import { thing, default as defaultThing } from './module.js';
import anotherDefaultThing from './module.js';
setTimeout(() => {
console.log(thing); // "changed"
console.log(defaultThing); // "initial"
console.log(anotherDefaultThing); // "initial"
}, 1000);
…і ми не очікували, що отримаємо «initial»!
Але… чому?
Ви можете export default
(експортувати типове) значення напряму:
export default 'hello!';
…чого ви не можете зробити з експортом, який має назву:
// Так це не працює:
export { 'hello!' as thing };
Щоб код export default 'hello!'
працював, специфікація надає для export default thing
іншу семантику, ніж для export thing
. Оскільки export default
обробляється як вираз, тож можна виконати export default 'hello!'
і export default 1 + 2
. Це також «працює» для export default thing
, але оскільки thing
розглядається як вираз, то thing
має бути прийнятим за значенням. Це все одно, що його призначено прихованій змінній перед експортуванням, а отже, коли для thing
присвоєно нове значення у setTimeout
, ця зміна не відбивається у прихованій змінній, яка фактично експортується.
Тож:
// Це дає «живі» (live) посилання на експортовані thing:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// Це присвоює поточне значення експорту новому ідентифікатору:
let { thing } = await import('./module.js');
// Це експорти «живих» (live) посилань:
export { thing };
export { thing as otherName };
// Це експорти поточного значення:
export default thing;
export default 'hello!';
У 'export { thing as default }' все інакше
Оскільки ви не можете скористатися export {}
для безпосереднього експортування значень, він завжди передає активне посилання. Приклад:
// module.js
let thing = 'initial';
export { thing, thing as default };
setTimeout(() => {
thing = 'changed';
}, 500);
І той самий ./main.js
, що й раніше:
// main.js
import { thing, default as defaultThing } from './module.js';
import anotherDefaultThing from './module.js';
setTimeout(() => {
console.log(thing); // "changed"
console.log(defaultThing); // "changed"
console.log(anotherDefaultThing); // "changed"
}, 1000);
export { thing as default }
експортує thing
як активне посилання, на відміну від export default thing
. Дивимося:
// Це дає «живі» (live) посилання на експортовані thing:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// Це присвоює поточне значення експорту новому ідентифікатору:
let { thing } = await import('./module.js');
// Це експорти «живих» (live) посилань:
export { thing };
export { thing as otherName };
export { thing as default };
// Це експорти поточного значення:
export default thing;
export default 'hello!';
Весело, га? Але ми ще не закінчили…
'export default function' — це ще один особливий випадок
Ми казали, що export default
обробляється як вираз, але це правило має винятки. Дивимося:
// module.js
export default function thing() {}
setTimeout(() => {
thing = 'changed';
}, 500);
Та:
// main.js
import thing from './module.js';
setTimeout(() => {
console.log(thing); // "changed"
}, 1000);
console.log
повертає "changed"
(змінене) значення, оскільки export default function
має свою особливу семантику; функція в цьому випадку передається за посиланням. Якщо ми змінимо module.js
на:
// module.js
function thing() {}
export default thing;
setTimeout(() => {
thing = 'changed';
}, 500);
…він більше не підпадає під визначення особливого випадку, тому він реєструється як ƒ thing() {}
, оскільки знову передається за значенням.
Але… чому?
Це стосується не лише export default function
— export default class
теж є особливим випадком. Це пов'язано з тим, як ці оператори змінюють поведінку, коли вони є виразами:
Але якщо зробити їх виразами:
function someFunction() {}
class SomeClass {}
console.log(typeof someFunction); // "function"
console.log(typeof SomeClass); // "function"
Оператори function
і class
створюють ідентифікатор у області/блоці, тоді як вирази function
і class
цього не роблять (хоча їхні назви можуть вживатися всередині функції/класу).
Поглянемо:
export default function someFunction() {}
console.log(typeof someFunction); // "function"
Якби export default function
не був особливим випадком, тоді функція оброблялась би як вираз, а console.log був би "undefined"
. Функції, які є особливими випадками, також допомагають з круговими залежностями, але ми розглянемо це згодом.
Підсумуємо:
// Це дає «живі» (live) посилання на експортовані thing:
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// Це присвоює поточне значення експорту новому ідентифікатору:
let { thing } = await import('./module.js');
// Це експорти «живих» (live) посилань:
export { thing };
export { thing as otherName };
export { thing as default };
export default function thing() {}
// Це експорти поточного значення:
export default thing;
export default 'hello!';
Це наче робить export default identifier
непарним. Ми розуміємо, що export default 'hello!'
потрібно передавати за значенням. Але це особливий випадок, тож export default function
передається за посиланням, і здається, що для export default identifier
теж повинен бути особливий випадок. Напевно, зараз це вже занадто пізно змінювати.
Дейв Герман, який брав участь у розробці модулів JavaScript каже, що деякі попередні розробки export default
були у формі export default = thing
, що зробило б очевиднішим, що thing
розглядається як вираз. Важко не погодитися!
А як щодо кругових залежностей?
Підняття (hoisting)
Можливо, ви стикалися з давньою дивиною JavaScript для функцій:
thisWorks();
function thisWorks() {
console.log('yep, it does');
}
Визначення функції по суті переміщені вгору файлу. Насправді це відбувається лише з декларуванням звичайних функцій:
// Не працює
assignedFunction();
// Теж не працює
new SomeClass();
const assignedFunction = function () {
console.log('nope');
};
class SomeClass {}
Якщо ви спробуєте отримати доступ до ідентифікаторів let
/const
/class
до їхнього створення, то повернеться помилка.
Все інакше з var
…тому що він такий.
var foo = 'bar';
function test() {
console.log(foo);
var foo = 'hello';
}
test();
Згадані console.log повертають undefined
, оскільки декларація var foo
у функції підіймається до початку роботи функції, але призначення 'hello'
залишається там, де воно є. Це така собі пастка, саме тому let
/const
/class
повертають помилку у схожих випадках.
То що ж з круговими залежностями?
Кругові залежності дозволені у JavaScript, але вони безладні і їх варто уникати. Наприклад, за допомогою:
// main.js
import { foo } from './module.js';
foo();
export function hello() {
console.log('hello');
}
Та:
// module.js
import { hello } from './main.js';
hello();
export function foo() {
console.log('foo');
}
Це працює! console.log повертає "hello"
, а потім "foo"
. Однак це діє лише через підняття, яке підіймає визначення обох функцій до їх виклику. Якщо ми змінимо код:
// main.js
import { foo } from './module.js';
foo();
export const hello = () => console.log('hello');
Та:
// module.js
import { hello } from './main.js';
hello();
export const foo = () => console.log('foo');
…код не виконається. Спочатку виконується module.js
, і в результаті він намагається отримати доступ до hello
раніше за його створення і видає помилку.
Спробуймо залучити export default
:
// main.js
import foo from './module.js';
foo();
function hello() {
console.log('hello');
}
export default hello;
Та:
// module.js
import hello from './main.js';
hello();
function foo() {
console.log('foo');
}
export default foo;
Попередній код не виконується, оскільки hello
у module.js
вказує на приховану змінну, експортовану main.js
. До неї здійснюється спроба доступу раніше за її ініціалізацію.
Якщо у main.js
застосувати export { hello as default }
, помилки не станеться, оскільки функція передаватиметься за посиланням і підійматиметься. Якщо у main.js
застосувати export default function hello()
, помилки теж не станеться, але цього разу це відбувається через його потрапляння до супермагічного особливого випадку export default function
.
Схоже, що це ще одна причина, чому export default function
було додано до особливих випадків — аби підіймання виконувалося як слід. Але знову ж таки, схоже що export default identifier
повинен був теж стати особливим випадком для узгодженості.
Ну, от і все! Сьогодні ми дізналися багато нового, але запам'ятайте головне — просто уникайте кругових залежностей 😀.
Ще немає коментарів