本想搞清楚ESM和CJS模块的互相转换问题,没想到写完我的问题更多了

简介: 本来只是好奇打包工具是如何转换ESM和CJS模块的,没想到带着这个问题阅读完编译的代码后,我的问题更多了。
本来只是好奇打包工具是如何转换ESM和CJS模块的,没想到带着这个问题阅读完编译的代码后,我的问题更多了。

目前主流的有两种模块语法,一是Node.js专用的CJS,另一种是浏览器和Node.js都支持的ESM,在ESM规范没有出来之前,Node.js的模块编写使用的都是CJS,但是现在ESM已经逐渐在替代CJS成为浏览器和服务器通用的模块解决方案。

那么问题来了,比如说我早期开发了一个CJS的包,现在想把它转成ESM语法用来支持在浏览器端使用,或者现在使用ESM开发的一个包,想转换成CJS语法用来支持老版的Node.js,转换工具有很多,比如Webpackesbuild等,那么你有没有仔细看过它们的转换结果都是什么样的,没有没关系,本文就来一探究竟。

ESM模块语法

先来简单过一下常用的ESM模块语法。

导出:

// esm.js
export let name1 = '周杰伦'
// 等同于
let name2 = '朴树'
export {
    name2
}
// 重命名
export {
    name1 as name3
}
// 默认导出
// 一个模块只能有一个默认输出,因此export default命令只能使用一次
// 本质上,export default就是输出一个叫做default的变量或方法,所以可以直接一个值,导入时可以使用任意名称
export default '华语乐坛经典人物'

导入:

// 具名导入
import title, { name1, name2, name3, name1 as name4 } from './esm.js';
// 整体导入
import title, * as names from './esm.js';

CJS模块语法

CJS模块语法会更简单一点,导出:

// 方式一
exports.name2 = '朴树'
// 等同于
module.exports.name1 = '周杰伦'

// 方式二
module.exports = {
    name1: '周杰伦',
    name2: '朴树'
}

导入:

// 整体
const names = require('./cjs.js')
console.log(names)
// 解构
const { name1, name2 } = require('./cjs.js')
console.log(name1, name2)

从我们肉眼观察的结果,CJSexports.xxx类似于ESMexport let xxxCJSmodule.exports = xxx类似于ESMexport default xxx,但是它们的导入形式是有所不同的,ESMimport xxxxxx代表的只是export default xxx的值,如果没有默认导出,这样导入是会报错的,需要使用import * as xxx语法,但是CJS其实无论使用的是exports.xxx = 还是module.exports =,实际上导出的都是module.exports这个属性最终的值,所以导入的也只是这个属性的值。

实际上,CJSESM有三个重大的差异:

  • CJS 模块输出的是一个值的拷贝,ESM 模块输出的是值的引用
  • CJS 模块是运行时加载,ESM 模块是编译时输出接口
  • CJS 模块的require()是同步加载模块,ESM 模块的import命令是异步加载,有一个独立的模块依赖的解析阶段

那么,在它们两者互相转换的过程中,是如何处理这些差异的呢,接下来我们使用esbuild来进行转换,为什么不用webpack呢,无他,唯简单尔,看看它是如何处理的,安装:

npm install esbuild

增加一个执行转换的文件:

// build.js
require("esbuild").buildSync({
  entryPoints: [''],// 待转换的文件
  outfile: "out.js",
  format: '',// 转换的目标格式,cjs、esm
});

然后我们在命令行输入node ./build.js命令即可看到转换结果被输出在out.js文件内。

ESM转CJS

转换导出

待转换的内容:

export let name1 = '周杰伦'
let name2 = '朴树'
export {
    name2
}
export {
    name1 as name3
}
export default '华语乐坛经典人物'

接下来看一下转换结果的代码,核心的导出语句如下:

module.exports = __toCommonJS(esm_exports);

导出的数据是调用__toCommonJS方法返回的结果,先来看看参数esm_exports

