原本只是好奇打包工具是如何转换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.jsexport 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.jsrequire("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.jsexport let name = '周杰伦'export const setName = (newName) => { name = newName}// other.jsimport { name, setName } form './esm.js'console.log(name)// 周杰伦setName('许巍')console.log(name)// 许巍
能够看到导入中央的值也跟着变了,然而在CJS
模块中就不会:
// cjs.jslet name = '周杰伦'const setName = (newName) => { name = newName}module.exports = { name, setName}// other.jslet { 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.jsexport let foo = 1// cjsUse.jsexport * 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 = resif (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.jsmodule.exports = { name1: '周杰伦', name2: '朴树'}
// cjs2.jsmodule.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.jsrequire("esbuild").buildSync({ entryPoints: [''], outfile: "out.js", format: '', bundle: true// ++});
而后再转换就不会报错了,后果如下:
// ...// cjs.jsvar require_cjs = __commonJS({ "cjs.js"(exports, module) { module.exports.name1 = "\u5468\u6770\u4F26"; exports.name2 = "\u6734\u6811"; }});// cjs2.jsvar require_cjs2 = __commonJS({ "cjs2.js"(exports, module) { module.exports = { name3: "\u8BB8\u5DCD", name4: "\u6881\u535A" }; }});// cjsUse.jsvar 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
语句。