CJS与ESM

789 阅读8分钟

我们都知道esm和commonjs是JavaScript编程中的模块规范。在了解它们之前,我们先逐步了解为什么需要模块化。

没有模块化的世界

小明正在参与一个系统的开发👇

// a.js
var username = "小明"
var age = 18

var info = {
    location: "宇宙中心",
    phone: 100,
}

var utils = {
    getRandomNum() {
        console.log(Math.random())
    },
    getUser() {
        console.log(username, age)
    }
}
<!-- index.html -->
<script src="./a.js"></script>
<script>
    console.log(username, utils.getUser())
</script>

到这里也还没什么,可随着项目参与的人越来多,代码量越写越大,很有可能会发生下面这种情况:

// b.js

var username = "小红"

info.location = "天宫"

var utils = {
    getSum(a, b) {
        return a + b
    },
    say() {
        console.log("什么时候下雨啊?")
    }
}
<!-- index.html -->
<script src="./a.js"></script>
<script src="./b.js"></script>
<!-- others scirpt... -->
<script>
    console.log(username, utils.getUser())
    // .....
    console.log(utils.say())
</script>

因为各个文件之间没有分隔,变量都存在与全局作用域中,不同文件相互之间都是可以访问,导致变量之间可以做到覆盖、访问、修改等,全局之间的变量冲突,污染变成了开发者的噩梦。后来小红实在受不了这破系统了,提桶跑路了。

提桶的小红:终于跑路了,这破系统终于不关我什么事儿了🤪
刚入职的小月:xxx哪儿来的xx,写的xxx什么xx代码,劳资真xxx服了,你xx怎么不找个厂上班啊?

模块化的原型

于是小月找到小明开会,小明说:既然写的变量会因为变量之间冲突,导致覆盖,那我们为什么不每个文件单独写一个object,把变量方法都写在里面,这样不就能解决变量冲突的问题吗?

// moduleA.js
var moduleA = {
    username: "小明",
    age: 18,
    ...
}

// moduleB.js
var moduleB = {
    getSum(a, b) {
        return a + b
    },
    say() {
        console.log("什么时候下雨啊?")
    }
}
<!-- index.html -->
<script src="./moduleA.js"></script>
<script src="./moduleB.js"></script>
<!-- others scirpt... -->
<script>
    console.log(moduleA.username, moduleA.getUser())
    // .....
    console.log(moduleB.say())
</script>

小月摇了摇头说道:这的确可以解决变量冲突的问题,可是在任何JavaScript代码中都可以访问到它们,随便一个地方都能够修改其他变量,譬如:moduleA = null,这将会导致各种问题的产生。

会议持续了三天三夜,小明和小月终于商讨出了一个办法,他们利用JavaScript语言的函数作用域的特性,使用闭包的方式封装数据和方法,使得各个模块之间的数据不能随便被访问和修改。

// moduleA.js
var moduleA = (function() {
    var username = "小明"
    // .....
    return {
        getName() {
            return username
        },
        // .....
    }
})()
<!-- index.html -->
<script src="./moduleA.js"></script>
<script src="./moduleB.js"></script>
<!-- others scirpt... -->
<script>
    console.log(moduleA.getName(), moduleA.getUser())
    // .....
    console.log(moduleB.say())
</script>

于是模块化的原型自此诞生啦 🎉
模块之间不能直接访问其他模块的数据,但是可以通过其他模块提供相应的接口进行访问,但这仍还有其他问题:

  1. 在初始化时,因为script加载顺序的原因,模块B可以访问模块A,相反则不能访问的问题
  2. 模块关系不明显,所有模块相互之间都能够被访问,并且不知道有哪些模块引入

这导致在一个业务量较庞大的项目中,模块难以维护,问题频出,开发困难。

CommonJS规范

要解决上面的问题,就需要制定模块化的规范,在维基百科中如此定义CommonJS:

CommonJS是一个项目,其目标是为JavaScript在网页浏览器之外创建模块约定。创建这个项目的主要原因是当时缺乏普遍可接受形式的JavaScript脚本模块单元,模块在与运行JavaScript脚本的常规网页浏览器所提供的不同的环境下可以重复使用。

CommonJS规范规定,每个文件就是一个模块,拥有独立的作用域,在其定义的变量、函数都是私有的,外部文件无法访问。

CommonJS的导入导出方式

同时,规范规定在模块中可以通过require导入模块,exports进行导出,示例如下👇

// a.js
const privateVariable = "没有导出,外面访问不到~"

const userinfo = {
    name: "小光",
    age: 18
}

const sayHi = (msg) => {
    console.log(`Hi,${userinfo.name}${msg}`)
}
exports.userinfo = userinfo
exports.sayHi = sayHi


// index.js
// 当然也可以不解构,直接通过定义一个模块变量,后续使用模块变量访问 userinfo 等属性
// 如 const modalA = require('./a.js'); 
// console.log(modalA.userinfo)
const { userinfo, sayHi } = require('./a.js')

console.log(userinfo) // { name: "小光", age: 18 }
sayHi("你好啊") // Hi, 小光,你好啊
console.log(privateVariable) // undefined

CommonJS 规范有1和2两个版本,上面示例的导出方式exports.xxx = yyy就是CommonJS 1的用法。在 CommonJS 2 规范中添加了module.exports的方式,示例如下👇

// a.js
const userinfo = {
    name: "小光",
    age: 18
}

const sayHi = (msg) => {
    console.log(`Hi,${userinfo.name}${msg}`)
}

module.exports = {
    userinfo,
    sayHi
}

值得注意的是,使用了module.exports以后exports.xxx导出的数据将失效。示例如下👇

// a.js
exports.a = 1

module.exports = {
    b = 2
}

// index.js
const {a, b} = require('./a.js')
console.log(a, b) // undefined 2

所以我们应该尽量使用module.exports的写法,避免使用exports.xxx = yyy的写法。

cjs 是动态加载的,所以可以在通过如下方式导入

const target = 'a'

const { a } = require(`./${target}.js`)

这也就意味着我们可以在运行时通过判断来加载我们想要加载的模块。

ESM(es module)

esm是ECMAScript的模块化规范,所以在 Node 与浏览器中均支持。

ESM 的使用

esm使用import/export进行模块导入导出。在 esm 中,导入导出有两种方式:

  1. 具名导入/导出
// a.mjs
let a = 1

const setA = (value) => {
    a = value
}

export {
    a,
    setA
}


// index.mjs
import { a, setA } from './a.mjs'

console.log(a) // 1
setA(11)
console.log(a) // 11
  1. 默认导入/导出
// a.mjs
let a = 1

const setA = (value) => {
    a = value
}

export default {
    a,
    setA
}

// index.mjs
import Module from './a.mjs'

这两种导出方式并不冲突,可以一起使用,但值得注意的是,默认导出不可以解构。阮一峰老师的ECMAScript 6 入门是这样解释的:

export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能唯一对应export default命令。

而我的理解是,假设export default也支持了解构,那么esm与cjs的导出一样只能写一种导出方式了,我们直接看代码理解👇

// a.mjs
let a = 1

const setA = (value) => {
    a = value
}

export {
    a,
    setA
}

export default {
    a: 1,
    setA: (value) => {
        console.log(value)
    }
}

// index.mjs
import { a, setA } from './a.mjs'

可以看到,倘若我们的export default也可以使用解构的方式导入,那么这里就不能使用到export导出的值了,这样的话就跟cjs没什么区别了,所以语言标准规定export default导出的方式不能使用解构的方式导入。

export default导出的方式在import xxx from './a.mjs'其实xxx就相当于是拿到a.mjsdefalut变量,只是这个default变量在导入时不可以被解构。

ESM与CJS之间的差异

除了ESM是语言标准规范和导入导出的关键字不同以外,esm与cjs有以下的差异点:

CJSESM
加载时机运行时(动态)编译时(静态)
导出值方式值的拷贝值的引用
Tree Shakingfalsetrue

ESM 是编译时加载

cjs的运行时我们已经体验过了,我们来看一下为什么说esm是编译时加载
这是一个在a.mjs文件中导入b.mjs,同时又在b.mjs中导入a.mjs,这是一个循环引用的示例👇

// index.mjs
import './a.mjs'
// a.mjs
import { b, setB } from './b.mjs'

console.log(b)

setB(b + 1)

let a = 1

function setA(value) {
    a = value
}

export {
    a,
    setA
}
// b.mjs
import { a, setA } from './a.mjs'

console.log('在b中打印setA', setA)
console.log('在b.mjs中打印a的值:', a)

setA(5)

let b = 2

function setB(value) {
    b = value
}

export {
    b,
    setB
}

使用node index.mjs运行,控制台如下👇

# 1. setA 被打印出
在b中打印setA [Function: setA]

# 2. console 变量 a 时抛错
# 标记出错误位置
console.log('在b.mjs中打印a的值:', a)
# 错误原因
ReferenceError: Cannot access 'a' before initialization

很明显,在a.mjs的一开头import './b.mjs'也就意味着需要去执行b.mjs的代码,在b.mjsconsole.log(a),但此时变量a并没有被初始化,所以报错了。但setA却被打印出来了,这与我们平时写JavaScript代码简直一模一样,所以我们说esm是编译时加载的。

上面想要代码正常运行,只需要将定义a的方式由let a变成var a即可

CJS与ESM导出值的差异

上面说了cjs导出的值是做了值拷贝的,而esm导出的是值的引用。 我们先看cjs的示例👇

// index.js
const { a, setA } = require('./a')

console.log(a) // 1
setA(11)
console.log(a) // 1


// a.js
let a = 1

exports.a = a

exports.setA = (value) => {
    a = value
}
setTimeout(() => {
    console.log(a) // 11
}, 1000)

可以发现,即便是通过调用setA来修改变量a以后,再次打印aa的值仍没有变化,但1s后在a.js中打印的值的a却是我们想要修改的值。我们使用webpack打包a.js,通过其产物看原因👇

/******/ (() => { // webpackBootstrap
    var __webpack_exports__ = {};
    // This entry need to be wrapped in an IIFE because it uses a non-standard name for the exports (exports).
    (() => {
        var exports = __webpack_exports__;

        let a = 1

        exports.a = a

        exports.setA = (value) => {
            a = 2
        }
    })();

    /******/
})();

可以看到a是赋值到exports这个对象的a属性上的,所以两者之间并无关系。

上面我们使用的是基本类型,如果使用的是引用类型,修改一个object内部的属性就会同步数据了。

esm 是值引用没什么说的,你可以将其看成我们正常加载一个JavaScript文件一样。

Tree Shaking(摇树)

cjs 是动态加载的,不能对其进行 Tree Shaking,这个在动态加载这里就可以看得出来,在运行时加载模块,在打包编译时并不能确定到底哪个模块不被加载。

esm 是静态导入,可以进行Tree Shaking。在打包编译时可以确定哪些模块不会被使用到,那么就可以不打包这种模块,进而减少js代码体积与运行时的加载速度。

动态 import

上面我们看到的 import 导入的模块是静态的,在加载时所有被导入的模块都将被编译,这将无法做到按需编译,首次加载速度大打折扣,并且无法做到像cjs那样灵活加载。

如果我们需要动态导入,我们可以使用import(module)来加载一个模块。

const moduleA = await import('./a.mjs')
console.log(moduleA.a)
  1. 通过import(module)导入的是一个promise
  2. 与cjs一样,无法被Tree Shaking

我们在使用动态导入时需要考虑到是否是必要的,否则尽量不要滥用(因为无法被Tree Shaking)。