1,前言
我们整天都在引入一些包来完成我们的开发工作,但是你没有疑惑过吗,为什么引入和抛出是这样一种方式,这里的又有什么规范可言那,今天我们就带着疑问走入这块的知识,js的模块化体系,先看问题:
1,什么是模块化,为什么要有模块化,
2,commonJs是什么,ESM又是什么,
3,CJS和ESM的区别在哪,两者的原理又是什么,
让我们开始今天的学习
2,为什么会有模块化
众所周知,早期的js并没有模块化这一说法,都是通过script标签直接引入js文件代码,但是这样就会出现问题,当我们的项目越来越庞大的时候,就会出现变量名称相互影响,相互污染的问题,造成项目中出现很多未知的bug和问题,排查起来也是非常困难。
举个例子,现在有index1.js和index2.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中,一个文件即是一个模块。exports和module.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 而不是 exports,exports只不过是指向的 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.js,index.json,index.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,输出如下:
我们可以看到执行顺序是先执行index.js文件,后去执行两个引用文件,而且export1.js文件和export.js文件并没有出现循环引用的情况,而且只执行了一次。这是为什么尼,我们上面说到一个文件是一个模块,我们上面已经将这个module对象打印出来了,上面有个loaded属性,这个属性代表了此模块是否已经加载完毕。
而在这里还有一个新概念Module,整个系统运行之后,会用 Module 缓存每一个模块加载的信息。所以引入流程可以描述如下:
- require 会接收一个参数——文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,加下来会从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容
- 如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件,加载完文件,将 loaded 属性设置为 true ,然后返回 module.exports 对象。借此完成模块加载流程。
我们可以描述上面例子的require引入过程,
- 首先运行index.js,碰到第一个require export.js文件,查找全局的Module上是否有export.js文件的缓存,发现没有缓存,那么将export.js加入Module的缓存,然后执行export.js文件(这个顺序非常重要,这样就避免了循环引用) ,然后去执行export.js文件,
- 执行export.js文件,发现export.js文件引入了export1.js文件,然后同样的去Module去找缓存,发现无缓存,然后将export1.js加入缓存,执行export1.js文件
- 结果发现export1.js文件里引用了export.js文件,但是此时export.js文件在Module已经有缓存了,所以直接返回缓存结果就可以了。接着往下执行export1.js文件的其他内容,log出'我是export1文件'
- export.js文件执行完毕,回来执行export.js文件。log出'我是export文件'
- 回到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');
输出如下:
我们可以看到,虽然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导出的一个引用,内部修改可以同步到外部。
参考文章: