webpack 系列 - CommonJs 和 ES6 Module

266 阅读6分钟

日常开发中我们使用最多的两种引入模块的方式 import 和 require,分别对应着两种模块标准,CommonJs 和 ES6 Module,js社区中还有一些其他的模块标准入 AMD、CMD等使用很少,CommonJs 和 ES6 Module 实际开发中使用得比较多有必要深入了解下

CommonJs

简称cjs、后面为了方便都会用cjs代替、是由js社区提出的一系列标准,Node.js中采用了其标准,这也是cjs活跃的一个重要原因

导出

在cjs中,通过module.exports的方式暴露自身

module.exports = {
    name: 'CommonJs',
    add(a, b) {
        return a + b
    }
}

在上面代码中我们暴露了一个对象,包含nameadd两个属性,还支持一种简化写法,如下写法与上面写法作用相同

exports.name = 'CommonJs'
export.add = (a, b) => a + b
  • 不要直接给exportsmodule赋值,那样可能达不到你想要的效果
exports = {
    name: 'CommonJs'
}

module = {
    exports: {
        name: 'CommonJs'
    }
}

以上两种写法都无法导出name属性

  • 不要混用
module.exports = {
    add(a, b) {
      return a + b
    }
}

exports.name = 'CommonJs'

以上代码只会到处add属性,原因:exports 你可以把它看成最开始声明在顶部的指向module.exports 的变量,module.exports 改变为其他对象后exports还是原来的对象

  • 导出位置并不一定是文件底部,我们应该遵循将exportsmodule.exports 放在文件底部的最佳实践

导入

在cjs中通过require语法进行导入

// common.js
module.exports = {
    name: 'CommonJs',
    add(a, b) {
        return a + b
    }
}

// index.js
const { name } = require('./common.js')
const add = require('./common.js').add

👆代码中我们两次使用了require引入了 common.js 文件,有人可能会问会不会导致文件被多次引用造成性能问题?答案是不会,这里我们可以看作系统为我们做了个缓存,如果没加载则加载并执行该文件,若加载过则直接获取上一次导出的内容

ES6 Module

简称esm、为了方便以下用简称替代,与cjs不同,esm是js官方发布的模块标准,属于语法的一部分

导出

esm使用export关键字进行导出模块,分为两种:默认导出、命名导出

命名导出

这个类似于cjs中的exports.xx

export const name = 'esm'
export function add(a, b) {
  return a + b
}

还有一种写法就是先声明

const name = 'esm'
function add(a, b) {
  return a + b
}

export {
  name,
  add,
  // 重命名
  name as newName
}

以上两种写法导出结果一样,不过第二种多了个newName,命名导出可以导出多个

默认导出

与命名导出不同,默认导出只能有一个,使用export default 导出模块

// 导出对象
export default {
    name: 'esm',
    add(a, b) {
        return a + b
    }
}
// 导出字符串
export default 'esm'
// 导出函数
export default function(a, b) {
    return a + b
}

同时使用

与cjs的exports和module.exports不同的是,这两种导出是可以同时使用的

export const name = 'esm'

export default function(a, b) {
    return a + b
}

这样就是既有默认导出也有命名导出的写法

导入

esm通过import进行导入

// main.js
export const name = 'esm'
export function add(a, b) {
    return a + b
}
export default 'modules'

// index.js
// 导入命名导出的模块
import { name, add } from './main.js'

// 重命名
import { name as newName } from './main.js'

// 导入默认导出模块
import modules from './main.js'

// 同时使用
import modules, {name, add} from './main.js'

还有一种整体导入的方式

import * as all from './main.js'

这时候我们的all对象上会包含nameadd的同时还会包含default 属性,他的值是默认导出的值,这种写法相信使用react的同学会熟悉

import React, { useState } from 'react'

复合写法

命名导出的模块有一个小技巧,就是当我们有个文件需要集合多个文件模块并导出时,我们可以这么写

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

// add.js
export function add(a, b) {
    return a + b
}

// main.js
export * from './name.js'
export * from './add.js'

这样main.js就导出了nameadd,默认导出的暂不支持这种写法

cjs(CommonJs) 和 esm(ES6 Module) 的区别

上面我们已经两者在语法上、环境上的区别,除了这些其实他们还有一些却别

