Skip to content

一. 认识模块化开发

什么是模块化

到底什么是模块化、模块化开发呢?

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

按照这种模块划分开发程序的过程,就是模块化开发的过程。

无论你多么喜欢 JS,以及它现在发展的有多好,它都有很多的缺陷:

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

对于早期没有模块化的 JS 来说,确实存在很多问题。

模块化的历史

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

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

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

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

所以,模块化已经是 JS 一个非常迫切的需求:

但是 JS 本身,直到 ES6(2015) 才推出了自己的模块化方案。 在此之前,为了让 JS 支持模块化,社区涌现出了很多不同的模块化规范:AMDCMDCommonJS 等。

接下来我们将详细讲解 JS 的模块化,尤其是 CommonJSES6 的模块化。

没有模块化带来的问题

早期没有模块化带来了很多的问题:比如多个文件中的命名冲突的问题:

文件本身是不存在作用域的,引入的文件,其实都是在一个全局作用域中的。

image-20220722140522222

当然,早期也有办法解决上面的问题:立即函数调用表达式IIFE,Immediately Invoked Function Expression)。

但是,我们其实带来了新的问题:

  1. 必须清晰记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用。
  2. 代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写。
  3. 在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况。
image-20220722140044982

所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。

我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码。
这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性。
JS 社区为了解决上面的问题,涌现出一系列好用的规范,接下来介绍具有代表性的一些规范。

二. CommonJS 和 Node

CJS 规范和 Node 关系

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

Node 是 CJS 在服务器端一个具有代表性的实现。
Browserify 库是 CJS 在浏览器中的一种实现,浏览器本身是没有实现 CJS 规范的,主要还是应用在服务器里面。
Webpack 打包工具具备对 CJS 的支持和转换。

image-20220722142216049

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

在 Node 中,每个 JS 文件都是一个独立的模块,每个模块都有独立的作用域
每个模块中包括 CJS 规范的核心变量:exportsmodule.exportsrequire
我们可以使用这些变量来方便的进行模块化开发。

前面我们提到过模块化的核心是导出和导入,Node 中对其进行了实现:

exports 和 module.exports 负责对模块中的内容进行导出。
require() 可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容。

exports 导出

exports 是一个空对象,添加的属性会被导出,我们可以往这个对象中添加多个属性:

js
exports.name = 'hehe'
exports.age = 18

我们来看两个文件:

js
console.log(name)
console.log(age)
sayHello('kobe')
js
const name = 'later'
const age = 18
function sayHello(name) {
  console.log('hello ', name)
}

a.js 必须导入 b.js
b.js 必须导出自己的内容

a.js 文件中可以导入 b.js:

js
// a.js
const b = require('./b')

上面这行完成了什么操作呢?理解下面这句话,Node 中的模块化一目了然:

导入的对象其内存地址指向的就是导出模块中的 exports 对象,本质就是引用赋值
也就意味着 a.js 中的 b 变量等于 b.js 中的 exports 对象。
也就是 a.js 中的 require 通过各种查找方式,最终找到了 b.js 中的 exports 对象,并将该 exports 对象赋值给了 b。

示例:

image-20220722164846941

module.exports 导出

但是 Node 中我们经常通过 module.exports 导出的, module.exports 和 exports 有什么区别呢?

Node 中每个模块中都有一个 module 对象,module 对象中有一个属性 exports。

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

CJS 规范中是没有 module.exports 的概念的

Node 中是基于 Module 的类实现的模块导出,每个模块都是 Module 类的一个实例,即 module。 所以在 Node 中真正用于导出的是 module.exports,而不是 exports

所以 module.exports 才是导出的真正实现者,示例:

但是,为什么 exports 也可以导出呢?

这是因为 exports 对象是 module.exports 的一个引用

module.exports、exports、导入模块中的对象,这三者都是指向同一个内存地址的对象

js
console.log(exports === module.exports) // true

所以如果使用 exports 导出对象时,只能通过添加属性的方式导出,如果修改内存引用,将无法被 require 查找到

