前端工程化-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.parentnull

exports 变量

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

由于 exportsmodule.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加载 bb加载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 为模块名称。

    工厂函数默认有三个参数:requireexportsmodule

  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 一样,exportsmodule.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 进行许可。