Node中的模块

364 阅读7分钟

CommonJS 和 ES Module 究竟有什么区别?

语法支持静态编译同步加载值拷贝
ES Module是(在编译时就完成模块加载)否(异步)否(导出值和导入值都指向同一块内存)
CommonJS否(支持动态导入require(${path}/xx.js))

为什么要使用模块化?

当你的网站开发越来越复杂代码越来越多的时候会经常遇到什么问题?

  • 烦人的命名冲突
  • 繁琐的文件依赖

模块化的优点

  • 可以解决命名冲突
  • 管理依赖
  • 提高代码的可读性
  • 代码解耦,提高代码的复用性

什么是模块?

模块是 Node.js 应用程序的基本组成部分,文件和模块是一一对应的。换言之,一个 Node.js 文件就是一个模块,这个文件可能是 Javascript 代码、JSON 或者编译过的 C/C++ 扩展。

模块内部定义的变量和函数默认情况下在外部无法得到 模块内部可以使用module.exports或exports对象进行成员导出, 使用require方法导入其他模块。

模块引用

在CommonJS规范中,存在require()方法,这个方法接受模块标识,以此引入一个模块的API到当前上下文中。

代码如下:

// 同目录下的b.js
let a = require('./a')

*** 注意:require方法是同步的 ***

// 同目录的b.js
let a = require('./a')
console.log(require.cache)