var esm_exports = {};
__export(esm_exports, {
  default: () => esm_default,
  name1: () => name1,
  name2: () => name2,
  name3: () => name1
});
let name1 = "周杰伦";
let name2 = "朴树";
var esm_default = "华语乐坛经典人物";

先定义了一个空对象esm_exports,然后调用了__export方法:

var __defProp = Object.defineProperty;
var __export = (target, all) => {
  // 遍历对象
  for (var name in all)
    // 给对象添加一个属性,并设置属性描述符的取值函数get为all对象上该属性对应的函数,那么该属性的值也就是该函数的返回值
    __defProp(target, name, { get: all[name], enumerable: true });
};

上面所做的事情就是给esm_exports对象添加了四个属性,这四个属性很明显就是我们使用ESMexport导出的所有变量,export default默认导出,本质上就是导出了一个叫做default的变量而已,没有什么特别的:

export default a
// 等同于
export {
    a as default
}

所以默认导出的变量会定义成名为default的属性添加到这个对象上,这很明显,因为我们知道CJS的导出其实是module.exports属性的值,那么我们使用ESM导出了多个变量,只能都添加到一个对象上来导出,注意看其中两点:

1.添加属性没有直接使用esm_exports.xxx的方式来添加,而是使用Object.defineProperty方法,并且只给属性定义了取值函数get,没有定义赋值函数set,这意味着esm_exports的这个属性的值是不能被修改的,这其实是CommonJSESM的一个不同点:ESM导出的接口不能修改,而CJS可以。

所以下面这些ESM做法都是会报错的:

import * as names from './esm.js';
names.name1 = '许巍';// 报错

import title, { name1, name2, name3, name1 as name4 } from './esm.js';
title = '许巍';// 报错
name1 = '许巍';// 报错

CJS不会:

const names = require('./cjs.js');
names.name1 = 1;// 成功

let { name1, name2 } = require("./cjs.js");
name1 = 1;// 成功

2.设置属性的描述符时没有直接使用value,比如:

var __export = (target, all) => {
  for (var name in all)
    __defProp(target, name, { value: all[name], enumerable: true });
};

__export(esm_exports, {
  default: esm_default,
  name1: name1,
  name2: name2,
  name3: name1,
  setName1: setName1
});

而是定义了get取值函数,通过函数的形式返回同名变量的值,这其实又是一个不同点了:CJS 模块输出的是一个值的拷贝,ESM 模块输出的是值的引用。

比如在ESM模块中:

// esm.js
export let name = '周杰伦'
export const setName = (newName) => {
    name = newName
}

// other.js
import { name, setName } form './esm.js'
console.log(name)// 周杰伦
setName('许巍')
console.log(name)// 许巍

可以看到导入地方的值也跟着变了,但是在CJS模块中就不会:

// cjs.js
let name = '周杰伦'
const setName = (newName) => {
    name = newName
}
module.exports = {
    name,
    setName
}

// other.js
let { name, setName } = require("./cjs.js")
console.log(name)// 周杰伦
setName('许巍')
console.log(name)// 周杰伦

正是如此,所以才需要通过设置get函数来实时取值,否则转换成CJS后,变量的值只拷贝了一份,后续变化了都不会再更新。

回到这行:

module.exports = __toCommonJS(esm_exports);

看完了esm_exports,接下来看看__toCommonJS方法:

var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);

首先创建了一个空对象,然后使用Object.defineProperty添加了一个__esModule=true的属性,这个属性是用于在导入的时候进行一些判断的,接下来调用了__copyProps方法:

var __getOwnPropNames = Object.getOwnPropertyNames;// 返回一个指定对象的所有自身属性的属性名(包括不可枚举属性但不包括 Symbol 值作为名称的属性)组成的数组
var __hasOwnProp = Object.prototype.hasOwnProperty;// 返回一个布尔值,指示对象自身属性中是否具有指定的属性(也就是,是否有指定的键),该方法会忽略掉那些从原型链上继承到的属性
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;// 返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)

