This content originally appeared on Jake Archibald's blog and was authored by Jake Archibald's blog
Dominic Elm DM'd me on Twitter to ask me questions about circular dependencies, and, well, I didn't know the answer. After some testing, discussion, and *ahem* chatting to the V8 team, we figured it out, but I learned something new about JavaScript along the way.
I'm going to leave the circular dependency stuff to the end of the article, as it isn't totally related. First up:
Imports are references, not values
Here's an import:
import { thing } from './module.js';
In the above example, thing
is the same as thing
in ./module.js
. I know that maybe sounds obvious, but what about:
const module = await import('./module.js');
const { thing: destructuredThing } = await import('./module.js');
In this case module.thing
is the same as thing
in ./module.js
, whereas destructuredThing
is a new identifier that's assigned the value of thing
in ./module.js
, and that behaves differently.
Let's say this is ./module.js
:
// module.js
export let thing = 'initial';
setTimeout(() => {
thing = 'changed';
}, 500);
And this is ./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);
Imports are live references, so they pick up the changes. The destructured import doesn't pick up the change because destructuring assigns the current value to a new identifier rather than a live reference.
It's similar to how this works:
const obj = { foo: 'bar' };
// This is shorthand for:
// let foo = obj.foo;
let { foo } = obj;
obj.foo = 'hello';
console.log(foo); // Still "bar"
Ok, so here's where we're at:
// 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');
But 'export default' works differently
Here's ./module.js
:
// module.js
let thing = 'initial';
export { thing };
export default thing;
setTimeout(() => {
thing = 'changed';
}, 500);
And ./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);
…and I wasn't expecting those to be "initial"
!
But… why?
You can export default
a value directly:
export default 'hello!';
…which is something you can't do with named exports:
// This doesn't work:
export { 'hello!' as thing };
To make export default 'hello!'
work, the spec gives export default thing
different semantics. Instead of passing thing
by reference (which would be impossible with 'hello!'
), it passes it 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.
So:
// 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!';
And 'export { thing as default }' is different
Since you can't use export {}
to export values directly, it always passes a live reference. So:
// module.js
let thing = 'initial';
export { thing, thing as default };
setTimeout(() => {
thing = 'changed';
}, 500);
And:
// 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);
Unlike export default thing
, export { thing as default }
exports thing
as a live reference. So:
// 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!';
Fun eh? Oh, we're not done yet…
'export default function' is another special case
So, with:
// module.js
export default function thing() {}
setTimeout(() => {
thing = 'changed';
}, 500);
And:
// main.js
import thing from './module.js';
setTimeout(() => {
console.log(thing); // "changed"
}, 1000);
It logs "changed"
, because export default function
is given its own special semantics; the function is passed by reference in this case. If we change module.js
to:
// module.js
function thing() {}
export default thing;
setTimeout(() => {
thing = 'changed';
}, 500);
…it no longer matches the special case, so it logs ƒ thing() {}
, as it's passed by value again. To sum up:
// 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!';
What about circular dependencies?
This came to light when Dominic messaged me about circular dependencies. First we need to talk about 'hoisting':
Hoisting
You might have encountered the age-old weird thing JavaScript does to functions:
thisWorks();
function thisWorks() {
console.log('yep, it does');
}
Function definitions are essentially moved to the top of the file. That only really happens with plain function declarations:
// Doesn't work
assignedFunction();
// Doesn't work either
new SomeClass();
const assignedFunction = function () {
console.log('nope');
};
class SomeClass {}
If you try to access a let
/const
/class
identifier before it's instantiated, it throws an error.
var is different
…because of course it is.
var foo = 'bar';
function test() {
console.log(foo);
var foo = 'hello';
}
test();
The above logs undefined
, because the declaration of var foo
in the function is hoisted to the start of the function, but the assignment of 'hello'
is left where it is.
What about circular dependencies?
Circular dependencies are allowed in JavaScript, but they're messy and should be avoided. For example, with:
// main.js
import { foo } from './module.js';
foo();
export function hello() {
console.log('hello');
}
And:
// module.js
import { hello } from './main.js';
hello();
export function foo() {
console.log('foo');
}
This works! It logs "hello"
then "foo"
. However, this only works due to hoisting, which lifts both function definitions above both of their calls. If we change the code to:
// main.js
import { foo } from './module.js';
foo();
export const hello = () => console.log('hello');
And:
// module.js
import { hello } from './main.js';
hello();
export const foo = () => console.log('foo');
…it fails. module.js
executes first, and as a result it tries to access hello
before it's instantiated, and throws an error.
Let's get export default
involved with:
// main.js
import foo from './module.js';
foo();
function hello() {
console.log('hello');
}
export default hello;
And:
// module.js
import hello from './main.js';
hello();
function foo() {
console.log('foo');
}
export default foo;
This is the example Dominic gave me. The above fails, because hello
in module.js
points to the hidden variable exported by main.js
, and it's accessed before it's initialized.
If main.js
is changed to use export { hello as default }
, it doesn't fail, because it's passing the function by reference and gets hoisted. If main.js
is changed to use export default function hello()
, again it doesn't fail, but this time it's because it hits that super-magic-special-case of export default function
.
So there you go! I learned something new. But, as with my last few posts, please don't add this to your interview questions, just avoid circular dependencies ?.
Huge thanks to Toon Verwaest, Marja Hölttä, and Mathias Bynens from the V8 team for making sure I'm using the correct terminology throughout this post, proof-readers Surma, Adam Argyle, Ada Rose Cannon, Remy Sharp, Lea Verou (heh, I got a lot of folks to read this, I wanted it to make as much sense as possible) and of course thanks to Dominic Elm for triggering this whole adventure!
This content originally appeared on Jake Archibald's blog and was authored by Jake Archibald's blog
Jake Archibald's blog | Sciencx (2021-07-03T01:00:00+00:00) `export default thing` is different to `export { thing as default }`. Retrieved from https://www.scien.cx/2021/07/03/export-default-thing-is-different-to-export-thing-as-default/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.