Remote-Url: https://jakearchibald.com/2021/export-default-thing-vs-thing-as-default/ Retrieved-at: 2022-02-13 22:16:05.433357+00:00 Dominic ElmDM'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 valuesHere's an import:import{thing}from'./module.js';In the above example,thingis the same asthingin./module.js. I know that maybe sounds obvious, but what about:constmodule=awaitimport('./module.js');const{thing:destructuredThing}=awaitimport('./module.js');In this casemodule.thingis the same asthingin./module.js, whereasdestructuredThingis a new identifier that's assigned the value ofthingin./module.js, and that behaves differently.Let's say this is./module.js:exportletthing='initial';setTimeout(()=>{thing='changed';},500);And this is./main.js:import{thingasimportedThing}from'./module.js';constmodule=awaitimport('./module.js');let{thing}=awaitimport('./module.js');setTimeout(()=>{console.log(importedThing);console.log(module.thing);console.log(thing);},1000);Imports are 'live bindings' or what some other languages call a 'reference'. This means when a different value is assigned tothinginmodule.js, that change is reflected in the import inmain.js. The destructured import doesn't pick up the change because destructuring assigns thecurrent value(rather than a live reference) to a new identifier.The destructuring behaviour isn't unique to imports:constobj={foo:'bar'};let{foo}=obj;obj.foo='hello';console.log(foo);The above feels natural in my opinion. The potential gotcha here is that named static imports (import { thing } …) kinda look like destructuring, but they don't behave like destructuring.Ok, so here's where we're at:import{thing}from'./module.js';import{thingasotherName}from'./module.js';import*asmodulefrom'./module.js';constmodule=awaitimport('./module.js');let{thing}=awaitimport('./module.js');But 'export default' works differentlyHere's./module.js:letthing='initial';export{thing};exportdefaultthing;setTimeout(()=>{thing='changed';},500);And./main.js:import{thing,defaultasdefaultThing}from'./module.js';importanotherDefaultThingfrom'./module.js';setTimeout(()=>{console.log(thing);console.log(defaultThing);console.log(anotherDefaultThing);},1000);…and I wasn't expecting those to be"initial"!But… why?You canexport defaulta value directly:…which is something youcan'tdo with named exports:To makeexport default 'hello!'work, the spec givesexport default thingdifferent semantics toexport thing. The bit afterexport defaultis treated like an expression, which allows for things likeexport default 'hello!'andexport default 1 + 2. This also 'works' forexport default thing, but sincethingis treated as an expression it causesthingto be passed by value. It's as if it's assigned to a hidden variable before it's exported, and as such, whenthingis assigned a new value in thesetTimeout, that change isn't reflected in the hidden variable that's actually exported.So:import{thing}from'./module.js';import{thingasotherName}from'./module.js';import*asmodulefrom'./module.js';constmodule=awaitimport('./module.js');let{thing}=awaitimport('./module.js');export{thing};export{thingasotherName};exportdefaultthing;exportdefault'hello!';And 'export { thing as default }' is differentSince you can't useexport {}to export values directly, it always passes a live reference. So:letthing='initial';export{thing,thingasdefault};setTimeout(()=>{thing='changed';},500);And the same./main.jsas before:import{thing,defaultasdefaultThing}from'./module.js';importanotherDefaultThingfrom'./module.js';setTimeout(()=>{console.log(thing);console.log(defaultThing);console.log(anotherDefaultThing);},1000);Unlikeexport default thing,export { thing as default }exportsthingas a live reference. So:import{thing}from'./module.js';import{thingasotherName}from'./module.js';import*asmodulefrom'./module.js';constmodule=awaitimport('./module.js');let{thing}=awaitimport('./module.js');export{thing};export{thingasotherName};export{thingasdefault};exportdefaultthing;exportdefault'hello!';Fun eh? Oh, we're not done yet…'export default function' is another special caseI said that the bit afterexport defaultis treated like an expression, but there are exceptions to that rule. Taking:exportdefaultfunctionthing(){}setTimeout(()=>{thing='changed';},500);And:importthingfrom'./module.js';setTimeout(()=>{console.log(thing);},1000);It logs"changed", becauseexport default functionis given its own special semantics; the functionispassed by reference in this case. If we changemodule.jsto:functionthing(){}exportdefaultthing;setTimeout(()=>{thing='changed';},500);…it no longer matches the special case, so it logsƒ thing() {}, as it's passed by value again.But… why?It isn't justexport default function–export default classis special-cased in the same way. It's to do with how these statements change behaviour when they're expressions:functionsomeFunction(){}classSomeClass{}console.log(typeofsomeFunction);console.log(typeofSomeClass);But if we make them expressions:(functionsomeFunction(){});(classSomeClass{});console.log(typeofsomeFunction);console.log(typeofSomeClass);functionandclassstatements create an identifier in the scope/block, whereasfunctionandclassexpressionsdo not (although their names can be used internal to the function/class).So:exportdefaultfunctionsomeFunction(){}console.log(typeofsomeFunction);Ifexport default functionwasn't special-cased, then the function would be treated as an expression, and the log would be"undefined". Special-casing functions also helps with circular dependencies, but I'll get onto that shortly.To sum up:import{thing}from'./module.js';import{thingasotherName}from'./module.js';import*asmodulefrom'./module.js';constmodule=awaitimport('./module.js');let{thing}=awaitimport('./module.js');export{thing};export{thingasotherName};export{thingasdefault};exportdefaultfunctionthing(){}exportdefaultthing;exportdefault'hello!';This kinda makesexport default identifierthe odd one out. I get thatexport default 'hello!'needs to be passed by value, but since there's a special case that makesexport default functionpassed by reference, it feels like there should be a special case forexport default identifiertoo. I guess it's too late to change it now.I had a chat withDave Hermanabout this, who was involved in the design of JavaScript modules. He said that some earlier designs of default exports were in the formexport default = thing, which would have made it more obvious thatthingis treated as an expression. I agree!What about circular dependencies?This came to light when Dominic messaged me about circular dependencies. First we need to talk about 'hoisting':HoistingYou might have encountered the age-old weird thing JavaScript does to functions:thisWorks();functionthisWorks(){console.log('yep, it does');}Function definitions are essentially moved to the top of the file. That only really happens with plain function declarations:assignedFunction();newSomeClass();constassignedFunction=function(){console.log('nope');};classSomeClass{}If you try to access alet/const/classidentifier before it's instantiated, it throws an error.var is different…because of course it is.varfoo='bar';functiontest(){console.log(foo);varfoo='hello';}test();The above logsundefined, because the declaration ofvar fooin the function is hoisted to the start of the function, but the assignment of'hello'is left where it is. This was seen as a bit of a gotcha, which is whylet/const/classthrow an error in similar cases.What about circular dependencies?Circular dependencies are allowed in JavaScript, but they're messy and should be avoided. For example, with:import{foo}from'./module.js';foo();exportfunctionhello(){console.log('hello');}And:import{hello}from'./main.js';hello();exportfunctionfoo(){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:import{foo}from'./module.js';foo();exportconsthello=()=>console.log('hello');And:import{hello}from'./main.js';hello();exportconstfoo=()=>console.log('foo');…it fails.module.jsexecutes first, and as a result it tries to accesshellobefore it's instantiated, and throws an error.Let's getexport defaultinvolved with:importfoofrom'./module.js';foo();functionhello(){console.log('hello');}exportdefaulthello;And:importhellofrom'./main.js';hello();functionfoo(){console.log('foo');}exportdefaultfoo;This is the example Dominic gave me. The above fails, becausehelloinmodule.jspoints to the hidden variable exported bymain.js, and it's accessed before it's initialized.Ifmain.jsis changed to useexport { hello as default }, it doesn't fail, because it's passing the function by reference and gets hoisted. Ifmain.jsis changed to useexport default function hello(), again it doesn't fail, but this time it's because it hits that super-magic-special-case ofexport default function.I suspect this is another reasonexport default functionwas special-cased; to make hoisting work as expected. But again, it feels likeexport default identifiershould have been special-cased in the same way for consistency.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 toToon Verwaest,Marja Hölttä, andMathias Bynensfrom the V8 team for making sure I'm using the correct terminology throughout this post,Dave HermanandDaniel Ehrenbergfor giving me some of the history around this, proof-readersSurma,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 toDominic Elmfor triggering this whole adventure!