CommonJs和EsModule的区别,

184 阅读9分钟

1,前言

我们整天都在引入一些包来完成我们的开发工作,但是你没有疑惑过吗,为什么引入和抛出是这样一种方式,这里的又有什么规范可言那,今天我们就带着疑问走入这块的知识,js的模块化体系,先看问题:

1,什么是模块化,为什么要有模块化,

2,commonJs是什么,ESM又是什么,

3,CJS和ESM的区别在哪,两者的原理又是什么,

让我们开始今天的学习

2,为什么会有模块化

众所周知,早期的js并没有模块化这一说法,都是通过script标签直接引入js文件代码,但是这样就会出现问题,当我们的项目越来越庞大的时候,就会出现变量名称相互影响,相互污染的问题,造成项目中出现很多未知的bug和问题,排查起来也是非常困难。

举个例子,现在有index1.jsindex2.js两个文件,在index1中定义了一个a变量,但是在index2文件中定义了一个a函数,我们使用script标签直接引入js文件如下:

// index.js
const a = 1;
​
// index2.js
function a() {
    return 2
}
​
<body>
    <script src="./index1.js"></script>
​
    <script src="./index2.js"></script>
​
    <script>
        console.log(a);
    </script>
​
    <div>afasdf</div>
</body>

此时我们打印的a是一个变量,这其实和我们引入的顺序有关系,如果我们先引入index2.js文件,那么log出来的a就是一个函数。可以看到通过script引入js文件,不仅会造成变量污染问题,还和文件的引入顺序有关系,可以说是非常的不友好,

为了解决上述问题呢,Js社区出现了CommonJs和Es Module两种方式,我们下面介绍下这两种方式

3,Common Js模块化

在CommonJs中,一个文件即是一个模块。exportsmodule.exports可以对模块中的内容进行导出,require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容,

CommonJs使用:

// export.js文件
const a = 1;
const func = function() {
    return 2
}
exports.a = a
exports.func = func
​
// require.js文件
const tem = require('./export')
console.log(tem);// 1
console.log(tem.func()); // 2

我们可以将export.js的module对象打印出来如下:

Module {
  id: '.',// 如果是 mainModule id 固定为 '.',如果不是则为模块绝对路径
  path: '',// 模块的绝对路径
  exports: { a: 1, func: [Function: func] }, // 模块最终 exports,
  parent: null, // 第一个引用该模块的模块
  filename: '',
  loaded: false,// 模块是否已加载完毕
  children: [],// 被该模块引用的模块
  paths: [
    // 模块的搜索路径
  ]
}

可以看到module对象中有很多属性,例如exports,这里就可以解释为什么导出有exports和module.exports,其实module.exports 初始值为一个空对象 {}require() 返回的是 module.exports 而不是 exportsexports只不过是指向的 module.exports 的引用, 我们导出的是一个值的拷贝。当我们使用exports时,其实是将module上的exports对象进行改变。

require的加载过程

此处参考此文章

首先我们看一下 nodejs 中对标识符的处理原则。

  • 首先像 fs ,http ,path 等标识符,会被作为 nodejs 的核心模块

  • ./../ 作为相对路径的文件模块/ 作为绝对路径的文件模块

  • 非路径形式也非核心模块的模块,将作为自定义模块

    1,对于核心模块的处理

    核心模块的优先级仅次于缓存加载,在 Node 源码编译中,已被编译成二进制代码,所以加载核心模块,加载过程中速度最快。

    2,对于路径模块的处理

    ./..// 开始的标识符,会被当作文件模块处理。require() 方法会将路径转换成真实路径,并以真实路径作为索引,将编译后的结果缓存起来,第二次加载的时候会更快。

    3,自定义模块处理: 自定义模块,一般指的是非核心的模块,它可能是一个文件或者一个包,它的查找会遵循以下原则:

    在当前目录下的 node_modules 目录查找。

    如果没有,在父级目录的 node_modules 查找,如果没有在父级目录的父级目录的 node_modules 中查找。

    沿着路径向上递归,直到根目录下的 node_modules 目录。

    在查找过程中,会找 package.json 下 main 属性指向的文件,如果没有 package.json ,在 node 环境下会以此查找 index.jsindex.jsonindex.node

那么现在大家可能就会有一个问题,require的过程中是如果避免重复引入和循环引用问题的那,我们用下面的这个例子来说明,

// export.js文件
const tem = require('./export1')
console.log('我是export文件');
exports.test = function() {
    return 2
}
​
// export1.js文件
const tem = require('./export')
console.log('我是export1文件');
exports.test = function() {
    return 2
}
​
// index.js文件
console.log('index开始');
const tem = require('./export')
const tem1 = require('./export1')
​
console.log('我是index文件');

如上,我们定义了三个文件,其中export1和export文件相互引用,index.js文件引用了export文件和export1文件,我们运行index.js,输出如下:

image-20220625161838832的副本.png

