node 中 commonjs 规范的实现

113 阅读4分钟

node 中的模块分类

  1. 内置模块、核心模块(不需要安装直接能用,node 自带的)
  2. 文件模块(自己实现的模块,有相对或者绝对路径)
  3. 第三方模块(比如 npm 包)

es6module 和 commonJs 导入方式的区别

  1. es6 Module 静态导入(前置导入),在编译的时候就可以知道使用了哪些变量,可以实现 tree-shaking,而 commonJs 模块使用的时候动态导入(随用随导),所以它是不支持 tree-shaking
  2. commonJs 会缓存导入的模块,也就是说,模块内值类型的更改,模块使用者并不会感知,对象类型因为导出的是个指针,所以引用者可以拿到新值。
// es6 module 静态导入,编译时已经确定要导入哪些内容,这里不考虑 es7 import() 语法
import { a } from './constant.js';


// commonJs 动态导入,编译时无法确定导入哪些内容
function fn() {
  var { a }  = require('./constant.js');
}

commonJs 模块定义

commonJs 模块定义了自己的规范:

  1. 如果想要使用哪个模块,直接 require 即可,后缀可以省略,默认会查找 .js 文件,没有则查找 .json 文件。
  2. 如果这个模块需要被别人使用,需要通过 module.exports 导出具体的内容。
  3. 在 node 中,每个 js、json 都是一个模块。
  4. 一个包由多个模块组成(每个包都必须配置一个 package.json 文件)。
// a.js
module.exports = 1;

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

console.log(a);  // 1s

commonJs 的理论实现

在浏览器环境中,js 如果想拿到某个第三方模块,不打包的情况下只能通过发送 http 请求,而 node 环境下 js 拥有文件读写的能力,所以 node 环境下引用模块使用的是同步文件读取的方式。

为什么 node 中引用模块是同步读取文件呢,想一下 node 引入其他模块的时候,没有写回调函数,而且下面立即可用~

我们知道,node 模块执行的时候,会在外部包裹一个自执行函数,并且接收五个参数,如果不知道的同学可以传送到我上一篇介绍 node 的博客,传送门

所以 index.js 实际的执行原理应该如下:

// @1 读取模块文件,读取到的是字符串哦
// @2 包装匿名函数,设置参数
// @3 默认返回 module.exports 对象
const a = function(module, exports, require, __dirname, __filename) {
  // 文件读取来的 a 模块
  module.exports = `module.exports=1`;

  return module.exports;
}();

console.log(a);

Node 断点调试 require

chrome inspect 调试

> node --inspect-brk index.js // 执行 index.js 并在第一行断住

打开 chrome://inspect/#devices 进行代码调试,具体参考 node 调试指南

vscode 调试

  1. 编辑器最左侧选择运行和调试
  2. 点击创建 launch.json 文件,弹出的下拉列表选择 Node.js,修改配置项

修改 launch.json 文件:

// launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "pwa-node",
      "request": "launch",
      "name": "Launch Program",
      // 跳过 node 源码,这里我们需要干掉
      // "skipFiles": [
      //   "<node_internals>/**"
      // ],
      "program": "${workspaceFolder}/node/module/index.js"
    }
  ]
}

点击左上角运行按钮,发现已经断到了文件,基本和浏览器调试一致~

ok,可以开始断点调试了。

断点分析 commonJS 实现原理

通过断点源码,我总结了如下几条 commonJS 实现的细节。

  1. 首先需要实现一个 require 方法,该方法接收一个路径
  2. _load 方法开始:执行 Module._load 方法,该方法返回 module.exports
  3. 全路径解析:执行 Module._resolveFilename 把文件路径解析成一个绝对路径
  4. 模块缓存:实现模块的缓存(根据绝对路径对模块进行缓存)
  5. 创建模块:根据文件绝对路径创建一个模块,一个模块必须要有三个属性, id、path、exports『后续文件导出的结果需要保存到这个变量上』
  6. 模块加载: module.load 根据创建的模块,进行模块加载
  7. 第三方模块路径解析:加载模块之前,会构建一个 paths 属性,这个属性就是第三方模块的查找路径数组(一层层的 node_modules),找到根目录为止。
  8. 调用加载模块的方法(策略模式):取出文件的后缀 Module._extensions,调用对应加载模块的策略方法。
  9. 读取文件: 策略方法中通过 fs.readFileSync 读取文件内容,并调用 module._compile 方法
  10. 函数包裹:_compile 方法中调用 Module.wrap 方法在文件内容外面包裹一个字符串形式的函数 wrapper,该方法接收 exports, require, module, __filename, __dirname 五个参数,并把模块内容塞进函数体中。
  11. 沙箱执行字符串:_compile 方法中中使用 vm.runInThisContext 使字符串函数 wrapper 变为可执行的函数 compiledWrapper,然后执行 compiledWrapper,传入 this, 传入五个参数,用户自己模块内设置的 module.exports = xxx 给 module.exports 赋值。
  12. 返回结果:最终 require 方法,会拿到 Module._load 的返回值,也就是 module.exports,完事儿

