Skip to content

JavaScript 的模块化

这种全局引入的方式会导致两个问题,变量污染和依赖混乱。

  • 变量污染:所有脚本都在全局上下文中绑定变量,如果出现重名时,后面的变量就会覆盖前面的;
  • 依赖混乱:当多个脚本有相互依赖时,彼此之间的关系不明朗;

早期 JavaScript 官方迟迟没有给出解法,所以社区实现了很多不同的模块化规范,按照出现的时间前后有 CommonJS、AMD、CMD、UMD。最后才是 JavaScript 官方在 ES6 提出的 ES Module。

重点了解 CommonJS 和 ES Module。

CommonJS

主要被应用于 Node 服务端。

js
// index.js 导入
const a = require('./a.js')
console.log('运行入口模块')

// a.js 导出
exports.a = 'a模块'
console.log('运行a模块')
  • exports 记录当前模块导出的变量
  • module 记录当前模块的详细信息
  • require 进行模块的导入

循环引入

js
//index.js
var a = require('./a')
console.log('入口模块引用a模块:', a)

// a.js
exports.a = '原始值-a模块内变量'
var b = require('./b')
console.log('a模块引用b模块:', b)
exports.a = '修改值-a模块内变量'

// b.js
exports.b = '原始值-b模块内变量'
var a = require('./a')
console.log('b模块引用a模块', a)
exports.b = '修改值-b模块内变量'

// 输出
// b模块引用a模块 { a: '原始值-a模块内变量' }
// a模块引用b模块:{ b: '修改值-b模块内变量' }
// 入口模块引用a模块:{ a: '修改值-a模块内变量' }

这种 AB 模块间的互相引用,本应是个死循环,但是实际并没有,因为 CommonJS 做了特殊处理——模块缓存。require 变量上会有 cache 属性,用于缓存已加载过的模块。每一个模块都先加入缓存再执行,每次遇到 require 都先检查缓存,这样就不会出现死循环。

多次引入

js
//index.js
var a = require('./a')
var b = require('./b')

// a.js
module.exports.a = '原始值-a模块内变量'
console.log('a模块执行')
var c = require('./c')

// b.js
module.exports.b = '原始值-b模块内变量'
console.log('b模块执行')
var c = require('./c')

// c.js
module.exports.c = '原始值-c模块内变量'
console.log('c模块执行')

// 输出
// a模块执行
// c模块执行
// b模块执行

同样由于缓存,一个模块不会被多次执行。

ES Module

尽管名为 CommonJS,但并不 Comomn(通用),它的影响范围还是仅仅在于服务端。前端开发更常用的是 ES Module。

ES Module 使用 import 命令来做导入,使用 export 来做导出。import 和 export 语句都是只能放在代码的顶层。值得一提的是,import 语句有提升的效果。

js
console.log('-----')
import module from 'module'

// 最终解析为
import module from 'module'
console.log('-----')
  1. 普通导入、导出
js
// index.js
import { propA, propB, propC, propD } from './a.js'

// a.js
const propA = 'a'
let propB = () => console.log('b')
var propC = 'c'

export { propA, propB, propC }
export const propD = 'd'
  1. 默认导入、导出
js
// 默认导入
import anyName from './a.js'

// 默认导出
export default function () {}
export default {}
export default 1
  1. 重命名导入
js
import * as resName from './a.js'
import { propA as renameA, propB as renameB } from './a.js'
  1. 重定向导出
js
export * from './a.js'
export { propA, propB, propC } from './a.js'
export { propA as renameA, propB as renameB } from './a.js'

循环引入

js
// index.js
import * as a from './a.js'
console.log('入口模块引用a模块:', a)

// a.js
import * as b from './b.js'
let a = '原始值-a模块内变量'
export { a }
console.log('a模块引用b模块:', b)
a = '修改值-a模块内变量'

// b.js
import * as a from './a.js'
let b = '原始值-b模块内变量'
export { b }
console.log('b模块引用a模块:', a)
b = '修改值-b模块内变量'

在代码执行前,首先要进行预处理,这一步会根据 import 和 export 来构建模块地图(Module Map),它类似于一颗树,树中的每一个“节点”就是一个模块记录,这个记录上会标注导出变量的内存地址,将导入的变量和导出的变量连接,即把他们指向同一块内存地址。ES Module 来处理循环使用一张模块间的依赖地图来解决死循环问题,标记进入过的模块为“获取中”,所以循环引用时不会再次进入。

总结

CommonJS 和 ES Module 都对循环引入做了处理,不会进入死循环,但方式不同:

  • CommonJS 借助模块缓存,遇到 require 函数会先检查是否有缓存,已经有的则不会进入执行,在模块缓存中还记录着导出的变量的拷贝值;
  • ES Module 借助模块地图,已经进入过的模块标注为获取中,遇到 import 语句会去检查这个地图,已经标注为获取中的则不会进入,地图中的每一个节点是一个模块记录,上面有导出变量的内存地址,导入时会做一个连接(即指向同一块内存)。

其它

  • CommonJS 的 export 和 module.export 指向同一块内存,但由于最后导出的是 module.export,所以不能直接给 export 赋值,会导致指向丢失。
  • 查找模块时,核心模块和文件模块的查找都比较简单,对于 react/vue 这种第三方模块,会从当前目录下的 node_module 文件下开始,递归往上查找,找到该包后,根据 package.json 的 main 字段找到入口文件。