前言

模块化是指根据用途或者逻辑将js代码分为多个js文件,且各个文件之间的数据相互隔离,互不影响。模块与模块之间可以通过导出和导入操作来共享或获取模块中想要的数据和功能。

  • 导出(暴露):模块公开其内部的变量和函数,通过导入和导出进行数据和功能的共享。
  • 导入(引入):模块引用和使用其他模块导出的内容,以重用代码和功能。
image-20240815120643834

为什么需要模块化开发呢?因为js早期是不存在模块化这个概念的,在实际开发过程中出现过如下问题:

  1. 全局类型污染(相同的变量名会相互覆盖)
  2. 依赖混乱(典型的jQuery和BootStrap的引入顺序问题)
  3. 数据安全问题(完整的数据被导出)

因此迫切需要使用模块化技术来解决上述问题。

模块化规范

出现过的模块化规范

  • CommonJs(常用)
  • AMD
  • CMD
  • ES6(常用)

随着 Node.js 的出现,JavaScript 不再局限于浏览器环境,而是开始进入服务器端编程领域。开发者们开始需要一个更好的方式来组织和重用代码。在浏览器环境中,通常使用 <script> 标签来加载脚本文件,这种方式对于服务器端环境来说并不适用,服务器端环境需要更灵活和强大的模块化支持。为了满足这一需求,由Mozilla工程师提出ServerJs,因此Nodejs社区制定了 CommonJS 规范。这个规范定义了一种标准的模块系统,允许开发者以文件的形式组织代码,并且可以在这些文件之间共享代码。因此CommonJs广泛用于服务端,也就是Nodejs环境(浏览器不直接支持这一规范

后来ECMA官方推出了ES6模块化规范,同时可以兼容服务端和浏览器。

CommonJs规范

快速上手

新建student.jsteacher.jsindex.js三个js文件

1
2
3
4
5
6
7
8
9
10
11
// student.js
const name = "John";
const age = 25;

function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}
1
2
3
4
5
6
7
8
9
10
11
// teacher.js
const name = "Tom";
const age = 35;

function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}
1
2
3
4
5
6
7
8
9
// index.js
const student = require("./student.js");
const teacher = require("./teacher.js");

console.log(student.name, student.getEmail());
console.log(teacher.name, teacher.getEmail());

// John 123456789@gmail.com
// Tom 79898989898@gmail.com

从上述代码中可以看出,导出使用的是exports,导入使用的是require

导出数据

导出的方式有两种

  1. 使用exports.name = value
  2. 使用module.exports = value

注意点:

  1. 只要其他地方导入了当前的js文件,即使js内部没有导出,也会默认导出{}

    1
    2
    3
    4
    5
    6
    // 假设student.js没有导出数据
    // index.js
    const student = require("./student.js");
    const teacher = require("./teacher.js");

    console.log(student) // 打印实际为{}
  2. module.exportsthisexports都指向同一个{},最终导出以module.exports为准

    image-20240815124326614

    导出的例子以如下代码为例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // student.js

    const name = "John";
    const age = 25;

    function getEmail() {
    return "123456789@gmail.com";
    }

    function getSkills() {
    return ["Python", "JavaScript", "Golang"];
    }

    exports = { a: 1 };
    exports.b = 2;

    module.exports.a = 3;
    module.exports = { name, getEmail };
    this.getSkills = getSkills;

    在index.js导入并打印student,得到的结果为

    1
    { name: 'John', getEmail: [Function: getEmail] }
  3. exportsmodule.exports的一个引用,主要用于给导出对象添加属性值exports.name = value

导入数据

导入数据使用require,可以将其作为一个对象接收(见快速上手),也可以使用解构的方式接收

1
2
3
4
5
6
7
8
9
10
11
12
13
// student.js
const name = "John";
const age = 25;

function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}

module.exports = { name, getEmail, getSkills };
1
2
3
4
5
6
7
8
9
10
11
12
13
// teacher.js
const name = "Tom";
const age = 35;

function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

module.exports = { name, getEmail, getSubjects };
1
2
3
4
5
const {name,getEmail,getSkills} = require("./student.js");
const {name,getEmail,getSubjects} = require("./teacher.js");
/* const {name,getEmail,getSubjects} = require("./teacher.js");
^
SyntaxError: Identifier 'name' has already been declared */

