深入解析ES Module(二):彻底禁用default export

一年前写了篇文章,讲了export default { x =1, y=2 }这种写法带来的问题,

杨健:深入解析ES Module(一)zhuanlan.zhihu.com图标

实际上不仅仅是export default object这种形式会带来问题 ,export default除了稍微简化导入方式这个功能,带来了相当多的问题,甚至应该彻底考虑禁用export default,本文继续讲述export default带来的种种问题,帮助大家更好的理解ES Module。

先看一个简单的case

有一天我们心血来潮,开发了个库叫secret,你也想分享给大家,现在都是9102年了,当然是用esnext进行开发了,分分钟我们就开发完了

// secret.js
function mylib(){
  return 42;
}
export default mylib;

因为我的库只是导出一个函数,我们理所当然的考虑使用default export,开发完我们简单的用babel|tsc处理了一下,就顺利发布到npm上了,说不定还在知乎或者twitter上推广一番,很快收到了大家的赞扬。

过了几天 ,你日常打开github闲逛,突然发现自己的仓库收获了一个issue。

这个库怎么在node下使用啊,为什么报下面这个错误啊
const fn = require("secret");
console.log(fn()); 
报错 TypeError: fn is not a function

这怎么可能,我的库怎么可能犯这种低级错误,你打开你的浏览器试了下,好像没啥问题啊,然后在node里试了下,发现果然有问题,你打开了了编译后的代码发现

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = lib;

function lib() {
   return 42;
}

然后试着打印导出来的是啥

居然是个对象,怪不得报错,这时候你陷入了沉思。目前貌似只有如下几种方案。

不支持node.js

但是想想,我的库也没有依赖浏览器啊,就因为个导出就决定不支持node.js,说不过去啊

更改node.js用户导入的方式

const fn = require('secret').default

你看了看这段代码,一整恶心涌上心头,就算用户能接受这种写法,你自己也接受不了啊

把导出改为module.exports

// secret.js
function mylib(){
  return 42;
}
module.exports = mylib;

测试了下,貌似一切完美,好了就这么干了

第二天你日常打开github,发现你库的issue区已经炸了。

这个库为什么突然就挂了啊,我也没升级版本啊,报了一堆的错
xxx.default is not defined.

垃圾库,稳定性这么差,为什么没有升级就挂了

你心中一凉,难道是昨天的修改引入的bug?你心中一凉,昨天明明测试的好好的啊,你打开了浏览器测试了下,发现果然挂了。修好了node环境,结果把浏览器又搞崩了,因为昨天修改为module.exports = lib结果导致不支持default import了。

此时你陷入了两难的境地,这可怎么办啊,怎么才能两边都支持啊,此时你突然想到,其他的库是怎么处理的呢,你开心的打开了你最爱的react的代码,react即支持服务端也支持浏览器端肯定都做了支持,我看看他们是怎么搞的。

react/index.js
react/src/React.js

module.exports = React.default || React 是什么神奇的东西,看着一点也不优雅啊。很明显React在模块导出这块,做的并不尽如人意。看参考如下两个issue

https://github.com/facebook/react/issues/11503github.com
https://github.com/facebook/react/issues/10021#issuecomment-335128611github.com

时至9102年,React至今只支持cjs的入口,连esm的入口都没有,问题的根源实际就在于React错误的使用了default export 而带来了相当多的麻烦。

对于使用过rollup来处理umd bundle的同学,假如处理过react的bundle问题,一定处理过如下这类问题 github.com/reduxjs/reac

这导致我们打包react的时候,经常需要对react进行特殊处理,如react-redux的处理方式github.com/reduxjs/reac

此时我们意识到,使用default export似乎并不是那么容易。下面我们就详细讨论下default export 带来的各个问题。

为了简化后续讨论,先定义如下术语

  • source: 使用 import (这里的 import 泛指 import 和 require) 的 module
  • target: 被 import 导入的 module
  • cjs模块: 使用 ​module.exports = {}​ 做导出的模块,不会设置 ​__esModule:true​
  • esm模块: 使用 export 做导出的模块,包括 default export 和 named export
  • 编译的esm模块: esm 经过babel或者ts等编译出来的模块,其会设置 ​__esModule:true

export default 的问题

对于一般的tsc和babel,通常会将export default进行如下编译