var __copyProps = (to, from, except, desc) => {
  if (from && typeof from === "object" || typeof from === "function") {
    for (let key of __getOwnPropNames(from))
      if (!__hasOwnProp.call(to, key) && key !== except)
        __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
  }
  return to;
};

这个方法做的事情是把from对象的所有属性都在to对象上添加一份,不过如果to对象上存在同名属性则不会覆盖,会发生在如下这种情况:

// cjs.js
export let foo = 1

// cjsUse.js
export * from './cjs.js'

export let foo = 2

存在同名导出,cjsUse模块会覆盖cjs模块的同名导出,所以最终导出的foo=2

同时会设置新添加属性的属性描述符,设置取值函数get,返回值为from对象的该属性值,因为没有设置get,所以添加的属性值也是不能被修改的。

简单来说就是创建了一个新对象,把esm_exports的属性都添加到新对象上,但是访问该新对象的属性时实际上最终访问的还是from对象的该属性值,相对于一个代理对象,然后对外导出该新对象。

百思不得解啊1:为啥要创建一个新对象,而不是直接导出 esm_exports对象呢?

另外我们可以发现,ESM的默认导出CJS是不支持的,在ESM中默认导出我们可以这么导入:

import defaultValue from 'xxx'

但是转成CJS后不能这样导入:

const defaultValue = require('xxx')

而是需要通过.default的形式才能获取到真正的defaultValue

const importData = require('xxx')
console.log(importData.default)

所以可以的话还是尽量少用默认导出吧。

转换导入

接下来看看导入的转换:

import title, { name1, name2, name3, name1 as name4 } from "./esm.js";
console.log(title, name1, name2, name3, name4);

转换结果:

var import_esm = __toESM(require("./esm.js"));
console.log(import_esm.default, import_esm.name1, import_esm.name2, import_esm.name3, import_esm.name1);

对导入的数据调用了__toESM方法:

var __create = Object.create;// 创建一个新对象,使用现有的对象来作为新创建对象的原型(prototype)
var __defProp = Object.defineProperty;
var __getProtoOf = Object.getPrototypeOf;// 返回指定对象的原型(内部[[Prototype]]属性的值)

var __toESM = (mod, isNodeMode, target) => (
  // 导入的模块存在,则使用该模块的原型为原型创建一个新对象作为target
  (target = mod != null ? __create(__getProtoOf(mod)) : {}),
  // 将导入模块的属性拷贝到target对象上
  __copyProps(
    isNodeMode || !mod || !mod.__esModule
      ? __defProp(target, "default", { value: mod, enumerable: true })
      : target,
    mod
  )
);
百思不得解啊2:为啥要以导入模块的原型为原型来创建一个新对象呢?

百思不得解啊3:为啥导入也要创建一个新对象?

可以看到也创建了一个新对象,然后把导入模块的属性添加到这个新对象上,前面在转换导出的时候会给导出的对象添加一个__esModule=true的属性,这里就用到了,为true就代表该模块是ESM转换而成的CJS模块,否则就是原始的CJS模块,这样的话会给target对象添加一个default属性,值就是导入的数据,这是为啥呢,其实是为了兼容导入原始的CJS模块,比如:

// 导出
export default class Person {}
// 导入
import Person from 'x'
new Person()

转换成CJS以后:

// 导出
module.exports = {
    default: () => Person
}
// 导入
const res = require('x')
new res.default()

但是如果x模块不是由ESM转换而来的,本身就是一个CJS模块:

module.exports = Person

那么res就是导出的类,再获取它的default属性显然是不对的,所以需要手动创建一个对象,并添加一个default属性来引用。

CJS转ESM

转换导出

待转换的内容如下:

module.exports.name1 = '周杰伦'
exports.name2 = '朴树'

转换结果如下:

// ...
export default require_cjs();

