前端工程化-JavaScript模块化

ObjectKaz Lv4

为什么需要模块化

很早的时候,所有开发者把Javascript代码都写在一个文件里面,浏览器执行时,只要加载这一个文件就够了。

到了后来,随着代码规模的不断增加,一个网页代码往往需要加载多个文件。

1
2
3
<script src="a.js"></script>
<script src="b.js"></script>
...

但这样写有很多缺点。

第一,浏览器在读取这些脚本文件的时候,浏览器会暂停渲染,增加网页失去响应的时间。

第二,难以处理各个文件的依赖关系。开发者必须严格确定脚本执行的顺序来保证正确加载。否则就会出现诸如变量未定义之类的错误。

第三,各个文件都是暴露在全局作用域下的,容易引起全局命名污染。

为了应付规模越来越大的前端项目,模块化的概念便出现了。

简易的模块化写法

最简单:函数写法

缺点:全局环境污染

1
2
3
4
5
6
7
function m1() {
console.log('method1')
}

function m2() {
console.log('method2')
}

对象写法

优点:不污染全局变量
缺点:暴露私有成员

1
2
3
4
5
6
let myMod = {
_id: 10,
getId() {
return this.id
}
}

IIFE(立即执行函数)写法

IIFE是目前一种普遍的写法。

优点:不污染全局变量、不暴露私有成员
缺点:没有实现模块继承

1
2
3
4
5
6
7
8
9
10
let myMod = function(){
let _id = 10;
function getId()
{
return _id;
}
return {
getId
}
}()

放大模式和宽放大模式

  1. 放大模式:模块继承
  2. 宽放大模式:"立即执行函数"的参数支持空对象,避免浏览器加载顺序导致对象不存在的问题。
1
2
3
4
5
6
7
8
9
10
11
let mod = function(mod){
mod._id = 10;
function getId()
{
return mod._id;
}
return {
getId
}
}(window.mod || {}) //宽放大模式
// }(mod) 放大模式

CommonJS规范

CommonJS 规范是 nodejs 中主流的一种模块方案。

其中每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。

它通过 require 函数来同步加载其他模块,通过 module.exports 导出需要暴露的接口。

这里需要注意一点,它采用的是 同步加载。对于 nodejs 来说,它的模块基本上都在磁盘上,所以加载模块的时间是很短的,对于应用性能的影响也比较小。

尽管这个模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了。以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。

global

global是一个对象,用于在各种模块中分享变量。

1
global.shared = 233; //数据是共享的

module

  1. 在每个模块内部,module代表当前模块,其中,exports属性是模块对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。
1
2
3
4
5
6
7
8
//hello.js
module.exports = function(){
console.log('hello world')
}

//index.js
let hello = require('./hello')
hello();
  1. module 的其他属性
属性解释
id模块的识别符,通常是带有绝对路径的模块文件名
filename模块的文件名,带有绝对路径
loaded返回一个布尔值,表示模块是否已经完成加载
parent返回一个对象,表示调用该模块的模块
children返回一个数组,表示该模块要用到的其他模块
exports表示模块对外输出的值
  1. 在 nodejs中,通过命令行直接调用某个模块,module.parent为 null。

exports 变量

为了方便,nodejs为每个模块提供一个 exports 变量,指向 module.exports 。

由于 exports 是 module.exports的引用,所以直接对 exports赋值将切断引用,而不会改变 module.exports。

require 函数

  1. require 函数的基本功能是,读入并执行一个 JavaScript 文件,然后返回该模块的 exports 对象。如果没有发现指定模块,会报错。

  2. 模块名规范

    • /开头:从绝对路径中加载
    • ./开头:从当前路径中加载
    • 没有上面的开头,非目录:加载核心模块,或者一个位于各级 node_modules 目录的已安装模块
    • 没有上面的开头且为目录:先找到第一级文件夹的路径,再以它为参数,找到后续路径。
    • 如果文件没有后缀,则会尝试为文件名添加 .js、.json、.node
  3. 使用 require.resolve()方法得到命令加载的确切文件名。

  4. require.cache[module] 中保存了模块的缓存,使用 delete 命令即可删除缓存。

  5. require.main 可以用来判断模块是直接执行,还是被调用执行。当直接执行时,其值为 true

  6. 当出现 a加载 b,b加载a的情况时,b 会加载 a 的不完整版本(b得到a加载 b 之前,module.exports 的值)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//a.js
module.exports = 'a1'
console.log('b:', require('./b'))
module.exports = 'a2'