上述代码展示了解构导入的命名冲突问题,有两种解决方法:

  1. 第二个对象不使用结构的方法,使用const teacher = require("./teacher.js");进行导入

  2. 使用重命名的语法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const {name:stuName,getEmail:stuEmail,getSkills} = require("./student.js");
    const {name:teaName,getEmail:teaEmail,getSubjects} = require("./teacher.js");

    console.log(stuName);
    console.log(stuEmail());
    console.log(getSkills());

    console.log(teaName);
    console.log(teaEmail());
    console.log(getSubjects());

扩展理解

为什么我们能在js中直接使用非关键字moduleexports

因为在本质上来说,当前js文件是被包裹在另一个function函数中的,示意如下图。

image-20240815132804344

可以打印看到函数的全貌

1
2
3
4
5
6
7
8
9
10
11
12
13
// teacher.js
const name = "Tom";
const age = 35;

function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

console.log(arguments.callee.toString());

得到的打印结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function (exports, require, module, __filename, __dirname) {
const name = "Tom";
const age = 35;

function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

console.log(arguments.callee.toString());

// module.exports = { name, getEmail, getSubjects };

}

浏览器运行

问题展示

因为在诞生之初,CommonJs就是为服务端进行制定的(因此还有另一个称呼叫做ServerJs),所以浏览器并不支持这个规范,我们新建一个index.html,尝试导入index.js

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="./index.js"></script>
</body>
</html>

打开浏览器得到的报错提示如下

image-20240815131531856

这也说明了浏览器并不支持CommonJs规范,需要使用额外的工具进行转化

转化工具

可以使用Browserify进行转化,将CommonJs的部分转化为浏览器支持的其他代码

安装Browerify

1
npm i browserify -g

编译

1
browserify index.js -o build.js

index.js是输入文件,build.js是输出文件

编译得到的结果为

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
34
35
36
37
38
39
40
41
42
43
44
// build.js
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
const {name:stuName,getEmail:stuEmail,getSkills} = require("./student.js");
const {name:teaName,getEmail:teaEmail,getSubjects} = require("./teacher.js");

console.log(stuName);
console.log(stuEmail());
console.log(getSkills());

console.log(teaName);
console.log(teaEmail());
console.log(getSubjects());

},{"./student.js":2,"./teacher.js":3}],2:[function(require,module,exports){
const name = "John";
const age = 25;

function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}

module.exports = { name, getEmail, getSkills };

},{}],3:[function(require,module,exports){
const name = "Tom";
const age = 35;

function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

// console.log(arguments.callee.toString());

module.exports = { name, getEmail, getSubjects };

},{}]},{},[1]);

在html中引入build.js,之后打开浏览器即可正常打印

image-20240815132544123

ES6规范

这是ECMAScript官方制定的JavaScript模块化规范

快速上手

和之前一样创建index.jsstudent.jsteacher.js三个js文件

1
2
3
4
5
6
7
8
9
10
11
// student.js
export const name = "John";
export const age = 25;

export function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}
1
2
3
4
5
6
7
8
9
10
11
// teacher.js
export const name = "Tom";
export const age = 35;

export function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}
1
2
3
4
5
6
// index.js
import * as student from "./student.js";
import * as teacher from "./teacher.js";

console.log(student);
console.log(teacher);

打印得到的结果为

1
2
3
4
5
6
7
8
9
10
[Module: null prototype] {       
age: 25,
getEmail: [Function: getEmail],
name: 'John'
}
[Module: null prototype] {
age: 35,
getEmail: [Function: getEmail],
name: 'Tom'
}

Node中运行ES6模块

Nodejs是无法直接运行ES6的模块化文件的,如果直接运行会出现报错。

d:\Web\module_test\index.js:1
import * as student from “./student.js”;
^^^^^^

SyntaxError: Cannot use import statement outside a module

这时候有三种办法:

  1. index.html中引入,将script标签的type属性设置为module

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    </head>
    <body>
    <script type="module" src="./index.js"></script>
    </body>
    </html>

    使用live Server创建本地服务器,打开控制台可以看到如下输出

    image-20240815153307030
  2. 将所有文件的后缀名改为.mjs,之后可直接在Nodejs中运行

  3. 创建package.json文件,加上"type":"module",之后可直接在Nodejs中运行

    1
    2
    3
    {
    "type": "module"
    }

导出数据

ES6模块导出有三种方法:

  1. 分别导出
  2. 统一导出
  3. 默认导出

为了方便演示,导入模块使用的是万能写法

1
2
3
4
5
import * as student from "./student.js";
import * as teacher from "./teacher.js";

console.log(student);
console.log(teacher);

分别导出

在每个想要导出的变量前使用export关键字

1
2
3
4
5
6
7
8
9
10
// student.js
export const name = "John";
export const age = 25;
export function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}

