模块化

为什么需要模块化

传统开发常见问题

  • 命名冲突和污染
  • 代码冗余,无效请求多
  • 文件间的依赖关系复杂

项目难以维护不方便复用。

模块与模块化

模块就是小而精且利于维护的代码片段。

模块化开发就是对这些代码片段进行组合使用,从而完成业务逻辑。

模块化历程

早期利用函数、对象、自执行函数实现分块。

模块化规范:

  • CommonJS 规范
    • 规定每个 js 文件都是一个模块,且每个模块都有自己的作用域
    • 模块内部可以使用变量、函数、类等,都是内部私有,外部无法访问
    • 提供 module.exportsexports 方式导出变量,使用 require 进行加载
    • Node.js 就是使用的 Commonjs 规范
      • Commonjs 规范是一个超集,是语言层面上的规范,类似于 ECMAScript 规范
      • 模块化规范只是其中一种,它还定义了 IO 流、二进制操作,或者 Buffer 规范等
      • Commonjs 规范模块加载都是同步完成的,并不适用于浏览器,因此后面又出现 AMD 异步加载规范
  • AMD
    • 提供 definerequire 关键字
    • 经典实现就是 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.exportsexports 指向同一内存地址。

需要注意的是我们不能给 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 函数
  • 调用函数时传入 exportsmodulerequire 等属性值

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