Module Of Front End

Table of Contents

前端模块化

1995 年 5 月, Brendan Eich 只用了 10 天,就设计完成了 JavaScript 的第一版,so 对于 JavaScript 的规范化,不要要求太多,比如,它早期根本没有考虑模块化……

随着 web2.0 的发展, Ajax 技术得到广泛应用,各种前端库层出不穷, 前端代码日益膨胀 , JavaScript 极其简单的代码组织规范已经 hold 不住如此庞大规模的代码了。

下面一起来看一下, JavaScript 模块化规范的血泪历程!

模块化的基础

1. 函数封装

最开始的模块就是在一个文件里面编写几个相关的函数,如此用时加载函数所在文件,直接调用即可。

1: function fn1() {
2:     // ....
3: }
4: 
5: function fn2() {
6:     // ....
7: }

缺点 :污染了全局变量,容易引起命名冲突,而且模块成员之间没什么关系。

2. 对象封装

为了解决上述问题,可以把所有的模块成员封装在一个对象中。如下:

 1: var moduleInObj = {
 2:     var1: 'Tom',
 3:     var2: 'Jerry',
 4: 
 5:     fn1: function() {
 6:         // ....
 7:     },
 8:     fn2: function() {
 9:         // ....
10:     }
11: }

如此,在需要模块时,引用包含模块的文件,通过 对象名.模块名 调用即可。