分别导出仅仅适用于要导出的数据相对较少的情况,而且分别导出也不能直观展示导出的所有数据和方法。

统一导出

统一导出能够一次性导出多个数据,和分别导出没有本质上的差别

1
2
3
4
5
6
7
8
9
10
11
12
// teacher.js
const name = "Tom";
const age = 35;
function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

export { name, age, getEmail };

导出打印的结果为

1
2
3
4
5
6
7
8
9
10
[Module: null prototype] {
age: 25,
getEmail: [Function: getEmail],
name: 'John'
}
[Module: null prototype] {
age: 35,
getEmail: [Function: getEmail],
name: 'Tom'
}

export后跟{},并不是说明导出的是一个对象,这从上述的打印结果中也可以看出,导出的数据和方法被包裹在默认的导出对象中。

默认导出

从分别导出和统一导出的打印结果可以看出,导出的数据都是统一放在一个对象中。

默认导出相比之前的两种导出方法得到的输出有所不同

1
2
3
4
5
6
7
8
9
10
11
12
// student.js
const name = "John";
const age = 25;
function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}

export default { name, age, getEmail };

打印的输出为

1
2
3
4
5
6
7
8
[Module: null prototype] {
default: { name: 'John', age: 25, getEmail: [Function: getEmail] }
}
[Module: null prototype] {
age: 35,
getEmail: [Function: getEmail],
name: 'Tom'
}

观察输出可以发现,使用export default导出后得到的数据被包裹在输出对象的default对象中,如果要进一步访问,则需要使用student.default.属性名进行获取

混合使用

上述的三种方法可以混合使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// teacher.js
export const name = "Tom"; // 分别导出
const age = 35;
function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

export { getEmail }; // 统一导出

export default { age }; // 默认导出

得到的打印结果为

1
2
3
4
5
[Module: null prototype] {
default: { age: 35 },
getEmail: [Function: getEmail],
name: 'Tom'
}

从打印的结果来看,完全符合上述不同导出方法对应的规则。

导入数据

导入有很多中方法,但是每种方法都有对应的导出方式,要注意相互的配对。

导入全部

即万能导入

1
2
3
4
5
6
// index.js
import * as student from "./student.js";
import * as teacher from "./teacher.js";

console.log(student);
console.log(teacher);

