跳到主要内容

16. 模块化

1. 模块化

模块化开发最终的目的是将程序划分成多个小结构,实现业务逻辑、功能和数据的隔离。

  • 封装。在模块中编写自己的逻辑代码,有自己的作用域,不会影响到其他模块;
  • 导出。可以将自己希望暴露的变量、函数、对象等导出给其他模块使用。
  • 引入。可以导入其他模块暴露的变量、函数、对象等;

1.1 模块化的历史

  1. 不存在

在网页开发的早期,Brendan Eich 开发 JavaScript 仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,代码量少

  • 只需 JavaScript 代码写到 <script>标签中即可;
  • 没有必要放到多个文件中来编写;
  1. 产生需求

随着前端和 JavaScript 的快速发展,JavaScript 代码变得越来越复杂了。

  • ajax 的出现,前后端开发分离,意味着后端返回数据后,需要通过 JavaScript 进行前端页面的渲染;
  • SPA 的出现,前端页面变得更加复杂:包括前端路由、状态管理等,复杂的需求需要通过 JavaScript 来实现;
  • Node 的实现,JavaScript 可编写复杂的后端程序,没有模块化是致命的硬伤。
  1. 产生模块化
  • 最先有民间解决方案:AMD、CMD、CommonJS。
  • 后面有 es6 更新:Js Module。

1.2 问题:为什么要引入模块化?

  1. 上文模块化的定义:封装、暴露、引入。
  2. 多人开发的情况下,项目体积增大,会存在命名空间冲突。

解决命名空间冲突:IIFE 立即执行函数,把逻辑包裹起来,形成一个模块。

这带来了新的问题:

  • 开发者必须记得每一个模块中返回对象的命名(暴露),才能在其他模块使用过程中正确的使用(引入);
  • 代码写起来结构混乱,每个文件中的代码都需要包裹在一个匿名函数中来编写;
  • 需要制定统一的模块化约束规范,让所有开发者都遵守规范。

1.3 CommonJs

CommonJS (CJS) 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为 ServerJS,后来为了 体现它的广泛性,修改为CommonJS。

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

所以,Node 中对 CommonJS 进行了支持和实现,使 node 实现模块化开发:

  • Node 中每一个 js 文件都是一个单独的模块;
  • CommonJS 规范的核心变量:exports (导出)、module.exports、require (引入);
    • exports 和 module.exports:导出模块的内容。
    • require:导入模块(自定义模块、系统模块、第三方库模块)的内容。

2. CommonJs

2.1 exports 导出

  • 如果 bar.js 中导出的变量,在 main.js 导入。然后在 main.js 对其进行修改。那么原来的 bar.js 中对应变量也会被修改。

方式一:导出模块中部分变量

/* ----bar.js---- */
const name = "ninjee";
const age = 18;

function sum(a, b) {
return a + b;
}

// 导出的变量,绑定在 module.exports 导出的那个对象上
module.exports = {
myName: name,
myAge: age,
sum
}

方式二:通过对象的形式,整体导出:

/* ----bar.js---- */
const info {
name: "ninjee",
age: 18,
sum: function(a, b) {
return a + b;
}
}

// 效果和刚才是一样的
module.exports = info;

方式三:exports 添加(很少用)

  • 相当于 exports = module.exports
/* ----bar.js---- */
const name = "ninjee";
const age = 18;

function sum(a, b) {
return a + b;
}

//【1】效果和刚才是一样的
exports.name = name;
exports.sum = sum;

//【2】或者是:
exports = {
name,
sum
}

2.2 require 导入

/* ----bar.js---- */
const bar = require("./bar.js");
const bar = require("./bar"); // .js 可省略

console.log(bar.myName); // "ninjee"

2.2.1 require 查找规则

假设导入格式如下:require(X)

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

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

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

  • 第一步:将 X 当做 文件 查找:
    • 如果有后缀名,按照后缀名的格式查找对应的文件。
    • 如果没有后缀名,会按照如下顺序:
      1. 直接查找文件 X
      2. 查找 X.js 文件
      3. 查找 X.json 文件
      4. 查找 X.node文件
  • 第二步:没有找到对应的文件,将 X 作为 目录 查找。按照如下顺序:
    1. 查找目录下面的 index 文件
    2. 查找 X/index.js 文件
    3. 查找 X/index.json 文件
    4. 查找 X/index.node 文件
  • 如果没有找到,那么报错:not found

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

