Node 模块解析

1,450 阅读6分钟

模块解析

相关代码大致位于 node/lib/internal/modules/cjs/loader.js

默认有 3 种 (v13 更新了第四种,还在实验),分别是 js,json,node。

> require('module')._extensions
[Object: null prototype] {
  '.js': [Function (anonymous)],
  '.json': [Function (anonymous)],
  '.node': [Function (anonymous)]
}

Node 在没有解析到后缀时,默认使用 CommonJS 模式,而非 JSON ,也是这里只涉及到的这种解析模式。(一直被各种配置文件的常用格式误导了。。)

具体步骤大致如下:

  1. 查询对应文件的绝对路径

  2. 读取文件的类型

  3. 通过封装(套函数外壳)创建私有作用域。

  4. 通过 VM 读取,执行代码

  5. 完成加载后,对应绝对路径缓存代码

关键模块

Node 在模块解析上有两个关键全局模块,不需要加载来完成模块引用和解析的操作。

  • Require

  • Module

可以理解成这样, require 作为交互用的指令, module 作为全部引用模块的管理者。 依赖的引用就是通过两者的合作完成的。

加载顺序

  1. 优先使用内置模块,如果有同名的会优先使用内置模块(像 requiremodule )。

  2. 查询缓存

  3. 查询路径

    1. 查询文件

    2. 查询文件夹

  4. 查询 node_modules

  5. 报错

第三部中的查询地址顺序可以通过打印 module 查看。 这是 Node 模块解析时查询 node_modules 的顺序和对应的路径。

// console.log(module)
Module {
    id: '.',
    path: '/Users/arius/Lib/node-test',
    exports: {},
    parent: null,
    filename: '/Users/arius/Lib/node-test/modules.js',
    loaded: false,
    children: [],
    paths: [
      '/Users/arius/Lib/node-test/node_modules',
      '/Users/arius/Lib/node_modules',
      '/Users/arius/node_modules',
      '/Users/node_modules',
      '/node_modules'
    ]
}

文件夹处理

在对应路径找不到文件的时候,会在该路径查询文件夹。

Module._resolveFilename 会判断是否是相对路径。

如果是 . 开头,就是相对路径,这个时候查询文件或者目录 index ;

如果不是 相对路径,则会查询 node_modules ,路径和顺序如上。

只有这个时候会触发 package.json 检索。

加载的 package.json{key: path, value: configJSON} , 的格式存到全局的 packageJSONCache 中。

查询的实现在 lib/internal/modules/esm/resolve.js::packageMainResolve

所以当文件不存在时,执行顺序如下:

  1. 查询缓存

  2. 判读是否是相对路径

    1. 如果是,查询路径下的 index

    2. 如果否,遍历 paths 查询 package.json ,并缓存

      1. 如果有,先查询是否有 exports (2020-04 的更新)

      2. 再查询是否有 main

      3. 再查询该路径是否存在 index

  3. 报错

模块解析

实现在 lib/internal/modules/cjs/loader.js::Module._compile

当查询到后缀为 c?js 或者默认处理时,走js解析。

可以把每个js文件简单理解成一个函数。

因为这个原因,每次引用最外层的代码会被执行,但只输出对应的 module

Node解释器就是执行这个函数的工具,将对应的参数传给它,保证执行的作用域。

> node                                                   
Welcome to Node.js v14.9.0.
Type ".help" for more information.
> require('module').wrapper
Proxy [
  [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
  ],
  { set: [Function: set], defineProperty: [Function: defineProperty] }
]
> 

其中

function(
  exports, // 当前模块 exports 的引用,和 module.exports 是一个东西
  require,// 一般就是 Module.prototype.require 即 _Module.prototype.require._load
  module, // 当前模块, 也是 this
  __filename, // 文件绝对路径
  __dirname // 文件相对路径
) {}

通过前后增加函数执行的外壳,和 Module 统一管理输入的文件访问参数, 生成一个匿名函数,抛给 VM 执行这串代码。

由于node使用函数方式来执行整个文件,所以可以直接打印看到整个模块的相关信息。

// args.js
console.log(arguments)
// run it
node args.js  
[Arguments] {
  '0': {},
  '1': [Function: require] {
    resolve: [Function: resolve] { paths: [Function: paths] },
    main: Module {
      id: '.',
      path: '/Users/arius/Lib/node-test/require-test',
      exports: {},
      parent: null,
      filename: '/Users/arius/Lib/node-test/require-test/args.js',
      loaded: false,
      children: [],
      paths: [Array]
    },
    extensions: [Object: null prototype] {
      '.js': [Function (anonymous)],
      '.json': [Function (anonymous)],
      '.node': [Function (anonymous)]
    },
    cache: [Object: null prototype] {
      '/Users/arius/Lib/node-test/require-test/args.js': [Module]
    }
  },
  '2': Module {
    id: '.',
    path: '/Users/arius/Lib/node-test/require-test',
    exports: {},
    parent: null,
    filename: '/Users/arius/Lib/node-test/require-test/args.js',
    loaded: false,
    children: [],
    paths: [
      '/Users/arius/Lib/node-test/require-test/node_modules',
      '/Users/arius/Lib/node-test/node_modules',
      '/Users/arius/Lib/node_modules',
      '/Users/arius/node_modules',
      '/Users/node_modules',
      '/node_modules'
    ]
  },
  '3': '/Users/arius/Lib/node-test/require-test/args.js',
  '4': '/Users/arius/Lib/node-test/require-test'
}

因为是引用的关系 module.exports 一般和 exports 指向同一个东西, 但需要注意,执行 require 导出永远的 module 是不会变的, exports 只是引用。 这也是为啥 exports=xxx 不会生效的原因。

module.exports = {
  a: '123', // 最终依然是 123
}
exports.a = '321';

依赖循环

而当 require 一个包或者文件的时候,执行(或者说依赖)的并不直接是这个包。 对应整个对象的 module, 会被缓存到全局对象 Module._cache[filename] 中。

假如执行 a.js

// a.js
const b = require('./b')
console.log(b) // 'b'
module.exports = 'a'
// b.js
const a = require('./a')
console.log(a) // {}, Module._cache中 a依然是 空的 Module {} 对象
module.exports = 'b'

依赖处理中, CJS 和 ES 运行顺序是一致的 (其中 default , 子模块都为 undefined ),不同的是 ES 可以动态替换引用。 只要引用是在加载完成之后被调用, 就不会有问题。 所以需要注意需要最外层执行的函数的顺序。

// a.js
import { b } from '../';
console.log(b)
export const a = 'a'
// b.js
import { a } from '../';
console.log(a) // undefined

export const callA = () => {
  console.log(a) // 'a'
}

export const b = 'b'
// index
export * from './a';
export * from './b';

如果是 mjs 的话,遇到循环依赖会报错,可能还没支持或者是新的规范。。

ReferenceError: Cannot access 'a' before initialization

那么 mjs 是啥?

ECMAScript 模块

官方实验 ES6 规范,以 mjs 后缀结尾。 cjsesm 的主要的区别在于, require + module.exports 对应 importexport

// 使用的时候会有一个 实验的 warning
(node:55066) ExperimentalWarning: The ESM module loader is experimental.
// 如果直接 import 调用对应不是 mjs 的模块
(node:54924) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.

Node 要求 esm 模块都使用 .mjs 后缀,且 默认启动严格模式

或者在 package.json 中制定为 typemodule ,这样执行目录内 js 脚本都会解析成 ES6 模块。这个解析仅限于 js 后缀, 其他(非 js )使用 cjs 或者 mjs 后缀文件会使用对应的解析方式。

与 Babel 时混用处理的方式不同, require 不能加载 mjs , mjs 中不能使用 require

相互调用

cjs 调用 mjs 时,需要用异步。 异步调用可能是想继承 esm 动态引用的机制,但是目前还不允许循环依赖。 而 require 不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层 await 命令,导致无法被同步加载。

(async () => {
    await import('./my-app.mjs');
})();

mjs 调用 cjs 的时候只能整体调用,无法解析,作 shaking 啥的。

支持多个模式

如果是 esm 模式则需要给一个整体的接口 export default 。 如果是 CommonJS 模式则需要封装一层,拆分一个单独的文件。

import cjsModule from '../index.cjs';

export const {...} = cjsModule;

或者使用 package.json 的方式修改, 其中 exports 除了相对路径替换外,还可以挑选调用方式。 具体如下:

// ./index.js
require('find-me');// Found you
// ./index.mjs
import 'find-me'; // Found me!


// ./node_modules/find-me/package.json
{
  "exports": {
    "import": "./me.mjs"
    "require": "./you.js"
  }
}
// ./node_modules/find-me/you.js
console.log('Found you')
// ./node_modules/find-me/me.mjs
console.log('Found me!')

FAQ

1. Node 中的错误行数问题

Node 本身并没有对错误函数做处理,一切错误处理都来自 VM,历史上 Node 上报堆栈失败的原因是那时候 Node 4 没有配置 displayErrors 参数。

github.com/nodejs/node…

wrapper 本身第一行没有换行,而且代码没有压缩,直接拼接,所以不会有行数问题。

Refers