模块化
为什么需要模块化
传统开发常见问题
- 命名冲突和污染
- 代码冗余,无效请求多
- 文件间的依赖关系复杂
项目难以维护不方便复用。
模块与模块化
模块就是小而精且利于维护的代码片段。
模块化开发就是对这些代码片段进行组合使用,从而完成业务逻辑。
模块化历程
早期利用函数、对象、自执行函数实现分块。
模块化规范:
- CommonJS 规范
- 规定每个 js 文件都是一个模块,且每个模块都有自己的作用域
- 模块内部可以使用变量、函数、类等,都是内部私有,外部无法访问
- 提供
module.exports
、exports
方式导出变量,使用require
进行加载 - Node.js 就是使用的 Commonjs 规范
- Commonjs 规范是一个超集,是语言层面上的规范,类似于 ECMAScript 规范
- 模块化规范只是其中一种,它还定义了 IO 流、二进制操作,或者 Buffer 规范等
- Commonjs 规范模块加载都是同步完成的,并不适用于浏览器,因此后面又出现 AMD 异步加载规范
- AMD
- 提供
define
、require
关键字 - 经典实现就是 RequireJS
- 提供
- CMD 规范
- 整合 CommonJS 和 AMD 特点,专门用来实现浏览器异步加载
- 最经典的就是 CJS
- ES Moudle 规范
- 15 年 TC39 发布,即 ESM
- 正式将模块化纳入到规范中,支持导入导出
模块化规范是前端走向工程化的一环。早期 JavaScript 语言层面并没有模块化规范。
最早都是由程序员自己利用函数、对象、自执行函数实现代码分块管理。
然后经过社区或者个人推动,产出 Commonjs,AMD,CMD,UMD 等模块化规范。分别有自己的特点和优势。
最后在 ES6 中将模块化纳入标准规范。当前常用规范就是 CommonJS 和 ES Module。
CommonJS 规范
not just for browsers any more!
Commonjs 规范主要应用于 Nodejs。它是语言层面上的规范,类似于 ECMAScript,模块化只是其中一部分。
模块化组成部分
- 模块引用
- 模块定义
- 模块标识
Node.js 与 CommnJS
- 任意一个文件都是模块,具有独立作用域
- 使用 require 导入其他模块
- 将模块 ID 传入 require 实现目标模块定位
module 属性
- 任意一个 js 文件都是一个模块,可以直接使用 module 属性
- id:返回模块标识符,一般是一个绝对路径
- filename:返回文件模块的绝对路径
- loaded:返回布尔值,表示模块是否完成加载
- parent:返回当前存放调用当前模块的模块
- children:返回数组,存放当前模块调用的其他模块
- exports:返回当前模块需要暴露的内容
- paths:返回数组,存放不同目录下的
node_modules
位置- 分析 node.js 加载流程时可以用到
module.exports 与 exports
module.exports
和 exports
指向同一内存地址。
需要注意的是我们不能给 exports
重新赋值,这样会使引用丢失。
require 属性
- 基本功能是读入并且执行一个模块文件
- resolve:返回模块文件绝对路径
- extensions:依据不同后缀名执行解析操作
- main:返回主模块对象
总结
- CommonJS 规范起初是为了弥补 JS 语言模块化缺陷
- CommonJS 规范是语言层面的规范,主要应用于 Node.js
- CommonJS 规定模块化分为引入、定义、标识符三个部分
- Module 在任何模块中可以被直接使用,包含很多模块信息
- Require 接收标识符,用于加载目标模块
- Exports 与 module.exports 都可以导出模块数据,指向同一引用
- CommonJS 规范定义模块加载是同步行为,正因为这个特点,所以并不适用于浏览器环境
Node.js 与 CommonJS
- 使用 module.exports 与 require 实现模块导入与导出
- module 属性及其常见信息获取
- exports 导出数据及其与 module.exports 区别
- CommonJS 规范下的模块同步加载
// index.js
const obj = require('./m')
console.log(obj)
console.log('index.js process')
console.log(require.main === module) // true
js
// m.js
const age = 24
const addFn = (x, y) => x + y
// module.exports = {
// age,
// addFn
// }
// 2. module
console.log(module)
// Module {
// id: '/usr/local/workspace/notes/node/core_module/_module/m.js',
// path: '/usr/local/workspace/notes/node/core_module/_module',
// exports: { age: 24, addFn: [Function: addFn] },
// filename: '/usr/local/workspace/notes/node/core_module/_module/m.js',
// loaded: false,
// children: [],
// paths: [
// '/usr/local/workspace/notes/node/core_module/_module/node_modules',
// '/usr/local/workspace/notes/node/core_module/node_modules',
// '/usr/local/workspace/notes/node/node_modules',
// '/usr/local/workspace/notes/node_modules',
// '/usr/local/workspace/node_modules',
// '/usr/local/node_modules',
// '/usr/node_modules',
// '/node_modules'
// ]
// }
// 3. exports
exports.age = age
exports.addFn = addFn
// 注意,不能能给 epxorts 直接赋值
// 这样赋值会导致 exports 和 module.exports 引用关系丢失
// exports = {
// age: 13,
// name: 'heora'
// }
// 4. 同步加载
const name = 'heora'
const time = new Date()
while (new Date() - time < 4000) {}
exports.name = name
console.log('m.js process')
// 5. 判断是否为主模块
console.log(require.main === module) // false
js
模块分类及加载流程
模块分类
- 内置模块
- 文件模块
加载速度
- 核心模块:Node 源码编译时写入到二进制文件中
- 文件模块:代码运行时,动态加载
加载流程
概述
- 路径分析:依据标识符确定模块位置
- 标识符
- 路径标识符
- 非路径标识符:常见于核心模块,例如 fs、path
- 标识符
- 文件定位:确定目标模块中具体文件及文件类型
- 项目下存在
m.js
模块,导入时使用require("m")
语法 - 查找顺序:
m.js
=>m.json
=>m.node
- 如果没有找到上述文件,会将其作为一个包处理,查找 package.json 文件,使用 JSON.parse() 解析
- 查询 main 属性值,如果没有后缀,继续查找
main.js
=>main.json
=>main.node
- 如果 main 属性值指定的文件在补足之后也不存在,node 会将 index 作为目标模块中的具体文件名称
- 首先在当前目录查找,如果没有找到,会向上级查找,如果没有找到,会抛出异常
- 项目下存在
- 编译执行:采用对应的方式完成文件的编译执行
- 将某个具体类型的文件按照相应的方式进行编译和执行
- 创建新对象,按路径载入,完成编译执行
编译执行
JS 文件编译执行
- 使用 fs 模块同步读入目标文件内容
- 对内容进行语法包装,生成可执行 JS 函数
- 调用函数时传入
exports
、module
、require
等属性值
JSON 文件编译执行
- 将读取到的内容通过 JSON.parse() 进行解析
- 将解析结果返回给
exports
对象即可
缓存优化原则
- 提高模块加载速度
- 优先查找缓存,当前模块不存在,需要经历一次完整加载流程
- 模块加载完成后,使用路径作为索引进行缓存
总结
- 路径分析:确定目标模块位置
- 文件定位:确定目标模块中的具体文件
- 编译执行:对模块内容进行编译,返回可用
exports
对象
VM 模块使用
VM 模块是内置核心模块,在 NodeJS 中,底层 require 实现也用到了这个模块。
它可以创建独立运行的沙箱环境,我们可以通过 VM 模块加载其他模块并执行。
const fs = require('fs')
const vm = require('vm')
const content = fs.readFileSync('test.txt', 'utf-8')
js
// 1. evea
eval(content)
console.log(age)
// eval 可以执行字符串形式代码,但是如果当前文件中还存在另一个 age 变量,就会报错
js
// 2. new Function
const fn = new Function('age', 'return age + 1')
console.log(fn(age))
// 使用 new Function 也可以执行字符串形式的代码,但是操作比较繁琐
js
// 3. vm
vm.runInThisContext(content)
console.log(age)
// 当我们使用 runInThisContext 方式运行代码时,函数内部环境和外部是隔离的
// 不能使用局部变量(const、let),可以使用全局变量
// 如果当前文件中存在 age 变量,不会产生冲突
js
模块加载模拟实现
以文件模块加载流程为例,梳理 NodeJS 模块加载流程 。
核心逻辑
- 路径分析
- 缓存优化
- 文件定位
- 编译执行
代码实现
const fs = require('fs')
const path = require('path')
const vm = require('vm')
function Module(id) {
this.id = id
this.exports = {}
}
Module._resolveFilename = function (filename) {
const absPath = path.resolve(__dirname, filename)
// 判断当前路径对应的内容是否存在
if (fs.existsSync(absPath)) {
// 如果条件成立,则说明 absPath 对应的内容是存在的
return absPath
}
// 1. 源码中需要对文件进行 .js、.json、.node 补足然后判断,
// 2. 如果补充完路径判断还不存在,会尝试将它当作目录,然后寻找 package.json 的 main 字段
// 3. 如果 main 对应的文件也不存在,继续寻找 index,如果还不存在,会按照查找路径向上查找
// 实际解析流程如上所述,这里仅处理文件的情况,尝试补全后缀进行读取
const suffix = Object.keys(Module._extensions)
for (let i = 0; i < suffix.length; i++) {
const newPath = absPath + suffix[i]
if (fs.existsSync(newPath)) {
return newPath
}
}
throw new Error(`${filename} is not exist`)
}
Module._extensions = {
'.js'(module) {
// 读取
let content = fs.readFileSync(module.id, 'utf8')
// 包装
content = Module.wrapper[0] + content + Module.wrapper[1]
// vm
const compileFn = vm.runInThisContext(content)
// 准备参数值
let exports = module.exports
let filename = module.id
let dirname = path.dirname(module.id)
// 调用函数
compileFn.call(exports, exports, $require, module, filename, dirname)
},
'.json'(module) {
module.exports = JSON.parse(fs.readFileSync(module.id, 'utf-8'))
}
}
Module.wrapper = ['(function (exports, require, module, __filename, __dirname) {', '})']
Module._cache = {}
Module.prototype.load = function () {
const extname = path.extname(this.id)
Module._extensions[extname](this)
}
const $require = filename => {
// 1. 处理路径
const modulePath = Module._resolveFilename(filename)
// 2. 缓存优先
const cacheModule = Module._cache[modulePath]
if (cacheModule) return cacheModule.exports
// 3. 创建空对象加载目标模块
const module = new Module(modulePath)
// 4. 缓存已加载过的模块
Module._cache[modulePath] = module
// 5. 编译执行过程
module.load()
// 6. 返回数据
return module.exports
}
console.log($require('./data'))
console.log($require('./data02'))
js
// data.js
const name = 'heora'
module.exports = name
js
// data02.json
{
"name": "heora",
"age": 24
}
js