JavaScript 高级见解:四种模块化规范
介绍
本文是 JavaScript 高级深入浅出的第 19
篇,本文将会介绍 JS 中的模块化
正文
1. 什么是模块化
所以到底是什么模块化、以及什么是模块化开发呢?
- 模块化开发的目的就是将代码分割为多个小单元
- 每个单元编写自己的逻辑代码,有自己的作用域,不会影响其他的代码
- 每个单元可以导入或暴露变量、函数、对象等,用于调用其他模块/供其他模块调用
每一个单元都可以视为一个模块,按照这种结构划分开发程序的过程,就是模块化开发的过程
1.1 模块化的历史
在 Brendan Eich 开发最初版 JS 的时候,仅仅是将这门语言作为一种脚本语言开发,做一些简单的表单验证或者动画,所以在很早期的时候,直接将 JS 代码通过 script
内嵌到 HTML 中,甚至流行在 script 中只写一行代码。
因此在早期 JS 就没有模块化的概念,但是随着前端和 JS 的高速发展,代码越来越复杂:
- ajax 的出现,前后端开发分离,意味着后端返回数据,我们需要通过 JS 来渲染前端页面
- SPA 的出现,前端页面越来越复杂:包括前端路由、状态管理等等一系列的复杂功能需要通过 JS 来处理
- 包括 Node 的出现,JS 也能写复杂的后端程序,没有模块化是硬伤
所以模块化是 JS 的一个迫切的需求:
- 但是 JS 本身,在 ES6 才推出自己的模块化方案
- 在此之前,为了支持模块化,出现很多不同的模块化规范:
AMD
、CMD
、CommonJS
等
1.2 没有模块化的弊端
命名冲突
// index.js Alex 开发
var aa = '123'
setTimeout(() => {
console.log(aa) // 发现打印的不是 123 而是 456
}, 0)
// demo.js John 开发
var aa = '456'
解决方案:IIFE(立即调用函数表达式)再 IIFE 中可以创建一块作用域,CDN 引用 Vue 的 production 文件也是这样做的
(function () {
// 这里会创建一块函数作用域
// 不会污染到全局环境
})()
一个模块想要调用另一个模块可以这样:
var moduleA = (function () {
var a = 123
})()
var moduleB = (function () {
moduleA.a
})()
当然这也是有弊端的,我们需要时刻记住每一个模块的名字,模块的名字不能改动,模块加载的顺序不能变等等
2. CommonJS 规范
CommonJS 是一种模块化规范,最初提出来是在浏览器以外的地方使用,并且当时命名为 ServerJS
,后来为了体现它的广泛性,更名为 CommonJS
,也可以简称为 CJS
- Node 是 CommonJS 在服务端一个具有代表性的实现
- Browserify 是 CommonJS 在浏览器端的一种实现
- webpack 具备对 CommonJS 的支持与转换
所以 Node 种对于 CommonJS 进行了支持和实现,让我们在开发 Node 应用时可以使用模块化:
- 在 Node 种每一个文件都是一个模块
- 这个模块中包括了 CommonJS 规范的核心变量:
exports
、module.exports
、require
- 我们可以使用这些变量来进行模块化开发
模块化的核心就是导入和导出,Node 中也对其进行了实现:
-
module.exports
和exports
负责模块的导出 -
require
负责模块的导入
// index.js
const fooModule = require('./foo')
console.log(fooModule.foo) // foo
// foo.js
exports.foo = 'foo'
2.1 CommonJS 的原理
// util.js
const info = {
name: 'alex'
}
module.exports = info
// main.js
const moduleInfo = require('./util.js')
- 此时 util.js 中的
info
、module.exports
- 和 main.js 中的
moduleInfo
其实都指向了一个内存地址,所以这三者指向到的是同一块内容 - 所以就实现了导入导出,导入导出的其实是引用
2.2 module.exports 和 exports
在 CommonJS 中存在两种导出模块的方式:
module.exports = {}
export.name = "name"
在具体引擎实现的源码中:
module.exports = {}
exports = module.exports
所以使用 exports.xx = 'xxx'
其实就是往 module.exports = {}
这个对象中添加属性
一些问题
// 大家要知道一点,只有 module.exports 才会真正暴露变量
// 但是这里强行逆转了 exports 的引用
// 而上文中我们已经知道原本 exports 的引用指向的是 module.exports 的引用
// 所以使用这种方式是无法暴露变量的
exports = {
name: '123'
}
// 这种方式 name 也无法被暴露
// 这是因为 exports.name 后
// JS 引擎又强行修改了 exports 的引用
// 此时 name 在原 exports 指向的对象中,肯定是无法暴露的
exports.name = "123"
module.exports = {}
那么为什么有了 module.exports
后还需要一个 exports
呢?其实根据 CommonJS 的规范来说是要通过 exports
来导出的,但是由于 Node 本身实现是通过 module.exports
来实现的,所以在 Node 开发中经常会使用 module.exports
反而不经常使用 exports
了
2.3 require 细节
require
是一个函数,可以帮助我们引入一个模块导出的对象:
require 查找细节
以下是常见的查找规则(由 coderwhy 老师所总结)导入格式: require(X)
- X 是 Node 的核心模块,比如
http
、path
等- 直接返回核心模块,并停止查找
- X 是以
./
、../
、/
开头的- 将 X 作为一个文件在对应的目录下查找:
- 如果有后缀名,就按照后缀名的格式去查找
- 如果没有后缀名,会按照下面的顺序去查找
- 直接查找文件本身
- 查找
X.js
- 查找
X.json
- 查找
X.node
- X 并不是路径也不是核心模块
- 会根据
module.path
一级一级的查找node_modules
中是否包含 X - 找不到会抛出异常 not found
- 会根据
2.4 模块的加载过程
结论一:模块在被第一次引入时,模块中的 js 代码会被执行一次
// foo.js
console.log('foo module execute')
// main.js
require('./foo') // 会运行一遍 foo 模块中的代码
结论二:模块在被多次加载后,代码也会只运行一次:
- 这是因为每个模块对象有一个
loaded
属性 - 加载了 loaded 就变为 true 了
require('./foo') // 也只会运行一次 foo 模块
require('./foo')
require('./foo')
结论三:如果有循环引用,那么加载顺序是什么?
比如出现下图的引用关系,那么加载的顺序是什么呢?
这个其实是一种数据结构:图结构
图结构在遍历的过程中,有深度优先搜索(DFS,depth first search)和广度优先搜索(BFS,breadth first search)
Node 采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee -> bbb
2.5 CommonJS 规范的缺点
CommonJS 模块的加载是同步的:
- 同步意味着需要等模块中加载完毕后,后面的逻辑才会执行
- 这个在服务器不会有什么问题,因为服务器加载的是本地 JS 文件,速度会很快
那么如果将 CommonJS 规范用于客户端(浏览器)呢?
- 浏览器加载 js 文件需要先从服务端下载下来,之后再加载运行
- 那么采用同步就意味着后续的 js 代码都无法正常运行,即使是一些简单的 DOM 操作
所以如果面向的是客户端,我们将不再采用 CommonJS 规范,在早期浏览器中如果使用模块化开发,通常会采用 AMD 和 CMD 规范。
- 但是由于目前 ES 已经支持模块化 ESM(ES Module),另一方面借助于 webpack 等打包工具也可以将 CommonJS 和 ESM 模块的转换
- 所以 AMD 和 CMD 用的已经非常少了,下面我们就简单的了解一下
3. AMD 规范
AMD 主要是应用于浏览器的一种模块化规范:
-
AMD 是
Asynchronous Module Definition
(异步模块定义)的缩写 -
它采用的是异步加载模块
-
事实上 AMD 的规范早于 CommonJS,但是现在 CommonJS 仍被使用,但 AMD 已经很少用了
-
实现 AMD 规范的库主要是
require.js
和curl.js
3.1 requirejs 的使用
-
下载 requirejs
-
在 html 中引入 requirejs
- 如果想要在加载完 requirejs 后再加载入口文件,需要这样,这样就会在加载 require 后再加载 index
<script src='./lib/require.js' data-main="./index.js"></script>
引入模块
// /index.js
// 先配置每个模块的路径
require.config({
paths: {
main: './src/main',
foo: './src/foo',
},
})
// 这里再引入模块
require(['foo'], function (foo) {
// 加载完成 foo 后触发回调 并传入加载的数据
console.log(foo.name)
console.log(foo.age)
console.log(foo.sum(1, 2))
})
导出模块
// /src/foo.js
// 在 define 中写逻辑
define(function () {
const name = 'foo'
const age = 18
function sum(num1, num2) {
return num1 + num2
}
// 这里的 return 就导出了
return {
name,
age,
sum,
}
})
4. CMD 规范
CMD 也是应用于浏览器的一种模块化规范:
-
CMD 是 Common Module Definition(通用模块定义)的缩写
-
他也是采用了异步加载模块,但是它将 CommonJS 的优点吸收了过来
-
这个目前也很少使用了
-
SeaJS 实现了 CMD 规范
4.1 seajs 的使用
在使用时需要这样:
<script src="./lib/sea.js"></script>
<script>
// 入口文件
seajs.use('./index.js')
</script>
导入模块
// /index.js
// define 函数接收一个回调函数,参数 require、exports、module
define(function (require, exports, module) {
// 通过 require 导入模块
const foo = require('./src/foo.js')
console.log(foo)
})
导出模块
// /src/foo.js
define(function (require, exports, module) {
const name = 'foo'
const age = 18
function sum(num1, num2) {
return num1 + num2
}
// 可以通过 exports.name = name
// 和 module.exports 两种
module.exports = {
name,
age,
sum,
}
})
所以可以看到 CMD 和 CommonJS 可以说是非常像的
5. ES Module 规范
ES Module 规范是 ES 提出的,所以是官方的模块化规范
ESM 和 CommonJS 的模块化有一些不同:
- ESM 使用了
import
(导入)和export
(导出) - ESM 采用编译期的静态分析,并且也加入了动态引用的方式
- 采用 ESM 将自动采用严格模式
use strict
如果在 html
中引入 ESM 模块,需要这样:加上 type="module"
<script src="./index.js" type="module"></script>
// 可以使用这种方式来导出
export const name = 'foo'
// 这种方式来导入
import { name } from './foo.js'
值得注意的是,如果在本地实验这种方式的话,直接通过浏览器访问 index.html
文件是会报错的,MDN 有具体解释
5.1 导出的几种方式
第一种
export const name = "123"
export function foo() {}
export class Person {}
第二种
// 这是一个固定的语法,可不是一个对象
export {
name,
foo
}
// 也可以给导入的成员起别名
export {
name as fName,
foo as fFoo
}
第三种
从另一个模块中导出
export { add } from './foo'
export * from './bar'
5.2 导入的几种方式
第一种
import { name, age } from './foo'
// 也可以给导入的成员起别名
import { fName as name, fAge as age } from './foo'
第二种
直接拿到所有的导出内容,放入到一个标识符中
import * as foo from './foo'
5.3 default 的用法
default 用于默认导入和默认导出
// 如果想要让 foo 作为默认导出
const foo = "foo value"
// 可以这样
export {
// 起别名就叫做 default
foo as default
}
// 也可以这样
export default foo
默认导出后
import foo from './foo'
// 这样导入拿到的就是默认导出
注意:
- 默认导出只能有一个
- 在导入时不需要 {},并可以自己指定名字
- 它也方便和 CommonJS 等规范相互操作
5.4 import 函数 - ES11 及以上
我们在导入的时候
import { name } from './foo'
// 在导入 name 之前,后续的代码是会阻塞的
如果想要异步的导入一个模块,可以使用 import()
函数
// 返回 Promise
import("./foo").then(module => {})
// 后面的代码不受到影响
import 有一个 meta 属性,包含了一个 url
属性,是当前的 js 文件的 url
console.log(import.meta.url)
5.5 ESM 的实现原理
ES Module 是如何被浏览器所解析并且让模块之间相互引用的呢?
- 文档地址
ESM 的解析过程可以划分为三个阶段:
- 阶段一:构建(Construction),根据地址查找 JS 文件,并且下载,将其解析为模块记录(Module Record)
- 阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址
- 阶段三:运行(Evaluation),运行代码,计算值,并将值填充到内存地址中
具体每个阶段的工作原理,请看第 20 篇。
总结
在本文中,我们介绍了什么是模块化,以及介绍了社区的三种模块化规范以及 ES 官方的模块化规范。