本文首发于 vivo互联网技术 微信公众号
连接: https://mp.weixin.qq.com/s/15sedEuUVTsgyUm1lswrKA
做者:Morrainjavascript
上一篇《前端科普系列(2):Node.js 换个角度看世界》,咱们聊了 Node.js 相关的东西,Node.js 能在诞生后火到如此一塌糊涂,离不开它成熟的模块化实现,Node.js 的模块化是在 CommonJS 规范的基础上实现的。那 CommonJS 又是什么呢?html
先来看下,它在维基百科上的定义:前端
CommonJS 是一个项目,其目标是为 JavaScript 在网页浏览器以外建立模块约定。建立这个项目的主要缘由是当时缺少广泛可接受形式的 JavaScript 脚本模块单元,模块在与运行JavaScript 脚本的常规网页浏览器所提供的不一样的环境下能够重复使用。java
咱们知道,很长一段时间 JavaScript 语言是没有模块化的概念的,直到 Node.js 的诞生,把 JavaScript 语言带到服务端后,面对文件系统、网络、操做系统等等复杂的业务场景,模块化就变得不可或缺。因而 Node.js 和 CommonJS 规范就相得益彰、相映成辉,共同走入开发者的视线。node
因而可知,CommonJS 最初是服务于服务端的,因此我说 CommonJS 不是前端,但它的载体是前端语言 JavaScript,为后面前端模块化的盛行产生了深远的影响,奠基告终实的基础。CommonJS:不是前端却革命了前端!webpack
在以前的《Web:一路前行一路忘川》中,咱们提到过 JavaScript 诞生之初只是做为一个脚本语言来使用,作一些简单的表单校验等等。因此代码量不多,最开始都是直接写到 <script> 标签里,以下所示:git
// index.html <script> var name = 'morrain' var age = 18 </script>复制代码
// index.html <script src="./mine.js"></script> // mine.js var name = 'morrain' var age = 18复制代码
// index.html <script src="./mine.js"></script> <script src="./a.js"></script> <script src="./b.js"></script> // mine.js var name = 'morrain' var age = 18 // a.js var name = 'lilei' var age = 15 // b.js var name = 'hanmeimei' var age = 13复制代码
为了解决全局变量污染的问题,开发者开始使用命名空间的方法,既然命名会冲突,那就加上命名空间呗,以下所示:es6
// index.html <script src="./mine.js"></script> <script src="./a.js"></script> <script src="./b.js"></script> // mine.js app.mine = {} app.mine.name = 'morrain' app.mine.age = 18 // a.js app.moduleA = {} app.moduleA.name = 'lilei' app.moduleA.age = 15 // b.js app.moduleB = {} app.moduleB.name = 'hanmeimei' app.moduleB.age = 13复制代码
聪明的开发者又开始利用 JavaScript 语言的函数做用域,使用闭包的特性来解决上面的这一问题。github
// index.html <script src="./mine.js"></script> <script src="./a.js"></script> <script src="./b.js"></script> // mine.js app.mine = (function(){ var name = 'morrain' var age = 18 return { getName: function(){ return name } } })() // a.js app.moduleA = (function(){ var name = 'lilei' var age = 15 return { getName: function(){ return name } } })() // b.js app.moduleB = (function(){ var name = 'hanmeimei' var age = 13 return { getName: function(){ return name } } })()复制代码
app.moduleA.getName() 来取到模块A的名字,可是各个模块的名字都保存在各自的函数内部,没有办法被其它模块更改。这样的设计,已经有了模块化的影子,每一个模块内部维护私有的东西,开放接口给其它模块使用,但依然不够优雅,不够完美。譬如上例中,模块B能够取到模块A的东西,但模块A却取不到模块B的,由于上面这三个模块加载有前后顺序,互相依赖。当一个前端应用业务规模足够大后,这种依赖关系又变得异常难以维护。web
综上所述,前端须要模块化,而且模块化不光要处理全局变量污染、数据保护的问题,还要很好的解决模块之间依赖关系的维护。
既然 JavaScript 须要模块化来解决上面的问题,那就须要制定模块化的规范,CommonJS 就是解决上面问题的模块化规范,规范就是规范,没有为何,就和编程语言的语法同样。咱们一块儿来看看。
Node.js 应用由模块组成,每一个文件就是一个模块,有本身的做用域。在一个文件里面定义的变量、函数、类,都是私有的,对其余文件不可见。
// a.js var name = 'morrain' var age = 18复制代码
CommonJS 规范还规定,每一个模块内部有两个变量可使用,require 和 module。
require 用来加载某个模块
module 表明当前模块,是一个对象,保存了当前模块的信息。exports 是 module 上的一个属性,保存了当前模块要导出的接口或者变量,使用 require 加载的某个模块获取到的值就是那个模块使用 exports 导出的值
// a.js var name = 'morrain' var age = 18 module.exports.name = name module.exports.getAge = function(){ return age } //b.js var a = require('a.js') console.log(a.name) // 'morrain' console.log(a.getAge())// 18复制代码
为了方便,Node.js 在实现 CommonJS 规范时,为每一个模块提供一个 exports的私有变量,指向 module.exports。你能够理解为 Node.js 在每一个模块开始的地方,添加了以下这行代码。
var exports = module.exports复制代码
// a.js var name = 'morrain' var age = 18 exports.name = name exports.getAge = function(){ return age }复制代码
有一点要尤为注意,exports 是模块内的私有局部变量,它只是指向了 module.exports,因此直接对 exports 赋值是无效的,这样只是让 exports 再也不指向module.exports了而已。
以下所示:
// a.js var name = 'morrain' var age = 18 exports = name复制代码
若是一个模块的对外接口,就是一个单一的值,可使用 module.exports 导出
// a.js var name = 'morrain' var age = 18 module.exports = name复制代码
require 命令的基本功能是,读入并执行一个 js 文件,而后返回该模块的 exports 对象。若是没有发现指定模块,会报错。
第一次加载某个模块时,Node.js 会缓存该模块。之后再加载该模块,就直接从缓存取出该模块的 module.exports 属性返回了。
// a.js var name = 'morrain' var age = 18 exports.name = name exports.getAge = function(){ return age } // b.js var a = require('a.js') console.log(a.name) // 'morrain' a.name = 'rename' var b = require('a.js') console.log(b.name) // 'rename'复制代码
如上所示,第二次 require 模块A时,并无从新加载并执行模块A。而是直接返回了第一次 require 时的结果,也就是模块A的 module.exports。
还一点须要注意,CommonJS 模块的加载机制是,require 的是被导出的值的拷贝。也就是说,一旦导出一个值,模块内部的变化就影响不到这个值 。
// a.js var name = 'morrain' var age = 18 exports.name = name exports.age = age exports.setAge = function(a){ age = a } // b.js var a = require('a.js') console.log(a.age) // 18 a.setAge(19) console.log(a.age) // 18复制代码
了解 CommonJS 的规范后,不难发现咱们在写符合 CommonJS 规范的模块时,无外乎就是使用了 require 、 exports 、 module 三个东西,而后一个 js 文件就是一个模块。以下所示:
// a.js var name = 'morrain' var age = 18 exports.name = name exports.getAge = function () { return age } // b.js var a = require('a.js') console.log('a.name=', a.name) console.log('a.age=', a.getAge()) var name = 'lilei' var age = 15 exports.name = name exports.getAge = function () { return age } // index.js var b = require('b.js') console.log('b.name=',b.name)复制代码
若是咱们向一个当即执行函数提供 require 、 exports 、 module 三个参数,模块代码放在这个当即执行函数里面。模块的导出值放在 module.exports 中,这样就实现了模块的加载。以下所示:
(function(module, exports, require) { // b.js var a = require("a.js") console.log('a.name=', a.name) console.log('a.age=', a.getAge()) var name = 'lilei' var age = 15 exports.name = name exports.getAge = function () { return age } })(module, module.exports, require)复制代码
知道这个原理后,就很容易把符合 CommonJS 模块规范的项目代码,转化为浏览器支持的代码。不少工具都是这么实现的,从入口模块开始,把全部依赖的模块都放到各自的函数中,把全部模块打包成一个能在浏览器中运行的 js 文件。譬如 Browserify 、webpack 等等。
咱们以 webpack 为例,看看如何实现对 CommonJS 规范的支持。咱们使用 webpack 构建时,把各个模块的文件内容按照以下格式打包到一个 js 文件中,由于它是一个当即执行的匿名函数,因此能够在浏览器直接运行。
// bundle.js (function (modules) { // 模块管理的实现 })({ 'a.js': function (module, exports, require) { // a.js 文件内容 }, 'b.js': function (module, exports, require) { // b.js 文件内容 }, 'index.js': function (module, exports, require) { // index.js 文件内容 } })复制代码
接下来,咱们须要按照 CommonJS 的规范,去实现模块管理的内容。首先咱们知道,CommonJS 规范有说明,加载过的模块会被缓存,因此须要一个对象来缓存已经加载过的模块,而后须要一个 require 函数来加载模块,在加载时要生成一个 module,而且 module 上 要有一个 exports 属性,用来接收模块导出的内容。
// bundle.js (function (modules) { // 模块管理的实现 var installedModules = {} /** * 加载模块的业务逻辑实现 * @param {String} moduleName 要加载的模块名 */ var require = function (moduleName) { // 若是已经加载过,就直接返回 if (installedModules[moduleName]) return installedModules[moduleName].exports // 若是没有加载,就生成一个 module,并放到 installedModules var module = installedModules[moduleName] = { moduleName: moduleName, exports: {} } // 执行要加载的模块 modules[moduleName].call(modules.exports, module, module.exports, require) return module.exports } return require('index.js') })({ 'a.js': function (module, exports, require) { // a.js 文件内容 }, 'b.js': function (module, exports, require) { // b.js 文件内容 }, 'index.js': function (module, exports, require) { // index.js 文件内容 } })复制代码
能够看到, CommonJS 核心的规范,上面的实现中都知足了。很是简单,没想像的那么难。
咱们对 CommonJS 的规范已经很是熟悉了,require 命令的基本功能是,读入并执行一个 js 文件,而后返回该模块的 exports 对象,这在服务端是可行的,由于服务端加载并执行一个文件的时间消费是能够忽略的,模块的加载是运行时同步加载的,require 命令执行完后,文件就执行完了,而且成功拿到了模块导出的值。
这种规范天生就不适用于浏览器,由于它是同步的。可想而知,浏览器端每加载一个文件,要发网络请求去取,若是网速慢,就很是耗时,浏览器就要一直等 require 返回,就会一直卡在那里,阻塞后面代码的执行,从而阻塞页面渲染,使得页面出现假死状态。
为了解决这个问题,后面发展起来了众多的前端模块化规范,包括 CommonJS 大体有以下几种:
在聊 AMD 以前,先熟悉一下 RequireJS。
官网是这么介绍它的:
"RequireJS is a JavaScript file and module loader. It is optimized for in-browser use, but it can be used in other JavaScript environments, like Rhino and Node. Using a modular script loader like RequireJS will improve the speed and quality of your code."
翻译过来大体就是:
RequireJS 是一个 js 文件和模块加载器。它很是适合在浏览器中使用,但它也能够用在其余 js 环境, 就像 Rhino 和 Node。使用 RequireJS 加载模块化脚本能提升代码的加载速度和质量。
它解决了 CommonJS 规范不能用于浏览器端的问题,而 AMD 就是 RequireJS 在推广过程当中对模块定义的规范化产出。
来看看 AMD 规范的实现:
<script src="require.js"></script> <script src="a.js"></script>复制代码
首先要在 html 文件中引入 require.js 工具库,就是这个库提供了定义模块、加载模块等功能。它提供了一个全局的 define 函数用来定义模块。因此在引入 require.js 文件后,再引入的其它文件,均可以使用 define 来定义模块。
define(id?, dependencies?, factory)复制代码
id:可选参数,用来定义模块的标识,若是没有提供该参数,就使用 js 文件名(去掉拓展名)对于一个 js 文件只定义了一个模块时,这个参数是能够省略的。dependencies:可选参数,是一个数组,表示当前模块的依赖,若是没有依赖能够不传 factory:工厂方法,模块初始化要执行的函数或对象。若是为函数,它应该只被执行一次,返回值即是模块要导出的值。若是是对象,此对象应该为模块的输出值。
因此模块A能够这么定义:
// a.js define(function(){ var name = 'morrain' var age = 18 return { name, getAge: () => age } }) // b.js define(['a.js'], function(a){ var name = 'lilei' var age = 15 console.log(a.name) // 'morrain' console.log(a.getAge()) // 18 return { name, getAge: () => age } })复制代码
它采用异步方式加载模块,模块的加载不影响它后面语句的运行。全部依赖这个模块的语句,都定义在回调函数中,等到加载完成以后,这个回调函数才会运行。
RequireJS 的基本思想是,经过 define 方法,将代码定义为模块。当这个模块被 require 时,它开始加载它依赖的模块,当全部依赖的模块加载完成后,开始执行回调函数,返回值是该模块导出的值。AMD 是 "Asynchronous Module Definition" 的缩写,意思就是"异步模块定义"。
和 AMD 相似,CMD 是 Sea.js 在推广过程当中对模块定义的规范化产出。Sea.js 是阿里的玉伯写的。它的诞生在 RequireJS 以后,玉伯以为 AMD 规范是异步的,模块的组织形式不够天然和直观。因而他在追求能像 CommonJS 那样的书写形式。因而就有了 CMD 。
Sea.js 官网这么介绍 Sea.js:
"Sea.js 追求简单、天然的代码书写和组织方式,具备如下核心特性:"
"简单友好的模块定义规范:Sea.js 遵循 CMD 规范,能够像 Node.js 通常书写模块代码。天然直观的代码组织方式:依赖的自动加载、配置的简洁清晰,可让咱们更多地享受编码的乐趣。"
来看看 CMD 规范的实现:
<script src="sea.js"></script> <script src="a.js"></script>复制代码
首先要在 html 文件中引入 sea.js 工具库,就是这个库提供了定义模块、加载模块等功能。它提供了一个全局的 define 函数用来定义模块。因此在引入 sea.js 文件后,再引入的其它文件,均可以使用 define 来定义模块。
// 全部模块都经过 define 来定义 define(function(require, exports, module) { // 经过 require 引入依赖 var a = require('xxx') var b = require('yyy') // 经过 exports 对外提供接口 exports.doSomething = ... // 或者经过 module.exports 提供整个接口 module.exports = ... }) // a.js define(function(require, exports, module){ var name = 'morrain' var age = 18 exports.name = name exports.getAge = () => age }) // b.js define(function(require, exports, module){ var name = 'lilei' var age = 15 var a = require('a.js') console.log(a.name) // 'morrain' console.log(a.getAge()) //18 exports.name = name exports.getAge = () => age })复制代码
Sea.js 能够像 CommonsJS 那样同步的形式书写模块代码的秘诀在于:当 b.js 模块被 require 时,b.js 加载后,Sea.js 会扫描 b.js 的代码,找到 require 这个关键字,提取全部的依赖项,而后加载,等到依赖的全部模块加载完成后,执行回调函数,此时再执行到 require('a.js') 这行代码时,a.js 已经加载好在内存中了
前面提到的 CommonJS 是服务于服务端的,而 AMD、CMD 是服务于浏览器端的,但它们都有一个共同点:都在代码运行后才能肯定导出的内容,CommonJS 实现中能够看到。
还有一点须要注意,AMD 和 CMD 是社区的开发者们制定的模块加载方案,并非语言层面的标准。从 ES6 开始,在语言标准的层面上,实现了模块化功能,并且实现得至关简单,彻底能够取代 CommonJS 和 CMD、AMD 规范,成为浏览器和服务器通用的模块解决方案。
事实也是如些,早在2013年5月,Node.js 的包管理器 NPM 的做者 Isaac Z. Schlueter 说过 CommonJS 已通过时,Node.js 的内核开发者已经决定废弃该规范。缘由主要有两个,一个是由于 Node.js 自己也不是彻底采用 CommonJS 的规范,譬如在CommonJS 之 exports 中的提到 exports 属性就是 Node.js 本身加的,Node.js 当时是决定再也不跟随 CommonJS 的发展而发展了。二来就是 Node.js 也在逐步用 ES6 Module 替代 CommonJS。
2017.9.12 Node.js 发布的 8.5.0 版本开始支持 ES6 Module。只不过是处于实验阶段。须要添加 --experimental-modules 参数。
2019.11.21 Node.js 发布的 13.2.0 版本中取消了 --experimental-modules 参数 ,也就是说从 v13.2 版本开始,Node.js 已经默认打开了 ES6 Module 的支持。
任何模块化,都必须考虑的两个问题就是导入依赖和导出接口。ES6 Module 也是如此,模块功能主要由两个命令构成:export 和 import。export 命令用于导出模块的对外接口,import 命令用于导入其余模块导出的内容。
具体语法讲解请参考阮一峰老师的教程,示例以下:
// a.js export const name = 'morrain' const age = 18 export function getAge () { return age } //等价于 const name = 'morrain' const age = 18 function getAge (){ return age } export { name, getAge }复制代码
使用 export 命令定义了模块的对外接口之后,其余 JavaScript 文件就能够经过 import 命令加载这个模块。
// b.js import { name as aName, getAge } from 'a.js' export const name = 'lilei' console.log(aName) // 'morrain' const age = getAge() console.log(age) // 18 // 等价于 import * as a from 'a.js' export const name = 'lilei' console.log(a.name) // 'morrin' const age = a.getAge() console.log(age) // 18复制代码
除了指定加载某个输出值,还可使用总体加载,即用星号(*)指定一个对象,全部输出值都加载在这个对象上面。
从上面的例子能够看到,使用 import 命令的时候,用户须要知道所要导入的变量名,这有时候比较麻烦,因而 ES6 Module 规定了一种方便的用法,使用 export default命令,为模块指定默认输出。
// a.js const name = 'morrain' const age = 18 function getAge () { return age } export default { name, getAge } // b.js import a from 'a.js' console.log(a.name) // 'morrin' const age = a.getAge() console.log(age) // 18复制代码
显然,一个模块只能有一个默认输出,所以 export default 命令只能使用一次。同时能够看到,这时 import 命令后面,不须要再使用大括号了。
除了基础的语法外,还有 as 的用法、export 和 import 复合写法、export * from 'a'、import()动态加载 等内容,能够自行学习。
前面提到的 Node.js 已经默认支持 ES6 Module ,浏览器也已经全面支持 ES6 Module。至于 Node.js 和 浏览器 如何使用 ES6 Module,能够自行学习。
CommonJS 只能在运行时肯定导出的接口,实际导出的就是一个对象。而 ES6 Module 的设计思想是尽可能的静态化,使得编译时就能肯定模块的依赖关系,以及导入和导出的变量,也就是所谓的"编译时加载"。
正由于如此,import 命令具备提高效果,会提高到整个模块的头部,首先执行。下面的代码是合法的,由于 import 的执行早于 getAge 的调用。
// a.js export const name = 'morrain' const age = 18 export function getAge () { return age } // b.js const age = getAge() console.log(age) // 18 import { getAge } from 'a.js'复制代码
也正由于 ES6 Module 是编译时加载, 因此不能使用表达式和变量,由于这些是只有在运行时才能获得结果的语法结构。以下所示:
// 报错 import { 'n' + 'ame' } from 'a.js' // 报错 let module = 'a.js' import { name } from module复制代码
前面在CommonJS 之 require有提到,require 的是被导出的值的拷贝。也就是说,一旦导出一个值,模块内部的变化就影响不到这个值。一块儿来看看,ES Module是什么样的。
先回顾一下以前的例子:
// a.js var name = 'morrain' var age = 18 exports.name = name exports.age = age exports.setAge = function(a){ age = a } // b.js var a = require('a.js') console.log(a.age) // 18 a.setAge(19) console.log(a.age) // 18复制代码
使用 ES6 Module 来实现这个例子:
// a.js var name = 'morrain' var age = 18 const setAge = a => age = a export { name, age, setAge } // b.js import * as a from 'a.js' console.log(a.age) // 18 a.setAge(19) console.log(a.age) // 19复制代码
ES6 Module 是 ES6 中对模块的规范,ES6 是 ECMAScript 6.0 的简称,是 JavaScript 语言的下一代标准,已经在 2015 年 6 月正式发布了。咱们在第一节的《Web:一路前行一路忘川》中提过,ES6 从制定到发布历经了十几年,引入了不少的新特性以及新的机制,对于开发者而言,学习成本仍是蛮大的。
下一篇,聊聊 ES6+ 和 Babel,敬请期待……
6、参考文献
更多内容敬请关注 vivo 互联网技术 微信公众号
注:转载文章请先与微信号:Labs2020 联系。