Nodejs 模块化实现示例深入探究

JavaScript/前端
269
0
0
2023-06-11
标签   NodeJs
目录
  • 正文
  • 一、CommonJS 规范
  • 1.1 CommonJS 的模块化规范
  • 1.1.1、模块引用
  • 1.1.2、模块定义
  • 1.1.3、模块标识
  • 二、Nodejs 的模块化实现
  • 2.1 优先从缓存加载
  • 2.2 路径分析
  • 2.2.1 核心模块
  • 2.2.2 路径形式的文件模块
  • 2.2.3 自定义模块
  • 2.3 文件定位
  • 2.3.1 文件扩展名分析
  • 2.3.2 目录和包分析
  • 2.4 模块编译
  • 2.4.1 JavaScript 模块的编译
  • 2.4.2 C/C++ 模块的编译
  • 2.4.3 JSON 文件的编译
  • 三、总结
  • 1、加载顺序和速度:
  • 2、加载基本步骤:

正文

本文只讨论 CommonJS 规范,不涉及 ESM

我们知道 JavaScript 这门语言诞生之初主要是为了完成网页上表单的一些规则校验以及动画制作,所以布兰登.艾奇(Brendan Eich)只花了一周多就把 JavaScript 设计出来了。可以说 JavaScript 从出生开始就带着许多缺陷和缺点,这一点一直被其他语言的编程者所嘲笑。随着 BS 开发模式渐渐地火了起来,JavaScript 所要承担的责任也越来越大,ECMA 接手标准化之后也渐渐的开始完善了起来。

在 ES 6 之前,JavaScript 一直是没有自己的模块化机制的,JavaScript 文件之间无法相互引用,只能依赖脚本的加载顺序以及全局变量来确定变量的传递顺序和传递方式。而 script 标签太多会导致文件之间依赖关系混乱,全局变量太多也会导致数据流相当紊乱,命名冲突和内存泄漏也会更加频繁的出现。直到 ES 6 之后,JavaScript 开始有了自己的模块化机制,不用再依赖 requirejs、seajs 等插件来实现模块化了。

在 Nodejs 出现之前,服务端 JavaScript 基本上处于一片荒芜的境况,而当时也没有出现 ES 6 的模块化规范(Nodejs 最早从 V8.5 开始支持 ESM 规范:Node V8.5 更新日志),所以 Nodejs 采用了当时比较先进的一种模块化规范来实现服务端 JavaScript 的模块化机制,它就是 CommonJS,有时也简称为 CJS。

这篇文章主要讲解 CommonJS 在 Nodejs 中的实现。

一、CommonJS 规范

在 Nodejs 采用 CommonJS 规范之前,还存在以下缺点:

  • 没有模块系统
  • 标准库很少
  • 没有标准接口
  • 缺乏包管理系统

这几点问题的存在导致 Nodejs 始终难以构建大型的项目,生态环境也是十分的贫乏,所以这些问题都是亟待解决的。

CommonJS 的提出,主要是为了弥补当前 JavaScript 没有模块化标准的缺陷,以达到像 Java、Python、Ruby 那样能够构建大型应用的阶段,而不是仅仅作为一门脚本语言。Nodejs 能够拥有今天这样繁荣的生态系统,CommonJS 功不可没。

1.1 CommonJS 的模块化规范

CommonJS 对模块的定义十分简单,主要分为模块引用、模块定义和模块标识三个部分。下面进行简单介绍:

1.1.1、模块引用

示例如下:

const fs = require('fs')

在 CommonJS 规范中,存在一个 require “全局”方法,它接受一个标识,然后把标识对应的模块的 API 引入到当前模块作用域中。

1.1.2、模块定义

我们已经知道了如何引入一个 Nodejs 模块,但是我们应该如何定义一个 Nodejs 模块呢?在 Nodejs 上下文环境中提供了一个 module 对象和一个 exports 对象,module 代表当前模块,exports 是当前模块的一个属性,代表要导出的一些 API。在 Nodejs 中,一个文件就是一个模块,把方法或者变量作为属性挂载在 exports 对象上即可将其作为模块的一部分进行导出。

// add.js
exports.add = function(a, b) {
    return a + b
}

在另一个文件中,我们就可以通过 require 引入之前定义的这个模块:

const { add } = require('./add.js')
add(, 2) // print 3

1.1.3、模块标识

模块标识就是传递给 require 函数的参数,在 Nodejs 中就是模块的 id。它必须是符合小驼峰命名的字符串,或者是以.、..开头的相对路径,或者绝对路径,可以不带后缀名

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

CommonJS 这套模块导出和引入的机制使得用户完全不必考虑变量污染。

以上只是对于 CommonJS 规范的简单介绍,更多具体的内容可以参考:CommonJS规范

二、Nodejs 的模块化实现

Nodejs 在实现中并没有完全按照规范实现,而是对模块规范进行了一定的取舍,同时也增加了一些自身需要的特性。接下来我们会探究一下 Nodejs 是如何实现 CommonJS 规范的。

在 Nodejs 中引入模块会经过以下三个步骤:

  • 路径分析
  • 文件定位
  • 编译执行

在了解具体的内容之前我们先了解两个概念:

  • 核心模块:Nodejs 提供的内置模块,比如 fs、url、http 等
  • 文件模块:用户自己编写的模块,比如 Koa、Express 等

核心模块在 Nodejs 源代码的编译过程中已经编译进了二进制文件,Nodejs 启动时会被直接加载到内存中,所以在我们引入这些模块的时候就省去了文件定位、编译执行这两个步骤,加载速度比文件模块要快很多。

文件模块是在运行的时候动态加载,需要走一套完整的流程:路径分析、文件定位、编译执行等,所以文件模块的加载速度比核心模块要慢。

2.1 优先从缓存加载

在讲解具体的加载步骤之前,我们应当知晓的一点是,Nodejs 对于已经加载过一边的模块会进行缓存,模块的内容会被缓存到内存当中,如果下次加载了同一个模块的话,就会从内存中直接取出来,这样就省去了第二次路径分析、文件定位、加载执行的过程,大大提高了加载速度。无论是核心模块还是文件模块,require() 对同一文件的第二次加载都一律会采用缓存优先的方式,这是第一优先级的。但是核心模块的缓存检查优先于文件模块的缓存检查。

我们在 Nodejs 文件中所使用的 require 函数,实际上就是在 Nodejs 项目中的 lib/internal/modules/cjs/loader.js 所定义的 Module.prototype.require 函数,只不过在后面的 makeRequireFunction 函数中还会进行一层封装,Module.prototype.require 源码如下:

// Loads a module at the given file path. Returns that module's
// `exports` property.
Module.prototype.require = function(id) {
    validateString(id, 'id');
    if (id === '') {
        throw new ERR_INVALID_ARG_VALUE('id', id,
                                        'must be a non-empty string');
    }
    requireDepth++;
    try {
        return Module._load(id, this, /* isMain */ false);
    } finally {
        requireDepth--;
    }
};

可以看到它最终使用了 Module._load 方法来加载我们的标识符所指定的模块,找到 Module._load:

Module._cache = Object.create(null);
// 这里先定义了一个缓存的对象
// ... ...
// Check the cache for the requested file.
//. If a module already exists in the cache: return its exports object.
//. If the module is native: call
//    `NativeModule.prototype.compileForPublicLoader()` and return the exports.
//. Otherwise, create a new module for the file and save it to the cache.
//    Then have it load  the file contents before returning its exports
//    object.
Module._load = function(request, parent, isMain) {
    let relResolveCacheIdentifier;
    if (parent) {
        debug('Module._load REQUEST %s parent: %s', request, parent.id);
        // Fast path for (lazy loaded) modules in the same directory. The indirect
        // caching is required to allow cache invalidation without changing the old
        // cache key names.
        relResolveCacheIdentifier = `${parent.path}\x${request}`;
        const filename = relativeResolveCache[relResolveCacheIdentifier];
        if (filename !== undefined) {
            const cachedModule = Module._cache[filename];
            if (cachedModule !== undefined) {
                updateChildren(parent, cachedModule, true);
                return cachedModule.exports;
            }
            delete relativeResolveCache[relResolveCacheIdentifier];
        }
    }
    const filename = Module._resolveFilename(request, parent, isMain);
    const cachedModule = Module._cache[filename];
    if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        return cachedModule.exports;
    }
    const mod = loadNativeModule(filename, request, experimentalModules);
    if (mod && mod.canBeRequiredByUsers) return mod.exports;
    // Don't call updateChildren(), Module constructor already does.
    const module = new Module(filename, parent);
    if (isMain) {
        process.mainModule = module;
        module.id = '.';
    }
    Module._cache[filename] = module;
    if (parent !== undefined) {
        relativeResolveCache[relResolveCacheIdentifier] = filename;
    }
    let threw = true;
    try {
        module.load(filename);
        threw = false;
    } finally {
        if (threw) {
            delete Module._cache[filename];
            if (parent !== undefined) {
                delete relativeResolveCache[relResolveCacheIdentifier];
            }
        }
    }
    return module.exports;
};

我们可以先简单的看一下源代码,其实代码注释已经写得很清楚了。

Nodejs 先会根据模块信息解析出文件路径和文件名,然后以文件名作为 Module._cache 对象的键查询该文件是否已经被缓存,如果已经被缓存的话,直接返回缓存对象的 exports 属性。否则就会使用 Module._resolveFilename 重新解析文件名,再查询一边缓存对象。否则就会当做核心模块来加载,核心模块使用 loadNativeModule 方法进行加载。

如果经过了以上几个步骤之后,在缓存中仍然找不到 require 加载的模块对象,那么就使用 Module 构造方法重新构造一个新的模块对象。加载完毕之后还会缓存到 Module._cache 对象中,以便下一次加载的时候可以直接从缓存中取到。

从源码来看,跟我们之前说的没什么区别。

2.2 路径分析

我们知道标识符是进行路径分析和文件定位的依据,在引用某个模块的时候我们就会给 require 函数传入一个标识符,根据我们使用的经历不难发现标识符基本上可以分为以下几种:

  • 核心模块:比如 http、fs 等
  • 文件模块:这类模块的标识符是一个路径字符串,指向工程内的某个文件
  • 非路径形式的文件模块:也叫做自定义模块,比如 connect、koa 等

标识符类型不同,加载的方式也有差异,接下来我将介绍不同标识符的加载方式。

2.2.1 核心模块

核心模块的加载优先级仅次于缓存,前文提到过由于核心模块的代码已经编译成了二进制代码,在 Nodejs 启动的时候就会加载到内存中,所以核心模块的加载速度非常快。它根本不需要进行路径分析和文件定位,如果你想写一个和核心模块同名的模块的话,它是不会被加载的,因为其加载优先级不如核心模块。

2.2.2 路径形式的文件模块

当标识符为路径字符串时,require 都会把它当做文件模块来加载,在根据标识符获得真实路径之后,Nodejs 会将真实路径作为键把模块缓存到一个对象里,使二次加载更快。

由于文件模块的标识符指明了模块文件的具体位置,所以加载速度相对而言也比较快。

2.2.3 自定义模块

自定义模块是一个包含 package.json 的项目所构造的模块,它是一种特殊的模块,其查找方式比较复杂,所以耗时也是最长的。

在 Nodejs 中有一个叫做模块路径的概念,我们新建一个 module_path.js 的文件,然后在其中输入如下内容:

console.log(module.paths)

然后使用 Nodejs 运行:

node module_path.js

我们可以看到控制台输入大致如下:

[ 'C:\\Users\\UserName\\Desktop\\node_modules', 'C:\\Users\\UserName\\node_modules', 'C:\\Users\\node_modules', 'C:\\node_modules' ]

此时我的 module_path.js 文件是放在桌面的,所以可以看到这个文件模块的模块路径是当前文件同级目录下的 node_modules,如果找不到的话就从父级文件夹的同名目录下找,知道找到根目录下。这种查找方式和 JavaScript 中的作用域链非常相似。可以看到当文件路径越深的时候查找所耗时间越长,所以这也是自定义模块加载速度最慢的原因。

在 Windows 环境中,Nodejs 通过下面函数获取模块路径:

Module._nodeModulePaths = function(from) {
    // Guarantee that 'from' is absolute.
    from = path.resolve(from);
    // note: this approach *only* works when the path is guaranteed
    // to be absolute.  Doing a fully-edge-case-correct path.split
    // that works on both Windows and Posix is non-trivial.
    // return root node_modules when path is 'D:\\'.
    // path.resolve will make sure from.length >= in Windows.
    if (from.charCodeAt(from.length -) === CHAR_BACKWARD_SLASH &&
        from.charCodeAt(from.length -) === CHAR_COLON)
        return [from + 'node_modules'];
    const paths = [];
    var p =;
    var last = from.length;
    for (var i = from.length -; i >= 0; --i) {
        const code = from.charCodeAt(i);
        // The path segment separator check ('\' and '/') was used to get
        // node_modules path for every path segment.
        // Use colon as an extra condition since we can get node_modules
        // path for drive root like 'C:\node_modules' and don't need to
        // parse drive name.
        if (code === CHAR_BACKWARD_SLASH ||
            code === CHAR_FORWARD_SLASH ||
            code === CHAR_COLON) {
            if (p !== nmLen)
                paths.push(from.slice(, last) + '\\node_modules');
            last = i;
            p =;
        } else if (p !== -) {
            if (nmChars[p] === code) {
                ++p;
            } else {
                p = -;
            }
        }
    }
    return paths;
};

代码和注释都写得很明白,大家看看就行,常量都放在 /lib/internal/constants.js 这个模块。

2.3 文件定位

2.3.1 文件扩展名分析

我们在引用模块的很多时候,传递的标识符都不会携带扩展名,比如

// require('./internal/constants.js')
require('./internal/constants')

很明显下面的方式更简洁,但是 Nodejs 在定位文件的时候还是会帮我们补齐。补齐的顺序依次为:.js、.json 和 .node,在补齐的时候 Nodejs 会依次进行尝试。在尝试的时候 Nodejs 会调用 fs 模块来判断文件是否存在,所以这里可能会存在性能问题,如果在引用模块的时候加上扩展名,可以使得模块加载的速度变得更快。

在 Nodejs 源码 中,我们可以看到当解析不到文件名的时候,会尝试使用 tryExtensions 方法来添加扩展名:

if (!filename) {
    // Try it with each of the extensions
    if (exts === undefined)
        exts = Object.keys(Module._extensions);
    filename = tryExtensions(basePath, exts, isMain);
}

而尝试的扩展名就是 Module._extensions 的键值,检索代码不难发现代码中依次定义了 .js、.json、.node、.mjs 等键,所以 tryExtensions 函数会依次进行尝试:

// Given a path, check if the file exists with any of the set extensions
function tryExtensions(p, exts, isMain) {
    for (var i =; i < exts.length; i++) {
        const filename = tryFile(p + exts[i], isMain);
        if (filename) {
            return filename;
        }
    }
    return false;
}

其中又调用了 tryFile 方法:

function tryFile(requestPath, isMain) {
    const rc = stat(requestPath);
    if (preserveSymlinks && !isMain) {
        return rc === && path.resolve(requestPath);
    }
    return rc === && toRealPath(requestPath);
}
// Check if the file exists and is not a directory
// if using --preserve-symlinks and isMain is false,
// keep symlinks intact, otherwise resolve to the
// absolute realpath.
function tryFile(requestPath, isMain) {
    const rc = stat(requestPath);
    if (preserveSymlinks && !isMain) {
        return rc === && path.resolve(requestPath);
    }
    return rc === && toRealPath(requestPath);
}
// 这个函数在其他地方还有用到,比较重要
function toRealPath(requestPath) {
    return fs.realpathSync(requestPath, {
        [internalFS.realpathCacheKey]: realpathCache
    });
}

可以看到最终还是依赖了 fs.realpathSync 方法,所以这里就跟之前说的是一样的,可能会存在性能问题,如果我们直接带上了扩展名的话,直接就可以解析出 filename,就不会去尝试扩展名了,这样可以稍微提高一点加载速度。

2.3.2 目录和包分析

我们写的文件模块可能是一个 npm 包,此时包内包含许多 js 文件,所以 Nodejs 加载的时候又需要定位文件。Nodejs 会查找 package.json 文件,使用 JSON.stringify 来解析 json,随后取出其 main 字段之后对文件进行定位,如果文件名缺少扩展的话,也会进入扩展名尝试环节。

如果 main 字段指定的文件名有误,或者压根没有 package.json 文件,那么 Nodejs 会将 index 当做默认文件名,随后开始尝试扩展名。

2.4 模块编译

Nodejs 中每一个模块就是一个 Module类实例,Module 的构造函数如下:

function Module(id = '', parent) {
    this.id = id;
    this.path = path.dirname(id);
    this.exports = {};
    this.parent = parent;
    updateChildren(parent, this, false);
    this.filename = null;
    this.loaded = false;
    this.children = [];
}

编译和执行是引入文件模块的最后一个环节,定位到具体文件后,Nodejs 会新建一个模块对象,然后根据路径载入缓存以后进行编译,扩展名不同,编译的方式也不同,它们的编译方法都注册在了 Module._extensions 对象上,前文有提到过:

  • .js 文件:通过同步读取文件内容后编译执行
  • .json 文件:通过 fs 模块读取文件,之后使用 JSON.parse 转化成 JS 对象
  • .node 文件:这是使用 C/C++ 编写的扩展模块,通过内置的 dlopen 方法加载最后编译生成的文件
  • .mjs 文件:这是 Nodejs 支持 ESM 加载方式的模块文件,所以使用 require 方法载入的时候会直接抛出错误

在 Nodejs 的 辅助函数模块 中,通过以下代码把 Module._extensions 传递给了 require 函数:

// Enable support to add extra extension types.require.extensions = Module._extensions;

所以我们可以通过在模块中打印 require.extensions 查看当前 Nodejs 能够解析的模块:

console.log(require.extensions)
// { '.js': [Function], '.json': [Function], '.node': [Function] }

另外我们可以看到上面第二段代码中的注释:Enable support to add extra extension types,也就是说我们可以通过修改 require.extensions 对象来注册模块的解析方法。

比如我们有一个 .csv 文件,我们想把它解析成一个二维数组,那么我们就可以写一下方法注册:

const fs = require('fs')
// 注册解析方法到 require.extensions 对象
require.extensions['.csv'] = function(module, filename) {
    // module 是当前模块的 Module 实例,filename 是当前文件模块的路径
    const content = fs.readFileSync(filename, 'utf'),
          lines = content.split(/\r\n/)
    const res = lines.map(line =&gt; line.split(','))
    // 注意导出是通过给 module.exports 赋值,而不是用 return
    module.exports = res
}
/*
*    demo.csv 的内容为:
*,2,3
*,3,4
*,6,7
*/
const arr = require('./demo.csv')
console.log(arr)
// output
// [ [ '', '2', '3' ], [ '2', '3', '4' ], [ '5', '6', '7' ] ]

但是在 v0.10.6 开始 Nodejs 就不再推荐使用这种方式来扩展加载方式了,而是期望现将其他语言转化为 JavaScript 以后再加载执行,这样就避免了将复杂的编译加载过程引入到 Nodejs 的执行过程。

接下来我们了解一下 Nodejs 内置的几种模块的加载方式。

2.4.1 JavaScript 模块的编译

在我们编写 Nodejs 模块的时候我们可以随意的使用 require、module、module、__dirname 和 __filename 等变量,仿佛它们都是 Nodejs 内置的全局变量一样,但是实际上他们都是局部变量。在 Nodejs 加载 JavaScript 模块的时候,会自动将模块内的所有代码包裹到一个匿名函数内,构成一个局部作用域,顺便把 require……等变量传入了匿名函数内部,所以我们的代码可以随意使用这些变量。

假设我们的模块代码如下:

exports.add = (a, b) => a + b

经过 Nodejs 加载之后,代码变成了下面这样:

(function(exports, require, module, __filename, __dirname) {
    exports.add = (a, b) => a + b
})

这样看起来的话,一切都变得很顺其自然了。这也是为什么每个模块都是独立的命名空间,在模块文件内随便命名变量而不用担心全局变量污染,因为这些变量都定义在了函数内部,成为了这个包裹函数的私有变量。

弄明白 Nodejs 加载 JavaScript 的原理之后,我们很容易就可以弄明白为什么不能给 exports 直接赋值了,根本原因就在于 JavaScript 是一门按值传递(Pass-by-Value)的语言,不管我们给变量赋值的是引用类型还是原始类型,我们得到变量得到的都是一个值,只不过赋值引用类型时,变量得到的是一个代表存储引用类型的内存地址值(可以理解为指针),而我们使用变量时 JavaScript 会根据这个值去内存中找到对应的引用类型值,所以看起来也像是引用传递。而一旦我们给 exports 这种变量重新赋值的时候,exports 就失去了对原来引用类型的指向,转而指向新的值,所以就会导致我们赋给 exports 的值并没有指向原来的引用类型对象。

看看下面这段代码:

function changeRef(obj) {
    obj =
}
const ref = {}
changeRef(ref)
console.log(ref) // {}

可以看到函数内对 obj 重新赋值根本不影响函数外部的 ref对象,所以如果我们在模块内(及包裹函数内)修改 exports 的指向的话,外部的 module.exports 对象根本不受影响,我们导出的操作也就失败了。

下面我们稍微看一下 Nodejs 源码是如何编译执行 JavaScript 代码的。

首先根据 Module._extensions 对象上注册的 .js 模块加载方法找到入口:

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
    const content = fs.readFileSync(filename, 'utf');
    module._compile(content, filename);
};

可以看到加载方法听过 fs.readFileSync 方法同步读取了 .js 的文件内容之后,就把内容交给 module 的 _compile 方法去处理了,这个方法位于 Module 类的原型上,我们继续找到 Module.prototype._compile 方法:

// Run the file contents in the correct scope or sandbox. Expose
// the correct helper variables (require, module, exports) to
// the file.
// Returns exception, if any.Module.prototype._compile = function(content, filename) {
    let moduleURL;
    let redirects;
    if (manifest) {
        moduleURL = pathToFileURL(filename);
        redirects = manifest.getRedirects(moduleURL);
        manifest.assertIntegrity(moduleURL, content);
    }
    const compiledWrapper = wrapSafe(filename, content);
    var inspectorWrapper = null;
    if (getOptionValue('--inspect-brk') &amp;&amp; process._eval == null) {
        if (!resolvedArgv) {
            // We enter the repl if we're not given a filename argument.
            if (process.argv[]) {
                resolvedArgv = Module._resolveFilename(process.argv[], null, false);
            } else {
                resolvedArgv = 'repl';
            }
        }
        // Set breakpoint on module start
        if (!hasPausedEntry &amp;&amp; filename === resolvedArgv) {
            hasPausedEntry = true;
            inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
        }
    }
    const dirname = path.dirname(filename);
    const require = makeRequireFunction(this, redirects);
    var result;
    const exports = this.exports;
    const thisValue = exports;
    const module = this;
    if (requireDepth ===) statCache = new Map();
    if (inspectorWrapper) {
        result = inspectorWrapper(compiledWrapper, thisValue, exports,
                                  require, module, filename, dirname);
    } else {
        result = compiledWrapper.call(thisValue, exports, require, module,
                                      filename, dirname);
    }
    if (requireDepth ===) statCache = null;
    return result;
};

可以看到最后还是交给了 compiledWrapper 方法来处理模块内容(inspectWrapper 是做断电调试用的,咱们可以不管它),继续看 compiledWrapper 方法。

compiledWrapper 方法来源于 wrapSafe 的执行结果:

const compiledWrapper = wrapSafe(filename, content);

而 wrapSafe 函数的定义如下:

function wrapSafe(filename, content) {
    if (patched) {
        const wrapper = Module.wrap(content);
        return vm.runInThisContext(wrapper, {
            filename,
            lineOffset:,
            displayErrors: true,
            importModuleDynamically: experimentalModules ? async (specifier) => {
                const loader = await asyncESM.loaderPromise;
                return loader.import(specifier, normalizeReferrerURL(filename));
            } : undefined,
        });
    }
    const compiled = compileFunction(
        content,
        filename,,
,
        undefined,
        false,
        undefined,
        [],
        [
            'exports',
            'require',
            'module',
            '__filename',
            '__dirname',
        ]
    );
    if (experimentalModules) {
        const { callbackMap } = internalBinding('module_wrap');
        callbackMap.set(compiled.cacheKey, {
            importModuleDynamically: async (specifier) => {
                const loader = await asyncESM.loaderPromise;
                return loader.import(specifier, normalizeReferrerURL(filename));
            }
        });
    }
    return compiled.function;
}
// Module.wrap
// eslint-disable-next-line func-style
let wrap = function(script) {
    return Module.wrapper[] + script + Module.wrapper[1];
};
const wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
];
Object.defineProperty(Module, 'wrap', {
    get() {
        return wrap;
    },
    set(value) {
        patched = true;
        wrap = value;
    }
});

