原本只是好奇打包工具是如何转换 ESM 和 CJS 模块的,没想到带着这个问题浏览完编译的代码后,我的问题更多了。
目前支流的有两种模块语法,一是 Node.js
专用的 CJS
,另一种是浏览器和Node.js
都反对的 ESM
,在ESM
标准没有进去之前,Node.js
的模块编写应用的都是 CJS
,然而当初ESM
曾经逐步在代替 CJS
成为浏览器和服务器通用的模块解决方案。
那么问题来了,比如说我晚期开发了一个 CJS
的包,当初想把它转成 ESM
语法用来反对在浏览器端应用,或者当初应用 ESM
开发的一个包,想转换成 CJS
语法用来反对老版的 Node.js
,转换工具有很多,比方Webpack
、esbuild
等,那么你有没有认真看过它们的转换后果都是什么样的,没有没关系,本文就来一探到底。
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)
从咱们肉眼察看的后果,CJS
的 exports.xxx
相似于 ESM
的export let xxx
,CJS
的 module.exports = xxx
相似于 ESM
的export default xxx
,然而它们的导入模式是有所不同的,ESM
的 import xxx
的xxx
代表的只是 export default xxx
的值,如果没有默认导出,这样导入是会报错的,须要应用 import * as xxx
语法,然而 CJS
其实无论应用的是 exports.xxx =
还是 module.exports =
,实际上导出的都是module.exports
这个属性最终的值,所以导入的也只是这个属性的值。
实际上,CJS
和 ESM
有三个重大的差别:
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
对象增加了四个属性,这四个属性很显著就是咱们应用 ESM
的export
导出的所有变量,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
的这个属性的值是不能被批改的,这其实是 CommonJS
和ESM
的一个不同点: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
上,无论是模块内应用还是导出都能够。
总结
舒适揭示,本文的内容纯正是笔者的个人观点,不肯定保障正确~ 另外以上这些问题也可能没有所谓的起因,换一个转换工具,比方 babel
、rollup
等可能又会生成不同的代码,有趣味的自行尝试吧。
总结一下:
ESM
转CJS
:所有导出的变量都挂载到一个对象上,而后module.exports
该对象。导入的话会判断是经ESM
转换的CJS
模块,还是原始的CJS
模块,都会先创立一个对象,原始CJS
模块的话会增加一个default
属性来保留导入的数据,非原始CJS
模块的话会间接将属性拷贝到新对象上,最初这个新对象作为导入的后果。CJS
转CSM
:将模块的内容包裹到一个函数内,通过参数的模式传入module
对象和module.exports
属性,函数的执行后果为module.exports
属性的值,并且通过高阶函数的模式来减少缓存导出的性能,转换导出的话间接export default
该函数的执行后果,导入的话不能独自转换,须要都打包到同一个文件中,所以也就不存在转换后的import
语句。