为什么要转换成默认导出而不是具名导出呢,一是因为require本身就很类似import xxx默认导入语法,二是转成具名导出不方便,比如如下导出:

const res = {
    name1: '周杰伦'
}
module.exports = res
if (Math.random() > 0.5) {
    res.name2 = '许巍'
} else {
    res.name3 = '朴树'
}

不实际执行代码压根不知道最终导出的是啥,所以具名导出就不可能,只能使用默认导出,这样我只管导出module.exports属性,至于它上面都有啥就不管了。

看看require_cjs方法:

var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) =>
  function __require() {
    return (
      mod ||
        (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod),
      mod.exports
    );
  };
var require_cjs = __commonJS({
  "cjs.js"(exports, module) {
    module.exports.name1 = "\u5468\u6770\u4F26";
    exports.name2 = "\u6734\u6811";
  },
});
百思不得解啊4:为啥要搞成这么奇怪的格式,直接传函数不行吗?

因为CJS的导出就是使用在module.exports对象上添加属性,或者是重写module.exports属性,所以直接将原模块的代码放到一个函数里,然后通过参数的形式传入module对象和exports属性,这样无需关心代码都做了什么,只要最后导出module.exports属性即可,并且还增加了缓存的机制,这也是CJS的一个特性,即同一个模块,只有第一次导入时会去执行该模块的代码,然后获取到导出的数据后就会把它缓存起来,后续再导入这个模块会直接从缓存里获取导出数据,这也是CJS不同于ESM的特性。

转换导入

待转换的代码:

const res = require('./cjs.js')
console.log(res);

转换结果:

报错了,提示目前不支持将require转换成esm,这是为啥呢,其实是因为require是同步的,运行时的,所以可以动态导入、条件导入,可以出现在非顶层,把它当做一个普通函数看待即可,但是import导入不行,它是静态编译的,必须出现在顶层,所以是无法转换的,那怎么办呢,很简单,只要把require干掉就行,也就是把所有模块都打包到同一个文件里,假设被引入的文件两个模块如下:

// cjs.js
module.exports = {
    name1: '周杰伦',
    name2: '朴树'
}
// cjs2.js
module.exports = {
    name3: '许巍',
    name4: '梁博'
}

导入它们的模块内容如下:

const res = require('./cjs.js')
console.log(res);

const res2 = require('./cjs2.js')
console.log(res2);

module.exports = {
    res,
    res2
}

然后修改一下我们执行转换的build.js文件:

// build.js
require("esbuild").buildSync({
  entryPoints: [''],
  outfile: "out.js",
  format: '',
  bundle: true// ++
});

然后再转换就不会报错了,结果如下:

// ...
// cjs.js
var require_cjs = __commonJS({
  "cjs.js"(exports, module) {
    module.exports.name1 = "\u5468\u6770\u4F26";
    exports.name2 = "\u6734\u6811";
  }
});

// cjs2.js
var require_cjs2 = __commonJS({
  "cjs2.js"(exports, module) {
    module.exports = {
      name3: "\u8BB8\u5DCD",
      name4: "\u6881\u535A"
    };
  }
});

// cjsUse.js
var require_cjsUse = __commonJS({
  "cjsUse.js"(exports, module) {
    var res = require_cjs();
    console.log(res);
    var res2 = require_cjs2();
    console.log(res2);
    module.exports = {
      res,
      res2
    };
  }
});
export default require_cjsUse();

可以看到其实和转换导出的逻辑是一样的,每个模块的内容都会包裹到一个函数里,然后生成一个函数,执行这个函数时就会执行该模块的代码,然后导出的数据就会挂载到module.exports上,无论是模块内使用还是导出都可以。

总结

温馨提醒,本文的内容纯粹是笔者的个人观点,不一定保证正确~另外以上这些问题也可能没有所谓的原因,换一个转换工具,比如babelrollup等可能又会生成不同的代码,有兴趣的自行尝试吧。