我们可以看到执行顺序是先执行index.js文件,后去执行两个引用文件,而且export1.js文件和export.js文件并没有出现循环引用的情况,而且只执行了一次。这是为什么尼,我们上面说到一个文件是一个模块,我们上面已经将这个module对象打印出来了,上面有个loaded属性,这个属性代表了此模块是否已经加载完毕。

而在这里还有一个新概念Module,整个系统运行之后,会用 Module 缓存每一个模块加载的信息。所以引入流程可以描述如下:

  • require 会接收一个参数——文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,加下来会从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容
  • 如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件,加载完文件,将 loaded 属性设置为 true ,然后返回 module.exports 对象。借此完成模块加载流程。

我们可以描述上面例子的require引入过程,

  1. 首先运行index.js,碰到第一个require export.js文件,查找全局的Module上是否有export.js文件的缓存,发现没有缓存,那么将export.js加入Module的缓存,然后执行export.js文件(这个顺序非常重要,这样就避免了循环引用) ,然后去执行export.js文件,
  2. 执行export.js文件,发现export.js文件引入了export1.js文件,然后同样的去Module去找缓存,发现无缓存,然后将export1.js加入缓存,执行export1.js文件
  3. 结果发现export1.js文件里引用了export.js文件,但是此时export.js文件在Module已经有缓存了,所以直接返回缓存结果就可以了。接着往下执行export1.js文件的其他内容,log出'我是export1文件'
  4. export.js文件执行完毕,回来执行export.js文件。log出'我是export文件'
  5. 回到index.js文件,log出我是index文件

但是上述存在一个问题,就是在3步时,当执行export1.js文件时,此时的export.js文件还没有执行,只是加入了Module的缓存,所以此时export1.js文件中获取不到export.js文件导出的test()方法。。那么如何获取test()方法那,有两个方法:

  • 放在异步方法中执行
  • 使用require的动态加载

动态加载只需要修改export.js文件如下即可:

// export.js文件
console.log('我是export文件');
exports.test = function() {
    require('./export1')
    return 2
}
​

3,Es Module

从Es6开始,JavaScript` 才真正意义上有自己的模块化规范,

Es Module 的产生有很多优势,比如:

  • 借助 Es Module 的静态导入导出的优势,实现了 tree shaking
  • Es Module 还可以 import() 懒加载方式实现代码分割。

Es Module 中用 export 用来导出模块,import 用来导入模块。大家可能对这种导入导出方式更加熟悉,

ES6 模块中不存在 require, module.exports, __filename 等变量,CommonJS 中也不能使用 import。两种规范是不兼容的,一般来说平日里写的 ES6 模块代码最终都会经由 Babel, Typescript 等工具处理成 CommonJS 代码。

使用 Node 原生 ES6 模块需要将 js 文件后缀改成 mjs,或者 package.json "type" 字段改为 "module",通过这种形式告知 Node 使用 ES Module 的形式加载模块。

关于export和import的导入导出方式想必大家都很熟悉了,这里也就不赘述了,这里主要介绍下import的执行顺序和import能做什么

import的执行顺序

我们通过下面这个例子来说明Es Module下的模块执行顺序

// child1.js
console.log('child1 start');
export const init = () => {
    console.log('i am child1');
}
​
// child2.js
import {init} from './child1.mjs'
console.log('child2 start');
​
// father.js
console.log('father start');
import {init} from './child1.mjs'
console.log('father end');
​

输出如下:

image-20220625173735470的副本.png

我们可以看到,虽然father.js和child2.js文件都引入了child1.js文件,但是其只运行了一次,还可以看出其运行顺序是先运行子模块,再运行父模块。而且ES6 module 的引入和导出是静态的,import 会自动提升到代码的顶层 ,import , export 不能放在块级作用域或条件语句中。

import可以做什么

动态加载

  • 首先 import() 动态加载一些内容,可以放在条件语句或者函数执行上下文中。
if(isRequire){
    const result  = import('./b')
}

懒加载

  • import() 可以实现懒加载,举个例子 vue 中的路由懒加载;
[
   {
        path: 'home',
        name: '首页',
        component: ()=> import('./home') ,
   },
]

Tree-shaking

Tree Shaking 在 Webpack 中的实现,是用来尽可能的删除没有被使用过的代码,一些被 import 了但其实没有被使用的代码。那么构建打包的时候,没有引用的方法,不被打包进来

4,两者对比

CommonJs总结

  • 语法不同,commonjs是module.exports,exports导出,require导入 ES6则是export导出,import导入,
  • ES module在编译期间会将所有import提升到顶部,commonjs不会提升require。
  • CommonJS 是可以动态加载的,对每一个加载都存在缓存,通过这个缓存,可以有效的解决循环引用的问题。 ES Module是在静态编译期间就确定模块的依赖。
  • commonjs导出的是一个值拷贝,会对加载结果进行缓存,一旦内部再修改这个值,则不会同步到外部。ES Module导出的一个引用,内部修改可以同步到外部。

参考文章:

juejin.cn/post/693858…

juejin.cn/post/699422…

www.ruanyifeng.com/blog/2015/1…