在导入第三方库的时,是这种路径方式。

  1. module.path 中依次查找:是 main.js 的绝对路径,指向 node_modules 文件夹。
  2. 如果上面的路径中都没有找到,那么报错:not found

举例1:

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

截屏2022-08-15 23.12.13

举例2:

比如 require("axios"),会找到 node_modules 中我们安装的 axios 包,然后找到 axios/index.js,然后在该文件中,导入了 ./lib/axios.js。所以最终导出的就是 lib 文件中的这个 axios 对象。

2.3 模块的加载过程

特点:

  • 引入时执行。模块在被第一次引入时,模块中的 js 代码会被 从头到尾运行一次

  • 只执行一次。模块被多次引入时,会缓存,最终只加载(运行)一次;

    • 每个模块对象 module 都有一个属性:loaded,记录是否被加载过。false 未加载,为 true 已加载;
  • 深度遍历优先。Node采用的是深度优先算法

问题:有循环引入,那么加载顺序是什么? 如果出现右图模块的引用关系,那么加载顺序是什么呢?

截屏2022-08-15 23.22.35

这是一种数据结构:图结构。

  • 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
  • Node采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee -> bbb

2.4 CommonJs 缺点

CommonJS 加载模块是同步的:

  • 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行;
  • 服务器 运行时,不会有什么问题,因为服务器加载的 js 文件都是本地文件,加载速度非常快;
  • webpack 中使用 CommonJS 也不受影响,它会将代码转译后打包,浏览器可以直接访问 js 代码。
  • 浏览器 运行时,浏览器加载 js 文件需要先从服务器将文件下载下来,之后再加载运行。
    • 采用同步的就意味着后续的 js 代码都被阻塞,即使是一些简单的 DOM 操作,也要先等待网络进程下载 js 文件。

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

  • 在早期,为了可以在浏览器中使用模块化,通常会采用 AMD 或 CMD。AMD和CMD已经使用非常少了,所以这里我们进行简单的演练。
  • 在现在,可以直接使用 ComminJs、或者 ES Module。原因有:
    • 现代浏览器已经支持ES Modules。
    • 借助于 webpack,可以实现对 CommonJS 或者 ES Module 代码的转换。

3. ES Module

3.1 ES Module 和 CommonJS 的区别

  • 关键字不同。ES 使用了 import 和 export 关键字:export 导出,import 导入。
  • 引入位置不同。ES 的 exportimport 均只能用在最顶层的作用域中。CommonJs 可以在任意位置引入,变成一个局部变量。
  • 原生支持。ES 采用编译期的静态分析,加入了动态引用的方式。CommonJs 只能动态引入。
  • 默认严格模式。使用 ES Module 将自动采用严格模式:use strict

3.2 export 导出

export 关键字放在声明的前面。

  • default export 导出的是这些变量 / 函数的地址(类似指针),而不是它们的值。所以,在一个函数 / 变量被导出后,这个函数 / 变量的结构或值发生了改变,外部也会得到对应的更新。

  • export 导出的是变量名称(标识符),也就是说当这个变量指向了新的函数,就会导出新的函数。

特点:

  1. 不支持双向绑定,也就是说,不支持对一个导入的模块进行修改,只能读取和使用。
  2. 在模块内没有全局作用域。在模块内是一个模块的作用域。
  3. 模块内没有用 export 标识的变量 / 函数都在莫块作用域内部保持私有,被标识的则会被导出。

1 命名导出

命名导出:导出变量 / 函数时,把标识符名称导出:

// 方式一:声明 + 导出
export function foo() {...}
export let arr = [1,2,3]
export let a = 42

// 方式二:导出、声明分开
function foo() {...}
let arr = [1, 2, 3]
let a = 42
export {foo, arr, a}

// 方式三:导出时取别称 (重命名)
function foo() {...}
export {foo as bar}

2 默认导出

默认导出,把一个特定导出,绑定设置为导入模块时的默认导出。绑定的名称就是 default

  • 默认导出时,可以不需要指定名字;导入 default 时,不需要大括号,并且可以自定义命名。

  • 每个模块定义只能有一个 default 默认导出。

  • 只有 export default 导出的是具体函数地址,其余 export 都是导出标识符。

// 情况一: 导出的是具体函数 - 函数地址值
function foo() {...}
export default foo

// 情况二: 导出的是具体函数 - 函数地址值
export default function foo() {...}


