Skip to content

关于esm的import和export用法及其原理,在前面js相关章节已经讲过了。这里我们主要研究esm和cjs一些互操作相关的知识点,以及结合构建工具看看他们互操作的一些知识点。

从而可以辅助我们在未来开发自己的npm包的时候,更好的作出最佳实践。

【Node.js】esm调用cjs

在我们讨论打包工具之前。这个场景,其实也只会发生在node.js环境下,因为浏览器下使用esm的时候他也只能加载esm的模块,不可能支持加载其他模块化方式的js。

那就说到Node.js, 它自从v13左右开始支持esm。然后开始支持esm和cjs共存。

esm调用cjs的时候,主要记住的是:

  • 当你的被调cjs里面直接给module.exports=xx这样的方式赋值如{a: 1, b: 2},那么最终esm那边就只能拿到一个默认导出。
  • 但是假如你module.exports = {a: function(){}, b: 2}, 即但凡有一个属性是函数,那么最终esm调用方那边就既可以拿到整个module.exports的默认导出,又能具名导出a (注意:仅有a这个函数会变成具名导出)

即,假设你的 cjs 模块是这样写:

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

那么,此时你esm那边:

js
import { a } from './cjstest.cjs'
// 这里会报错。原因是因为cjs模块缺少exports.xxx=xxx的语法,所以esm那边只能拿到默认导出。
// 假设在这种情况下,你在esm中这样写:
import * as obj from './cjstest.cjs'
// 则你会发现obj对象就只有 {default: {a: 1, b: 2}}。 这就导致你只能import e from './cjstest.cjs' 这样导入才行。

那么,如何才能给esm调用方一个具名导出呢? 答案是,你必须cjs里面这么写上exports.xx语法才行:

js
exports.a = 1
// 或者,即使你给module.exports重新赋值。那么也要再加一句exports.xxx,这个xxx才算是具名导出。

module.exports = {
    xx: '11',
    str: '22'
}
module.exports.a = 'gogogo'
// 此时如果你的esm里面 import * as obj from './cjstest.js',那么你拿到的obj就是 {default: {xx:11,str:22},  a: 'gogogo'}

当然,假设你的a是一个函数,那么这个a即使这样写:

js
module.exports = {
    a: function() {}
}

这个a也会额外变成具名导出。

基于此,这么复杂的规律。 咱们建议还是乖乖的用 exports.a = 1; exports.b = 2; 这样的语法吧。

【Node.js】cjs调用esm

js
// 具体是引入mjs还是js,取决于你当前项目package.json里面写的type是module还是commonjs。
(async () => {
  const moduleExportedObj = await import('./my-app.mjs或my-app.js');
})();

咱们假设你的 my-app.js是这么写的esm模块:

js
export const a = function() {
    return 'fet-block-dep3'
}

export default {
    xx: 'xxxx'
}

经测试,这样引入的my-app.js,其机构是这样的:

js
[Module: null prototype] { a: [Function: a], default: { xx: 'xxxx' } }

导入后,如果你想使用默认导出,就读它的default属性,如果你想使用具名导出,就读它的具体名称的属性即可。

可以看到,commonjs引入esm是可以通过上述方法正常引入的。而且我测试了去通过赋值或defineProperty方式修改这个对象的a属性,发现确实还不让修改。这确实满足了esm模块的特性----即导入的模块是只读的。

但这种cjs引入esm的方式,有缺点:

  • 只能使用这种异步import写法,会导致我们同步代码无法使用。例如我导入的moduleExportedObj,希望再次导出给别人使用。等我import完了再导出已经晚了,需要调用方自己setTimeout3秒后再使用才能有,这样也很有问题。
  • 虽然它cjs代码里面确实不让你修改那个import进来的esm模块的a属性。但是你修改的时候,它也不报错,这也会带来潜在的未知风险。

esm的动态导入

esm里面有个import函数动态导入模块的概念。这个倒是有点类似于cjs的require,不过cjs的require天然会阻塞,而import是个异步动态导入(不卡线程)。

那么,对于nodejs的两种模块化互操作来说。import动态导入也是支持的。使用方法

js
const moduleres = import('./tmp2.cjs')
// 它这里拿到的 moduleres结构,就等于如下写法拿到的结构:
import * as moduleres from './tmp2.cjs'
// 即 {default: {} , xx: 'xxx'}。 它依然遵循上文所讲的cjs如何导出具名导出的用法,请参考上文即可。

总结

至此,我们就学会了nodejs中esm调cjs,以及cjs调esm。

由于现代通常我们项目编写都是采用esm,所以我们要着重记住 esm 调用cjs的用法。反而这个用法其实很容易记忆,即:

  • 文件扩展名或者package.json里面的type字段,决定了你nodejs文件里到底该用哪个模块化语法。建议直接package.json里面写type"module",然后文件扩展名直接用js,即编写esm语法。
  • 基于第一点,如果你使用cjs扩展名(即cjs模块语法),则你文件中就用 require 来加载cjs模块,或async function(){ import ('./xxx')}() 语法来加载esm模块。----------由于现在主流是迁移到esm,所以这一种cjs文件,但凡咱们自己的项目能控制,咱们日常工作就不建议用了。
  • 更建议用esm语法,该用户既可以加载cjs的又可以加载esm的模块。即:esm语法的文件中,则只能用 import * as obj from ''import obj from ''import {a} from '' 这三种语法来加载模块。当然,还有esm的动态import()函数这种语法。
  • esm里面要想导入cjs里面整个module.exports对象,那么就直接import obj from 'xxx'
  • esm里面要想导入cjs里面的具名导出,那么就import {a} from 'xxx'。但前提是cjs里面它必须得有 exports.xx=xx 这样写过具名导出才行。
  • 假设esm里面直接在模块代码顶层用await import写法动态导入另外一个模块(无论是导入cjs 还是 esm), 这个导入都会卡住模块加载时候的编译过程,导致本模块卡住,以及调用本模块的人也都会卡住。但幸亏import函数是异步的,一般你业务逻辑中可以通过利用await等待期间做其他事情,这个就靠你自己编码时注意了。

本文所讨论的仅仅是node.js原生代码的cjs和esm互操作,并不是rollup打包环境下的互操作。