模块依赖关系引入时间

可以理解为引入的模块内的代码执行的时间, esm发生在编译阶段,模块的引入必须生命在文件头部,且路径名不能动态制定,

cjs发生在执行阶段,且模块路径可以通过字符串拼接等运算动态指定

// module.js
console.log('done')
// cjs 导出
exports.name = 'cjs'
// esm 导出
export const name ='esm'


// index.js
// esm 写法
import {name} from './module'
document.querySelector('#btn').addEventListener('click', () => {
  console.log(name, '>>>>')
})

// cjs写法
document.querySelector('#btn').addEventListener('click', () => {
  const name = require('./module.js')
  console.log(name, '>>>>')
})

如上,当我们使用ems写法时候,打印的done会在没做任何操作的时候就输出,而cjs写法只有在点击按钮的时候才会输出

值复制与映射

在cjs中我们导入的值是一个导出值的副本,而在esm中我们获取到的是一个映射

// module.js
let name = 'cjs'

module.exports = {
  name,
  changeName(value) {
    name = value;
    return name
  }
}

// index.js
let { name, changeName } = require('./module')

console.log(name) // 'cjs'

changeName('changed')

console.log(name) // 'cjs'

name = 'changed'

console.log(name) // 'changed'

而当我们用ems的时候情况就会有所变化

// module.js
let name = 'cjs'

function changeName(value) {
  name = value
}

export {
  name,
  changeName
}

// index.js
import { name, changeName } from './module'

console.log(name) // 'esm'

changeName('changed')

console.log(name) // 'changed'

name = 'esm'

console.log(name) // 'esm'

当我们使用默认导入模块的时候,导入的模块是只读的

// module.js
export default 'default'

// index.js
import name from './module.js'

name = 'changed' // Error

循环依赖

所谓循环依赖就是A依赖了B,B依赖了A,比如:

// a.js
import { name } from 'b.js'

// b.js
import { age } from 'a.js

通常情况下我们在项目中应该避免这种情况的发生,但是当项目复杂度上升,开发的人变多,就很容易不经意间出现这种关系,因此esm和cjs提出了不同的处理方案

cjs处理循环依赖

设想我们有如下代码,a.js和b.js互相依赖,index.js中引入了a.js

// a.js
const name = require('./b.js')

console.log('b.name', name)

module.exports = 'a.js'

//b.js
const name = require('./a.js')

console.log('a.name',  name)

module.exports = 'b.js'

//index.js
require('./a.js')

在cjs中会输出如下结果

a.name {}
b.name b.js

我们来看看cjs中的实际执行流程,

  1. index.js中引入a.js,首先会进入a.js
  2. a.js开头引入了b.js,,进入b.js
  3. b.js开头引入了a.js,这里产生了循环依赖,但是因为a.js已经在index.js中引入过,则直接取他的结果,但是这时候由于a.js还未执行完成,获取到的结果是默认的module.exports即空对象,所以此处的name会是{}
  4. b.js执行完成导出了b.js,执行权交回a.js
  5. a.js此时获取到的是b.js导出的结果b.js
  6. a.js执行完成交回index.js

esm 处理

我们用esm的重写上面的例子

// a.js
import name from './b.js'

console.log('b.name', name)

export default 'a.js'

//b.js
import name from './a.js'

console.log('a.name',  name)

export default 'b.js'

//index.js
import './a.js'

以上代码无论是在webpack中还是在浏览器原生模块中都会报错

// 原生
Uncaught ReferenceError: Cannot access 'name' before initialization

//webpack 
Uncaught ReferenceError: Cannot access '__WEBPACK_DEFAULT_EXPORT__' before initialization

这个报错就是临时性死区的一个报错,因为当你在b.js导入的a.js中的name,而此时a.js还未定义,类似如下代码

console.log(name)

const name = 'a.js'

所以我们如果是使用的esm的方式,基本上可以避免循环依赖的问题😏,那要是已经出现了循环依赖,如何快速定位呢,webpack也有一个插件circular-dependency-plugin

总结

本文主要讲解了esmcjs的用法以及主要区别,对比了循环依赖的解决方案,部分结果和我找到的一些网上的结果不一致,结果均已通过代码论证,有不同欢迎讨论