一文彻底搞懂前端模块化:CommonJS规范 与 ES Module规范

3/25/2021 JavaScriptNode模块化

# 文章目录

# 一、什么是模块化?

  • 事实上模块化开发最终的目的是将程序划分成一个个小的结构
  • 这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构;
  • 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用
  • 也可以通过某种方式,导入另外结构中的变量、函数、对象

上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程

无论你多么喜欢 Javascript,以及它现在发展的有多好,我们都需要承认在 B/ endan Eichi 用了 10 天写出 javascript 的时候,它都有很多的缺陷:

  • 比如 var 定义的变量作用域问题;
  • 比如 Javascript 的面向对象并不能像常规面向对象语言一样使用 class;
  • 比如 avascript 没有模块化的问题

在网页开发的早期, Brendan Eich 开发 javascriptt 仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:

  • 这个时候我们只需要讲 Javascript 代码写到< script>标签中即可;
  • 并没有必要放到多个文件中来编写;甚至流行:通常来说 Javascript 程序的长度只有一行。

但是随着前端和 javascriptl 的快速发展, Javascript 代码变得越来越复杂了

  • ajax 的出现,前后端开发分离,意味着后端返回数据后,我们需要通过 javascripti 进行前端页面的渲染;
  • SPA 的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过 Javascript 来实现
  • 包括 Node 的实现, Javascript 编写复杂的后端程序,没有模块化是致命的硬仿;

所以,模块化已经是 Javascript 一个非常迫切的需求
但是 Javascript 本身,直到 ES6(2015)オ推出了自己的模块化方案
在此之前,为了让 Javascript 支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、 Commonjs 等;

# 二、CommonJS 与 Node

我们需要知道 CommonJs 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为 ServerJs ,后来为了体现它的广泛性,修改为 CommonJs ,平时我们也会筒称为 CJS.

  • Node 是 CommonJs 在服务器端一个具有代表性的实现;
  • Browserify 是 CommonJS 在浏览器中的一种实现
  • webpack 打包工具具备 Commonjs 的支持和转换;

所以,Node 中对 CommonsJS 进行了支持和实现,让我们在开发 node 的过程中可以方便的进行模块化开发:

  • 在 Node 中每一个 js 文件都是一个单独的模块
  • 这个模块中包括 CommonJs 规范的核心变量 exports、 module. exports、 require;

我们可以使用这些变量来方便的进行模块化开发;
exports 和 module. exports 可以负责对模块中的内容进行导出 ;
require 函数可以帮助我们 导入其他模块(自定义模块、系统模块、第三方库模块) 中的内容

# 2.1 exports 的 导入与导出

bar.js

// Node中每一个Js文件就是一个模块
const name = "PengSir";
const age = 18;
let message = "My name is peng";

function sayHello(name) {
  console.log("hello" + name);
}

// exports 默认是空对象
// console.log(exports); // {}

// 1.导出
exports.name = name;
exports.age = age;
exports.sayHello = sayHello;
exports.message = message;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

main.js

// 导入:require 是一个函数
// const bar = require('./bar')
const { name, age, sayHello, message } = require("./bar");

console.log(name); // PengSir
1
2
3
4
5

图解:导入导出原理
在这里插入图片描述
在每个 Node 应用中都有一个 exports 对象,在其他文件导入某个文件时,其实就是拿到该对象的内存地址。

为了验证这一想法,写个 demo 来确定一下。

bar.js

// 就是一个模块
let name = "PengSir";

setTimeout(() => {
  exports.name = "test";
}, 1000);
console.log(name);
exports.name = name;
1
2
3
4
5
6
7
8

main.js

const bar = require("./bar.js");

console.log(bar.name);
setTimeout(() => {
  console.log(bar.name);
});
1
2
3
4
5
6

输出:

PengSir
test
1
2

可以发现结果确实如我们所料。
所以,bar 对象是 exports 对象的浅拷贝(引用赋值)

# 2.2 module.exports 又是什么东西?

但是在 Node 中我们经常导出东西的时候,又是通过 module. exports 导出的

  • module. exports 和 exports1 有什么关系或者区别呢?