//b.js
module.exports = 'b1'
console.log('a:', require('./a'))
module.exports = 'b2'

//index.js
console.log(require('./a'))
console.log(require('./b'))

//node index.js
a: a1
b: b2
a2
b2

AMD:RequireJS的模块规范

AMD(Asynchronous Module Definition,异步模块定义)

前面提到了,CommonJS 中,模块都是同步加载,对于 nodejs,影响比较小。

但是在浏览器环境中,模块都是通过网络请求来加载的,这意味着一个 require 可能需要很长时间,在模块加载的过程中,js程序不能完成其他事情。

所以,在浏览器环境中,不能使用同步加载的方案。RequireJS 则提出了一个为 AMD 的规范,用于浏览器中模块的加载。

基本操作

  1. 模块调用形式:require([module], callback),callback 的参数为加载的模块(多参数)。
  2. 模块定义形式:define(id?: String, dependencies?: String[], factory: Function|Object)。其中
    • id:模块的名字。若不填写,则模块文件的文件名就是模块标识。
    • dependencies:模块的依赖。如果没有指定 dependencies ,那么它的默认值是 ["require", "exports", "module"]。依赖模块必须根据模块的工厂方法优先级执行。
    • factory 是最后一个参数,它包裹了模块的具体实现,它是一个函数或者对象。如果是函数,那么它的返回值就是模块的输出接口或值。

加载模块

  1. 引入文件require.js: <script src="js/require.js" data-main="js/main"></script> ,其中 data-main用于指定主模块,即加载 js/main.js。
1
2
3
4
5
6
7
8
9
//定义模块:hello.js
define(['jquery'], function($) {
$('body').text('hello world');
});

//使用模块:index.js
require(['hello'], function (hello) {
hello();
});
  1. 使用 require.config 控制加载行为,为模块名添加路径。
1
2
3
4
5
6
7
8
9
require.config({
baseUrl: 'js/lib', //修改加载基路径
   paths: {
    "jquery": "https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min", //支持网络路径
    "underscore": "underscore.min",
    "backbone": "backbone.min"

   }
  });

定义模块

使用 define 定义模块。其形式如下:

  • define(factory: Function|Object)
  • define(deps: Array<String>, factory: Function)
  • define(name: String,deps: Array<String>, factory: Function)

factory 的参数为加载完的依赖对象。

1
2
3
define(['jquery'], function($) {
$('body').text('hello world');
});

加载非规范的模块

在 require.config 中指定 shim 属性,用于配置这些库的导出:

1
2
3
4
5
6
7
8
9
10
11
require.config({
shim: {
underscore: {
exports: '_'
},
backbone: {
        deps: ['underscore', 'jquery'],
        exports: 'Backbone'
     }
}
})

小结

require.js 定义模块时,需要预先指定它需要引用的其他所有模块。这称为 依赖前置。

在加载模块之前,需要加载它引用的所有模块。这称为 提前执行。

CMD:SeaJS的模块规范

CMD(Common Module Definition,通用模块定义)

CMD规范是 SeaJS 在推广过程中对模块定义的规范化产出的。它也是针对浏览器推出的一个另一个模块化系统。

define

CMD 模块规范中,一个模块就是一个文件,遵循统一的写法。

  1. define 是一个全局函数,用来定义模块。它有如下调用格式:

    • define(factory)
    • define(id?, deps?, factory)(这个不属于规范)

    其中,factory 为工厂函数,也可以是一个对象或模板字符串(模板名使用{{ name }}表示),deps 为依赖模块的名称,id 为模块名称。

    工厂函数默认有三个参数:require、exports 和 module

  2. define.cmd 是一个空对象,可用来判定当前页面是否有 CMD 模块加载器。

1
2
3
4
5
//定义模块:hello.js
define(function(require,exports,module) {
$('body').text('hello world');
exports.say = () => console.log('hi')
});

require参数

  1. require 是一个函数,接受模块标识作为唯一参数,用来获取其他模块提供的接口。
  2. 模块名规范类似 CommonJS规范。除此之外,由于模块内的 require 采用静态分析的策略,require 有一些其他的规则:
    • 模块 factory 构造方法的第一个参数 必须 命名为 require
    • 不要重命名 require 函数,或在任何作用域中给 require 重新赋值
    • require 的参数值 必须 是字符串直接量。
1
2
3
4
5
6
define(function(require, exports) {

// 获取模块 hello 的接口
let hello = require('./hello');
hello.say()
});
  1. 动态加载依赖 require.async(id,callback),其中回调的参数为加载的模块对象。
