-
Notifications
You must be signed in to change notification settings - Fork 84
Node interopability with default exports #85
Comments
I would have thought this module would already do the |
The problem is that the ES6 semantic module and the Node/AMD semantic model are fundamentally incompatible, by design (X_x). Trying to reconcile them, e.g. by making a default export and the module instance object into the same thing like Node does, will lead to numerous problems of a much worse and more subtle sort than the existing ones. The existing solution has the virtue that transpiled code will work the same as non-transpiled code. On the contrary, the proposed "daring suggestion" will break that property. You will build up a large library of code meant to work with the transpiler, and then try to use native ES6 support, and everything will break, because you used the transpiler's daring conflating-semantics instead of ES6's separation-semantics. This hazard seems, to me, unacceptable. The best solution, I believe, is to work on modifications to Node's module loading mechanism to be ES6 aware. This could be done in user-space with |
I believe this is a solved problem. @wycats/@thomasboyt should likely confirm export $, { ajax } from 'jQuery';
// tag the root exported object with the es6 additions
jQuery._es6_module_something = {
ajax: jQuery.ajax
};
module.exports = jQuery; imports should be mostly compatible |
So (Stef please correct me if I misunderstood) the idea is that the transpiler would tack This seems reasonable to me. |
This problem goes beyond the server use-case IFAIK, for example: when trying to do interoperation between an AMD module and transpiled to AMD module, which is a common use-case if you have a large scale project with a lot of legacy pieces where new pieces are going to be written in ES6. The AMD module will have to know the nature of the transpiler when importing stuff (e.g.: know that we have been thinking about this issue for a while, and the only solution that I can think of is the inverse of what @stefanpenner is proposition, here is the pseudo code for a module exporting a default function:
this approach will work just fine in CJS, AMD and YUI, we can define more properties on As for exporting a default object instead of a function, we can do the same by using the function as a shim for the actual object:
Does this makes any sense? /cc @ericf |
I would encourage everyone to re-read #66 and #69, where this all has been discussed before. In particular, they give concrete examples of the hazard I spoke of above, where transpilation strategies like this would allow you to write code that is not ES6-semantics compatible, even though it uses ES6 syntax. |
@caridy not saying anything negative about your suggestion, but my example was not a proposition, rather I believe it is the solution @wycats / @dherman / @thomasboyt and friends came up with. Although the transpiler does not yet implement it yet, the plan is for it too. If you have specific concerns, it is likely important to bring them up. |
@domenic wrote:
It sounds like these hacks would have to be activated before require'ing the transpiled module in question. As a library author, if I'm putting an ES6-to-Node-transpiled module on GitHub (say like rsvp), I probably wouldn't want to ask users to activate hacks before require'ing my module, would I? Also, would we be able to isolate this to ES6 modules? I'm also worried that this might make the module loader infrastructure brittle in subtle ways. Can you elaborate a bit more on what you're envisaging? |
Re @caridy's suggestion: @caridy, I'm not sure I understand your code, especially why the default export is turning into a function. Ping me on IRC maybe? I also believe @domenic's worry of creating a hazard when we move to untranspiled ES6 is quite justified: We wouldn't want |
Re @stefanpenner's solution: Stef, I wonder about the problem that @caridy alluded to: In untranspiled code, how are we planning to consume named ES6 exports? It seems to me that you'd end up with define(["jQuery"], function(jQuery) {
// This is not transpiled, but actual code that library users would have to write:
var ajax = jQuery._es6_module_exports.ajax;
}); or var ajax = require('jquery')._es6_module_exports.ajax; This syntax is so awkward that, as a library author, you basically can't use the named exports feature for your external API. (Assuming you care about Node or AMD, which most of the time you do.) You could of course copy your library's named exports onto the Is this limitation acceptable? (Or am I missing some other solution?) @domenic, am I correct that the forward-compatibility hazard you're talking about doesn't apply to Stef's example? I believe |
About the "In untranspiled code, how are we planning to consume transpiled modules?" scenario, what we do today in YUI is to have a feature flag (very similar to My main concern though, is the future, because today we have a two way street with one way being block by construction :), and that's why we are looking for solutions to write ES modules and be able to use them in some of the existing loader implementations, but the problem of tomorrow (when the blocked lane gets ready) is going to be how to load legacy modules (AMD, CJS, YUI, etc) thru an ES Loader instance without changing them (or transpiling them), otherwise we will end up transpiling them into ES, lol. That's why |
btw, if we end up choosing |
Because of 1JS, ES7 modules will just be a superset of ES6 modules. What about Another option would be for ES friendly loaders to produce a generated symbol (a la jQuery) at System.brand, and loaders use that one if they find on boot, or generate a new one if not. Then all generated modules can do |
the package is called called |
@wycats: I think the generated symbol thru But we have been trying out few ideas with the loader, and there is one thing that is is getting messy when using
The same applies when loading a transpiled module into The question is: does this means that default exports are mostly for
which is going to be equivalent to use the import syntax for a named export at a module level:
So far so good, it looks nice, clean, but that's only if you DO NOT use the default export for This is why we were playing around with the shim mechanism described in #85 (comment), to try to get to a middle ground where:
Is this ideal? is this doable? I don't know :). |
This is exactly the kind of code I am worried about. ES6 does not work this way. You need to do |
@domenic alright, fair enough. I'm sold with solution 1 from @joliss then, If
instead of
because it is easier to translate that (visually) to others systems:
or
and even when it comes to load it at the app code level:
|
I can't figure out how the Say you have module import { bar } from 'b'
export default function() {} console.log('In a. bar = ' + bar)
export let foo = 'foo' and package import { foo } from 'a'
export default function() {} console.log('In b. foo = ' + foo)
export let bar = 'bar' My understanding of the proposed solution is that the transpiled var _b_module = require('b')
module.exports = function() { console.log('In a. bar = ' + _b_module.__es6ModuleExports.bar) }
module.exports.__es6ModuleExports = { 'default': module.exports } // perhaps
module.exports.__es6ModuleExports.foo = 'foo' ( Because the |
I have the same question @joliss, if we have to deal with |
@joliss in your final example, can you show me how you would expect that to work with existing CommonJS modules? |
@joliss or is your question about the fact that the claim is that we could make the full set of circularity features work when compiling from ES6 modules, while maintaining compatibility with existing node practice? |
@joliss here is a gist of how I imagine it would work: https://gist.github.com/wycats/7983305 |
@wycats, I think your gist to make cycles work in |
So to summarize for everyone, the idea would be: When ES6 modules are transpiled to CommonJS (and presumably AMD), their module object is the // Get ES6 `default` export from transpiled 'metamorph' module
var Metamorph = require('metamorph') ES6 named exports are accessible from other transpiled ES6 modules (implemented via a hidden This would mean that ES6 modules that need to work on Node will have to generally expose their named exports on the export { foo, bar }
// foo and bar are part of this package's external API, so we add an
// artificial default export to make them accessible from Node:
export default { foo: foo, bar: bar } Note: Therefore in practice, any code that needs to work on Node cannot have named exports and a separate Let's go the other way and ES6ify an existing Node module. Say you have a module with this interface: exports.foo = 'foo'
exports.bar = 'bar' If you wanted to migrate the source to ES6, but continue to provide the transpiled output to Node, then this is equivalent ES6 code: export default { foo: 'foo', bar: 'bar' } Presumably, you would also add named exports as a new API into your module: export default { foo: 'foo', bar: 'bar' }
// As a courtesy to our fellow ES6 users, we add two named exports to our API:
export var foo = 'foo'
export var bar = 'bar' |
I want to argue one more thing: To ES6 code, non-ES6 Node modules should look like they have one First, observe that clearly this should work import mkdirp from 'mkdirp' // mkdirp is a regular Node module as it's semantically correct. (I have seen people suggest Now it's tempting to try and make this work: module fs from 'fs'
fs.stat(...) But I want to argue that the 'fs' module should only expose its exports on the import fs from 'fs' // yes, really
fs.stat(...) Here's why: First, to make Second, if import fs from 'fs' // works because 'fs' has a default export
fs.stat(...)
// *and*
import { stat } from 'fs'
stat(...) So if you ever were to turn That might be an acceptable promise for var someObject = new Foo
someObject.someProperty = 'I am internal to the Foo instance'
module.exports = someObject If I ES6ify this, I technically have to So in summary, for a regular non-ES6 Node module |
Finally, I believe all of the above should work the same way in AMD land. |
@joliss thanks for starting a discussion around this - it is important stuff to work out. I like the ideas, I'm just trying to work this out. Apologies in advance if I'm missing something, but let me know if this sounds about right with what you are suggesting: ES6 Module: import { q } from './some-dep';
export var p = 'hello';
export default 'test'; CommonJS Transpiled Version var q = require('./some-dep').__es6_module.q;
module.exports = 'test';
exports.__es6_module = {
p: 'hello',
default: module.exports
} By adding this Or does the |
@guybedford Yes, that's the plan; we'd be using Here is an example illustrating how var defaultExport = function () {}
defaultExport.regularProperty = 'foo'
// Like `defaultExport._es6Module = { namedExport: 'test' }`, but not enumerable:
Object.defineProperty(defaultExport, '_es6Module', {
enumerable: false,
value: { namedExport: 'test' }
})
// _es6Module can be read like a regular property ...
console.log(defaultExport.regularProperty) // => foo
console.log(defaultExport._es6Module) // => { namedExport: 'test' }
// ... but is invisible to enumeration
console.log(Object.keys(defaultExport).indexOf('regularProperty')) // => 0
console.log(Object.keys(defaultExport).indexOf('_es6Module')) // => -1
for (key in defaultExport) if (key === '_es6Module') throw 'never happens'
// As an aside, note that hasOwnProperty is still true
console.error(defaultExport.hasOwnProperty('_es6Module')) // => true |
When the
I'm uncertain. I was going to suggest (3), but this scenario is so edge-casey that perhaps we can go with (1) for now, and document it as a to-do. [1] Technical side-note: The named exports would be briefly visible to cyclic imports, and then disappear once the primitive |
I'm attempting to solve some of the outstanding issues in this project and this has struck me as one of the biggest. I'm going to brain-dump here in the hopes that it'll help me grok the situation. node.js interopThe question is: How do I expose the ES6 interface to ES6 modules and the CommonJS interface to CommonJS modules? Let's assume we have this code with two separate npm packages: // exporter.js
export default { a: 1 };
export var b = 2;
// importer.js
import value from 'exporter';
import { b } from 'exporter';
console.log(value.a);
console.log(b); Proposal:
|
Yours is a very tricky question :), if we are transpiling from ES6, it is imperative to maintain the semantics and the ES behavior, otherwise the following statement becomes true: "not all ES6 modules can be transpiled to CJS", and that will be a shame. Can we survive that statement? we certainly can, we have been doing modules without cycle behavior for a long time. On the other side of the coin, your target module system (CJS) is what matters, it is where you will be using those ES6 modules, thereby, focusing on the semantic around default exports is probably the best we can do, even if that means sacrificing the cyclical references from ES6. I know, I know, I didn't choose one, but I guess I'm shooting for a the future here (one no so far away future, hopefully), where we will be using |
@eventualbuddha @caridy both of you have summed up where we are on this issue nicely, but they one additional perspective I'd like to add around context. If I'm writing application code using ES6 syntax which I want to run in Node.js, and I don't intended to share this code (e.g., it's not a library, If I'm writing a library which I intended to share with people which I want to run in Node.js, then I'd rather that library play nice within that ecosystem. In this case I can't (and shouldn't) assume the importer is written as an ES6 module that will be transpiled, so I'd prefer the To me, it's this context which dictates which features get priority and which edge-cases/features I'm willing to give up on. This leads me to believing that we need both options. And we can provide guidance on which one you choose and when. |
I think there can be a middle ground between the two and that is basically with a forked compile process in the This is the method I've been using within SystemJS, and it is working very well within the ES6 environment. So my perspective very much comes from there. Hopefully by explaining it clearly you can verify if it can work in the CommonJS scenarios. If not, I've at least given my opinion. Yes I am repeating myself, so if you've heard this all before and I've missed the arguments please do say so. When compiling I can indicate whether I want the Consider: export default 'test';
export __useDefault: true; // flag tells compiler that the module in CommonJS should be interpreted as the default export -> compiles into module.exports = 'test'; We don't need to add any Without that same flag we compile exports separately. And add the So we allow the In SystemJS this |
Sorry, I'm not an expert in ES6 modules. Can someone explain me why do we need the I understand this decision has been made because "The default export is just another named export". But "imports" and "exports" are only ES6 concepts. There are no "imports" and "exports" in ES5. It means that trancompilers can do whatever they want with regard to "default" field, and they should do the best they can to support existing module systems. That's what transcompilers are for, right? Why not just export default field as root object, preserving semantics of CommonJS / AMD modules? As far understand there are few cases:
Note you never want to You can't clash on "default" label because it's reserved word in ES6. The only thing you can clash on is some field of exported default, for example:
ES6 native runtime has no problem with consuming it. The only non-clear thing is what should following do when consuming this module from ES5: |
@sheerun it is certainly possible to make an ad-hoc rule that when there is only one default export, the transpiler treats that as |
@guybedford Much better, but here is the deal: This ad-hoc rule implies that using multiple exports when transcompiling to ES5 is not-so-good idea, because you suddenly can't consume default object from non-transcompiled ES5 without knowledge that you're consuming transcompiled output: Think about what is 6to5 only job: Transcompiling to ES5. Always. I have hard time comprehending why is there even such thing as Anyway, it means using multiple exports in 6to5 is always not-so-good idea. Unless you make a rule that default export is, no matter what, exported as root object. What I mean is: if you transcompile to ES5 + CommonJS, please transcompile to it like you mean it. Please don't make transcompiled output be like "ES5 + CommonJS + ES6 export conventions, make sure you use our way of importing this module". As I said, I don't fully comprehend ES6 modules, and that's why I'm asking for explanation of That said, I could live with current implementation, but I think it can be better. |
I think by introducing this ad-hoc rule you're damning multiple export for next 5-8 years. Nobody is going to use it because it makes root object be exported under If you allowed multiple export, but made it usable by ES5 (by assigning to default export), it can be used even now. Yes, exported root object is going to be polluted with extra exports, but only until transcompiled environment. In future, in native environment, it'll be cleanly separated, without changing anything. |
@sheerun this does not do that at all - it gives users choice. Perhaps it is easiest to show some examples of the new output format: // caseA
export var p = 42;
// ->
exports.p = 42;
// caseB
export default fn() {};
// ->
module.exports = function fn() {};
// caseC
export var p = 42;
export default function fn() {};
// ->
exports.p = 42;
exports.default = function fn() {}; The thing to note is that the above ad-hoc rule does not affect how we load modules because the import statements are converted to pick up from the default if it is provided: import {p} from './caseA';
// ->
var p = require('./caseA').p;
import p from './caseB';
// ->
var p = require('./caseB').default || require('./caseB'); // works
import p from './caseC';
// ->
var p = require('./caseC').default || require('./caseC'); // works too So we're all good in all situations. it is important to be able to convert ES6 to CommonJS because it is important to be able to author in ES6. |
@guybedford I don't deny that it's important to be able to convert to CommonJS. I'm denying that What is wrong with following? // caseA
export var p = 42;
// ->
var p = 42;
module.exports.p = p;
// caseB
export default fn() {};
// ->
module.exports = function fn() {};
// caseC
export var p = 42;
export default function fn() {};
// ->
var p = 42;
module.exports = function fn() {};
module.exports.p = p; And: import {p} from './caseA';
// ->
var p = require('./caseA').p;
import {p} from './caseA';
import fn from './caseB';
// ->
var p = require('./caseB').p;
var fn = require('./caseB');
import {p} from './caseC';
import fn from './caseC';
// ->
var p = require('./caseC').p;
var fn = require('./caseC'); // works too You see how semantics of |
Upgrading the CJS code to ES6 becomes more involved. The CJS code isn't importing an ES6 module's default export it's exporting a CJS default export. Those are two different things meaning that when you upgrade to ES6 in your code you will then have a potential code change step in the rest of your module. While if you import an ES6 module with ES6 module semantics you have no work to carry out in your module body. |
It's been fun, but I recommend using either 6to5, which has a CommonJS interop mode that I've found works reasonably well in practice, or http://esperantojs.org/, which is very fast and has a bundler similar to this project. |
Using this module in node previously required a `var ContentEditable = require("react-contenteditable").default` Now, importing the module is done with `var ContentEditable = require("react-contenteditable")` This commit BREAKS COMPATIBILITY (thus, there was a version bump) Fixes #12 Thanks to @sebasgarcep See esnext/es6-module-transpiler#85 for more info
This is not a bug in the transpiler, but I thought this repo might be a good place to have this discussion:
I want ES6 modules to succeed, because it offers some clear technical advantages. But if we write our modules in ES6, we'll generally want to also transpile them to Node's CommonJS to publish them on npm, so good CJS interopability will be important for ES6 adoption.
But there's an interop problem: Say you have module foo, and it has a single export (
export default Foo
, to be used likeimport Foo from 'foo'
). Right now, this transpiles toexports["default"] = Foo
, to be used likevar Foo = require('foo').default
. This extra.default
is clearly suboptimal, in that it breaks the convention of the existing Node ecosystem. I worry that having.default
will be unappealing and make our transpiled modules look like "second-class Node citizens"."Oh," you say, "but we can simply wrap our transpiled modules in a bit of helper code to get rid of the
.default
." (See the handlebars wrapper for Node for an example.) Sadly, I believe this actually makes things worse: Say another package "bar", written in ES6 as well, hasimport Foo from 'foo'
. When bar is transpiled to CJS, it will say (approximately)var Foo = require("foo").default
. The transpiler cannot know that "foo" is specially wrapped on Node and doesn't need.default
, so now we need need to manually remove the.default
in bar's CJS output. (Am I missing something here?)I've also heard people suggest that Node could simply adopt ES6, so that these troubles would be irrelevant. But switching Node to ES6 is probably not a matter of just enabling the syntax in V8. Rather, the big hurdle is interopability with the existing ecosystem. (Also keep in mind that Node doesn't have a pressing need to switch to ES6.) So if Node is to adopt ES6 modules at all, figuring out a good interop story is probably a prerequisite.
So here are some possible solutions, as I see them:
.default
will be all over the place on Node..default
on CJS. As I point out above, this might get troublesome once we have an ecosystem of packages written in ES6 that need to also play together on Node..default
. (Or add a "Node" mode in addition to CJS that omits.default
.) Soexport default Foo
would transpile tomodule.exports = Foo
, andimport Foo from 'foo'
andmodule Foo from 'foo'
would both transpile tovar Foo = require('foo')
. (If, transpiling to CJS, a module has bothdefault
and named exports, we might throw an error, or require some kind of directive for the transpiler, [update:] or tack the named exports onto thedefault
object, see @caridy's comment below.) This change would acknowledge that.default
is really something you never want on Node. It falls short when modules havedefault
and named exports. (Does this happen much at all?) I believe it also makes circulardefault
imports impossible to support. This is fairly easy to work around though - intra-package cycles can use named imports, and inter-package cycles are very rare.default
as the root-level object, and tack a property like._es6_module_exports
onto it for named exports. See @stefanpenner's comment below.default
as the root-level object only if there are no named exports. Use an._es6Module
property to preserve ES6 semantics. See my comment way below.What do you think about those? Any other ideas?
The text was updated successfully, but these errors were encountered: