前端模块标准之CommonJS和ES6 Module的区别

3,009 阅读5分钟

前端模块标准之CommonJS和ES6 Module的区别

CommonJS和ES6 Module是什么?

CommonJS 和 ES6 Module 的区别

  1. 导出方式的不同
  2. 动态与静态
  3. 值拷贝与动态映射
  4. 循环依赖

1. 导出方式的不同

在 ESM 中,导入导出有两种方式:

  1. 具名导出/导入: Named Import/Export
  2. 默认导出/导入: Default Import/Export

代码示例如下:

// Named export/import
export { sum }
import { sum } from 'sum'

// Default export/import
export default sum
import sum from 'sum'
复制代码

而在 CommonJS 中,导入导出的方法只有一种:

module.exports = sum

而所谓的 exports 仅仅是 module.exports 的引用而已

// 实际上的 exports
exports = module.exports

// 以下两个是等价的
exports.a = 3
module.exports.a = 3
复制代码

PS: 一道题关于 exportsmodule.exports 的区别,以下 console.log 输出什么

// hello.js
exports.a = 3
module.exports.b = 4

// index.js
const hello = require('./hello')
console.log(hello)

再来一道题:

// hello.js
exports.a = 3
module.exports = { b: 4 }

// index.js
const hello = require('./hello')
console.log(hello)

结果

{
  a: 3,
  b: 4
}

{
  b: 4
}

本质是导出exports对象{}

  • 第二次是把exports对象的引用都改了,所以之前的a:3就丢失了

2. 动态与静态

CommonJS与ES6 Module最本质的区别是:动态vs静态

  • CommonJS对模块依赖的解决是“动态的”(只能在运行时,分析出对应的依赖关系

  • ES6 Module是“静态的”(可以在编译时,就分析出对应的依赖关系,才能做tree shaking

先看一个CommonJS的例子

// calculator.js
module.exports = {
    name: 'calculator'
}
// index.js
const name = require('./calculator.js').name

在上面介绍CommonJS的部分时我们提到过,当模块A加载模块B时(在上面的例子中是index.js加载calculator.js),会执行B中的代码,并将其module.exports对象作为require函数的返回值进行返回。

  • require的模块路径可以动态指定,支持传入一个表达式,我们甚至可以通过if语句判断是否加载某个模块。

  • 因此,在CommonJS模块被执行前,并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段

同样的例子,让我们再对比看下ES6 Module的写法

// calculator.js
export const name = 'calculator'

// index.js
import { name } from './calculator.js'

特点:

  1. ES6 Module的导入、导出语句都是声明式的,它不支持导入的路径是一个表达式
  2. 并且导入、导出语句必须位于模块的顶层作用域(比如不能放在if语句中)。 因此我们说,ES6 Module是一种静态的模块结构,在ES6代码的编译阶段就可以分析出模块的依赖关系。

优势:ES6 Module它相比于CommonJS来说具备以下几点优势:

  1. 死代码检测和排除。(tree shaking)
    • 我们可以用静态分析工具检测出哪些模块没有被调用过。比如,在引入工具类库时,工程中往往只用到了其中一部分组件或接口,但有可能会将其代码完整地加载进来。未被调用到的模块代码永远不会被执行,也就成为了死代码。通过静态分析可以在打包时去掉这些未曾使用过的模块,以减小打包资源体积。
  2. 模块变量类型检查
    • JavaScript属于动态类型语言,不会在代码执行前检查类型错误(比如对一个字符串类型的值进行函数调用)。ES6 Module的静态模块结构有助于确保模块之间传递的值或接口类型是正确的。
  3. 编译器优化
    • 在CommonJS等动态模块系统中,无论采用哪种方式,本质上导入的都是一个对象,而ES6 Module支持直接导入变量,减少了引用层级,程序效率更高。

3. 值拷贝与动态映射

在导入一个模块时

  • 对于CommonJS来说获取的是一份导出值的拷贝
  • 而在ES6 Module中则是值的动态映射,并且这个映射是只读的

上面的话直接理解起来可能比较困难,首先让我们来看一个例子,了解一下什么是CommonJS中的值拷贝。

// calculator.js
var count = 0
module.exports = {
    count,
    add: function (a, b) {
        count += 1
        return a + b
    }
}

// index.js
var count = require('./calculator.js').count
var add = require('./calculator.js').add

console.log(count) // 0(这里的count是对 calculator.js 中 count 值的拷贝)
add(2, 3)
console.log(count) // 0(calculator.js中变量值的改变不会对这里的拷贝值造成影响)

count += 1
console.log(count) // 1(拷贝的值可以更改)

index.js中的count是对calculator.js中count的一份值拷贝,因此在调用add函数时,虽然更改了原本calculator.js中count的值,但是并不会对index.js中导入时创建的副本造成影响。

另一方面,在CommonJS中允许对导入的值进行更改。我们可以在index.js更改count和add,将其赋予新值。同样,由于是值的拷贝,这些操作不会影响calculator.js本身


下面我们使用ES6 Module将上面的例子进行改写

// calculator.js
let count = 0
const add = function (a, b) {
    count += 1
    return a + b
}
export { count, add }

// index.js
import { count, add } from './calculator.js'

console.log(count) // 0(对 calculator.js 中 count 值的引用)
add(2, 3)
console.log(count) // 1(因为是引用,所以和calculator.js中的count值保持一致)

// count += 1 // 不可更改,会报错 SyntaxError: 'count' is read-only

上面的例子展示了ES6 Module中导入的变量其实是对原有值的动态映射。

  • index.js中的count是对calculator.js中的count值的实时反映,当我们通过调用add函数更改了calculator.js中count值时,index.js中count的值也随之变化。

  • 我们不可以对ES6 Module导入的变量进行更改,可以将这种映射关系理解为一面镜子,从镜子里我们可以实时观察到原有的事物,但是并不可以操纵镜子中的影像。

4. 循环依赖

循环依赖是指模块A依赖于模块B,同时模块B依赖于模块A

  • 一般来说工程中应该尽量避免循环依赖的产生

ES6 Module的特性使其可以更好地支持循环依赖,只是需要由开发者来保证当导入的值被使用时已经设置好正确的导出值。

详细内容,我的另一篇:juejin.cn/post/700796…

5. import xx from 'aaa' 和 require('aaa') 到底引入了aaa的什么文件?

根据package.json识别的。 优先 module 字段,否则 main

{
  "name": "aaa",
  "main": "index.js",
  "module": "dist/vue.runtime.esm-bundler.js",
  ...
}

码字不易,点赞鼓励!

  • 部分参考《Webpack实战:入门、进阶与调优》(居玉皓)