一. 认识模块化开发
1. 什么是模块化?
- 到底什么是模块化、模块化开发呢?
- 事实上模块化开发最终的目的是将程序划分成一个个小的结构
- 这个结构中编写属于自己的逻辑代码,有自己的作用域,定义变量名词时不会影响到其他的结构
- 这个结构可以将自己希望暴露的变量、函数、对象等导出给其结构使用
- 也可以通过某种方式,导入另外结构中的变量、函数、对象等
- 上面说提到的结构,就是模块;按照这种结构划分开发程序的过程,就是模块化开发的过程
- 无论你多么喜欢
js
,以及它现在发展的有多好,它都有很多的缺陷:- 比如
var
定义的变量作用域问题 - 比如
js
的面向对象并不能像常规面向对象语言一样使用class
- 比如
js
没有模块化的问题
- 比如
- 对于早期的
js
没有模块化来说,确确实实带来了很多的问题
2. 模块化的历史
- 在网页开发的早期,
Brendan Eich
开发js
仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:- 这个时候我们只需要将
js
代码写到<script>
标签中即可 - 并没有必要放到多个文件中来编写;甚至流行:通常来说
js
程序的长度只有一行
- 这个时候我们只需要将
- 但是随着前端和
js
的快速发展,js
代码变得越来越复杂了:ajax
的出现,前后端开发分离,意味着后端返回数据后,我们需要通过js
进行前端页面的渲染SPA
的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过js
来实现- 包括
Node
的实现,js
编写复杂的后端程序,没有模块化是致命的硬伤
- 所以,模块化已经是
js
一个非常迫切的需求:- 但是
js
本身,直到ES6(2015)
才推出了自己的模块化方案 - 在此之前,为了让
js
支持模块化,社区涌现出了很多不同的模块化规范:AMD
、CMD
、CommonJS
等
- 但是
- 在我们的课程中,将详细讲解
js
的模块化,尤其是CommonJS
和ES6
的模块化
3. 没有模块化带来的问题
早期没有模块化带来了很多的问题:比如多个文件中的命名冲突的问题
- 文件本身是不存在作用域的,引入的文件,其实都是在一个全局作用域中的
当然,我们有办法可以解决上面的问题:立即函数调用表达式(
IIFE
)IIFE (Immediately Invoked Function Expression)
但是,我们其实带来了新的问题:
第一,必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用
第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写
第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况
所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的
- 我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码
- 这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性
js
社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们就学习具有代表性的一些规范
二. CommonJS 和 Node
1. CommonJS 规范和 Node 关系
我们需要知道
CommonJS
只是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS
,后来为了体现它的广泛性,修改为CommonJS
,平时我们也会简称为CJS
Node
是cjs
在服务器端一个具有代表性的实现Browserify
库是cjs
在浏览器中的一种实现,主要是应用在服务器里面,浏览器本身是没有实现CommonJS
规范的webpack
打包工具具备对cjs
的支持和转换
所以,
Node
中对cjs
进行了支持和实现,让我们在开发node
的过程中可以方便的进行模块化开发:- 在
Node
中每一个js
文件都是一个单独的模块,而每个模块是有自己独立的作用域的 - 这个模块中包括
cjs
规范的核心变量:exports
、module.exports
、require
- 我们可以使用这些变量来方便的进行模块化开发
- 在
前面我们提到过模块化的核心是导出和导入,
Node
中对其进行了实现:exports
和module.exports
可以负责对模块中的内容进行导出require
函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容
2. 模块化案例
我们来看一下两个文件:
js// a.js console.log(name) console.log(age) sayHello('kobe')
js// b.js const name = 'later' const age = 18 function sayHello(name) { console.log('hello ', name) }
a.js
必须导入b.js
b.js
必须导出自己的内容
3. exports 导出
exports
是一个空对象,我们可以在这个对象中添加很多个属性,添加的属性会导出jsexports.name = 'hehe' exports.age = 18
a.js
文件中可以导入b.js
:jsconst b = require('./b');
上面这行完成了什么操作呢?理解下面这句话,
Node
中的模块化一目了然意味着
a.js
中的b
变量等于exports
对象导入的对象内存地址指向的就是导出模块的
exports
对象,本质就是引用赋值也就是
require
通过各种查找方式,最终找到了exports
这个对象并且将这个
exports
对象赋值给了b
变量b
变量就是exports
对象了
4. module.exports 导出
但是
Node
中我们经常导出东西的时候,又是通过module.exports
导出的:module.exports
和exports
有什么关系或者区别呢?
node
中每个模块中都有一个module
对象,module
对象中有一个属性是exports
我们追根溯源,通过维基百科中对
cjs
规范的解析:cjs
中是没有module.exports
的概念的但是为了实现模块的导出,
Node
中使用的是Module
的类(function Module(){...}
),每一个模块都是Module
的一个实例,也就是module
所以在
Node
中真正用于导出的其实根本不是exports
,而是module.exports
因为
module
才是导出的真正实现者
但是,为什么
exports
也可以导出呢?这是因为
module
对象的exports
属性是exports
对象的一个引用也就是说
module.exports = exports
=a.js
中的bar
module.exports
、exports
、导入模块中的对象,这三者都是指向同一个内存地址的对象jsconsole.log(exports === module.exports) // true
所以如果使用
exports
导出对象时,只能通过添加属性的方式导出,如果修改内存地址,将无法在require
时获取到js// a.js const b = require('./b') console.log(b) // {} // 获取到的是空对象,因为b.js中修改了exports原本指向module.exports的内存地址,而真正用于导出的是module.exports,所以是无效的 // b.js exports = { name: 'b' }
5. 内存表现