手动实现 commonJS 规范

// a.js
module.exports = 1;
// b.json
{
  "name": "埃?就是玩"
}

myRequire 实现

const path = require('path');
const fs = require('fs');
const vm = require('vm'); // 可以创建独立沙箱去执行字符串

function Module(id) { 
  this.id = id; // 全路径
  this.exports = {}; // 模块最终导出结果
}

// 策略模式,根据不同扩展名,调用不同加载模块的方法
Module._extensions = {
  '.js'(module) {
    let script = fs.readFileSync(module.id, 'utf8'); // 读取文件内容
    let wrapper = `(function(exports, module, require, __filename, __dirname) { ${ script } })`;
    // 把字符串函数转为 可执行的 js 函数
    let compiledWrapper = vm.runInThisContext(wrapper);
    let exports = module.exports; // 为了实现一个简写
    // 模块执行中的 this,this = exports = module.exports = {}; 
    let thisValue = exports; 
    let filename = module.id; // 模块的全路径
    let dirname = path.dirname(filename);

    // 凑齐五个参数,可以召唤神龙了,利用 ${ script } 修改 module.exports
    compiledWrapper.call(thisValue, exports, module, myRequire, filename, dirname);
  },
  '.json'(module) {
    let json = fs.readFileSync(module.id, 'utf8'); // 读取文件内容
    
    // json 对象不需要写 module.exports 语法 需要我们手动更改
    module.exports = JSON.parse(json);
  }
}

// 静态方法 把文件路径解析成一个绝对路径 并补全后缀
Module._resolveFilename = function(filename) {
  const filePath = path.resolve(__dirname, filename); 
  let exists = fs.existsSync(filePath); // 当前文件是否存在

  if (exists) return filePath;

  // 尝试添加 .js 或者 .json 后缀
  let keys = Reflect.ownKeys(Module._extensions);

  for (let i = 0; i < keys.length; i++) {
    let newPath = filePath + keys[i];

    if (fs.existsSync(newPath)) return newPath;
  }

  // 最后也没匹配到要引用的模块 则报错
  throw new Error('module not found');
}

// 模块缓存
Module._cache = {};

// 模块加载
Module.prototype.load = function() {
  // 根据要加载的文件扩展名,来决定使用哪个策略加载
  let extension = path.extname(this.id); 

  // 加载模块
  Module._extensions[extension](this);
}

/**
 * @description 手动实现 commonJS require 方法
 * @param { String } filename 文件路径, 可能不具备后缀哦
 */
function myRequire(filename) { 
  let fullPath = Module._resolveFilename(filename);
  let cachedModule = Module._cache[fullPath]; // 缓存中是否存在

  if (cachedModule) {
    return cachedModule.exports;
  }

  // 创造一个模块
  let module = new Module(fullPath);

  Module._cache[fullPath] = module;

  // 获取模块中的内容,包装函数,让函数执行,给 module.exports 赋值
  module.load();

  return module.exports;
}

let a = myRequire('../a');
console.log(a); // 1

// let b = myRequire('../b');
// console.log(b);

思考:为什么不能使用 expors = 1 导出变量

// a.js
exports = 1;

// index.js
let a = require('./a');
console.log(a); // {}

为什么取不到值呢?我们根据源码看一下

let exports = module.exports = {};

exports = 1; 
// module.exports 仍然执行空对象的堆内存地址
// 如果 exports.a = 1; 或者 this.a = 1; 这时候 module.exports 就会变了~

共享 global

// a.js
global.a = 1;

// index.js
let a = require('./a');
console.log(global.a); // 1

这样也是可以拿到的,因为源码中会把 a.js 中的代码放到 index.js 中去执行(拿一个函数包裹),只要执行了,global 上就有 a 属性。不过并不建议这么玩,会污染全局变量哦。