1
2
require.async('./a',(a) => null)
require.async(['./a','./b'], (a, b) => null)
  1. require.resolve(name) 解析模块路径:该函数不会加载模块,只返回解析后的绝对路径。

exports

  1. exports 是一个对象,用来向外提供模块接口。
  2. 和 commonJS 一样,exports 是 module.exports 的一个引用,因此不可直接修改exports。

module

module 是一个对象,上面存储了与当前模块相关联的一些属性和方法。

属性解释
id模块的唯一标识
uri根据模块系统的路径解析规则得到的模块绝对路径
dependencies当前模块的依赖
exports当前模块对外提供的接口。(对其的赋值必须同步执行)

启动模块

  1. seajs 中启动一个模块:seajs.use('./main') 会自动加载 ./main.js

小结

SeaJS中的模块加载器,在模块代码执行之前,对模块代码进行静态分析,并动态生成依赖列表。

模块加载过程中,只有在真正需要这个模块时,这个模块才会被加载。这称为 依赖就近,延迟执行。

UMD规范

前面提到了CommonJS、AMD、CMD,这三种差异挺大的模块化规范。

对于一个库作者来说,这是很致命的,因为它需要想办法同时支持这三种模块规范。

而 UMD模块规范,则就是这些作者想到的办法。

关于这个规范的具体内容,可参考UMD官方主页: https://github.com/umdjs/umd

ESM:ES6模块规范

到了2015年,Ecma官方终于推出了官方的模块化规范 ES Module。

ES Module 和 CommonJS 可以说是最常用的两种模块化规范了。

但它们俩又有一些区别。其中一个便是 ES Module 采用了静态解析的方法,这样就可以在编译期解析并加载模块,提高了运行效率。

而且,由于它是静态的,编辑器可以在编辑的时候对模块进行解析,从而提供更好的类型提示。

对于 Webpack 这样的模块编译工具来说,无需运行代码,就可以获取模块之间的依赖关系。

export

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。若需要外部能够读取模块内部的某个变量,就必须使用 export 关键字输出该变量。

export关键字支持输出变量、函数、类:

1
2
3
4
5
6
7
export let name = 'kaz'
export let age = 18

// 等价于
let name = 'kaz'
let age = 18
export {name, age}

通常情况下,export 输出的变量就是本来的名字,但是可以使用 as 关键字重命名。

1
2
3
let name = 'kaz'
let age = 18
export {name as myName, age as myAge}

export 命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

1
2
3
4
5
6
7
8
9
export 233 //error

var m = 1
export m //error,还是直接输出1
export {m} //ok

function f() {}
export f; //error
export {f} //ok

export 语句输出的接口,与其对应的值是动态绑定关系,即通过该接口,可以取到模块内部实时的值。这也是它和 CommonJS的另一个区别。

需要注意一点,export 语句必须出现在顶层作用域,不能写在函数内。

import

import 命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块对外接口的名称相同。

from 指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。

as 关键字用于取别名。

1
import { lastName as name } from './profile'

import 进来的变量是只读的,不能直接赋值。但改写对象的属性是可以的。

此外,import 命令具有提升效果,会提升到整个模块的头部,首先执行。一个推荐的做法便是将它们写在代码的顶端。

import 命令中,大括号不能使用表达式和变量。(因为 import 是静态执行)

1
import {'f' + 'oo'} from 'foo'

还有一种不输入值的加载方法,这种情况下在执行时只会执行代码,而不引用变量。

1
import 'lodash';
  1. 重复的 import 语句会被优化成一次。
  2. 整体加载:使用 *
1
import * as lodash from 'lodash';

由于 import 是静态的,所以 lodash 下所有属性不可修改

export default

  1. export default 命令为模块指定默认的输出,在 import 时可以任意指定名字,且无需大括号。
  2. export default 命令只能使用一次。
1
2
3
4
5
6
7
8
//hi.js
export default function() {
console.log('hi')
}

//index.js
import hi from 'hi'
hi()

export default 命令后面不能跟变量声明语句(但可以跟函数声明语句)。但 export default 命令允许输出值。

1
export default 233

export default 本质是输出一个叫做 default的变量或方法:

1
2
3
4
5
6
7
8
9

export {foo as default};
//等价于
export default foo

//另一个文件
import { default as foo } from 'a'
//等价于
import foo from 'a'

export from

