[자바스크립트] [번역] export default thing vs export { thing as default }

김학재·2021년 7월 26일
0

자바스크립트

목록 보기
16/17

Javascript Weekly를 받아보고는 있었는데 이 공부 저 공부 한다고 소홀히 하다가 최근 흥미로운 글들이 많이 보여 공부 겸 번역해보기로 하였다.

원문 링크 : 'export default thing' is different to 'export { thing as default }'


글쓴이 Jake Archibald는 크롬에서 일하는 개발자로 트위터에서 순환 종속(circular dependencies)에 관한 질문을 받고 이 글을 작성하였다.

import는 값이 아닌 참조다

이것은 import문의 예시입니다.

import { thing } from './module.js'

위 예시에서, thing./module.jsthing과 같습니다. 당연한 소리지만 그렇다면 다음은 어떨까요

const module = await import('./module.js')
const { thing : destructuredThing } = await import ('./module.js')

이 경우에서 module.thing./module.jsthing과 같은 반면에, destructuredThing./module.jsthing의 값을 가지는 새로운 식별자이면서 다르게 동작합니다.

다음은 ./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);

import는 'live binding' (다른 언어에서는 소위, 참조 : reference) 입니다. 이는 module.jsthing에 다른 값이 할당되면, 이 변화는 main.js의 import에 반영되는 것을 의미합니다. destructured import는 새로운 식별자에 현재 값(live reference가 아닌)을 할당하기 때문에 이 변화를 반영하지 않습니다.

destructuring은 import에만 국한되지 않습니다.

const obj = { foo: 'bar' };

// This is shorthand for:
// let foo = obj.foo;
let { foo } = obj;

obj.foo = 'hello';
console.log(foo); // Still "bar"

제 생각에는 위의 내용이 자연스러운 것 같습니다. 여기서 주목할 점은 named static imports (import { thing } ... )이 destructuring과 비슷하게 보이지만 destructuring과는 다르게 동작합니다.

자, 그래서 현재 우리는 이 상태입니다.

// These give you a live reference to the exported thing(s):
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// This assigns the current value of the export to a new identifier:
let { thing } = await import('./module.js');

export default는 다르게 동작한다

./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!';

이런 방식은 named exports로는 불가능합니다.

// This doesn't work
export { 'hello!' as thing };

export default 'hello!'를 가능하게 하는 것은, export thing과 다른 방식입니다. export default 바로 다음 구문은 표현식처럼 처리되서, export default 'hello!'export default 1 + 2와 같은 구문이 가능하게 합니다.

export default thing의 경우에도 같은 방식으로 동작하지만, thing이 표현식처럼 처리되서 값에 의한 전달이 발생하게 됩니다. 이는 즉, export가 되기 바로 전 일종의 숨어있는 변수(hidden variable)에 할당되서 (setTimeout에서 새로운 값 할당), 그 변화가 export 시에 반영되지 않는 것입니다.

To make export default 'hello!' work, the spec gives export default thing different semantics to export thing.
The bit after export default is treated like an expression, which allows for things like export default 'hello!' and export default 1 + 2.
This also 'works' for export default thing, but since thing is treated as an expression it causes thing to be passed by value. It's as if it's assigned to a hidden variable before it's exported, and as such, when thing is assigned a new value in the setTimeout, that change isn't reflected in the hidden variable that's actually exported.

따라서

// These give you a live reference to the exported thing(s):
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// This assigns the current value of the export to a new identifier:
let { thing } = await import('./module.js');

// These export a live reference:
export { thing };
export { thing as otherName };

// These export the current value:
export default thing;
export default 'hello!';

export { thing as default }는 다르다

export {}는 값이 아닌 live reference를 전달하기 때문에 사용할 수 없습니다. 그래서

// module.js
let thing = 'initial';

export { thing, thing as default };

setTimeout(() => {
  thing = 'changed';
}, 500);
// main.js
import { thing, default as defaultThing } from './module.js';
import anotherDefaultThing from './module.js';

setTimeout(() => {
  console.log(thing); // changed
  console.log(defaultThing); // changed
  donsole.log(anotherDefaultThing); // changed
}, 1000);

export default thing과 다르게, export { thing as default }thing을 live reference로 넘겨 줍니다. 따라서,

// These give you a live reference to the exported thing(s):
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// This assigns the current value of the export to a new identifier:
let { thing } = await import('./module.js');

