模块化的背景

要了解什么是前端模块化,就要知道前端模块化出现的背景,当一个项目工程越来越大时,我们会需要在一个html中引入很多js文件,这就会出现一些问题,比如

  1. 请求过多。首先我们要依赖多个模块,那样就会发送多个请求,导致请求过多
  2. 依赖模糊。我们不知道js的具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。
  3. 难以维护。以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题,更别提存在无法实现按需加载、重复加载和循环引用的问题了。

模块化的理解

1.模块的定义

  • 将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起
  • 块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信

2.模块化的进化过程

  • 全局function模式 : 将不同的功能封装成不同的全局函数

    • 编码: 将不同的功能封装成不同的全局函数
    • 问题: 污染全局命名空间, 容易引起命名冲突或数据不安全,而且模块成员之间看不出直接关系,比如下面这段代码:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      // 张三定义了setValue函数
      function setValue(name){
      document.getElementById('username').value = name;
      }
      // 张三定义了getValue函数
      function getValue(){
      return document.getElementById('username').value;
      }

      // 李四定义了setValue函数
      function setValue(name){
      document.getElementById('phone').value = name;
      }
      // 李四定义了getValue函数
      function getValue(){
      return document.getElementById('phone').value;
      }

      张三定义了setValuegetValue方法,实现了自己的功能,测试了下没有问题

    第二天李四增加了功能,也定义了setValuegetValue方法,测试了下自己的功能,也没有问题

    第三天,测试给张三提bug了。

    所以,这种方式也有弊端:会污染全局命名空间, 容易引起命名冲突,而且模块成员之间看不出依赖

  • namespace模式 : 简单对象封装

    • 作用: 减少了全局变量,解决命名冲突
    • 问题: 数据不安全(外部可以直接修改模块内部的数据)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      let myModule = {
      data: 'www.baidu.com',
      foo() {
      console.log(`foo() ${this.data}`)
      },
      bar() {
      console.log(`bar() ${this.data}`)
      }
      }
      myModule.data = 'other data' //能直接修改模块内部的数据
      myModule.foo() // foo() other data

      这样的写法会暴露所有模块成员,内部状态可以被外部改写。
  • IIFE模式:匿名函数自调用(闭包)

    • 作用: 数据是私有的, 外部只能通过暴露的方法操作
    • 编码: 将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口
    • 问题: 如果当前这个模块依赖另一个模块怎么办?
      1
      2
      3
      4
      5
      6
      7
      8
      9
      // index.html文件
      <script type="text/javascript" src="module.js"></script>
      <script type="text/javascript">
      myModule.foo()
      myModule.bar()
      console.log(myModule.data) //undefined 不能访问模块内部数据
      myModule.data = 'xxxx' //不是修改的模块内部的data
      myModule.foo() //没有改变
      </script>
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      // module.js文件
      (function(window) {
      let data = 'www.baidu.com'
      //操作数据的函数
      function foo() {
      //用于暴露有函数
      console.log(`foo() ${data}`)
      }
      function bar() {
      //用于暴露有函数
      console.log(`bar() ${data}`)
      otherFun() //内部调用
      }
      function otherFun() {
      //内部私有的函数
      console.log('otherFun()')
      }
      //暴露行为
      window.myModule = { foo, bar } //ES6写法
      })(window)

      最后得到的结果:

  • IIFE模式增强 : 引入依赖

    这就是现代模块实现的基石

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // module.js文件
    (function(window, $) {
    let data = 'www.baidu.com'
    //操作数据的函数
    function foo() {
    //用于暴露有函数
    console.log(`foo() ${data}`)
    $('body').css('background', 'red')
    }
    function bar() {
    //用于暴露有函数
    console.log(`bar() ${data}`)
    otherFun() //内部调用
    }
    function otherFun() {
    //内部私有的函数
    console.log('otherFun()')
    }
    //暴露行为
    window.myModule = { foo, bar }
    })(window, jQuery)
    1
    2
    3
    4
    5
    6
    7
    8
    // index.html文件
    <!-- 引入的js必须有一定顺序 -->
    <script type="text/javascript" src="jquery-1.10.1.js"></script>
    <script type="text/javascript" src="module.js"></script>
    <script type="text/javascript">
    myModule.foo()
    </script>

    上例子通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显