上面这段代码可以看到 wrapSafe 方法通过 Module.wrap 将模块代码构造成了一个匿名函数,随后扔给了 vm.runInThisContext 或者 compileFunction 去执行,这两函数都开始涉及到 JavaScript 跟 C/C++ 的底层了,作者水平渣渣,不再进行下一步解读,感兴趣的童鞋可以自己找到源码继续阅读。

2.4.2 C/C++ 模块的编译

Nodejs 通过调用 process.dlopen 加载和执行 C/C++ 模块,该函数在 Window 和 *nix 系统下有不同的实现,通过 linuv 兼容层进行了封装。

实际上 .node 模块不需要编译,因为是根据 C/C++ 编译而成的,所以只有加载和执行过程。编写 C/C++ 模块能够提高 Nodejs 的扩展能力和计算能力,我们知道 Nodejs 是单线程异步无阻塞的语言,优势在于 IO 密集型场景而非计算密集型场景。当我们有大量的计算操作需要执行时,我们可以将计算操作放到 C/C++ 模块中执行,这样可以提升 Nodejs 在计算密集型场景下的表现。但是 C/C++ 的编程门槛比 Nodejs 高很多,所以这也是一大缺点。

Nodejs 在 v10.x 中引入了 Worker Threads 特性,并且这一特性在 v12.x 中开始默认启用,大大提高了 Nodejs 在计算密集型场景下的表现,在某种程度上减少了开发者所需要编写的 C/C++ 代码量。

2.4.3 JSON 文件的编译

JSON 文件的编译是最简单的,通过 fs.readFileSync 读取文件内容后,调用 JSON.parse 转化成 JavaScript 对象导出就行了。

由于作者水平有限,关于核心模块以及 C/C++ 模块的书写和编译不再讲解。

三、总结

通过这篇文章,我们至少学习到了以下几点:

CommonJS 模块化规范的基本内容

CommonJS 规范主要包括 模块引用模块定义模块标识,规定了一个模块从引入到消费以及导出的整个过程。通过给 require 方法传递模块标识符(路径字符串或者模块名称)来引入 CJS 模块,导出时给 module.exports 或者 exports 赋值或者添加属性即可。

Nodejs 引入模块的加载顺序和基本步骤

1、加载顺序和速度:

require 函数接收到模块标识符时,会优先检查内存中是否已经有缓存的模块对象,有的话直接返回,没有就继续查找。所以缓存的加载优先级和加载速度是最高的,其次是核心模块,因为核心模块已经被编译到了 Nodejs 代码中,Nodejs 启动的时候就已经把核心模块的内容加载到了内存中,所以核心模块的加载顺序和加载速度位于第二,仅次于内存。然后就是文件模块,Nodejs 通过找到文件然后使用对应的方法加载文件中的代码并执行。最后才是自定义模块。

2、加载基本步骤:

加载步骤大概有路径分析、文件定位和编译执行三个过程。

Nodejs 在拿到模块标识符之后,会进行路径分析,获得了入口文件的绝对路径之后就会去内存检索,如果内存中没有缓存的话就会进入下一步,进行文件定位。注意自定义模块会有个 模块路径 的概念,加载自定义模块时会首先在当前文件的同级 node_modules 目录下查找,如果没有找到的话就向上一级继续查找 node_modules,直到系统根目录(Windows 的盘符目录,比如 C:\ 或者 *nix 的根目录 /),所以自定义模块的加载耗时最长。

路径分析之后会进行文件定位,尝试多种不同的扩展名然后判断文件是否存在,如果最终都不存在的话就会继续把这个模块当做自定义模块进行加载,如果还是找不到就直接报错。扩展判断的顺序依次为 .js、.json 和 .node。

Nodejs 对于不同模块的编译方式

  • JavaScript 模块通过包裹函数包裹之后交给系统函数运行
  • JSON 模块通过 JSON.parse 转化为 JavaScript 对象然后返回结果
  • C/C++ 模块通过系统级的 process.dlopen 函数加载执行