js
const b = require('./b')
console.log(b) // {} 
// 为什么的是空对象?
// 因为 b.js 中修改了 exports 原本指向 module.exports 的内存地址,
// 而真正用于导出的是 module.exports,所以导出的是空对象。
js
exports = { 
  name: 'b'
}

内存表现

image-20220722182153104

require 方法实际导出的是 module.exports,而不是 exports

module.exports 和 exports 对象默认都指向同一个内存地址即:exports = module.exports)。

如果查看 require 的源码,可以得知:

通过显式绑定模块中的 this,该 this 也是等于 module.exports 和 exports 的。

而每个模块之所以有这些全局变量,如:__dirname、exports 等。 其内部是将 module 中的代码放入到一个函数中执行,而该函数是有指定 __dirname、exports 这些形参的。

三. require 函数解析

引言

require 函数用于导入模块、JSON 和本地文件。

js
const module = require(module_name)

// module_name: <string> 模块名称或路径 
// module:     <any>    导出的模块内容

支持从 node_modules 导入依赖模块,例如:const Koa = require('koa')

支持使用 相对路径导入本地模块和 JSON 文件(例如 ././foo./bar/baz../foo),该路径将根据 __dirname(如果有定义)命名的目录或当前工作目录进行解析。

__dirname:当前模块的目录名,与 __filename 的 path.dirname() 相同。

require 文件查找细节

我们现在已经知道,require 是一个函数,可以引入一个文件(模块)中导出的对象

那么,require 的查找规则是怎么样的呢?

  • 这里总结比较常见的查找规则
  • 导入格式如下:require(X)

情况一:X 是一个 Node 内置模块,比如 path、http

js
// 导入 node 提供的内置模块
const path = require("path")
const http = require("http")

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

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

js
// 根据路径导入自己编写模块
const utils = require("./utils")
  • 第一步:将 X 当做一个文件在对应的目录下查找。

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

    • 查找目录下面的 index 文件。
      1. 查找 X/index.js 文件
      2. 查找 X/index.json 文件
      3. 查找 X/index.node 文件

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

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

js
const axios = require("axios")

先在当前目录查找 node_modules,没有再依次往上查找 node_modules 文件,直至根目录,还没有就报错:not found。

有 node_modules 文件的话,就会去查找 node_modules 中是否存在 X 文件,存在的话,再去查找 x 文件下的 index.js 文件。

如果上面的路径中都没有找到,那么报错:not found。

模块加载过程及顺序

结论一:模块在被第一次引入时,模块中的 JS 代码会被运行一次

结论二:模块被多次引入时,会缓存,最终只加载(运行)一次

为什么只会加载运行一次呢?

这是因为每个模块对象 module 都有一个属性:loaded
module.loaded 默认为 false,表示还没有加载,为 true 表示已经加载,模块加载过一次就会设置为 true

结论三:如果有循环引入,那么加载顺序是什么?

image-20220722192646554

如果出现上图模块的引用关系,那么加载顺序是什么呢?

这其实是一种数据结构:图结构。 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和 广度优先搜索(BFS, breadth first searc)。
Node 采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee -> bbb。

image-20220722193121733

四. AMD 和 CMD

CJS 规范弊端

CJS 是 同步加载模块的:

同步则意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行,也就是说后面导入的模块将会受到阻塞

这在服务器不会有什么问题,因为服务器加载的 JS 文件都是本地文件,加载速度非常快。

那在浏览器中应用呢?

浏览器加载 JS 文件需要先从服务器将文件下载下来,之后再加载运行。
那么采用同步就意味着后续的 JS 代码都无法正常运行,即使是一些简单的 DOM 操作。

所以在浏览器中,通常不使用 CJS 规范:

当然在 Webpack 中使用 CJS 是另外一回事。
因为它会将我们的代码转成浏览器可以直接执行的代码,无需加载模块,直接运行。

所以在早期为了可以在浏览器中使用模块化,通常会采用 AMD 或 CMD:

但是目前一方面现代的浏览器已经支持 ESModules,另一方面借助于 Webpack 等工具可以实现对 CJS 或 ESM 代码的转换。 因此 AMD 和 CMD 已经很少使用了。