如果在一个模块之中,先输入后输出同一个模块,import 语句可以与 export 语句写在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//(1)
export {a, b} from 'a'
//相当于
import {a, b} from 'a'
export {a, b}

//(2)更改名字
export {a as c, b} from 'a'

//(3)输出全部接口
export * from 'a'

//(4)输出默认接口
export { default } from 'a'

[ES2020]import()

import() 只是一种特殊语法,只是恰好使用了括号,而不是一个函数。

  1. import 用于动态加载一个模块,其返回值为 Promise 对象。
1
import(specifier)
  1. import()加载模块成功以后,这个模块会作为一个对象,当作 then 方法的参数。
  2. 如果模块有 default 输出接口,可以用参数直接获得。
1
2
3
import('...').then(({default: btn}) =< {
//code...
})
  1. 适用场合:按需加载、条件加载、动态模块路径

Module 的加载

浏览器中加载

  1. 加载外部ES6模块:指定 type="module"

    对于带有 type="module" 的脚本,浏览器会自动开启 defer 属性,等待页面渲染完成再加载脚本。

1
<script type="module" src="./foo.js"></script>
  1. 内嵌入网页:
    • 代码在模块作用域中执行,模块内部的顶层变量,外部不可见。
    • 自动开启严格模式。
    • 可以使用 import 命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL)
1
2
3
4
5
<script type="module">
import a from "a";

// other code
</script>
  1. 动态导入 import()可以设置 script type="module"

nodejs中加载

  1. Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。
  2. 脚本文件里面使用 import 或者 export 命令,必须使用 .mjs 为后缀。
  3. 也可以在 package.json 中加入:
1
2
3
{
"type": "module"
}

一旦设置了以后,所有 JS 脚本,就被解释为 ES6 模块。此时,使用 CommonJS 模块必须使用 .cjs 后缀。

  1. .mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于package.json 里面 type 字段的设置。

  2. 在 CommonJS 文件中加载 esm 模块: import() 异步加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//a.mjs
export function esmModuleA() {
return 'I am ESM Module A';
};

export default esmModuleA;

//index.js
async function main() {
const {
esmModuleA
} = await import('./a.mjs');
console.log(esmModuleA());
}
main();

运行命令(nodejs12):

1
node --experimental-modules index.js
  1. 在 ES6 中加载 CommonJS 模块:只能全局导入
1
2
3
4
5
6
7
// a.js
module.exports = function () {
return 'I am CJS module A';
};
// index.mjs
import cjsModuleA from './a.js';
console.log(cjsModuleA());

运行命令(nodejs12):

1
node --experimental-modules index.mjs

ESM和 CJS 的区别小结

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。因此,CJS模块检测不到 模块的内部变化。
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
  • CommonJS 模块的 require() 是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立模块依赖的解析阶段。

参考

  1. Javascript模块化编程(一):模块的写法——阮一峰的网络日志:http://www.ruanyifeng.com/blog/2012/10/javascript_module.html
  2. Javascript模块化编程(二):AMD规范——阮一峰的网络日志:http://www.ruanyifeng.com/blog/2012/10/asynchronous_module_definition.html
  3. Javascript模块化编程(三):require.js的用法——阮一峰的网络日志:http://www.ruanyifeng.com/blog/2012/11/require_js.html
  4. AMD规范——Webpack 中文指南:http://shouce.jb51.net/webpack/amd.html
  5. CommonJS规范——JavaScript标准参考教程:https://javascript.ruanyifeng.com/nodejs/module.html#toc0
  6. CMD模块定义规定——seajs/seajs:https://github.com/seajs/seajs/issues/242
  7. 从 CommonJS 到 Sea.js——seajs/seajs:https://github.com/seajs/seajs/issues/269
  8. 前端模块化开发那点历史——seajs/seajs:https://github.com/seajs/seajs/issues/588
  9. 前端模块化开发的价值——seajs/seajs:https://github.com/seajs/seajs/issues/547
  10. Module 的语法——阮一峰ES6教程:https://es6.ruanyifeng.com/#docs/module
  11. Module 的加载实现——阮一峰ES6教程:https://es6.ruanyifeng.com/#docs/module-loader
  12. Node.js 12 中的 ES 模块——前端先锋:https://zhuanlan.zhihu.com/p/75326798
  • 标题: 前端工程化-JavaScript模块化
  • 作者: ObjectKaz
  • 创建于: 2021-08-20 13:24:30
  • 更新于: 2022-06-27 14:47:59
  • 链接: https://www.objectkaz.cn/3b76bbe4e626.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。