一文理清 node.js 模块查找策略

清楚 node 模块查找策略可以帮我们更好熟悉工程化,毕竟模块化是工程化之基,本篇文章就带大家一起学习 node.js 模块查找策略

参考文献:CommonJS 模块 | Node.js v24 文档

node.js 模块查找算法

node 官网中关于 node.js 模块查找算法的解释总结如下

翻译出来就是下面的意思,记住这张图,里面的函数稍后会作解释

这里有两个参数,其中 X 就是 require 的入参,Y 就是当前文件的路径,一个文件就是一个模块,因此我们也可以说 Y 就是当前模块的所在路径

可以看到这个算法有 7 个步骤,除了里面涉及到的几个函数,其余我们都可以理解

第一步:内置模块去查找

node 内置模块有很多,比如 fs,http, path 等,其余的模块就是一些需要 install 的依赖,比如 lodash,因此 node 会优先识别内置模块,要是找不到就往后续步骤执行

第二步:X/ 开头,则识别为绝对路径去查找

这里我以 windows 举例,windows 的绝对路径和 mac/linux 不同,windows 用的是反斜杠 \,而反斜杠 \ 又恰好是 js 的转义字符,所以这里需要写成 两个 \ 才行

第三步:X.//../ 开头,则识别为相对路径去查找

如果 X 等于 '.',或者 X 以 './'、'/' 或 '../' 开头,那么就会依次执行下面三个步骤

  1. LOAD_AS_FILE(Y + X)
  2. LOAD_AS_DIRECTORY(Y + X)
  3. 抛出 "not found" 错误

先看 LOAD_AS_FILE(Y + X) 的内容

若不考虑 LOAD_AS_FILE(Y + X) 的第二小步的继续深入,其实 LOAD_AS_FILE(Y + X) 的意思就是

  1. 直接加载对应后缀的文件,比如 require('./test.js')
  2. 自动添加 .js 后缀,比如当前目录下有 test.js ,我们还可以直接写 require('./test')
  3. 直接加载 .json 文件,比如 require('./config.json')
  4. 直接加载 .node 文件,比如 require('./test.node'),一般 node 都是二进制无法阅读

现在我们进入 LOAD_AS_FILE(Y + X) 的第二小步,也就是假设 require('./test') 时当前目录存在 test.js 时,node 应该如何加载 这个 test.js

md 复制代码
a. 找到距离 X 最近的包作用域 SCOPE。
b. 如果没有找到作用域
  1. MAYBE_DETECT_AND_LOAD(X.js)
c. 如果 SCOPE/package.json 包含 "type" 字段,
  1. 如果 "type" 字段是 "module",将 X.js 作为 ECMAScript 模块加载。停止。
  2. 如果 "type" 字段是 "commonjs",将 X.js 作为 CommonJS 模块加载。停止。
d. MAYBE_DETECT_AND_LOAD(X.js)

这里解释什么是 包作用域 SCOPE,所谓包作用域 SCOPE 就是包含 package.json 的目录,比如 vue 源码里面的 各种 package ,其中有 compiler-sfc ,那么这个目录下的范围就是一个 包作用域 SCOPE

也就是说一个包的 package.json 里面的 type 字段直接影响了这个包内所有的 .js 文件的加载方式,比如 type: module 就意味着这个包只能使用 ES 模块加载方式,与之对应的 type: commonjs 就只能使用 commonjs 模块加载方式

如果没有找到 package.json ,或者有 package.json 但是没有 type 字段,那么就会调用 MAYBE_DETECT_AND_LOAD(X.js) ,这个函数作用是自动检测模块语法

🙋‍♀️🌰,我在 package.json 设置了 type: module

json 复制代码
{
  "name": "demo",
  "type": "module"
}

那么我在 app.js 想要导入 test.js ,那么就不能用 require() ,只能 import

js 复制代码
import testFunc from './test.js';

要是 没有 package.json 或者 package.json 没有设置 type 字段,MAYBE_DETECT_AND_LOAD(X.js) 是如何自动检测模块语法的呢

其实很简单,我们也可以想到,import/exportesm 语法,module.exports/requirecommonjs 语法,它会扫描这个关键字来自动判断

第一个函数没有结果就执行 第二个函数 LOAD_AS_DIRECTORY,也就是说文件找不到时,那就当做文件夹去找

总共就两个步骤,先深入第一步

这个函数处理的就是文件夹的情况,先看 若 X 是文件夹,那其下的 package.jsonmain 字段,也就是包的入口文件,一般都会设置成 main.js,若字段为空,执行 第二小步 的 LOAD_INDEX(X),这个函数下面解释

第三小步,加载 X + main 字段的值

第四小步,main 文件不存在,尝试以 main 作为 目录,也就是执行上面的 LOAD_AS_FILE

第五小步,若 main 字段的文件不存在,尝试加载 x/index.js ,可以理解为 main 的默认值就是 index.js

第二步就是 LOAD_INDEX(X) ,这个函数的步骤,就是针对 index 换后缀,顺序依次为 jsjsonnode

第四步:X# 开头

require() 的参数若 以 # 开头,就是表示这个包是私有包,外部无法访问,这个还比较少见

比如我在当前目录下 app.js 文件中 require('#utils')

那么意味着当前目录的 package.json 配置就需要在 imports 字段中声明这个私有包的位置

json 复制代码
{
  "name": "demo",
  "imports": {
    "#utils": "./src/utils.js"
  }
}

我若是在 当前目录下新建一个 包(包含package.json),然后里面去通过 # 引用外层的私有包 utils,那么就会报错,不过依旧可以去用 相对路径 引用,所以这么看这个私有好像也没啥用

回到第四步,若 参数 以 # 开头,那么就会调用函数 LOAD_PACKAGE_IMPORTS(X, dirname(Y))

DIR 是入参 dirname(Y),前面提到过 Y 是当前 require() 所在文件的路径,那么 dirname(Y) 就是当前文件所在的文件夹的路径,也就是去掉了当前文件,所以 DIR 就是当前包

md 复制代码
Y (当前文件路径): C:\Users\22922\Desktop\module\app.js
dirname(Y) (目录部分): C:\Users\22922\Desktop\module

第二步,找最近的 SCOPE指的是从当前 SCOPE 向外层找

第四步,若启用了 --experimental-require-module ,指的是命令行,比如 scripts 中我们可以设置 这个命令行

json 复制代码
{
  "scripts": {
    "dev": "node --experimental-require-module app.js"
  }
}

启用了这个 ,node 内部会设置 conditions 数组,启用后的作用就是可以让 commonjs 同步 require esm 语法导出的模块,require 本身就是同步的,这个启动配置可以让其支持 同步 esm 语法的导出

第五步其实就是将 X 也就是 # 开头的参数用 pathToFileURL 解析成 file://URL 格式,其实就是 #utils 映射成 相对路径,然后 内部添加 conditions 数组

第六步,将 file://URL 转换回文件系统路径,验证文件真实存在,以及后缀,成功加载并返回模块内容

第五步:LOAD_PACKAGE_SELF(X, dirname(Y))

同样,前两步从自身 SCOPE 往外层查找,其实就是找 package.json

第三步检查 package.jsonexports 字段

json 复制代码
{
  "name": "my-package",
  "exports": {
    ".": "./main.js",
    "./utils": "./utils.js",
    "./config": "./config.json"
  }
}

第四步检查包名匹配,比如这里 require('my-package') 就会加载 main.js

第五步 PACKAGE_EXPORTS_RESOLVE 解析路径

require('my-package') 就是 "." + "" 找到 ./main.js

require('my-pckage/utils') 就是 "." + "/utils" 找到 ./utils.js

第六步 就是将 上一步 返回的 match (url) 转换回文件系统路径,然后检查文件真实存在,依据后缀名返回实际的模块内容

第六步:LOAD_NODE_MODULES(X, dirname(Y))

第一步生成 node_modules 目录路径(可能包含多种情况),比如 当前 scope/home/user/project/src/components

那么 DIRS 数组可能为

js 复制代码
DIRS = [
  '/home/user/project/src/components/node_modules',
  '/home/user/project/src/node_modules', 
  '/home/user/project/node_modules',
  '/home/user/node_modules',
  '/home/node_modules',
  '/node_modules',
  ...全局目录
]

第二步,尝试在每个 DIR 中尝试三种加载方式

第一种使用包的 exports 字段加载,这就要看 package.jsonexports 字段配置了

第二种将当前 require 入参作为文件加载

第三种将当前 require 入参作为目录加载

第七步:return 'not found'

简洁版

看完肯定很绕,而且一些内容是我们平时开发中很少接触的,那就总结一下 node 模块查找算法的简洁版

  1. 内置模块(比如 fs,path,file)

  2. 路径(先绝对后相对)

  3. 尝试作为文件查找(LOAD_AS_FILE

    a. ./config

    b. ./config.js(根据最近包(从自身往外层)的 package.jsontype字段)

    c. ./config.json

    d. ./config.node

  4. 尝试作为文件夹查找(LOAD_AS_DIRECTORY,看 package.jsonmain 入口文件,要是没注册就默认 index.js

  5. # 开头的私有包(需要 package.jsonimports 去注册)

  6. 包内部引用自己的子模块(require('my-package/utils'),需要 exports 字段注册)

  7. 查找依赖

  8. 找不到报错

这么看第三步和第四步才是核心,原来从内置模块查找第三方库的依赖需要调尽这个算法全部过程

文章中若出现错误内容还请各位大佬见谅并指正。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!欢迎关注我的公众号: Dolphin_Fung

相关推荐
海天胜景3 小时前
vue3 el-table动态表头
javascript·vue.js·elementui
中国lanwp4 小时前
Spring Boot 中使用 Lombok 进行依赖注入的示例
java·spring boot·后端
胡萝卜的兔4 小时前
golang -gorm 增删改查操作,事务操作
开发语言·后端·golang
凌辰揽月5 小时前
AJAX 学习
java·前端·javascript·学习·ajax·okhttp
掘金码甲哥6 小时前
Golang 文本模板,你指定没用过!
后端
然我6 小时前
防抖与节流:如何让频繁触发的函数 “慢下来”?
前端·javascript·html
lwb_01186 小时前
【springcloud】快速搭建一套分布式服务springcloudalibaba(四)
后端·spring·spring cloud
烛阴7 小时前
非空断言完全指南:解锁TypeScript/JavaScript的安全导航黑科技
前端·javascript
爱掉发的小李8 小时前
前端开发中的输出问题
开发语言·前端·javascript
张先shen8 小时前
Spring Boot集成Redis:从配置到实战的完整指南
spring boot·redis·后端