// These export a live reference:
export { thing };
export { thing as otherName };
export { thing as default };
// These export the current value:
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);

export default function은 고유의 특별한 구문이므로 changed가 출력됩니다. 이 경우 함수는 참조에 의해 전달됩니다.
만약 module.js를 바꾼다면

// module.js
function thing() {}

export default thing;

setTimeout(() => {
  thing = 'changed';
}, 500);

더 이상 특별 케이스가 아니므로 값에 의해 전달되서 f thing() {}이 출력됩니다.

왜?
export default function뿐 아니라 export default class도 같은 방식으로 특별한 경우입니다. 이는 이러한 문장이 표현식일 때 나타나는 동작의 변화와 관련이 있습니다.

function someFunction() {}
class SomeClass {}

console.log(typeof someFunction); // "function"
console.log(typeof SomeClass); // "function"

하지만 표현식으로 바꾼다면

(function someFunction() {});
(class SomeClass {});

console.log(typeof someFunction); // "undefined"
console.log(typeof SomeClass); // "undefined"

functionclass 선언문이 스코프/블록에 식별자를 만드는 반면 function, class표현 식은 그렇지 않습니다.

따라서,

export default function someFunction() {}
console.log(typeof someFunction); // "function"

만약 export defatul function이 특수 케이스가 아니었다면, 함수는 표현식처럼 처리되어서 undefined가 출력됐을 것입니다. 특수 케이스는 또한 순환 종속의 경우에도 도움이 됩니다. (이 부분은 후술)

정리하자면

// These give you a live reference to the exported thing(s):
import { thing } from './module.js';
import { thing as otherName } from './module.js';
import * as module from './module.js';
const module = await import('./module.js');
// This assigns the current value of the export to a new identifier:
let { thing } = await import('./module.js');

// These export a live reference:
export { thing };
export { thing as otherName };
export { thing as default };
export default function thing() {}
// These export the current value:
export default thing;
export default 'hello!';

export default identifier가 살짝 이상해 보이긴 합니다. export default 'hello!'는 값에 의해 전달될 필요가 있지만, export default function이라는 참조로 전달되는 특수 케이스가 있는 만큼, export default identifier같은 경우를 위한 특수 케이스가 필요해 보입니다. 제 생각에 지금 바꾸기엔 너무 늦은 것 같네요.

저는 자바스크립트 모듈 디자인과 관련 있는 Dave Herman과 얘기를 나눴습니다. 그는 default exports의 초기 디자인은 thing을 좀 더 명확히 표현식처럼 처리할 수 있는 export default = thing이었다고 했습니다. 전적으로 동의합니다!


순환 종속의 경우에는 어떤가?

우선 hoisting에 대해 알아야 합니다.

thisWorks();

function thisWorks() {
  console.log('yep, it does');
}

순수 함수 선언의 경우 항상 최상단으로 옮겨지게 됩니다.

// Doesn't work
assignedFunction();
// Doesn't work either
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();

위 코드는 함수 내의 var foo는 함수의 최상단으로 호이스팅되지만, 'hello'의 할당은 그대로기 때문에 undefined가 출력됩니다.let, const, class 가 에러를 발생시키는 것과 비슷한 경우입니다.

순환 종속의 경우?
자바스크립트는 순환 종속을 허용하지만 기피해야 합니다. 예를 들어

// 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');
}

이것은 동작합니다! "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;

이 코드가 제가 질문받은 내용입니다. module.jshellomain.js에 의해 export된 숨겨진 변수를 가리키고, 초기화되기 전 접근을 하기 때문에 실패합니다.

만약 main.jsexport { hello as default }로 바뀐다면 함수를 참조에 의해 전달하고 호이스팅되기 때문에 실패하지 않습니다.
만약 main.jsexport default function hello()로 바뀐다면, export default function의 경우이기 때문에 실패하지 않습니다.

제 생각에는 export default function이 특별한 이유가 호이스팅이 가능하게 하는데 있다고 생각합니다. 그러나 export default identifier는 일관성으로 인해 특수한 경우라고 생각합니다.

내용은 여기까지입니다.


내용이 이렇게까지 길 줄은 몰랐다...
다시 읽어보고 내용을 정리해야 겠지만 대충 골조는 export default문의 사용을 기피해야 한다는 점?
이 내용이 내 것이 된다면 코드를 아주 멋드러지게 짤 수 있을 것 같다

profile
YOU ARE BREATHTAKING

0개의 댓글