1. ES Module 与 CommonJS 的概念
模块化编程是个老生常谈的问题了,Javascript 有着沉重的模块化历史包袱,之前引入 Javascript 代码只能通过 Script 标签引入,这样就容易产生如下的问题:
- js文件作用域都是顶层,这会造成变量污染
- js文件多,变得不好维护
- js文件依赖问题,稍微不注意顺序引入错,代码全报错
于是为了解决上述的问题,Javascript 的社区上首先出现了名为 CommonJS 的规范,NodeJS 在 v13.2.0 之前就是基于 CommonJS 规范实现模块化的。但是 CommonJS 只是一个规范,并不是浏览器下的一个功能,因此如果要将 CommonJS 规范应用与前端开发,那还必须要有构建工具的参与,常用的如 browserify,通过对入口代码的打包编译,生成一个 bundle.js
文件引入到 HTML 页面中。
随着 Javascript 语言的逐渐发展,模块化是其必然的一个趋势,因此在 ES6 里,Javascript 引入了可以使用 import
export
简洁语句来实现模块化的 ES Module 概念,我们可以创建一个 <script type="module" src="xxx.js"></script>
标签来引入一个使用了 ES Module 规则的 js 文件,从浏览器端实现了模块化编程的问题。
但是在实际的开发过程中,如果我们使用了框架就会发现 CommonJS 与 ES Module 可以混合使用,这其实是打包工具在帮助我们做转化,具体的转化原理可以参考这篇文章:import、require、export、module.exports 混合使用详解
ES Module 与 CommonJS 都是模块化的解决方案,但是两种方式还是有很大差别的,接下来我们就会来对其差别进行一个更为详细的讨论。
2. 使用方式的区别
2.1 CommonJS
基础使用:
创建模块:
1 | // module_a.js |
使用模块:
1 | const module_a = require("./module_a.js") |
在 CommonJS 规范中,我们来通过对 exports
对象上追加多个属性,当其他 js 文件引入该模块时,实际上就是获取了模块的 exports
对象,并调用对象上的各个方法。
同时我们还会发现有时 CommonJS 的模块导出会写为:
1 | module.exports.x = x |
这其实与使用 exports
方式导出对象并无差异,只不过是我们可以在模块内部使用 module
来获取到整个 module
对象,而 module
对象上又挂载着 exports
对象。exports
对象就表示模块对外输出的值,其他文件加载该模块,实际上就是读取 module.exports
变量。
使用 module.exports
也可以优化我们模块导出的写法,比如:
1 | // module_a.js |
同时 module
对象上还有其他属性:
module.id
模块的识别符,通常是带有绝对路径的模块文件名。module.filename
模块的文件名,带有绝对路径。module.loaded
返回一个布尔值,表示模块是否已经完成加载。module.parent
返回一个对象,表示调用该模块的模块。module.children
返回一个数组,表示该模块要用到的其他模块。module.exports
表示模块对外输出的值。
2.2 ES Module
暂略
3. 对于值的引用
3.1 CommonJS
对于 CJS,看了很多文章,对其形容比较晦涩,我们举例来说明,可总结为以下几点:
1. 如果导出的值是基本类型,会对该值进行复制,不与外部共享该值
比如:
1 | // mod.js |
1 | // index.js |
2. 如果导出的值是引用类型,会对该值进行浅拷贝,与外部共享该值
比如:
1 | // mod.js |
1 | // index.js |
3. 工作空间可以修改引入的值
CJS 并未对内部的变量进行保护,因此在使用模块时,可以修改模块导出的值。但是要注意,由于 CJS 导出的值会被缓存,当修改了导出的值后,会影响到其他模块对该值的引用:
1 | // mod.js |
1 | // utils.js |
1 | // main.js |
本质上,使用
module.exports
导出的就是一个对象,那么对于这个对象上所有的引用与修改都遵循 JavaScript 对于一个对象的处理方式。
3.2 ES Module
相对于 CJS 导出的是一个 exports
对象,ESM 我们可以理解为导出的是模块内声明的各种变量。其最大的一个特点就是,导出的值是只读的,不能从外部修改,但是可以调用内部方法对其进行修改,比如:
1 | // module.js |
1 | // index.js |
这时候有的小聪明就要问了,你这里用的是解构赋值,赋值给了一个 constance 变量,如果我使用 import * as xxx
来直接获取导出对象,修改导出对象上的值能修改成功吗?不妨来试一下:
1 | // index.js |
我们可以看出,导出的模块在本质上就是一个不可修改的值。
4. 模块导入的执行顺序与循环引用
4.1 CommonJS
CJS 在模块引用时有一个重要的特性就是 加载时执行,的执行规则是沿着入口文件开始,逐次向下执行,遇到 require
语句后执行 require 的模块的内部代码;
如果在模块内部又再次遇到 require
语句,会将当前的代码缓存住,同时检查该模块是否有被引用过(也就是是否存在缓存),这就需要分为两种情况:
- 如果 require 的模块之前未被引用过,则暂停当前模块的解析,进入新的模块,并执行新模块内部的代码
- 如果 require 的模块之前被引用过,则无视该 require 语句,继续向下执行
这种引用方式,可以让 CJS 避免循环引用造成代的码锁死,但是也会造成引用顺序不当从而导致某些模块的变量未被创建就本引用的问题。
以下的这个示例就能很好的展示 CJS 的模块引用顺序:
1 | // index.js |
执行图解如下:
4.2 ES Module
ES6模块的运行机制与 CommonJS 不太一样,它遇到模块加载命令 import
时,生成的是一个引用,等到真正是用的时候才会去取值.
ES6模块不会缓存运行结果,而是动态地去被加载的模块取值,以及变量总是绑定其所在的模块。这导致 ES6 处理”循环加载”与 CommonJS 有本质的不同。ES6根本不会关心是否发生了”循环加载”,只是生成一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
举例来说:
1 | // a.js |
1 | // b.js |
代码可以正常执行,会输出随机概率个 执行完毕
。
然而如果换成 CJS 的写法,代码是无法运行的:
1 | // a_cjs.js |
1 | // b_cjs.js |