NodeJS原理解析之模块机制

761 阅读7分钟

一、CommonJS的模块规范

(1)模块引用

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

(2)模块定义

// main.js
exports.add = () => {
    console.log('my name is main');
}
// program.js
const main = require('./main');
main.add(); // my name is main

require() ==方法==用来引入模块

exports ==对象==用于导出当前模块的方法或者变量,并且它是唯一的导出出口

module 代表模块自身,而exports是module的属性

console.log(exports === module.exports); // true

在Node中,一个文件就是一个模块。

(3)模块标识

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

  模块的定义十分简单,接口也十分简洁。它的意义在于将类聚的方法和变量等限定在私有的作用域中(详见JavaScript模块的编译),同时支持引入和导出功能以顺畅地连接上下游依赖。

二、模块实现

在Node种引入模块,需要经历3个步骤:路径分析、文件定位、编译执行。

模块两类:

  核心模块:Node提供,加载最快

  文件模块:用户编写

加载顺序:

(1)缓存

  模块加载一次便会缓存,与浏览器不同的是,浏览器缓存的是文件,Node缓存的是编译和执行之后的对象。

(2)路径分析 ==(加载速度从快到慢)==

  • 核心模块:   提前编译为二进制文件,所以加载速度最快,如http、fs、path等。 路径形式的文件模块:指明了真实路径,所以加载速度仅次于核心模块。
  • 自定义模块: 模块路径生成规则:
    • 当前文件目录下的node_modules目录

    • 父级目录下的node_modules目录

    • 父级的父级目录下的node_modules目录

    • 沿路径向上逐级递归,直到根目录下的node_modules目录

具体递归路径可以通过module.paths看出

console.log(module.paths);
// 输出
[
  'c:\\Users\\123\\Desktop\\node\\TDD\\test\\node_modules',
  'c:\\Users\\123\\Desktop\\node\\TDD\\node_modules',
  'c:\\Users\\123\\Desktop\\node\\node_modules',
  'c:\\Users\\123\\Desktop\\node_modules',
  'c:\\Users\\123\\node_modules',
  'c:\\Users\\node_modules',
  'c:\\node_modules'
]

(3)文件定位

  1. 文件拓展名分析

  如果require()参数不包含拓展名,则按照.js、.json、.node的次序补足拓展名,依次尝试。使用fs模块同步阻塞式的判断文件是否存在。

  1. 目录分析和包

  如果没有找到对应文件却找到一个目录,则会查找该目录下的==package.json文件==,通过JSON.parse()解析出包描述对象,从中取出==main属性==指定的文件名进行定位。如果文件名缺少拓展名,则会进入拓展名分析步骤。

  如果main属性指定文件名错误,或压根没有package.json文件,==Node会将index当作默认文件名==,然后依次查找index.js、index.json、index.node。 如果

  如果目录分析的过程中没有定位成功任何文件,则进入下一个模块路径进行查找。如果模块路径数组都遍历完毕,依然没有查找到目标文件,则抛出查找失败的异常。

(4)模块编译

  定位到具体文件后,Node会新建一个module对象,然后根据路径载入并编译。每一个编译成功的模块,都会将其文件路径作为索引缓存在Module._cache对象上,以提高二次引入的性能。

module对象

console.log(module);
// 输出
Module {
  id: '.',
  path: 'c:\\Users\\123\\Desktop\\node\\TDD\\test',
  exports: {},
  parent: null,
  filename: 'c:\\Users\\123\\Desktop\\node\\TDD\\test\\program.js',
  loaded: false,
  children: [],
  paths: [
    'c:\\Users\\123\\Desktop\\node\\TDD\\test\\node_modules',
    'c:\\Users\\123\\Desktop\\node\\TDD\\node_modules',
    'c:\\Users\\123\\Desktop\\node\\node_modules',
    'c:\\Users\\123\\Desktop\\node_modules',
    'c:\\Users\\123\\node_modules',
    'c:\\Users\\node_modules',
    'c:\\node_modules'
  ]
}

缓存对象

// program.js
const main = require('./main');
const Module = require("module");

console.log(Module._cache);

