Gatsby Starter Blog

工程化: 彻底禁止使用export default

January 12, 2020

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

杨健:深入解析 ES Module(一)

实际上不仅仅是 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/rea ct/issues/11503 https:// github.com/facebook/rea ct/issues/10021#issuecomment-335128611

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

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

这导致我们打包 react 的时候,经常需要对 react 进行特殊处理,如 react-redux 的处理方式 https:// github.com/reduxjs/reac t-redux/blob/master/rollup.config.js#L31-L40

此时我们意识到,使用 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 的作者是鼎鼎大名的 https:// 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,这实际上是不可控的