Різниця між `export default thing` та `export { thing as default }`

9 хв. читання

Пропонуємо сьогодні поговорити про кругові залежності в 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 functionexport 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 повинен був теж стати особливим випадком для узгодженості.

Ну, от і все! Сьогодні ми дізналися багато нового, але запам'ятайте головне — просто уникайте кругових залежностей 😀.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.9K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація