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

4次阅读

共计 8989 个字符,预计需要花费 23 分钟才能阅读完成。

原本只是好奇打包工具是如何转换 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 语句。
正文完
 0