实现模块化的方案规范

历史上,JavaScript 一直没有模块(module)体系,无法将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比如 Ruby 的require、Python 的import,甚至就连 CSS 都有@import,但是 JavaScript 任何这方面的支持都没有,这对开发大型的、复杂的项目形成了巨大障碍。

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

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

目前实现模块化的规范主要有:

  • CommonJS
  • CMD
  • AMD
  • ES6模块

CommonJS

CommonJS概述

一个文件代表一个模块,有自己的作用域。模块中定义的变量、函数、类,都是私有的,通过暴露变量和api方法给外部使用。
Node采用了CommonJS模块规范,但并非完全按照CommonJS规范实现,而是对模块规范进行了一定的取舍,同时也增加了少许自身需要的特性。

CommonJS特点

  • 所有代码都运行在模块作用域,不会污染全局作用域。
  • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
  • 模块加载的顺序,按照其在代码中出现的顺序。
  • 运行时同步加载

CommonJS基本用法

模块定义和导出

1
2
3
4
5
6
7
8
9
10
11
// moduleA.js
let count = 0;

module.exports.increase = () => {
count++;
};

module.exports.getValue = () => {
return count;
}

模块引入

require命令用于加载模块文件,如果没有发现指定模块,会报错。

可以在一个文件中引入模块并导出另一个模块。

1
2
3
4
5
6
7
8
9
10
// moduleB.js
// 如果参数字符串以“./”或者“../”开头,则表示加载的是一个相对路径的文件
const { getValue, increase } = require('./moduleA');
increase();
let count = getValue();
console.log(count);
module.exports.add = (val) => {
return val + count;
}

模块标识

模块标识就是require(moduleName)函数的参数moduleName,参数需符合规范:

  • 必须是字符串
    • 如果是第三方模块,则moduleName为模块名
    • 如果是自定义模块,moduleName为模块文件路径; 可以是相对路径或者绝对路径
  • 可以省略后缀名

CommonJS模块规范的意义在于将变量和方法等限制在私有的作用域中,每个模块具有独立的空间,它们互不干扰,同时支持导入和导出来衔接上下游模块。

CommonJS模块的加载机制

一个模块除了自己的函数作用域之外,最外层还有一个模块作用域,module代表这个模块,是一个对象,exportsmodule的属性,是对外暴露的接口。

require也在这个模块的上下文中,用来引入外部模块,其实就是加载其他模块的module.exports属性。

接下来分析下CommonJS模块的大致加载流程

1
2
3
4
5
6
7
8
function loadModule(filename, module, require, __filename, __dirname) {
const wrappedSrc = `(function (module, exports, require, __filename, __dirname) {
${fs.readFileSync(filename, "utf8")} // 使用的是fs.readFileSync,同步读取
})(module, module.exports, require, __filename, __dirname)`;
eval(wrappedSrc);
}


这里只是为了概述加载的流程,很多边界及安全问题都不予考虑,如:

这里我们只是简单的使用 eval来我们的JS代码,实际上这种方式会有很多安全问题,所以真实代码中应该使用 vm来实现。

源代码中还有额外两个参数: __filename__dirname,这就是为什么我们在写代码的时候可以直接使用这两个变量的原因。

require实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function require(moduleName) {
// 通过require.resolve解析补全模块路径,得到一个绝对路径字符串
const id = require.resolve(moduleName);
// 先查询下该id路径是否已经缓存到require.cache中,如果已经缓存过了,则直接读缓存
if (require.cache[id]) {
return require.cache[id].exports;
}
// module 元数据
const module = {
exports: {},
id,
};
// 新加载模块后,将模块路径添加到缓存中,方便后续通过id路径直接读缓存
require.cache[id] = module;
// 加载模块
// loadModule(id, module, require);
// 直接将上面loadModule方法整合进来
(function (filename, module, require) {
(function (module, exports, require) {
fs.readFileSync(filename, "utf8");
})(module, module.exports, require);
})(id, module, require);

// 返回 module.exports
return module.exports;
}

require.cache = {};
require.resolve = (moduleName) => {
/* 解析补全模块路径,得到一个绝对路径字符串 */
return '绝对路径字符串';
};

上面的模块加载时,将module.exports对象传入内部自执行函数中,模块内部将数据或者方法挂载到module.exports对象上,最后返回这个module.exports对象。

以前面的moduleA.jsmoduleB.js模块为例:

  • moduleA 模块中将 increasegetValue 方法挂载到 上下文的 module.exports对象上
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // moduleA.js
    let count = 0;

    module.exports.increase = () => {
    count++;
    };

    module.exports.getValue = () => {
    return count;
    }

  • moduleB 模块中 requiremoduleA,并return 挂载了increasegetValue方法的module.exports对象;这个对象经过结构赋值,最终被moduleB中的increasegetValue变量接收。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // moduleB.js
    const { getValue, increase } = require('./moduleA');

    //等价于
    // let m = require('./moduleA');
    // const getValue = m.getValue;
    // const increase = m.increase;

    increase();
    let count = getValue();
    console.log(count);

    module.exports.add = (val) => {
    return val + count;
    }

require.resolve加载策略

在前面我们已经知道了resolverequire的方法属性。它的作用就是把传递进来的路径进行补全得到一个绝对路径的字符串。

1
2
3
4
5
6
7
8
9
10
function require(moduleName) {
......
// 返回 module.exports
return module.exports;
}
require.resolve = (moduleName) => {
/* 解析补全模块路径,得到一个绝对路径字符串 */
return '绝对路径字符串';
};

在实际项目中,我们经常使用的方式有:

  • 导入自己写的模块文件
  • 导入nodejs提供的核心模块
  • 导入node_modules里的包模块

我们可以简单地概括下加载策略:

  • 首先判断是否为核心模块,在nodejs自身提供的模块列表中进行查找,如果是就直接返回
  • 判断参数 moduleName 是否以./或者../开头,如果是就统一转换成绝对路径进行加载后返回
  • 如果前两步都没找到,就当做是包模块,去最近的node_moudles目录中查找

由于moduleName是可以省略后缀名的,所以应该遵循一个后缀名判断规则,不同后缀名判断的优先级顺序如下:

  • 如果moduleName是带有后缀名的文件,则直接返回;
  • 如果moduleName是不带后缀名的路径,则按照以下顺序加载
    • moduleName.js
    • moduleName.json
    • moduleName.node
    • moduleName/index.js
    • moduleName/index.json
    • moduleName/index.node
  • 如果是加载的是包模块的话,就会按照包模块中package.json文件的main字段属性的值来加载

Nodejs的模块化实现

Nodejs模块在实现中并非完全按照CommonJS来,进行了取舍,增加了一些自身的的特性。

Nodejs中一个文件是一个模块: module, 一个模块就是一个Module的实例

Nodejs中Module构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Module(id, parent){
this.id = id;
this.exports = {};
this.parent = parent;
if(parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}

//实例化一个模块
var module = new Module(filename, parent);

其中id是模块id,exports是这个模块要暴露出来的api接口,parent是父级模块,loaded表示这个模块是否加载完成。

AMD

AMD(Asynchronous Module Definition),异步模块定义:主要用于浏览器,由于该规范不是原生js支持的,使用AMD规范进行开发的时候需要引入第三方的库函数,也就是流行的RequireJS RequireJS是AMD规范的一种实现。其实也可以说AMD是RequireJS在推广过程中对模块定义的规范化产出。

AMD是一个异步模块加载规范,它与CommonJS的主要区别就是异步加载,允许指定回调函数。模块加载过程中即使require的模块还没有获取到,也不会影响后面代码的执行。

由于Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑异步加载的方式,所以CommonJS规范会比较适用。但是,浏览器环境,要从服务器端下载模块文件,这时就必须采用异步加载,因此浏览器端一般采用AMD规范。此外AMD规范比CommonJS规范在浏览器端实现要早。

AMD规范基本语法

RequireJS定义了一个define函数,用来定义模块

语法

1
define([id], [dependencies], factory)

参数

  • id:可选,字符串类型,定义模块标识,如果没有提供参数,默认为文件名
  • dependencies:可选,字符串数组,即当前模块所依赖的其他模块,AMD 推崇依赖前置
  • factory:必需,工厂方法,初始化模块需要执行的函数或对象。如果为函数,它只被执行一次。如果是对象,此对象会作为模块的输出值

模块定义和导出

  • 定义没有依赖的独立模块
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // module1.js
    define({
    increase: function() {},
    getValue: function() {},
    });

    // 或者
    define(function(){
    return {
    increase: function() {},
    getValue: function() {},
    }
    });
  • 定义有依赖的模块
    1
    2
    3
    4
    5
    6
    7
    8
    9
    // module2.js
    define(['jQuery', 'tool'], function($, tool){
    return {
    clone: $.extend,
    getType: function() {
    return tool.getType();
    }
    }
    });
  • 定义具名模块
    1
    2
    3
    4
    5
    6
    7
    8
    define('module1', ['jQuery', 'tool'], function($, tool){
    return {
    clone: $.extend,
    getType: function() {
    return tool.getType();
    }
    }
    });

引入使用模块

1
2
3
4
require(['module1', 'module2'], function(m1, m2){
m1.getValue();
m2.getType();
})

require()函数加载依赖模块是异步加载,这样浏览器就不会失去响应

AMD规范和CommonJS规范对比

  • CommonJS一般用于服务端,AMD一般用于浏览器客户端
  • CommonJSAMD都是运行时加载

什么是运行时加载?

  • CommonJSAMD模块都只能在运行时确定模块之间的依赖关系
  • require一个模块的时候,模块会先被执行,并返回一个对象,并且这个对象是整体加载的

小结:AMD模块定义的方法能够清晰地显示依赖关系,不会污染全局环境。AMD模式可以用于浏览器环境,允许异步加载模块,也可以根据需要动态加载模块。

CMD

CMD(Common Module Definition),通用模块定义,它解决的问题和AMD规范是一样的,只不过在模块定义方式和模块加载时机上不同,CMD也需要额外的引入第三方的库文件,SeaJS CMD 是 SeaJS 在推广过程中对模块定义的规范化产出。

CMD规范基本语法

define 是一个全局函数,用来定义模块

语法

1
define([id], [dependencies], factory)

参数:

id:可选,字符串类型,定义模块标识,如果没有提供参数,默认为文件名
dependencies:可选,字符串数组,即当前模块所依赖的其他模块,CMD 推崇依赖就近
factory:必需,工厂方法,初始化模块需要执行的函数或对象。如果为函数,它只被执行一次。如果是对象,此对象会作为模块的输出值

模块定义和导出

除了给 exports 对象增加成员,还可以使用 return 直接向外提供接口

  • 定义没有依赖的模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
define(function(require, exports, module) {
module.exports = {
count: 1,
increase: function() {},
getValue: function() {}
};
})

// 或者
define(function(require, exports, module) {
return {
count: 1,
increase: function() {},
getValue: function() {}
};
})
  • 定义有依赖的模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
define(function(require, exports, module){
// 引入依赖模块(同步)
const module1 = require('./module1');

// 引入依赖模块(异步)
require.async('./tool', function (tool) {
tool.getType();
})

// 暴露模块
module.exports = {
value: 1
};
})

引入使用模块

1
2
3
4
5
6
7
define(function (require) {
var m1 = require('./module1');
var m2 = require('./module2');

m1.getValue();
m2.getType();
})

CMD规范专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。CMD规范整合了CommonJS和AMD规范的特点。

ES6模块化

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及导入和导出的变量。CommonJS 和 AMD 模块,都只能在运行时才能确定。

ES6模块化语法

export命令用于暴露模块的对外接口,import命令用于导入其他模块。

模块定义和导出

1
2
3
4
5
6
7
8
9
10
// moduleA.js
let count = 0;

export const increase = () => {
count++;
};

export const getValue = () => {
return count;
}

模块引入

1
2
3
4
5
6
7
8
9
10
// moduleB.js
import { getValue, increase } from './moduleA.js';

increase();
let count = getValue();
console.log(count);

export function add(val) {
return val + count;
}

导入模块时可以给变量或方法指定别名,需要使用as关键字来定义别名

1
2
3
4
5
6
// moduleB.js
import { getValue as getCountValue, increase as increaseHandler } from './moduleA.js';

increaseHandler();
let count = getCountValue();
console.log(count);

如上例所示,使用import命令的时候,需要知道所要加载的变量名或函数名,否则无法加载。

为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

模块默认导出后, 其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。

1
2
3
4
5
6
7
8
// add.js
export default function (a, b) {
return a + b;
}

// demo.js
import add from './add';
console.log(add(1, 2)); // 3

如果想在一条import语句中,同时导入默认方法和其他变量、方法,可以写成下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// moduleA.js
let count = 0;

export const increase = () => {
count++;
};
export const getValue = () => {
return count;
}
export default {
a: 1
}

// moduleB.js
import _, { getValue, increase } from './moduleA.js';

increase();
let count = getValue();
console.log(count);

console.log(_);

这种用法在react项目中随处可见

1
2
3
4
5
6
7
8
9
10
11
import React, { useState } from 'react';

function Hello() {
let [ count, setCount ] = useState(0);
return (
<div>
<p>You click { count } times</p>
<button onClick={() => setCount(count + 1)}>设置count</button>
</div>
)
}

整体导入

除了指定加载某些输出值,还可以使用整体加载,即用星号( *)指定一个对象,所有输出值都加载在这个对象上面。

1
2
3
4
5
6
7
// moduleB.js
// import { getValue, increase } from './moduleA.js';
import * as handler from './moduleA.js';

handler.increase();
let count = handler.getValue();
console.log(count);

其他情况

import命令具有提升效果,会提升到整个模块的头部,首先执行。

1
2
foo();
import { foo } from './foo.js';

上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。

由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

1
2
3
4
5
6
7
8
9
10
11
import { 'f' + 'oo' } from './foo'; // 报错

let module = './foo';
import { foo } from module; // 报错

// 报错
if (x === 1) {
import { foo } from './foo1';
} else {
import { foo } from './foo2';
}

上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是无法得到值的。

import语句会执行所加载的模块,因此可以有下面的写法。

1
import './initData';

initData.js中会自执行初始化数据的方法,并不需要导出变量和方法。所以只需要import这个模块,执行初始化操作即可。

ES6 模块与 CommonJS 模块的差异

它们有两个重大差异:

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

2、CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

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

ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import有点像 Unix 系统的“符号连接”,原始值变了,import加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

小结

由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如类型检验(type system)等只能靠静态分析实现的功能。

总结

CommonJS规范主要用于服务端编程,加载模块是同步的;在浏览器环境中,同步加载会导致阻塞,所以不适合这个规范,因此有了AMD和CMD规范。

AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难。

CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。

ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。