前端模块化

为什么需要模块化?

早期的开发没有模块化,会有两个灾难性的问题:即 全局污染 以及 依赖管理混乱

1. 全局污染:

A 引入 a.js,B 引入 b.js,这些代码最后都是存在于全局作用域里,难保不会出现变量命名冲突的问题。

2. 依赖管理混乱:

js 文件之间存在依赖关系,那么被依赖项必须出现在前面,也就是说要遵守一定的顺序。要是有几十个文件,那么就得先确定好互相之间的依赖关系,然后手动排序,累觉不爱。

早期解决方案:

IIFE

每个 js 文件中都用一个匿名自执行函数来封装数据。

// a.js
(function(){
  var num = 1;
  function add(){
    num++;
  }
})()

// b.js
(function(){
  var num = 2;
  function sub(){
    num--;
  }
})()

nice,这样子 a.js 和 b.js 都有各自的 num,互不影响了。但是,我在全局作用域下好像拿不到函数里的东西???

IIFE 增强版

让 IIFE 返回一个对象,暴露给全局作用域

// a.js
var moduleA = (function(){
  var num = 1;
  return {
    gain:function(){
      return num;
    },
    add:function(){
      num++;
    }
  }
})()

这样,全局可以通过 moduleA 拿到函数里的变量。不过,要是 b.js 不小心脑袋抽筋,也将 IIFE 返回给一个叫做 moduleA 的变量呢?命名冲突的问题还是没解决。

这之后提出了 模块化 的概念。

模块化解决方案:

那么,模块化到底需要解决什么问题呢?我们先设想一下可能有以下几点:

  • 安全地包装一个模块的代码,避免全局污染
  • 唯一标识一个模块
  • 优雅地将模块 api 暴露出去
  • 方便地使用模块
  • …….

1.CommonJS

1.1 介绍:

CommonJS 的一个模块就是一个脚本文件,通过执行该文件来加载模块

。CommonJS 规范规定,每个模块内部, module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即 module.exports )是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。

1.2 导出模块:

Node.js 是 CommonJS 规范的实现。为了方便,Node.js 为每个模块提供一个 exports 变量,指向 module.exports 。这等同在每个模块头部,有一行这样的命令:

var exports = module.exports;

所以,我们有两种导出模块的方式:

// module.js
var num = 1;
function print(){
    num++;
    return num;
}

// 方式1
 module.exports = {
    num,
    print
}

// 方式2
exports.num = num;
exports.print = print;

1.3 加载模块:

另外,我们也有两种加载模块的方式:

// main.js

// 方式1
var obj = require('./module.js');
console.log(obj.num);
console.log(obj.print());

// 方式2(解构赋值)
var { num,print } = require('./module.js');
console.log(num);
console.log(print());

CommonJS 的特点是:

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。

2.AMD

CommonJS 是针对服务端的模块化解决方案,为何它不能用于前端呢?因为 CommonJS 是同步而不是异步的,在我们 require 模块的时候,如果迟迟没有返回结果,那么就会阻塞后面代码的执行,甚至会阻止页面的渲染。

所以这时候有了 AMD 规范,即 异步模块加载规范

AMD 与 CommonJS 的主要区别就是异步模块加载 —— 即使 require 的模块还没有获取到,也不会影响后面代码的执行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

RequireJS 实现了这个规范。

当然,后面还出现了 CMD、UMD。

3. ES6 Module

3.1 介绍:

ES6 在语言规格层面上实现了模块功能,完全可以取代现有的 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

3.2 导出模块

有三种方式可以导出模块:

// module.js

// 方式一(声明的同时导出)
export var num = 1;
export function add(){
  num++;
}

// 方式二(统一导出。推荐)
var num = 1;
function add(){
  num++;
}
export { num,add }

// 方式三(允许重命名,次数不限)
var num = 1;
function add(){
  num++;
}
export {
  num as new_num;
  add as new_add;
  add as newer_add;
}

3.3 加载模块

同样的,加载模块也有多种方式。其中,整体加载会把之前导出的变量和函数挂载在一个对象上。