// src/index.js
function lib(){
}
export default lib;

// dist/index.js 编译后的文件
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = lib;

function lib() {}

我们发现根据导出的文件,在cjs情况下我们只能使用如下方式导入库

const lib = require('yourlib').default

这种方式很不优雅,如果你说我就接受这种导入,似乎已经万事大吉了。

但是esm编译成cjs的工具并不只有babel和tsc,还有我们可爱的rollup,实际上当我们使用rollup编译上述代码

$ rollup src/index.js -f cjs

生成如下文件

'use strict';

function lib (){

}

module.exports = lib;

rollup编译要简洁很多,但是这时候你的应用就炸了

此时lib的导入结果变成了undefined

这意味你每次使用一个使用default export的模块,你需要关心他到底使用哪种编译工具(还有其他的编译工具呢,比如parcel和bubble,甚至babel的不同版本对default export处理方式都不一致)

甚至很有可能你使用的第三方库将编译工具从babel迁移到了rollup,且没有把这个标明为major breaking change,不注意的话你的应用就炸了。实际上chalk就是这么干的,在2.x的版本支持

const chalk = require('chalk').default

而在3的版本却废弃了这个支持

const chalk = require('chalk').default // 结果为undefined

你此时说大不了我通过chalk2那种hack方式做支持喽

很不幸这种fake default的方式,虽然同时支持了下面两种方式,似乎一切很完美

import chalk from 'chalk'
const chalk = require('chalk')

但是仍然导致了两个问题

  • 丑陋的导出对象,对象内存在循环引用

如果使用者需要对这个对象进行JSON序列化,那么就会出问题

要知道chalk的作者是鼎鼎大名的github.com/sindresorhus,也在default export上栽了跟头,default export的处理并没有那么容易。

我们发现 export default xxx存在种种风险,实际上不仅如此,使用import default也存在一定的风险。

import default 问题

cjs模块

// lib1.js
"use strict";

const a = 1;
const b = 2;
exports.a = 1;
exports.b = 2;

编译的esm模块

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.b = exports.a = void 0;
const a = 1;
exports.a = a;
const b = 2;
exports.b = b;

你知道上述两者的区别吗。

看样子仅仅是后者多了个__esModule的属性而已,能有什么影响

import lib1 from 'lib1'; // {a: 1,b:2}
import lib2 from 'lib2'; // 结果是undefined在开启了esModuleInterop情况下

在前一篇文章里我们讲过,在开启esModuleInterop的情况下,可以简化esm对cjs引入处理

主要表现在

如果lib是

module.exports = xxx;

那么在开启esModuleInterop的情况下我们可以通过default import 导入

import lib fro 'lib'; // 结果为xxx

在不开启esModuleInterop的情况下

import lib from 'lib'; // 结果为undefined

事实上第一种情况等价于下面

module.exports = { a: 1, b: 2} 

所以能使用default import导入{a:1,b:2}

然而对于第二种情况,由于含有__esModule标记,实际上并不会被视为cjs模块,所以使用import default并不能导入,实际上第二种情况是由下述代码编译而来

export const a = 1;
export const b = 2;

所以这样可以看出,import default明显无法导入任何对象。 很不幸上述代码的编译结果也不是确定的,如对于rollup来说上述的esm的源码,既可以编译成第一种代码,也可能编译成第二种代码。所以如果你使用import default你得需要确定源码是使用哪种编译方式。

default export 的最佳实践

虽然我非常反对使用export default,推荐尽可能的使用named export,但是如果非要使用default export的话,最佳的编译处理方式应该是使用rollup的auto模式。即

// src/index.js
function lib(){
}
export default lib;

// dist/index.js
module.exports = lib;

这种方式可以很自然的使用

const lib = require('lib')

在开启了esModuleInterop的情况下也可以使用下述方式(babel 7和tsc的默认配置均默认开启了esModuleInterop)

import lib from 'lib';

这种方式的vscode支持也非常良好,但是仍然存在如下问题

  1. 目前只有rollup的auto模式支持上述编译,babel和tsc貌似都不支持,这意味着如果你使用babel和tsc的话,可能就难以处理了(如果使用Typescript也可以使用 exports = xxx这种方式)
  2. 这仍然要求开发者开启了esModuleInterop:true,这实际上是不可控的

编辑于 2019-12-14