我们追根溯源,通过维基百科中对 Commonjs 规范的解析:

  • CommonJS 中是没有 module. exports 的概念的
  • 但是为了实现模块的导出,Node 中使用的是 Module 的类,每一个模块都是 Module 的一个实例,也就是 new module( 一个 JS 文件就是一个 Module 实例 )
  • 所以在 Node 中真正用于导出的其实根本不是 exports,而是 module. exports;
  • 因为 module 才是导出的真正实现者

一个文件把它当成一个对象的时候,Node 底层就会 new module

Node 的底层 实际上做了这么一步操作 module.exports = exports

所以咱们上述的bar = exports = module.exports

验证如下:

bar.js

const name = "PengSir";
setTimeout(() => {
  module.exports.name = "hahaha";
  console.log(exports.name);
}, 1000);
exports.name = name;
1
2
3
4
5
6

main.js

const bar = require("./bar");
console.log(bar.name);

setTimeout(() => {
  console.log(bar.name);
}, 2000);
1
2
3
4
5
6

output:

PengSir
hahaha
hahaha
1
2
3

由此可见,我们上述的论述完全正确,即bar = exports = module.exports

图解:
在这里插入图片描述

在这里插入图片描述

# 2.3 require 的细节

require 的加载过程是同步的,意味着必须等到引入的文件(模块)加载完成之后,才会继续执行其他代码,也就是会造成阻塞(因为引入一个文件则该文件内部的所有代码都会被执行一次)。

require 是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。

那么,require 的查找规则是怎样的呢?
中文文档:require (opens new window)

这里总结一下 require 常见的查找规则:
require(X)

情况一:X 是一个核心模块,比如 path、http

  • 直接返回核心模块,并停止查找

情况二:X 是以./..//(根目录)开头的

  • 第一步:将 X 当做一个文件在对应的目录下查找;
    1. 如果有后缀名,按照后缀名的格式查找对应的文件
    2. 如果没有后缀名,按照如下顺序
    3. 直接查找文件 X
    4. 查找 X.js
    5. 查找 X.json
    6. 查找 X.node
  • 第二步:没有找到对应的文件,将 X 作为一个目录
    • 查找目录下面的 index 文件 1. 查找 X/index.js 2. 查找 X/index.json 3. 查找 X/index.node

情况三:直接是一个 X(没有路径),并且 X 不是一个核心模块