总结一下:

  • ESMCJS:所有导出的变量都挂载到一个对象上,然后module.exports该对象。导入的话会判断是经ESM转换的CJS模块,还是原始的CJS模块,都会先创建一个对象,原始CJS模块的话会添加一个default属性来保存导入的数据,非原始CJS模块的话会直接将属性拷贝到新对象上,最后这个新对象作为导入的结果。
  • CJSCSM:将模块的内容包裹到一个函数内,通过参数的形式传入module对象和module.exports属性,函数的执行结果为module.exports属性的值,并且通过高阶函数的形式来增加缓存导出的功能,转换导出的话直接export default该函数的执行结果,导入的话不能单独转换,需要都打包到同一个文件中,所以也就不存在转换后的import语句。
相关文章
|
6月前
|
JavaScript 前端开发 编译器
js开发: 请解释什么是Babel,以及它在项目中的作用。
**Babel是JavaScript编译器,将ES6+代码转为旧版JS以保证兼容性。它用于前端项目,功能包括语法转换、插件扩展、灵活配置和丰富的生态系统。Babel确保新特性的使用而不牺牲浏览器支持。** ```markdown - Babel: JavaScript编译器,转化ES6+到兼容旧环境的JS - 保障新语法在不同浏览器的运行 - 支持插件,扩展编译功能 - 灵活配置,适应项目需求 - 富强的生态系统,多样化开发需求 ```
55 4
|
2月前
|
JavaScript
Nest.js 实战 (十一):配置热重载 HMR 给服务提提速
这篇文章介绍了Nest.js服务在应用程序引导过程中,TypeScript编译对效率的影响,以及如何通过使用webpackHMR来降低应用实例化的时间。文章包含具体教程,指导读者如何在项目中安装依赖包,并在根目录下新增webpack配置文件webpack-hmr.config.js来调整HMR相关的配置。最后,文章总结了如何通过自定义webpack配置来更好地控制HMR行为。
|
5月前
|
缓存 JavaScript 前端开发
前端小白也能懂:ES模块和CommonJS的那些事
【6月更文挑战第1天】在JavaScript的世界中,模块化是构建大型应用的关键。ES模块(ESM)和CommonJS是两种主流的模块系统,它们各自有着不同的特性和使用场景。你了解它们的区别吗?
343 2
|
4月前
|
JSON JavaScript 前端开发
死磕Node模块兼容性,ESM和CJS我全都要!
死磕Node模块兼容性,ESM和CJS我全都要!
300 0
|
6月前
|
JavaScript 前端开发 编译器
什么是TypeScript模块?为啥那么重要?
什么是TypeScript模块?为啥那么重要?
89 0
|
Web App开发 前端开发 JavaScript
UMD 被淘汰了吗?不考虑的 UMD 的库如何在纯 UMD 前端项目中运行?
UMD 被淘汰了吗?不考虑的 UMD 的库如何在纯 UMD 前端项目中运行?
231 0
|
JavaScript 前端开发
全解析 ESM 模块语法,出去还是进来都由你说了算
模块语法是ES6的一个重要特性,它的出现让JavaScript的模块化编程成为了可能。在JavaScript中可以直接使用import和export关键字来导入和导出模块。
172 0
|
JSON JavaScript 前端开发
终于搞懂了 ESM 和 CJS 互相转换
终于搞懂了 ESM 和 CJS 互相转换
439 0
|
前端开发
前端学习案例18-使用babel-polyfill补充ES6代码实现
前端学习案例18-使用babel-polyfill补充ES6代码实现
69 0
前端学习案例18-使用babel-polyfill补充ES6代码实现
|
JavaScript 前端开发 索引
【JS编码技巧】技巧掌握的好,业务代码才能写的溜(二)
语言的一些高级特性的使用可以帮助我们更好去实现一些复杂的功能,以尽量简短的语句来使代码看上去更简洁、逻辑更完善,而且这样减少了出现 bug 的风险。接下来让我总结一些 JavaScript 中的一些简写小技巧。
151 0