// 控制台输出
[Object: null prototype] {
  '/Users/wanglin/Desktop/CommonJS/b.js': Module {
    id: '.',
    path: '/Users/wanglin/Desktop/CommonJS',
    exports: {},
    parent: null,
    filename: '/Users/wanglin/Desktop/CommonJS/b.js',
    loaded: false,
    children: [ [Module] ],
    paths: [
      '/Users/wanglin/Desktop/CommonJS/node_modules',
      '/Users/wanglin/Desktop/node_modules',
      '/Users/wanglin/node_modules',
      '/Users/node_modules',
      '/node_modules'
    ]
  },
  '/Users/wanglin/Desktop/CommonJS/a.js': Module {
    id: '/Users/wanglin/Desktop/CommonJS/a.js',
    path: '/Users/wanglin/Desktop/CommonJS',
    exports: { name: 'wl', age: 18 },
    parent: Module {
      id: '.',
      path: '/Users/wanglin/Desktop/CommonJS',
      exports: {},
      parent: null,
      filename: '/Users/wanglin/Desktop/CommonJS/b.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    filename: '/Users/wanglin/Desktop/CommonJS/a.js',
    loaded: true,
    children: [],
    paths: [
      '/Users/wanglin/Desktop/CommonJS/node_modules',
      '/Users/wanglin/Desktop/node_modules',
      '/Users/wanglin/node_modules',
      '/Users/node_modules',
      '/node_modules'
    ]
  }
}

模块定义

在模块中,上下文提供require()方法来引入外部模块。对应引入的功能,上下文提供了exports对象用于导出当前模块的方法或者变量,它是module(模块自身)对象上的一个属性,导出对象最终以module.exports为准。

// 同目录的a.js
let name = 'wl'
let age = 18

module.exports = { name, age }

/**
 * 正确导出方式:
 * module.exports = { name, age }
 * 或者:
 * module.exports.naem = name
 * module.exports.age = age
 * 或者:
 * exports.naem = name
 * exports.age = age
 */
 
 /**
 * 错误导出方式:
 * exports = { name, age }
 */

模块标识

模块标识其实就是传递给require()方法的参数,它必须是符合小驼峰命名的字符串,以.、..开头的相对路径,或者绝对路径。它可以没有文件名后缀。


模块的分类

  • 第一类:核心模块:如 http fs path vm
  • 第二类:文件模块:const utils = require('./utils.js')
  • 第三类:第三方模块:const multer = require('koa-multer')

Node的模块实现

在node引入模块,需要经历如下3个步骤

路径分析

因为标识符有几种形式,对于不同的标识符,模块的查找和定位有不同程度上的差异。

  • 核心模块

    • 核心模块的优先级仅次于缓存加载,它在Node的源代码编译过程中已经编译为二进制代码,其加载过程最快。
  • 文件模块

    • 以.、..和/开始的标识符,这里都被当做文件模块来处理。在分析路径模块时,require()方法会将路径转为绝对路径,将编译执行的结果存放到缓存中,以使二次加载时更快。
  • 第三方模块

    • 这类模块的查找最费时,它的查找方式与原型链或作用域十分相似,在加载过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件为止。
  • 文件定位

    • require()在分析标识符的过程中,会出现标识符中不包含文件扩展名的情况,这种情况下,Node会按.js、.json、.node的顺序补足扩展名,依次尝试。
    • 在分析标识符的过程中,require()通过分析文件扩展名之后,如果仍然没有找到对应文件,但却得到一个目录,此时Node会将目录当做一个包来处理。首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位,如果文件名缺少扩展名,将会进入扩展名分析的步骤。而如果main属性指定的文件错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js、index.json、index.node。如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果所有模块路径数组都遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。
  • 编译执行

    • 编译和执行是引入模块的最后一个阶段。定位到具体的文件后,Node会新建一个模块对象,然后根据路径载入并编译。对不同的文件扩展名,其载入方法也有所不同:
      • .js文件: 通过fs模块同步读取文件后编译执行
      • .json文件: 通过fs模块同步读取文件后,用JSON.parse()解析返回结果。
      • .node文件: 这是用C/C++编写的扩展文件,通过dlopen()方法加载最后编译生成的文件
      • 其余扩展名文件: 它们都会被当做.js文件载入

*** 注意: ***

在编译.js文件的过程中,Node对获取的javascript文件内容进行了头尾包装。在头部添加了(function (exports, require, module, __filename, __dirname) { \n,在尾部添加了\n});。这样每个模块文件之间都进行了作用域隔离。包装之后的代码会通过vm原生模块的runInThisContext()方法执行(类似eval,只是具有明确上下文,不污染全局),返回一个具体的function对象。最后将当前模块对象的exports属性、 require()方法、module、以及在文件定位中得到的完整文件路径和文件目录作为参数传递给这个function()执行。




模块加载概述

    1. 简而言之,如果require绝对路径的文件,查找时不会去遍历每一个node_modules目录,其速度最快。其余流程如下:
    1. 从module path数组中取出第一个目录作为查找基准。直接从目录中查找该文件,如果存在,则结束查找。如果不存在,则进行下一条查找。
    1. 尝试添加.js、.json、.node后缀后查找,如果存在文件,则结束查找。如果不存在,则进行下一条。
    1. 尝试将require的参数作为一个包来进行查找,读取目录下的package.json文件,取得main参数指定的文件。
    1. 尝试查找该文件,如果存在,则结束查找。如果不存在,则进行第3个步骤查找。
    1. 如果继续失败,则取出module path数组中的下一个目录作为基准查找,循环第1至5个步骤。
    1. 如果继续失败,循环第1至6个步骤,直到module path中的最后一个值。
    1. 如果仍然失败,则抛出异常。

简易版实现

const path = require('path')
const fs = require('fs')
const vm = require('vm')

function Module (id = '') {
  this.id = id
  this.exports = {}
}

Module.warp = function (script) {
  const arr = [
    '(function (exports, require, module, __filename, __dirname) { ',
    script,
    '\n});'
  ];
  return arr.join('')
}

Module._extensions = {
  '.js': function (module) {
    let content = fs.readFileSync(module.id, 'utf8') // 读取出的是字符串
    let fnStr = Module.warp(content)
    let fn = vm.runInNewContext(fnStr)
    let exports = module.exports
    let require = myRequire
    let __filename = module.id
    let __dirname = path.dirname(module.id)
    // 这里的this 就是exports对象
    fn.call(exports, exports, require, module, __filename, __dirname)
    // 用户会给module.exports赋值
  },
  '.json': function (module) {
    let content = fs.readFileSync(module.id, 'utf8') // 读取出的是字符串
    module.exports = JSON.parse(content)
  },
  '.node': function () {}
}

Module._resolveFilename = function (filepath) {
  // 根据当前路径实现解析
  let filePath = path.resolve(__dirname, filepath)
  // 判断当前文件是否存在
  let exists = fs.existsSync(filePath)
  if (exists) return filePath // 如果存在直接返回路径

  // 尝试添加后缀
  let keys = Object.keys(Module._extensions)
  for (let i = 0; i < keys.length; i++) {
    let currentPath = filePath + keys[i]
    if(fs.existsSync(currentPath)) return currentPath // 尝试添加后缀查找
  }
  throw new Error('模块不存在')
}

Module.prototype.load = function(filename) {
  // 获取文件的后缀来进行加载
  let extname = path.extname(filename) // 获取扩展名
  Module._extensions[extname](this) // 根据对应的后缀名进行加载
}

Module.cache = {}

Module._load = function (filepath) {
  // 将路径转换成绝对路径
  let filename = Module._resolveFilename(filepath)

  // 获取路径后不要立即创建模块,先看一眼能否找到以前加载过的模块
  let cacheModule = Module.cache[filename]
  if (cacheModule) return Module.cache[filename].exports // 直接返回上一次require的结果

  // 保证每个模块的唯一性,需要通过唯一路径进行查找 创建模块
  let module = new Module(filename); // id当前文件绝对路径, exports对应的当前模块的结果

  Module.cache[filename] = module

  module.load(filename) // 加载模块

  return module.exports
}

function myRequire (filepath) {
  // 根据路径加载这个模块
  return Module._load(filepath)
}

let r = myRequire('./b')