缺点 :外部可以随意修改内部成员,如 moduleInObj.var2 = 'Bach'moduleInObj.fn2 = function() { //....} ,不安全啊。

3. 立即执行函数封装

为了隐藏细节,可以使用立即执行函数,如下:

 1: var moduleInIIEF = (function() {
 2:     var var1 = 'Tom';
 3:     var var2 = 'Jerry';
 4: 
 5:     function fn1() {
 6:         // ....
 7:     }
 8: 
 9:     function fn2() {
10:         // ....
11:     }
12: 
13:     // 只暴露想要暴露的变量或函数
14:     return {
15:         var1: var1,
16:         fn2: fn2
17:     }
18: })

如此,在模块外部无法修改没有暴露出来的变量、函数。

总结

上述做法就是模块化的基础了,现在,让我们来看一下当前通行的 JavaScript 模块规范。

服务器端模块化

CommonJS

CommonJS 是 NodeJS 在服务端的模块化规范:

  • 一个单独的文件就是一个模块,每个模块都是一个独立的作用域;
  • 模块只有一个出口 – module.exports 对象,把模块需要暴露的内容放入该对象;
  • 加载模块使用 require 方法,该方法读取一个文件并执行,返回文件内部的 module.exports 对象。
 1: // moduleInCommonJS.js
 2: 
 3: var var1 = 'Tom';
 4: var var2 = 'Jerry';
 5: 
 6: function fn1() {
 7:     // ....
 8: }
 9: 
10: function fn2() {
11:     // ....
12: }
13: 
14: // 通过 module.exports 对象暴露模块内容
15: module.exports = {
16:     fn1: fn1,
17:     fn2: fn2
18: }

如此,当需要使用模块功能时,如下操作:

1: // 使用 require 加载 moduleInCommonJS
2: var moudleInCommonJS = require('./moduleInCommonJS.js');
3: 
4: // 调用模块的函数
5: moduleInCommonJS.fn1();

注意,不同的实现对 require 时的路径有不同要求,通常可以省略 .js 扩展名,可以使用相对路径,也可以使用绝对路径。对于系统内置模块,甚至可以不写路径,直接使用模块名。

CommonJS 中的 require 加载是 同步的 ,模块系统需要 同步读取 模块文件的内容(服务端内的硬盘读写速度很快),并编译执行以得到模块接口。

然而,浏览器端就尴尬了 -_||

NodeJS 如何实现不同模块可以使用相同的变量名1

JavaScript 语言本身并没有一种机制来保证不同的模块可以使用相同的变量名,那么 Node.js 是如何实现这一点的呢?

!!!闭包!!!

JavaScript 是一种函数式编程语言,它支持闭包 – 把一段 JS 代码用一个函数包装起来,这段代码的所有“全局”变量就变成了函数内部的局部变量。

如我们编写的 hello.js 代码如下:

1: var s = 'Hello'
2: var name = 'world'
3: 
4: console.log(s + ' ' + name + '!')

↓↓↓ Node.js 加载了 hello.js 后,会把代码包装一下再执行,如下:

1: (function () {
2:     // 读取的 hello.js 代码
3:     var s = 'Hello'
4:     var name = 'world'
5: 
6:     console.log(s + ' ' + name + '!')
7:     // hello.js 代码结束
8: })()

如此以来,原来的全局变量 s 现在变成了匿名函数内部的局部变量。

模块的输出 module.exports 如何实现

Node 利用 JavaScript 的函数式编程的特性,轻而易举实现了模块的隔离。但是,模块的输出 module.exports 怎么实现呢?

首先,Node 可以先准备一个对象 module

 1: // 准备 module 对象
 2: var module = {
 3:     id: 'hello',
 4:     exports: {}
 5: }
 6: 
 7: var load = function(module) {
 8:     // 读取的 hello.js 代码
 9:     function greet(name) {
10:         console.log('Hello, ' + name + '!')
11:     }
12: 
13:     module.exports = greet
14:     // hello.js 代码结束
15: 
16:     return module.exports
17: }
18: 
19: var exported = load(moudle)
20: 
21: // 保存 module
22: save(module, exported)

可见,变量 module 是 Node 在加载 js 文件之前准备的一个变量,并将其传入加载函数,我们可以直接使用变量 module 原因就在于它实际上是函数的一个参数。

exported →→ load(module) →→ module.exports
↓↓↓
save(module.exported) →→ save(module, module.exports)

Node 会把 module 变量保存到某个地方,i.e. Node 保存了所有导入的 module ,当我们用 require() 获取 module 时,Node 找到对应的 module ,并把这个 moduleexports 变量返回,这样另一个模块就顺利拿到了模块的输出。

exports 和 module.exports 的异同

默认情况下,Node 准备的 export 变量和 module.exports 变量实际上是同一个变量,并且初始化为空对象 {} ,如此你就可以直接往里面加东西,如:

1: exports.foo = function() { return 'foo' }
2: exports.bar = function() { return 'bar' }
3: 
4: module.exports.foo = function() { return 'foo' }
5: module.exports.bar = function() { return 'bar' }

但是,如果我们要输出的是一个函数或数组,那么,只能给 module.exports 赋值,如:

1: module.exports = function() { return 'foo' }; // 
2: exports = function() { return 'foo};          // ✘

为什么呢?

其实,从 模块的输出 module.exports 如何实现 ↑ 章节的 load 加载函数中就可以知道,最终返回的只有 module.exportsexports 不过是指向 module.exports ,也就是说, exports 的内存地址中的值存储的是指向 module.exports 的指针, module.exports 中的值才是真正返回的对象。

exports → module.exports → {}

exports ✘ module.exports → otherObjectTwo
↓
otherObjectOne

// 加载函数返回的只是 module.exports

!!!推荐始终使用 module.exports 这种相对安全的输出方式。

浏览器端模块化

浏览器端,加载 JavaScript 文件的方式,是在文档头部插入 <script> 标签,但是脚本标签天生 异步 ,传统的 CommonJS 模块在浏览器环境中就变成了 普通的 .js 文件 – 即没有避免全局变量污染,也没有解决依赖性。

那么,浏览器端的模块化,到底如何去规范呢??

解决思路之一:开发一个服务器端组件,对模块代码作静态分析,将 模块与它的依赖列表 一起返回给浏览器端。这需要服务器安装额外的组件,并因此要调整一系列底层架构。

解决思路之二:用一套标准模板来封装模块定义,但是如何定义,如何加载呢?

AMD·RequireJS

RequireJS

AMD(Asynchronous Module Definition)异步模块定义,是一个浏览器端模块化开发的规范,是 RequireJS 在推广过程中对模块定义的规范化产出。

注意, AMD 并不是 JavaScript 原生支持,使用 AMD 需要引入对应的库函数 – RequireJS 。

RequireJS 主要解决两个问题:模块的 异步加载依赖性

1. 定义模块

RequireJS 定义了一个全局函数 define ,用来定义模块,语法如下:

define(id?, dependencies?, factory);

其中:

  • id ,可选参数,用来定义模块的标识,缺省为脚本文件名;
  • dependencies ,是一个当前模块依赖的模块名称数组;
  • factory ,工厂方法,模块初始化要执行的 函数或对象 ,如果为函数,它应该只被执行一次;如果是对象,它应该为模块的输出值。

2. 加载模块

AMD 推崇 依赖前置 ,在定义模块的时候就要声明其依赖的模块。

在页面上使用 require 函数加载模块,语法如下:

require(dependencies?, callback);

其中, require() 函数接受两个参数:

  • dependencies ,表示所以来的模块数组;
  • callback ,是一个回调函数,当依赖的模块都加载成功后,被调用。加载的模块会以参数形式传入该函数,从而在回调函数内部就可以使用这些模块。

3. 示例代码

来看一个简单的 RequireJS 示例代码。

假设文件的目录架构,如下:

Proj
|
|-- js
|   |-- lib
|       |-- jquery.js
|       |-- require.js
|
|-- index.html
|-- main.js

index.html 头部引入 require.js 文件,如下:

1: <scrpit data-main="./main" src="js/lib/require.js" defer async></scrpit>

其中,当 require.js 加载的时候会检查 data-main 属性,以获取 入口脚本 (这里是 main.js )。

编写 main.js 即可,如下:

 1: require.config({
 2:     baseUrl: './js/',
 3:     paths: {
 4:         'jquery': 'lib/jquery'
 5:     }
 6: });
 7: 
 8: require(['jquery'], function($) {
 9:     // jquery → $
10:     $(document).ready(() => {
11:         console.log('Hello World.');
12:     })
13: })

CMD·SeaJS

SeaJS

CMD(Common Module Definition)通用模块定义,是国内发展出来的浏览器端模块化开发规范,相似, CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。

1. 定义模块

SeaJS 也是为了解决模块的依赖性,语法如下:

define(id?, deps?, factory)

其中, factory 接受三个参数 – function(require, exports, module) ,如下:

  • 第一个参数 require(id) 是一个方法,接受模块标识作为唯一此参数,用来获取其他模块提供的接口;
  • 第二个参数 exports 是一个对象,用来向外提供模块接口;
  • 第三个参数 module 是一个对象,上面存储了与当前模块相关联的一些属性和方法。

2. 加载模块

CMD 推崇 就近依赖 ,只有在用到某个模块的时候再去 require 加载。

3. 示例代码

来看一个简单的 SeaJS 示例代码:

1: // → hello.js
2: define(function(require, exports, module) {
3:     var $ = require('jquery');
4: 
5:     exports.sayHello = function() {
6:         $('#hello').toogle('slow');
7:     };
8: });

通过 SeaJS 来加载使用上面的模块,如下:

1: seajs.config({
2:     alias: {
3:         'jquery': 'http:/modules.seajs.org/jquery/1.7.2/jquery.js'
4:     }
5: });
6: 
7: seajs.use(['./hello', 'jquery'], function(hello, $) {
8:     $('#beautiful-sea').click(hello.sayHello);
9: });

4. AMD vs CMD

AMD 和 CMD 的模块加载都是异步的,最大的区别是对 依赖模块的执行时机 处理不同。

AMD 在加载模块完成后就会执行该模块,所有模块都加载执行完成后,进入 require 的回调函数执行主逻辑。如此,依赖模块的执行顺序和书写顺序便无法保证一致 – 谁下载快谁先执行 ,但是主逻辑一定在所有依赖加载完成后才执行。

CMD 加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到 require 语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。

一般来说, AMD 用户体验好,因为没延迟,依赖模块提前执行了; CMD 性能号,因为只有用户需要的时候才执行。

ES6 模块化2

ES6 模块的设计思想是尽量的 静态化 ,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。 CommondJS 和 AMD 模块,都只能在运行时确定这些东西。比如, CommonJS 模块就是对象,输入时必须查找对象属性。

1. ES6 模块的定义和加载

ES6 的模块化语法: export 命令用于规定模块的对外接口, import 命令用于输入其他模块提供的功能。如下:

1: // 定义模块 math.js
2: var basicNum = 0;
3: var add = function(a, b) {
4:     return a + b;
5: };
6: 
7: export {basicNum, add};

如需用到该模块,使用 import 引用即可,如下:

1: // 引用模块
2: import { basicNum, add } from './math';
3: 
4: function test(ele) {
5:     ele.textContent = add(99 + basicNum);
6: }

注意,使用 import 命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到 export default 命令,为模块指定默认输出。

1: // export-default.js
2: export default function() {
3:     console.log('Hello World');
4: }

如此,其他模块加载该模块的时候, import 命令可以为该匿名函数指定任意名字,如:

1: // import-default.js
2: import customName from './export-default';
3: 
4: customName();                   // → 'Hello World'

2. ES6 模块和 CommonJS 模块的差异

它们有两个重大差异:

  • CommonJS 模块输出的是一个 值的拷贝 ,ES6 模块输出的是 值的引用
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

来看一个代码示例:

 1: // lib.js
 2: export let conuter = 3;
 3: export function incCounter() {
 4:     counter++;
 5: }
 6: 
 7: // 在 main.js 中加载 lib.js
 8: import { counter, incCounter } from './lib';
 9: 
10: console.log(counter);           // → 3
11: 
12: incCounter();
13: console.log(counter);           // → 4

可见,ES6 模块的运行机制与 CommonJS 不一样。ES6 模块是 动态引用 ,并不会缓存值,模块里面的变量绑定其所在的模块。

第二个差异是因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成;而 ES6 模块不是对象,它的对外接口只是已经静态定义,在代码静态解析阶段就会生成。

总结

小结一下:

  • CommonJS 规范主要用于服务端编程,加载模块是同步的,并不适合浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了 AMD 、CMD 解决方案;
  • AMD 规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD 规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅;
  • CMD 规范与 AMD 规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在 Node.js 中运行。但是依赖 SPM 打包,模块的加载逻辑偏重;
  • ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

Footnotes:

Date: 2020-01-19 Sun 19:49

Author: Jack Liu

Created: 2020-05-02 Sat 09:09

Validate