这种方法是通用的,但是如果有模块使用的是默认导出,那么得到导出数据的路径会变长(比如student.default.name

命名导入

如果导出模块使用的是分别导出或统一导出(也可以两者混用),那么可以使用命名导入的方式

1
2
3
4
5
6
7
8
9
10
11
12
// student.js
const name = "John";
const age = 25;
export function getEmail() {
return "123456789@gmail.com";
}

function getSkills() {
return ["Python", "JavaScript", "Golang"];
}

export { name, age };
1
2
3
4
5
6
7
// index.js
import { name, age, getEmail } from "./student.js";
// import * as teacher from "./teacher.js";

console.log(name); // John
console.log(age); // 25
console.log(getEmail()); // 123456789@gmail.com

如果想要更换变量命名,使用name as newName的方式

1
2
3
4
5
6
7
// index.js
import { name as stuName, age as stuAge, getEmail as stuGetEmail } from "./student.js";
// import * as teacher from "./teacher.js";

console.log(stuName); // John
console.log(stuAge); // 25
console.log(stuGetEmail()); // 123456789@gmail.com

默认导入

如果导出模块使用的是默认导出,那么可以使用默认导入的形式,直接给导入的模块命一个模块名即可

1
2
3
4
5
6
7
8
9
10
11
12
// teacher.js
const name = "Tom";
const age = 35;
function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

export default { age, name, getEmail };
1
2
3
4
5
6
7
8
// index.js
// import { name as stuName, age as stuAge, getEmail as stuGetEmail } from "./student.js";
import teacher from "./teacher.js";


console.log(teacher.name); // Tom
console.log(teacher.age); // 35
console.log(teacher.getEmail()); // 79898989898@gmail.com

命名和默认混用

如果出现导出方式混用,可以使用对应的导入方法进行一次性的导入

1
2
3
4
5
6
7
8
9
10
11
12
// teacher.js
const name = "Tom";
const age = 35;
export function getEmail() {
return "79898989898@gmail.com";
}

function getSubjects() {
return ["Math", "Science", "English"];
}

export default { age, name };
1
2
3
4
5
6
7
// index.js
// import { name as stuName, age as stuAge, getEmail as stuGetEmail } from "./student.js";
import teacher, { getEmail } from "./teacher.js";

console.log(teacher.name); // Tom
console.log(teacher.age); // 35
console.log(getEmail()); // 79898989898@gmail.com

动态导入

动态导入即在需要的情况下才导入模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// index.js
let count = 0;

function increment() {
count++;
}

async function fetchData() {
if (count === 1) {
const data = await import("./student.js");
console.log(data);
}
}

increment();
fetchData();

当count达到要求后,导入student.js模块并打印,打印的结果如下

1
2
3
4
5
[Module: null prototype] {       
age: 25,
getEmail: [Function: getEmail],
name: 'John'
}

导入不接收任何数据

如果不需要接收任何数据,那么可以直接导入模块地址,形如import "模块地址"

1
2
// teacher.js
console.log(Math.random());
1
2
// index.js
import "./teacher.js";

上面的例子,每次运行index.js都会打印出一个随机数

关于问题解决的理解

那么通过模块化是否解决了开头提出的三个问题呢?

  1. 全局类型污染(相同的变量名会相互覆盖)
  2. 依赖混乱(典型的jQuery和BootStrap的引入顺序问题)
  3. 数据安全问题(完整的数据被导出)

个人的理解如下:

  1. 全局污染的问题已经可以通过在导入时重命名的方法防止同名变量的覆盖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { name as stuName, age as stuAge, getEmail as stuGetEmail } from "./student.js";
    import {name as teaName,age as teaAge,getEmail as teaGetEmail} from "./teacher.js";

    console.log(stuName); // John
    console.log(stuAge); // 25
    console.log(stuGetEmail()); // 123456789@gmail.com

    console.log(teaName); // Tom
    console.log(teaAge); // 35
    console.log(teaGetEmail()); // 79898989898@gmail.com
  2. 依赖混乱问题的解决可以通过下图来论证(假设存在如下图的相互依赖关系)

    image-20240815163315119

    可以看到,各个模块不再需要考虑有关导入顺序的问题,当模块需要其他模块作为依赖时,只需关注如何导入该依赖模块,而不需要注意导入的顺序。

  3. 数据安全问题

    student.jsindex.js为例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // student.js
    const name = "John";
    const age = 25;
    export function getEmail() {
    return "123456789@gmail.com";
    }

    function getSkills() {
    return ["Python", "JavaScript", "Golang"];
    }

    export { name, age };
    1
    2
    3
    4
    5
    6
    7
    // index.js
    import { name, age, getEmail } from "./student.js";
    // import teacher, { getEmail } from "./teacher.js";

    console.log(name);
    console.log(age);
    console.log(getEmail());

    在index.html中引入index.js

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    </head>
    <body>
    <script type="module" src="./index.js"></script>
    </body>
    </html>

    打开浏览器,尝试获取nameagegetEmail,除了name,发现返回的都是xxx is not defined,这已经说明解决了数据安全问题,在没有导出数据的情况下,是无法获取到数据的。(实际上,这就是module方式带来的便利之处)

    image-20240815163935566

    name可以返回一个”“的原因是window默认自带name属性,且默认值就为”“

数据引用问题

commonjs和es6处理导出数据的隔离方式有所不同,还要从下面的例子说起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function count() {
let sum = 1;
function increment() {
sum += 1;
console.log(sum);
}
return { sum, increment };
}

const { sum, increment } = count();

console.log(sum); // 1
increment(); // 2
increment(); // 3
console.log(sum); // 1

可以看出解构出的sum和函数内的sum值地址已经完全不同,相当于将函数内的sum复制了一份给解构的sum

CommonJs也符合上述的规则

1
2
3
4
5
6
7
8
9
// teacher.js
let sum = 1;

function increment() {
sum += 1;
console.log(sum);
}

module.exports = {sum, increment};
1
2
3
4
5
6
7
// index.js
const { sum, increment } = require("./teacher.js");

console.log(sum); // 1
increment(); // 2
increment(); // 3
console.log(sum); // 1

但是ES6的模块化规范却截然相反

1
2
3
4
5
6
7
8
9
// teacher.js
let sum = 1;

function increment() {
sum += 1;
console.log(sum);
}

export {sum, increment}
1
2
3
4
5
6
7
// index.js
import { sum, increment } from "./teacher.js";

console.log(sum); // 1
increment(); // 2
increment(); // 3
console.log(sum); // 3

最终打印得到的sum值为3,这说明导出模块与倒入模块使用的sum是同一个内存地址,修改一次则处处变化,这可能会导致一些意想不到的问题。

因此在使用ES6的模块化规范下,最好是将导出的数据设置为常量(const