// main.js

// 方式一:
import { num,add } from './module.js'

// 方式二(允许重命名):
import {
  num as new_num;
  add as new_add;
} from './module.js'

// 方式三(整体加载):
import * as obj from './module.js'

3.4 export default

export default 其实用得更多。 import 在非整体加载的时候要求我们事先知道导出的变量或者函数的名字,但是如果使用 export default 导出,那么后续加载模块的时候,名字可以任取,也就是说,我们并不需要知道原模块中变量或者函数的名字。例如:

// module.js
export default function(){
  .....
}

// main.js
import func from './module.js'

此外,要注意两点:

  • export default 实际上是把后面跟着的东西赋值给 default 变量,所以后面不能是变量的声明

  • 因为 export default 是指定的 默认输出 ,这意味着一个模块文件中只能有一条 export default 语句(当然,可以与 export 一起用),也因为这样, import 后面不需要大括号,因为它只可能接受一个项。

ES6 模块与 CommonJS 模块的差异

  • CommonJS 模块输出的是值的拷贝,ES6 模块输出的是值的引用

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口

其一

CommonJS 模块输出的是值的拷贝:

也就是说,输出之后,原模块内部该值怎么变化,都不会影响到导出去的那个值,两者在内存中有各自的空间。

关于这点,很多文章会用类似下面的方式去证明:

// module.js
var num = 1;
function add(){
  num++;
};
module.exports = { num,add };

// main.js
var obj = require('./module.js');
console(obj.num);   // 1
obj.add();
console.log(obj.num);  // 1

因为这里是拷贝了 num ,所以 add 操作后只是 module.js 中的 num 加一(词法作用域), main.js 中拷贝得到的 num 不变。

这个证明方法其实有问题。因为 module.exports 对象中的 num 属性本来就有值的拷贝了,此方法并不能证明值的拷贝是由 CommonJS 的底层实现的。,而且,把上面代码改为对应的 es6 module 版本(此时本来应该是引用),会发现得到同样的结果,更证明了这一点。详情看:

如何正确证明 CommonJS 模块导出是值的拷贝,而 ES module 是值的引用?

ES6 模块输出的是值的引用:

JS 引擎对脚本静态分析的时候,遇到模块加载命令 import ,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。

这意味着,原模块中值的改变会动态映射到 main.js

// module.js
var num = 1;
function add(){
  num++;
}
export { num.add };

// main.js
import { num,add } from './module.js';
console.log(num); // 1
add();
console.log(num);  // 2

注意这个引用是动态变化的。

另外,原模块导出的变量在 main.js 中表现为一个只读常量,也就是说我们不能在 main.js 中对它重新赋值,这会报错:

import { num,obj } from './module.js';

console.log(num); // 1
num++;  // TypeError: Assignment to constant variable

console.log(obj);  // {.......}
obj.name = "Sam";  // 没毛病
obj = {};  // TypeError: Assignment to constant variable 

对于引用类型,可以给它添加属性,但赋值同样是不行的。

其二

运行时加载:

CommonJS 是运行时加载的。也就是说,在 require 时,先执行整个模块(加载里面所有的方法),生成一个对象,然后再从这个对象上面读取实际要用到的方法,这种加载称为“运行时加载”。

编译时加载:

ES6 模块是运行时加载的。也就是说,其设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量(只加载需要的方法)。这种加载称为“编译时加载”。

import 有提升现象,因为这是在编译阶段就执行的。

以这段代码为例:

//ES6模块
import { a,b,c } from 'module.js';

//CommonJS 模块
let { a,b,c } = require('module.js');
  • 对于 CommonJS,当 require 模块时,原模块会运行一遍,并返回一个包含所有 api 的对象,并将这个对象缓存起来。此后,无论多少次加载这个模块都是取这个缓存的值,也就是第一次运行的结果,除非手动清除。

  • 对于 ES6,在编译阶段遇到 import 时,不会像 CommonJS 一样去执行模块,而是生成一个动态的只读引用,当真正需要的时候再到模块里去取值,所以 ES6模块是动态引用,并且不会缓存值。