16. 模块化
1. 模块化
模块化开发最终的目的是将程序划分成多个小结构,实现业务逻辑、功能和数据的隔离。
- 封装。在模块中编写自己的逻辑代码,有自己的作用域,不会影响到其他模块;
- 导出。可以将自己希望暴露的变量、函数、对象等导出给其他模块使用。
- 引入。可以导入其他模块暴露的变量、函数、对象等;
1.1 模块化的历史
- 不存在
在网页开发的早期,Brendan Eich 开发 JavaScript 仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,代码量少
- 只需 JavaScript 代码写到
<script>
标签中即可; - 没有必要放到多个文件中来编写;
- 产生需求
随着前端和 JavaScript 的快速发展,JavaScript 代码变得越来越复杂了。
- ajax 的出现,前后端开发分离,意味着后端返回数据后,需要通过 JavaScript 进行前端页面的渲染;
- SPA 的出现,前端页面变得更加复杂:包括前端路由、状态管理等,复杂的需求需要通过 JavaScript 来实现;
- Node 的实现,JavaScript 可编写复杂的后端程序,没有模块化是致命的硬伤。
- 产生模块化
- 最先有民间解决方案:AMD、CMD、CommonJS。
- 后面有 es6 更新:Js Module。
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 当做 文件 查找:
- 如果有后缀名,按照后缀名的格式查找对应的文件。
- 如果没有后缀名,会按照如下顺序:
- 直接查找文件 X
- 查找
X.js
文件 - 查找
X.json
文件 - 查找
X.node
文件
- 第二步:没有找到对应的文件,将 X 作为 目录 查找。按照如下顺序:
- 查找目录下面的 index 文件
- 查找
X/index.js
文件 - 查找
X/index.json
文件 - 查找
X/index.node
文件
- 如果没有找到,那么报错:not found
情况三::直接是一个X(没有路径),并且 X 不是一个核心模块
在导入第三方库的时,是这种路径方式。
- 在
module.path
中依次查找:是main.js
的绝对路径,指向node_modules
文件夹。 - 如果上面的路径中都没有找到,那么报错:not found
举例1:
/Users/coderwhy/Desktop/Node/TestCode/04_learn_node/05_javascript-module/02_commonjs/main.js
中,编写 require('why’)
。
举例2:
比如 require("axios")
,会找到 node_modules 中我们安装的 axios 包,然后找到 axios/index.js
,然后在该文件中,导入了 ./lib/axios.js
。所以最终导出的就是 lib
文件中的这个 axios
对象。
2.3 模块的加载过程
特点:
引入时执行。模块在被第一次引入时,模块中的 js 代码会被 从头到尾运行一次;
只执行一次。模块被多次引入时,会缓存,最终只加载(运行)一次;
- 每个模块对象 module 都有一个属性:loaded,记录是否被加载过。false 未加载,为 true 已加载;
深度遍历优先。Node采用的是深度优先算法
问题:有循环引入,那么加载顺序是什么? 如果出现右图模块的引用关系,那么加载顺序是什么呢?
这是一种数据结构:图结构。
- 图结构在遍历的过程中,有深度优先搜索(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 的
export
和import
均只能用在最顶层的作用域中。CommonJs 可以在任意位置引入,变成一个局部变量。 - 原生支持。ES 采用编译期的静态分析,加入了动态引用的方式。CommonJs 只能动态引入。
- 默认严格模式。使用 ES Module 将自动采用严格模式:
use strict
。
3.2 export 导出
export
关键字放在声明的前面。
default export
导出的是这些变量 / 函数的地址(类似指针),而不是它们的值。所以,在一个函数 / 变量被导出后,这个函数 / 变量的结构或值发生了改变,外部也会得到对应的更新。export
导出的是变量名称(标识符),也就是说当这个变量指向了新的函数,就会导出新的函数。
特点:
- 不支持双向绑定,也就是说,不支持对一个导入的模块进行修改,只能读取和使用。
- 在模块内没有全局作用域。在模块内是一个模块的作用域。
- 模块内没有用
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"
这段代码的意思是:
- 把
foo.js
文件中,导出的成员全部导入到当前模块中; - 把这些成员全部绑定到
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 是如何被浏览器解析并且让模块之间可以相互引用的呢?
三个阶段:
- 构建(Construction)。静态分析:根据地址查找 js 文件,并且下载。每个 js 文件都解析为一个模块记录(Module Record);
- 实例化(Instantiation)。对模块记录进行实例化对象,分配内存空间, 仅解析模块的导入和导出语句,把模块指向对应的内存地址。
- 运行(Evaluation)。运行代码,计算值,并且将值填充到内存地址中;
第一阶段:
- 静态分析阶段:在 Module Record 中,有一个 RequestedModules,查找依赖文件,然后场景请求下载这些文件。
- Module Map:通过一张表,记录了项目中哪些文件已经被下载,防止重复下载。
第二、第三阶段:
实例化:
导出:会把 Bindings 统计的导出变量,集中创建在一个内存空间中。此时变量初始化
undefined
。导入:会把 Bindings 统计的导入变量,指向内存空间中对应的导出变量。
运行:
- 执行 js 代码时,会把导出变量的具体值,赋值在内存空间中。这样导入变量就可正确的获取值。