例如我在如下目录的main.js中编写了require('test’)

/Users/coderwhy/Desktop/Node/TestCode/04_learn_node/0 5_javascript-module/02_commonjs/main.js中编写require('why’)

则它的查找规则如下,会逐级查找上一层目录下的node_modules
在这里插入图片描述

如果都没找到,那么报错 not found

# 2.4 模块的加载过程

  • 结论一:模块在第一次被引入的时候,模块中的 js 代码会被运行一次
  • 结论二:模块被多次引入时,会缓存,最终只加载(运行)一次
    • 为什么只会加载运行一次呢?
    • 这是因为每个模块对象 module 都有一个属性: loaded
    • 为 false 表示还没有加载,为 true 表示已经加载
  • 结论三:如果有循环引入,那么加载顺序是什么?

顺序为:图结构的深度优先算法

在这里插入图片描述

# 三、ES Module

小知识:ES Module 是 ES6 推出的。 即 es 2015

Javascript.没有模块化一直是 它的痛点 ,所以オ会在社区产生许多的规范: Commonjs、AMD、CMD 等,所以在 ES 推出自己的模块化系统时,大家也是兴奋异常

ES Module 和 Commonjs 的模块化有一些不同之处:

  • 一方面它使用了 Import 和 export 关键字 ,不是模块也不是函数。
  • 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式

ES Module 模块采用 export 和 import 关键字来实现模块化:

  • export负责将模块内的内容导出
  • import负责从其他模块导入内容

ES Module 将自动采用严格模式:use strict

# 3.1 尝试使用 ES Modules

注意在浏览器使用 ES Modules 时,要在script标签上加上 type="module",且要在服务器上运行,不支持本地运行的 file 协议(触发 CORS),

index.html

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

index.js

console.log("hello EsModules");
1

结果:

hello EsModules
1

# 3.2 常见的导入与导出方式 export 与 import

index.html

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

index.js 导入

console.log("hello EsModules");

// 常见的导入方式

// 方式一: import {} from '路径'
// 注意此处的{}不是对象,导入时后边必须要加.js,脚手架里和webpack会自动加
// import { name, age, sayhello } from './modules/foo.js'

// 方式二:导出的变量可以起别名
// import { name as Fname, age as Fage, sayhello as FsayHello } from './modules/foo.js'

// 2.1 导出时已经起了别名的,接收要使用别名接受,可以给别名再起别名
// import {Fname as FooName,Fage as FooAge,FsayHello as FooSayHello} from './modules/foo.js'

// 方式三:import * as foo from '路径'
import * as foo from "./modules/foo.js";

console.log(foo.name);
console.log(foo.age);
foo.sayHello("彭先生");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

foo.js 导出

const name = "pengsir";
const age = 18;
const sayHello = function(name) {
  console.log("姓名" + name);
};

// 1.导出方式

// 方式一:

// export const name = 'pengsir'
// export const age = 18
// export const sayHello = function (name) {
//   console.log('姓名' + name);
// }

// 方式二: 常用!!!!!!
// {} 这里不是类 就和 if(){} 的大括号一样
// {放置要导出变量的引用列表}

export { name, age, sayHello };

// 方式三:{} 导出时,可以给变量起别名
// export {
//   name as Fname,
//   age as Fage,
//   sayHello as FsayHello
// }
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

输出结果:

hello EsModules
pengsir
18
姓名彭先生
1
2
3
4

# 3.3 export default

前面我们学习的导出功能都是有名字的导出( named exports)

  • 在导出(exporte) 时指定了名字;
  • 在导入(import) 时需要知道具体的名字

某些情况下不是很方便,所以还有另外一种导出方式,叫做 default export

  • 默认导出 export 时可以不需要指定名字;
  • 在导入时十分方便,并且可以自己来指定名字,类如我们导入 Vue、Vuex、VueRoter 时内部就是使用的默认导出;

bar.js 导出 :

// 方式四:默认导出
export default function format() {
  console.log("对某一个东西,进行格式化!");
}
1
2
3
4

index.js 导入:

// 方式四: 演示 export default如何导入
import utils from "./modules/foo.js";

utils(); // 实际是调用 format
1
2
3
4

结果:

对某一个东西,进行格式化!
1

注意: 一个文件只能有一个默认导出:export default

# 3.4 import 函数

通过 Import 加载的模块,是不可以在其放到逻辑代码中的,比如:

let flag = true;
if (flag) {
  // 错误用法,语法错误,不能在逻辑在逻辑代码中使用 import 关键字
  import format from "./modules/foo.js";
}
1
2
3
4
5

为什么会出现这个情况呢?
parse => AST => 字节码 =>

  • 这是因为 ES Module 在被 JS 引擎(parse)解析时,就必须知道它的依赖关系
  • 由于这个时 JS 代码没有任何的运行,所以无法在进行类似于 if 判断中根据代码的执行情况

解决办法:

import() 函数 或者 require()

// 方式五:import() 函数
// 注意:上边使用import时是作为关键字使用,现在是作为函数使用,
// 该函数为异步函数,返回值为promise
let flag = true;
if (flag) {
  import("./modules/foo.js").then(
    (res) => {
      console.log("then里边的回调");
      console.log(res);
    },
    (err) => {
      console.log(err);
    }
  );
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 3.5 异步的 import

使用 type="module"时,加载该模块时异步加载的,就相当于给script加了一个async属性。

示例:
index.html

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

index.js

console.log("hello EsModules");
1

normal.js

console.log("我是普通的js文件");
1

结果:

我是普通的js文件
hello EsModules
1
2

由此可见我们的 ES Module 是异步的。

# 3.6 ES Module 的加载过程

  • 结论一:ES Module 导出的数据是实时变化的
    • 如果在bar.js中导出一个变量 A,在index.js中导入该变量,如果 1 秒之后bar.js中的变量 A 值被修改,index.js中的导入的该变量也会修改。
    • 但是我们在index.js中却不可以修改导入的该变量,除非该变量是一个对象类型(保存的是 该对象的内存地址),因为 ES Module 在底层实现的时候,每次 导出的变量发生变化,都会在模块环境记录中创建一个最新的该变量,类似:const name = name发生变化后const name = name; const name = name,所以能拿到最新的值,且因为底层是使用 const 定义的,所以导入后的变量内存地址不能发生变化,但是对象类型的值却可以。

在这里插入图片描述

最后更新于: 2021年9月15日星期三晚上10点43分
Faster Than Light
Andreas Waldetoft / Mia Stegmar