Nodejs和ES6模块化不同点
前言
历史上,JavaScript一直没有自己模块体系(module),无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。Javascript不是一种模块化编程语言,它不支持”类”(class),更遑论”模块”(module)了。
其他语言如java、python、php等都具备这项功能,唯独Javascript没有,在ES6之前,要想在前端做模块化开发,必须依赖第三方框架来实现,如:CommonJS、requireJS和seaJS等。ES6中首次引入模块化开发规范ES Module,让Javascript首次支持原生模块化开发,从此前端开发一发不可收拾。
模块化其实是一种规范、约束。这种规范约束能让我们的代码更具可观性和后续维护性。这种方式会大大的提高我们的工作效率,同时减少了后面维护的时间。
模块化对于现在的开发人员来说并不陌生,可以说是相当熟悉了。但是如果问 JavaScript 中的模块发展过程,相信有挺多人都是不太了解的。接下来我们就来回顾一下 JavaScript 的发展历程。
一、JavaScript模块化发展历程简介
直接定义依赖 (1999年): 由于当时 js 文件非常简单,模块化方式非常简单粗暴 —— 通过全局方法定义、引用模块。这种定义方式与现在的 commonjs 非常神似,区别是 commonjs 以文件作为模块,而这种方法可以在任何文件中定义模块,模块不与文件关联。
闭包模块化模式 (2003年): 用闭包方式解决了变量污染问题,闭包内返回模块对象,只需对外暴露一个全局变量。
模版依赖定义 (2006年): 这时候开始流行后端模版语法,通过后端语法聚合 js 文件,从而实现依赖加载,说实话,现在 go 语言等模版语法也很流行这种方式,写后端代码的时候不觉得,回头看看,还是挂在可维护性上。
注释依赖定义 (2006年): 几乎和模版依赖定义同时出现,与 1999 年方案不同的,不仅仅是模块定义方式,而是终于以文件为单位定义模块了,通过 lazyjs 加载文件,同时读取文件注释,继续递归加载剩下的文件。
外部依赖定义 (2007年): 这种定义方式在 cocos2d-js 开发中普遍使用,其核心思想是将依赖抽出单独文件定义,这种方式不利于项目管理,毕竟依赖抽到代码之外,我是不是得两头找呢?所以才有通过 webpack 打包为一个文件的方式暴力替换为 commonjs 的方式出现。
Sandbox模式 (2009年): 这种模块化方式很简单,暴力,将所有模块塞到一个 sanbox
变量中,硬伤是无法解决明明冲突问题,毕竟都塞到一个 sandbox
对象里,而 Sandbox
对象也需要定义在全局,存在被覆盖的风险。模块化需要保证全局变量尽量干净,目前为止的模块化方案都没有很好的做到这一点。
依赖注入 (2009年): 就是大家熟知的 angular1.0,依赖注入的思想现在已广泛运用在 react、vue 等流行框架中。但依赖注入和解决模块化问题还差得远。
CommonJS (2009年): 真正解决模块化问题,从 node 端逐渐发力到前端,前端需要使用构建工具模拟。2009年1月,Mozilla 的工程师 Kevin Dangoor 创建了一个项目,当时的名字是 ServerJS。在2009年8月,这个项目被改名为 CommonJS,以显示其 API 的更广泛实用性,其中,Node.js 采用的就是这个规范。
AMD (2009年): 都是同一时期的产物,这个方案主要解决前端动态加载依赖,相比 commonJs,体积更小,按需加载。RequireJs是js模块化的工具框架,是AMD规范的具体实现。但是有意思的是,RequireJs诞生之后,推广过程中产生的AMD规范。
CMD(2011年):同样是受到Commonjs的启发,国内(阿里)诞生了一个CMD(Common Module Definition)规范。该规范借鉴了Commonjs的规范与AMD规范,在两者基础上做了改进。SeaJs是CMD规范的实现,跟RequireJs类似,CMD也是SeaJs推广过程中诞生的规范。CMD借鉴了很多AMD和Commonjs优点,同样SeaJs也对AMD和Commonjs做出了很多兼容。
UMD (2011年): 兼容了 CommonJS 与 Amd,其核心思想是,如果在 commonjs 环境(存在 module.exports
,不存在 define
),将函数执行结果交给 module.exports
实现 Commonjs,否则用 Amd 环境的 define
,实现 Amd。
Labeled Modules (2012年): 和 Commonjs 很像了,没什么硬伤,但生不逢时,碰上 Commonjs 与 Amd,那只有被人遗忘的份了。
YModules (2013年): 既然都出了 Commonjs Amd,文章还列出了此方案,一定有其独到之处。其核心思想在于使用 provide
取代 return
,可以控制模块结束时机,处理异步结果;拿到第二个参数 module
,修改其他模块的定义(虽然很有拓展性,但用在项目里是个搅屎棍)。
ES2015 Modules (2015年): 就是我们现在的模块化方案,还没有被浏览器实现,大部分项目已通过 babel
或 typescript
提前体验。
二、CommonJs模块化
2009年Nodejs发布,其中Commonjs是作为Node中模块化规范以及原生模块面世的。Node中提出的Commonjs规范具有以下特点:
- 原生Module对象,每个文件都是一个Module实例
- 文件内通过require对象引入指定模块
- 所有文件加载均是同步完成
- 通过module关键字暴露内容
- 每个模块加载一次之后就会被缓存
- 模块编译本质上是沙箱编译
- 由于使用了Node的api,只能在服务端环境上运行
基本上Commonjs发布之后,就成了Node里面标准的模块化管理工具。同时Node还推出了npm包管理工具,npm平台上的包均满足Commonjs规范,随着Node与npm的发展,Commonjs影响力也越来越大,并且促进了后面模块化工具的发展,具有里程碑意义的模块化工具。代码例子:
a.js
var c = require('./c');
module.exports = {
aStr: 'aa',
aNum: c.cNum + 1
};
b.js
var a = require('./a');
exports.bStr = a.aStr + ' bb';
c.js
exports.cNum = 0;
index.js 就是入口文件
var a = require('./a');
var b = require('./b');
console.log(a.aNum, b.bStr);
可以直观的看到,使用Commonjs管理模块,十分方便。Commonjs优点在于:
- 强大的查找模块功能,开发十分方便
- 标准化的输入输出,非常统一
- 每个文件引入自己的依赖,最终形成文件依赖树
- 模块缓存机制,提高编译效率
- 利用node实现文件同步读取
- 依靠注入变量的沙箱编译实现模块化
这里补充一点沙箱编译:require进来的js模块会被Module模块注入一些变量,使用立即执行函数编译,看起来就好像:
(function (exports, require, module, __filename, __dirname) {
//原始文件内容
})();
看起来require和module好像是全局对象,其实只是闭包中的入参,并不是真正的全局对象。
三、ES6中的模块化
在之前的javascript中是没有模块化概念的。如果要进行模块化操作,需要引入第三方的类库。随着技术的发展,前后端分离,前端的业务变的越来越复杂化。直到2015年,ES6规范中,终于将模块化纳入JavaScript标准,从此js模块化被官方扶正,也是未来js的标准。
Commonjs的代码例子再用ES6的方式实现一次:
a.js
import {cNum} from './c';
export default {
aStr: 'aa',
aNum: cNum + 1
};
b.js
import {aStr} from './a';
export const bStr = aStr + ' bb';
c.js
export const bNum = 0;
index.js
import {aNum} from './a';
import {bStr} from './b';
console.log(aNum, bStr);
可以看到,ES6中的模块化在Commonjs的基础上有所不同,增加了关键字import,export,default,as,from,而不是全局对象。另外深入理解的话,有两点主要的区别:
- CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
一个经典的例子:
// counter.js
exports.count = 0
setTimeout(function () {
console.log('increase count to', ++exports.count, 'in counter.js after 500ms')
}, 500)
// commonjs.js
const {count} = require('./counter')
setTimeout(function () {
console.log('read count after 1000ms in commonjs is', count)
}, 1000)
//es6.js
import {count} from './counter'
setTimeout(function () {
console.log('read count after 1000ms in es6 is', count)
}, 1000)
分别运行 commonjs.js 和 es6.js:
➜ test node commonjs.js
increase count to 1 in counter.js after 500ms
read count after 1000ms in commonjs is 0
➜ test babel-node es6.js
increase count to 1 in counter.js after 500ms
read count after 1000ms in es6 is 1
这个例子解释了CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import
,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import
有点像 Unix 系统的“符号连接”,原始值变了,import
加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。