// 情况三: 导出的是foo标识符 - 表示符 foo
function foo() {...}
export { foo as default }

export default .. 接收一个表达式,导出的是这个表达式返回地址值。

情况一 和 情况二:默认导出的是 foo 绑定的那个表达式地址,而不是标识符 foo。这意味着如果后续代码把 foo 修改引用了其他函数 / 变量,默认导出的依然是最初的那个函数。

  • 情况一和情况二,是两种不同的表达方式,通常会使用更简洁的情况二。

情况三:默认导出的是标识符 foo,也就是说,后续如果把 foo 标识符引用别的函数 / 变量,导出的值也就跟着发生改变,

3 连续导出

连续导出,当从一个模块导入一些函数 / 变量后,可以选择再次将它们导出:

export {foo, bar} from "baz";
export {foo as f, bar as b} from "baz";
export * from "baz";

为什么要这样做呢?

  • 在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中;
  • 这样方便指定统一的接口规范,也方便阅读;
  • 这个时候,我们就可以使用 export 和 import 结合使用;

3.3 import 导入

导入一个模块 API 的某个特定成员到当前模块的顶层作用域中。

import {foo, bar, baz} from "foo";

// 支持重命名
import {foo as f} from "foo";

// 只有一个导入模块时,省略括号
import foo from "foo";

// 把foo.js 默认导出 和 命名导出 的成员,全部一起导入:
import defaultFoo, {bar, baz as b} from "foo";
// defaultFoo 就是默认导出的成员,此为对这个成员进行命名

命名空间导入 namespace import

import * as foo from "foo"

// 该方式必须用通配符,不可以像下面这样只导入一部分:
import {bar,baz} as foo from "foo"

这段代码的意思是:

  1. foo.js 文件中,导出的成员全部导入到当前模块中;
  2. 把这些成员全部绑定到 foo 对象名下。

如果全部导入中,有默认成员,则这个默认成员的名称就是 default。比如上例中,该模块中导入的默认成员名称为 foo.default

所有导入的成员是只读的,不可修改,否则会报错:TypeError!

注意:原生 js 导入需要引入模块化的 js 文件时,要添加 module 类型:

<script src="./main.js" type="module"></script>

3.4 异步 import

使用 import() 可以动态、异步的加载模块

import {name, age} from "./foo.js"

console.log('name');
console.log('后续的代码会被阻塞');

默认 import 导入时,会阻塞当前文件中 js 代码的执行。此时执行顺序是这样的:

  • 加载 foo.js 文件,执行 foo.js 代码,当前文件导入 name、age 变量。执行当前文件中的代码。

而如果我们不想让 import 导入阻塞当前文件中代码的执行,可以通过 import 函数动态导入。

  • 或者有需求:如果根据不同的条件,动态来选择加载模块的路径
// 返回一个promise
import("./foo.js").then(res => {
console.log("res", res.name);
})

console.log('后续的代码不会阻塞');


// 可以用if按需加载
if (flag) {
import("./aaa.js").then(res => {
console.log("res", res.aaa);
})
} else {
import("./bbb.js").then(res => {
console.log("res", res.bbb);
})
}

3.5 import meta

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

  • 包含当前模块的信息,比如说这个模块的 URL 下载地址。

3.6 ES Module 解析过程

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

三个阶段:

  1. 构建(Construction)。静态分析:根据地址查找 js 文件,并且下载。每个 js 文件都解析为一个模块记录(Module Record);
  2. 实例化(Instantiation)。对模块记录进行实例化对象,分配内存空间, 仅解析模块的导入和导出语句,把模块指向对应的内存地址。
  3. 运行(Evaluation)。运行代码,计算值,并且将值填充到内存地址中;

截屏2022-08-16 15.37.48

第一阶段

  • 静态分析阶段:在 Module Record 中,有一个 RequestedModules,查找依赖文件,然后场景请求下载这些文件。
  • Module Map:通过一张表,记录了项目中哪些文件已经被下载,防止重复下载。

截屏2022-08-16 15.40.01

第二、第三阶段:

  • 实例化:

    • 导出:会把 Bindings 统计的导出变量,集中创建在一个内存空间中。此时变量初始化 undefined

    • 导入:会把 Bindings 统计的导入变量,指向内存空间中对应的导出变量。

  • 运行:

    • 执行 js 代码时,会把导出变量的具体值,赋值在内存空间中。这样导入变量就可正确的获取值。

截屏2022-08-16 15.40.11