AMD 规范

AMD 主要是应用于浏览器的一种模块化规范,采用异步加载模块:

AMD 是 Asynchronous Module Definition(异步模块定义)的缩写。
事实上 AMD 规范早于 CJS 出现,但是 CJS 目前依然在被使用,而 AMD 使用的较少了。

我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:

AMD 实现的比较常用的库是 require.js 和 curl.js。

require.js 的使用

第一步:下载 require.js

下载地址,找到其中的 require.js 文件。

第二步:定义 HTML 的 script 标签引入 require.js 和定义入口文件:

html
<!-- data-main 属性的作用是在加载完 src 的文件后,会加载执行该文件。 -->
<script src="./lib/require.js" data-main="./index.js"></script>

示例:

CMD 规范

CMD 规范也是应用于浏览器的一种模块化规范:

CMD 是 Common Module Definition(通用模块定义)的缩写。
它也是采用异步加载模块,但是它将 CJS 的优点吸收了过来。
但目前使用也非常少了。

CMD 也有自己比较优秀的实现方案:SeaJS。

SeaJS 的使用

第一步:下载 SeaJS

下载地址,找到 dist 文件夹下的 sea.js。

第二步:引入 sea.js 和使用主入口文件:

seajs 是指定主入口文件的。

示例: image-20220722202616954

五. ESModule

认识 ES Module

JS 没有模块化一直是它的痛点,所以才会产生一些社区规范:CJS、AMD、CMD 等,所以在 ECMA 推出自己的模块化系统时,大家也是兴奋异常。

ES Module (ESM) 和 CJS 的模块化有一些不同之处:

一方面它使用了 import 和 export 关键字。
另一方面它采用编译期的静态分析,并且也加入了动态引用 import() 的方式

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

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

⚠️ 注意

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

案例代码结构组件

这里是在浏览器中演示 ES6 的模块化开发:

html
<!-- 
注意事项一: 
  在浏览器中直接使用 ES Module 时, 必须在文件后加上后缀名.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>

如果直接在浏览器中运行代码,会报如下错误:

image-20220722211222554

这个在 MDN 上面有给出解释

你需要注意本地测试时,如果你通过本地路径加载 html 文件 (比如一个 file:// 路径的文件), 你将会遇到 CORS 错误,因为 JS 模块安全性需要。
所以你需要通过启用一个服务来测试。

我们这里使用 VSCode 插件:Live Server 来解决这个问题。

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
}

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()

export 和 import 结合使用

特殊语法:export {...} from '...'

js
import { 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 结合使用。

default

前面的导出功能都是具名导出(named exports):

在导出 export 时指定了具体的变量名或函数名。
在导入 import 时指定了具体的变量名或函数名。

还有一种导出叫做默认导出(default export)

默认导出 export default 时,可以不指定变量名或函数名。
在导入时不需要使用 {},且可以指定任意名字。
它也方便我们和现有的 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

import() 函数

通过 import 加载一个模块,是不可以将其放到逻辑代码中的,比如:

js
let flag = true
if (flag) {
  // 不允许在逻辑代码中编写 import 导入声明语法, 只能写到 js 代码顶层
  import { name, age, sayHello } from "./foo.js" 
}

为什么会出现这个情况呢?

这是因为 ES Module 在被 JS 引擎解析时,就必须知道它的依赖关系。
由于这个时候 JS 代码没有任何的运行,所以无法在进行类似于 if 判断中根据代码的执行情况。
甚至拼接路径的写法也是错误的:因为我们必须到运行时,才能确定 path 的值。

js
import { 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)
  })
}

import meta

import.meta 是一个给 JS 模块暴露特定上下文的元数据属性的对象。

它包含了这个模块的信息,比如说这个模块的 URL。
在 ES11(ES2020) 中新增的特性。

js
console.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 运行原理

解析流程与执行顺序

ES Module 是如何被浏览器解析并且让模块之间可以相互引用的呢?

https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/

ES Module 的解析过程可以划分为三个阶段:

image-20220722224512083

阶段一:构建(解析语法、收集 import/export),根据地址查找并下载 JS 文件,将其解析成模块记录(Module Record)。

阶段二:实例化(建立绑定关系、分配内存),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址。

阶段三:运行(按依赖顺序执行),运行代码,计算值,并且将值填充到内存地址中。

阶段一:构建阶段

  • 根据模块地址下载并读取模块文件(通常为 .js)。
  • 解析模块语法。
  • 收集所有静态 import / export 语句。
  • 创建模块记录(Module Record),但此时并不执行任何模块代码

说明:静态 import 是编译阶段就能解析的语句,因此它们会参与模块依赖图的构建;动态 import() 属于 runtime 行为,不参与依赖图。

阶段二/三:实例化/运行阶段

实例化:

  • 基于依赖图,递归实例化所有模块。
  • 为模块的导出变量分配“存储槽”(environment record)。
  • 建立导入模块和导出模块之间的“实时绑定”(live binding)关系。

模块只会实例化一次,多个模块导入同一个模块时,指向的都是同一个模块实例(同一份内存空间)。

运行:

  • 按模块依赖图的深度优先顺序执行代码。
  • 执行结果(变量、函数)被填入实例化阶段创建的内存空间中。

先执行依赖的模块,再执行当前模块。

验证执行流程

示例一:基础的依赖执行顺序:

js
console.log('---- main start ----')
import { aDone } from './a.js'
console.log('in main, a.done = %j', aDone)
console.log('---- main end ----')
js
console.log('---- a start ----')
var done = false
export { done as aDone }
console.log('---- a end ----')

执行 main.js 的输出如下:

js
---- a start ----
---- a end ----
---- main start ----
in main, a.done = false
---- main end ----

由此可以推断出:

静态 import 会参与依赖图的构建,因此在当前模块执行之前,其依赖模块会按照依赖图的顺序优先执行。

也就是说:

  • 与“import 写在文件上方还是下方”无关。
  • 与“先加载后执行”这种直觉式说法无关。
  • 真正起决定作用的是 解析阶段建立的模块依赖图 与 按依赖顺序执行。

⚠️ 注意

import() 语法,通常称为动态导入,是一种类似函数的表达式,允许将 ECMAScript 模块异步动态地加载到潜在的非模块环境中。

动态导入(import())不参与依赖图构建,是运行到该语句时才会异步加载并执行模块。

示例二:循环引用(互相 import),测试 ESM 在循环依赖中的执行与变量状态。

js
console.log('---- main start ----')
import { aDone } from './a.js'
console.log('in main, a.done = %j', aDone)
console.log('---- main end ----')
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
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 ----')

执行 main.js 输出如下:

js
---- 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 模块中:

结论一:当前模块中导出的 var 定义的变量,因为作用域提升,允许在本模块代码执行之前访问(export default 导出除外),此时模块代码还未执行变量赋值,所以值为 undefined

结论二:当前模块中导出的函数声明,在实例化时已创建绑定,因此允许在本模块代码执行之前访问。

结论三:当前模块中导出的 let/const 定义的变量,没有作用域提升,不允许在当前模块代码执行之前访问,会抛出 TDZ 错误。

示例三:多次导入同一模块是否会重复执行该模块中的代码?

js
console.log('---- main start ----')
import {} from './a.js'
console.log('--------')
import {} from './b.js'
console.log('---- main end ----')
js
console.log('---- a start ----')
import {} from './b.js'
console.log('---- a end ----')
js
console.log('---- b start ----')
console.log('---- b end ----')

执行 main.js 输出如下:

js
---- b start ----
---- b end ----
---- a start ----
---- a end ----
---- main start ----
--------
---- main end ----

由此可以得知:

ESM 中多次导入同一个模块,只会执行第一次导入,后续的导入将不再执行被导入模块中的代码,而是将第一次导入时的执行结果(导出对象的内存地址)返回

CJS 模块测试也是多次导入只会执行第一次导入,后续导入直接返回第一次导入时的执行结果

如有转载或CV请标注本站原文地址