// 输出
// 路径为索引,对应的module对象为值
{
  'c:\\Users\\123\\Desktop\\node\\TDD\\test\\program.js': Module {
    id: '.',
    path: 'c:\\Users\\123\\Desktop\\node\\TDD\\test',
    exports: {},
    parent: null,
    filename: 'c:\\Users\\123\\Desktop\\node\\TDD\\test\\program.js',
    loaded: false,
    children: [ [Module] ],
    paths: [
      'c:\\Users\\123\\Desktop\\node\\TDD\\test\\node_modules',
      'c:\\Users\\123\\Desktop\\node\\TDD\\node_modules',
      'c:\\Users\\123\\Desktop\\node\\node_modules',
      'c:\\Users\\123\\Desktop\\node_modules',
      'c:\\Users\\123\\node_modules',
      'c:\\Users\\node_modules',
      'c:\\node_modules'
    ]
  },
  'c:\\Users\\123\\Desktop\\node\\TDD\\test\\main.js': Module {
    id: 'c:\\Users\\123\\Desktop\\node\\TDD\\test\\main.js',
    path: 'c:\\Users\\123\\Desktop\\node\\TDD\\test',
    exports: { add: [Function (anonymous)] },
    parent: Module {
      id: '.',
      path: 'c:\\Users\\123\\Desktop\\node\\TDD\\test',
      exports: {},
      parent: null,
      filename: 'c:\\Users\\123\\Desktop\\node\\TDD\\test\\program.js',
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    filename: 'c:\\Users\\123\\Desktop\\node\\TDD\\test\\main.js',
    loaded: true,
    children: [],
    paths: [
      'c:\\Users\\123\\Desktop\\node\\TDD\\test\\node_modules',
      'c:\\Users\\123\\Desktop\\node\\TDD\\node_modules',
      'c:\\Users\\123\\Desktop\\node\\node_modules',
      'c:\\Users\\123\\Desktop\\node_modules',
      'c:\\Users\\123\\node_modules',
      'c:\\Users\\node_modules',
      'c:\\node_modules'
    ]
  }
}

  Node默认支持编译.js、.json、.node文件,如果想编译其他格式的文件,可以通过require.extensions['.xxx']或Module._extensions进行扩展。

编译txt文件

// abc.txt
我是一个txt文件

// main.js
const fs = require("fs");
const Module = require("module");
Module._extensions[".txt"] = (module, filename) => {
  const content = fs.readFileSync(filename, "utf-8");
  try {
    module.exports = content;
  } catch (err) {
    err.message = filename + ":" + err.message;
    throw err;
  }
};

const a = require("./abc.txt");
console.log(a); // 我是一个txt文件

1. JavaScript模块的编译

  我们知道每个模块文件中存在着==require、exports、module、__filename、__dirname==这5个变量,但是他们在模块中并没有定义,那么从何而来呢?

  事实上,在编译过程中,Node会将模块文件的内容进行头尾包装,包装成如下格式:

包装前

const path = require("path");
const src = path.resolve(__dirname, "src");
console.log(src); // c:\Users\123\Desktop\node\TDD\test\src
exports.area = (r) => {
  return Math.PI * Math.pow(r, 2);
};

包装后

(function (exports, require, module, __filename, __dirname) {
  const path = require("path");
  const src = path.resolve(__dirname, "src");
  console.log(src); // c:\Users\123\Desktop\node\TDD\test\src
  exports.area = (r) => {
    return Math.PI * Math.pow(r, 2);
  };
});

  这样再通过外层将require、exports、module、__filename、__dirname这5个变量通过参数的形式传进了,就可以很优雅的使用了。并且这种方式还起到了作用域隔离的作用,使得每个模块都有独立的空间,互不干扰,在引用时也显得干净利落。

  但是新的问题又产生了,被函数包裹的区域还是可以访问并修改父级乃至全局作用域的变量,如下面这段代码所示。

let num = 1;
function setNum() {
  num = 99;
}
setNum();
console.log(num); // 99

  可以看到,在setNum()方法里任然可以修改父级作用域中的num变量,还是没有起到完全隔离的效果,让我们来看看Node是怎么做的。

  Node将function包装后的代码会通过字符串的形式传给vm原生模块的==runInThisContext()方法==执行,返回一个具体的function对象。最后将模块对象的require、exports、module、__filename、__dirname传这个function()执行,返回最后的exports对象。

runInThisContext的作用是建立一个沙箱环境,避免污染父级作用域。

// 使用runInThisContext
const vm = require("vm");
let num = 1;
vm.runInThisContext("function setNum() {num = 99;}");
setNum();
console.log(num); // 1
console.log(global.num); // 99

题外知识: 我们知道web中定义变量如果不写var、let、const关键字, 则会将该变量赋值给window属性。同理在Node中也存在全局变量global, 此时沙箱中的num被赋值给了gloabal对象。

  此外,可能你会产生疑问,为什么存在exports的情况下,我们却通常使用module.exports导出模块呢?

  因为exports是作为形参的方式传入的,在JavaScript规范中,不推荐我们直接修改形参。并且如果你一不小心给形参重新赋值的话,便只能改变形参的引用,不会改变作用域外的值,这种操作将不会导出任何东西。所以最好使用module.exports导出模块。

2. C/C++模块的编译

  Node调用process.dlopen()方法进行加载和执行,加载过程中,Node将C/C++文件编译为.node模块,执行过程中exports与.node模块产生联系,返回给调用者。

  C/C++模块的优势是执行效率更快,劣势是编写门槛较高。

3. JSON文件的编译

  Node利用fs模块同步读取JSON文件内容后,调用JSON.parse()方法得到对象,最后通过exports导出。