您现在的位置是:网站首页> 编程资料编程资料
Nodejs 模块化实现示例深入探究_node.js_
2023-05-24
429人已围观
简介 Nodejs 模块化实现示例深入探究_node.js_
正文
本文只讨论 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(1, 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. // 1. If a module already exists in the cache: return its exports object. // 2. If the module is native: call // `NativeModule.prototype.compileForPublicLoader()` and return the exports. // 3. 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}\x00${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 '
相关内容
- React Hook中的useEffecfa函数的使用小结_React_
- JavaScript canvas 实现用代码画画_javascript技巧_
- React中的路由嵌套和手动实现路由跳转的方式详解_React_
- Vue项目新一代状态管理工具Pinia的使用教程_vue.js_
- 如何在React中直接使用Redux_React_
- Vue悬浮窗和聚焦登录组件功能实现_vue.js_
- Vue组件与Vue cli脚手架安装方法超详细讲解_vue.js_
- TypeScript中泛型的使用详细讲解_javascript技巧_
- 关于javascript解决闭包漏洞的一个问题详解_javascript技巧_
- Vuex与Vue router的使用详细讲解_vue.js_
点击排行
本栏推荐