- 导出实际导出的是
module.exports
这个属性所指向的值,而不是exports
对象所指向的值 module.exports
和exports
对象默认是指向同一个内存地址的(exports = module.exports
)- 如果查看
require
的源码,可以得知:- 通过显式绑定模块中的
this
,该this
也是等于module.exports
和exports
的 - 而每个
module
之所以有这些全局变量,如:__dirname
、exports
等 - 其内部是将
module
中的代码放入到一个函数中执行,而该函数是有指定__dirname
,exports
这些形参的
- 通过显式绑定模块中的
三. require 函数解析
require(id)
id
:<string>
模块名称或路径- 返回:
<any>
导出的模块内容
用于导入模块、
JSON
和本地文件模块可以从
node_modules
导入可以使用相对路径导入本地模块和
JSON
文件(例如./
、./foo
、./bar/baz
、../foo
),该路径将根据__dirname
(如果有定义)命名的目录或当前工作目录进行解析
补充:
__dirname
:当前模块的目录名- 这与
__filename
的path.dirname()
相同
1. require 文件查找细节
我们现在已经知道,
require
是一个函数,可以引入一个文件(模块)中导出的对象那么,
require
的查找规则是怎么样的呢?- 这里总结比较常见的查找规则
- 导入格式如下:
require(X)
情况一:
X
是一个Node
核心模块,比如path
、http
直接返回核心模块,并且停止查找
js// 导入node提供给内置模块 const path = require("path") const http = require("http")
情况二:
X
是以./
或../
或/
(根目录)开头的第一步:将
X
当做一个文件在对应的目录下查找- 如果有后缀名,按照后缀名的格式查找对应的文件
- 如果没有后缀名,会按照如下顺序:
- 直接查找文件
X
- 查找
X.js
文件 - 查找
X.json
文件 - 查找
X.node
文件
- 直接查找文件
第二步:没有找到对应的文件,将
X
作为一个目录- 查找目录下面的
index
文件- 查找
X/index.js
文件 - 查找
X/index.json
文件 - 查找
X/index.node
文件
- 查找
- 查找目录下面的
如果没有找到,那么报错:
not found
js// 根据路径导入自己编写模块 const utils = require("./utils")
情况三:直接是一个
X
(没有路径),并且X
不是一个核心模块先在当前目录查找
node_modules
,没有再依次往上查找node_modules
文件,直至根目录,还没有就报错:not found
有
node_modules
文件的话,就会去查找node_modules
中是否存在x
文件,存在的话,再去查找x
文件下的index.js
文件jsconst axios = require("axios")
如果上面的路径中都没有找到,那么报错:
not found
2. 模块的加载过程顺序的解析
结论一:模块在被第一次引入时,模块中的
js
代码会被运行一次结论二:模块被多次引入时,会缓存,最终只加载(运行)一次
- 为什么只会加载运行一次呢?
- 这是因为每个模块对象
module
都有一个属性:loaded
- 默认为
false
表示还没有加载,为true
表示已经加载,模块加载过一次就会设置为true
结论三:如果有循环引入,那么加载顺序是什么?
- 如果出现上图模块的引用关系,那么加载顺序是什么呢?
- 这个其实是一种数据结构:图结构
- 图结构在遍历的过程中,有深度优先搜索(
DFS, depth first search
)和广度优先搜索(BFS, breadth first search
) Node
采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee -> bbb
四. AMD 和 CMD
1. CommonJS 规范缺点
cjs
加载模块是同步的:- 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行,也就是说后面导入的模块将会受到阻塞
- 这个在服务器不会有什么问题,因为服务器加载的
js
文件都是本地文件,加载速度非常快
- 如果将它应用于浏览器呢?
- 浏览器加载
js
文件需要先从服务器将文件下载下来,之后再加载运行 - 那么采用同步的就意味着后续的
js
代码都无法正常运行,即使是一些简单的DOM
操作
- 浏览器加载
- 所以在浏览器中,我们通常不使用
cjs
规范:- 当然在
webpack
中使用cjs
是另外一回事 - 因为它会将我们的代码转成浏览器可以直接执行的代码
- 当然在
- 在早期为了可以在浏览器中使用模块化,通常会采用
AMD
或CMD
:- 但是目前一方面现代的浏览器已经支持
ESModules
,另一方面借助于webpack
等工具可以实现对CommonJS
或者ESModule
代码的转换 AMD
和CMD
的使用已经非常少了
- 但是目前一方面现代的浏览器已经支持
2. AMD 规范
AMD
主要是应用于浏览器的一种模块化规范:AMD
是Asynchronous Module Definition
(异步模块定义)的缩写- 它采用的是异步加载模块
- 事实上
AMD
的规范还要早于cjs
,但是cjs
目前依然在被使用,而AMD
使用的较少了
- 我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:
AMD
实现的比较常用的库是require.js
和curl.js
3. require.js 的使用
第一步:下载
require.js
- 下载地址:https://github.com/requirejs/requirejs
- 找到其中的
require.js
文件
第二步:定义
HTML
的script
标签引入require.js
和定义入口文件:data-main
属性的作用是在加载完src
的文件后会加载执行该文件<script src="./lib/require.js" data-main="./index.js"></script>
4. CMD 规范
CMD
规范也是应用于浏览器的一种模块化规范:CMD
是Common Module Definition
(通用模块定义)的缩写- 它也采用的也是异步加载模块,但是它将
CommonJS
的优点吸收了过来 - 但是目前
CMD
使用也非常少了
CMD
也有自己比较优秀的实现方案:SeaJS
5. SeaJS 的使用
第一步:下载
SeaJS
- 下载地址:https://github.com/seajs/seajs
- 找到
dist
文件夹下的sea.js
第二步:引入
sea.js
和使用主入口文件seajs
是指定主入口文件的
五. ESModule 用法详解
1. 认识 ES Module
js
没有模块化一直是它的痛点,所以才会产生我们前面学习的社区规范:cjs
、AMD
、CMD
等,所以在ECMA
推出自己的模块化系统时,大家也是兴奋异常ES Module
(简称:ES
) 和cjs
的模块化有一些不同之处:- 一方面它使用了
import
和export
关键字 - 另一方面它采用编译期的静态分析,并且也加入了动态引用
import()
的方式
- 一方面它使用了
ES Module
模块采用export
和import
关键字来实现模块化:export
负责将模块内的内容导出import
负责从其他模块导入内容
注意:
- 采用
ES Module
将自动采用严格模式:use strict
2. 案例代码结构组件
这里是在浏览器中演示
ES6
的模块化开发:html<!-- 注意事项一: 在浏览器中直接使用esmodule时, 必须在文件后加上后缀名.js import { name, age, sayHello } from "./foo.js" --> <!-- 注意事项二: 在我们打开对应的html时, 如果html中有使用模块化的代码, 那么必须开启一个服务来打开 --> <!-- type="module" 就是告诉浏览器这是一个模块化的文件,有自己的作用域,浏览器就会当成一个模块去解析 --> <script src="./foo.js" type="module"></script> <script src="./main.js" type="module"></script>
如果直接在浏览器中运行代码,会报如下错误:
这个在
MDN
上面有给出解释:- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules
- 你需要注意本地测试 — 如果你通过本地加载
Html
文件 (比如一个file://
路径的文件), 你将会遇到CORS
错误,因为js
模块安全性需要 - 所以你需要通过一个服务器来测试
我们这里使用的
VSCode
插件:Live Server
3. export 关键字
export
关键字将一个模块中的变量、函数、类等标识符导出我们希望将其他中内容全部导出,它可以有如下的方式:
方式一:在语句声明的前面直接加上
export
关键字方式二:将所有需要导出的标识符,放到
export
后面的{}
中注意:
- 这里的
{}
里面不是ES6
的对象字面量的增强写法,{}
也不是表示一个对象的,就是一个特殊的写法 - 所以:
export {name: name}
,是错误的写法
- 这里的
方式三:导出时给标识符起一个别名
- 通过
as
关键字起别名
js// 3.导出方式三: 这种方式不能起别名 export const name = "why" export const age = 18 export function sayHello() { console.log("sayHello") } export class Person {} // 1.导出方式一: export { name, age, sayHello } // 2.导出方式二: 导出时给标识符起一个别名 export { name as fname, age, sayHello }
- 通过
4. import 关键字
import
关键字负责从一个模块中导入内容导入内容的方式也有多种:
方式一:
import {标识符列表} from '模块'
注意:
- 这里的
{}
也不是一个对象,里面只是存放导入的标识符列表内容 - 浏览器中导入的模块需要加后缀名,否则会找不到对应的文件而报错,而
webpack
中不需要,会自动添加的
- 这里的
方式二:导入时给标识符起别名
- 通过
as
关键字起别名
- 通过
方式三:通过
*
将模块功能放到一个模块功能对象(a module object
)上js// 1.导入方式一: import { name, age, sayHello } from "./foo.js" // 2.导入方式二: 导入时给标识符起别名 import { name as fname, age, sayHello } from "./foo.js" // 3.导入时可以给整个模块起别名 import * as foo from "./foo.js" const name = "main" console.log(name) console.log(foo.name) console.log(foo.age) foo.sayHello()
5. export 和 import 结合使用
补充:
export
和import
可以结合使用特殊语法:
export {...} from '...'
jsimport { formatCount, formatDate } from './format.js' import { parseLyric } from './parse.js' export { formatCount, formatDate, parseLyric } // 等价于上面写法 优化一 export { formatCount, formatDate } from './format.js' export { parseLyric } from './parse.js' // 等价于上面写法 优化二 export * from './format.js' export * from './parse.js'
为什么要这样做呢?
- 在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中
- 这样方便指定统一的接口规范,也方便阅读
- 这个时候,我们就可以使用
export
和import
结合使用
6. default 用法
前面我们学习的导出功能都是有名字的导出(
named exports
):- 在导出
export
时指定了名字 - 在导入
import
时需要知道具体的名字
- 在导出
还有一种导出叫做默认导出(
default export
)- 默认导出
export
时可以不需要指定名字 - 在导入时不需要使用
{}
,并且可以指定任意名字 - 它也方便我们和现有的
cjs
等规范相互操作
注意:
- 在一个模块中,只能有一个默认导出
js// 1.默认的导出: // 1.1. 定义函数 function parseLyric() { return ["歌词"] } const name = 'hello esModule' export { parseLyric, name } // 1.2.默认导出 export default parseLyric // 2.定义标识符直接作为默认导出 export default function() { return ["新歌词"] } // 可以允许别的导出,但是导入的时候需要使用import {},而默认导出是不需要{} export { name } import parseLyric from "./parse_lyric.js" import { name } from './parse_lyric.js' console.log(parseLyric()) // ['新歌词'] console.log(name) // hellow esModule
- 默认导出
7. import() 函数
通过
import
加载一个模块,是不可以将其放到逻辑代码中的,比如:jslet flag = true if (flag) { // 不允许在逻辑代码中编写import导入声明语法, 只能写到js代码顶层 import { name, age, sayHello } from "./foo.js" }
为什么会出现这个情况呢?
这是因为
ES Module
在被JS
引擎解析时,就必须知道它的依赖关系由于这个时候
js
代码没有任何的运行,所以无法在进行类似于if
判断中根据代码的执行情况甚至拼接路径的写法也是错误的:因为我们必须到运行时能确定
path
的值jsimport { name, age, sayHello } from "./foo" + ".js" // 报错不被允许
但是某些情况下,我们确确实实希望动态的来加载某一个模块:
如果根据不同的条件,动态来选择加载模块的路径
这个时候我们需要使用
import()
函数来动态加载import
函数返回一个Promise
,可以通过then
获取结果
js// foo.js // export const name = "foo" // export const age = 18 let flag = true if (flag) { import("./foo.js").then(res => { console.log(res.name, res.age) }) }
8. import meta
import.meta
是一个给js
模块暴露特定上下文的元数据属性的对象- 它包含了这个模块的信息,比如说这个模块的
URL
- 在
ES11(ES2020)
中新增的特性
jsconsole.log(import.meta) // {url: 'http://127.0.0.1:5500/02_%E5%89%8D%E7%AB%AF%E6%A8%…%E5%8C%96%E5%BC%80%E5%8F%91/10_ESModule_05/foo.js'}
- 它包含了这个模块的信息,比如说这个模块的
六. ESModule 运行原理
1. ES Module 的解析流程
ES Module
是如何被浏览器解析并且让模块之间可以相互引用的呢?ES Module
的解析过程可以划分为三个阶段:- 阶段一:构建(Construction),根据地址查找并下载
js
文件,将其解析成模块记录(Module Record
) - 阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址
- 个人理解:也就是说
a
模块被多个地方引入,a
模块内部代码只会执行一次,指向的都是同一个内存地址
- 个人理解:也就是说
- 阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中
- 阶段一:构建(Construction),根据地址查找并下载
2. 阶段一:构建阶段

3. 阶段二和三:实例化阶段 – 求值阶段

四. 验证执行流程
先看个简单的:
js// a.js console.log('---- a start ----') var done = false export { done as aDone } console.log('---- a end ----')
js// main.js console.log('---- main start ----') import { aDone } from './a.js' console.log('in main, a.done = %j', aDone) console.log('---- main end ----')
执行
main.js
,打印结果如下:js// output: ---- a start ---- ---- a end ---- ---- main start ---- in main, a.done = false ---- main end ----
由此可以推断出:
ES
模块中,当模块中存在有 非动态导入import
别的模块时,会优先加载并执行别的模块,执行完别的模块中的代码后,才会执行当前模块中的代码注意:
import()
语法,通常称为动态导入,是一种类似函数的表达式,允许将ECMAScript
模块异步动态地加载到潜在的非模块环境中
再来个循环引用的:
js// a.js console.log('---- a start ----') var done = false const bar = 'bar' function foo() { console.log('foo execute~') } export { done as aDone, foo, bar } import { bDone } from './b.js' console.log('in a, b.done = %j', bDone) done = true console.log('---- a end ----')
js// b.js console.log('---- b start ----') var done = false export { done as bDone } import { aDone, foo, bar } from './a.js' console.log('in b, a.done = %j', aDone) console.log('foo: ', foo) foo() console.log('bar: ', bar) done = true console.log('---- b end ----')
js// main.js console.log('---- main start ----') import { aDone } from './a.js' console.log('in main, a.done = %j', aDone) console.log('---- main end ----')
执行
main.js
,打印结果如下:js// output: ---- b start ---- in b, a.done = undefined foo: function() {//...} foo execute~ ---- b end ---- ---- a start ---- in a, b.done = true ---- a end ---- ---- main start ---- in main, a.done = true ---- main end ----
由此可以推断出:
ES
模块中,当前模块中导出的函数声明,在编译阶段就被提前处理,允许在本模块代码执行之前调用ES
模块中,当前模块中导出的var
定义的变量,因为作用域提升,允许在本模块代码执行之前访问(export default
导出除外),此时变量还未初始化,值为undefined
ES
模块中,当前模块中导出的let
、const
定义的变量,没有作用域提升,不允许在本模块代码执行之前访问
再来测试个多次导入同一模块是否会多次执行该模块中的代码
js// a.js console.log('---- a start ----') import {} from './b.js' console.log('---- a end ----')
js// b.js console.log('---- b start ----') console.log('---- b end ----')
js// main.js console.log('---- main start ----') import {} from './a.js' console.log('--------') import {} from './b.js' console.log('---- main end ----')
执行
main.js
,打印结果如下:js---- b start ---- ---- b end ---- ---- a start ---- ---- a end ---- ---- main start ---- -------- ---- main end ----
由此可以得知:
ES
模块中多次导入同一个模块,只会执行第一次导入,后续的导入将不再执行被导入模块中的代码,而是将第一次导入时的执行结果(导出对象的内存地址)返回CJS
模块测试也是多次导入只会执行第一次导入,后续导入直接返回第一次导入时的执行结果