Node.js 模块解析机制和 package exports 是怎么回事?
简化版
Node.js 模块解析会根据内置模块、相对路径、绝对路径、包名和 node_modules 层级查找模块;现代包还会通过 package.json 的 exports 字段限制和声明可导入入口。exports 能让包入口更明确,但也会让未声明的深层路径导入失效。
详细版
模块解析要先看导入写法:
node:fs或fs:优先解析内置模块。./a.js、../a.js、/abs/a.js:按文件路径解析。lodash、@scope/pkg:按包名从当前目录向上查找node_modules。
CommonJS 里 require() 还会尝试文件、目录、扩展名和 package.json 的 main。ESM 更强调显式路径和包导出,很多情况下相对路径要写完整扩展名。
exports 是现代包的重要字段。它可以声明包对外暴露的入口,并为 import、require、node、browser 等条件提供不同文件。使用 exports 后,包内未暴露的路径通常不能再被外部随意深层导入。
完整版教学
一、模块解析先回答“这个名字指向谁”
当代码写下 import x from 'pkg' 或 require('./util') 时,Node.js 不会凭空知道文件位置,它必须把说明符解析成一个具体模块。说明符大致分三类:内置模块、路径模块、包模块。
import fs from 'node:fs'; // 内置模块
import config from './config.js'; // 相对路径
import express from 'express'; // 包名
内置模块最直接,路径模块从当前文件位置出发,包模块则沿目录向上查找 node_modules。如果当前文件在 project/src/routes/user.js,导入 express 时会依次考虑 project/src/routes/node_modules、project/src/node_modules、project/node_modules 等位置。
这个机制解释了很多工程问题:为什么 monorepo 里依赖版本会被提升,为什么同名包可能出现多份,为什么某些脚本换了执行目录就找不到模块。
二、CommonJS 的 require 有历史包袱
CommonJS 诞生很早,它的 require() 解析非常照顾历史兼容。导入相对路径时,可能尝试精确文件、补扩展名、目录入口等规则。
require('./foo')
-> ./foo
-> ./foo.js
-> ./foo.json
-> ./foo.node
-> ./foo/package.json main
-> ./foo/index.js
这让早期开发很方便,但也带来隐式性。你看到 require('./foo') 时,不一定马上知道最终加载的是文件、JSON、原生扩展还是目录入口。大型项目里,隐式解析越多,重构时越容易踩坑。
ESM 更强调静态结构和明确路径。尤其是相对路径导入时,很多情况下需要写完整扩展名,例如 import './foo.js'。这能让浏览器、打包器和运行时更容易达成一致。
三、包名解析和 node_modules 层级
包名解析会从当前文件所在目录向上查找 node_modules。这个设计让子目录可以使用上层项目安装的依赖,也让不同子树可以安装不同版本。
app/
node_modules/react@18
packages/admin/
node_modules/react@17
src/page.js
如果 page.js 导入 react,它可能先拿到 packages/admin/node_modules/react@17,而不是根目录的 react@18。这种规则很灵活,但也解释了“同一个项目里为什么有两份 React”。
假设一个页面依赖链中出现两份 React,包体可能从 130 KB 增到 260 KB,更严重的是 Hook、Context、instanceof、单例缓存可能因为模块实例不同而失效。面试回答模块解析时,如果能联系依赖重复和包管理,会更有工程感。
四、main、module、exports 的演进
早期包常用 main 声明入口。后来前端打包生态里出现了 module 字段,常用来指向 ESM 构建产物,但它不是 Node.js 标准包入口的核心字段。
现代 Node 更推荐用 exports 明确声明入口。它不仅能声明默认入口,还能限制子路径,并按条件选择文件。
{
"name": "demo-lib",
"main": "./dist/index.cjs",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": {
"import": "./dist/utils.js",
"require": "./dist/utils.cjs"
}
}
}
有了这份配置,使用方可以导入 demo-lib 和 demo-lib/utils,但不能随意导入 demo-lib/dist/private.js。这提升了包的封装性,也让包作者能重构内部目录而不破坏公开 API。
五、exports 的条件匹配与双格式包风险
exports 可以根据条件返回不同入口,例如 import 给 ESM,require 给 CommonJS。条件导出非常强大,但双格式包最怕产生“双包实例”。
| 条件 | 常见含义 | 风险 |
|---|---|---|
import | ESM 使用入口 | 与 CJS 入口状态分裂 |
require | CommonJS 使用入口 | 与 ESM 入口不是同一实例 |
node | Node 环境入口 | 浏览器构建可能不同 |
default | 默认兜底入口 | 条件顺序要谨慎 |
例如一个库内部维护全局计数器,如果 ESM 入口和 CJS 入口各自加载一份实现,那么 import 用户看到的计数和 require 用户看到的计数可能不一致。库作者应尽量让两个入口共享同一份核心实现,或者清晰声明状态边界。
易错点:
exports不是“多写几个入口方便导入”,它是在定义包的公开边界。边界一旦发布,就等于 API 承诺。
六、深层导入为什么容易破坏升级
很多项目喜欢写 import debounce from 'pkg/lib/debounce.js',原因是想减少包体或绕开主入口。但这种深层导入依赖的是包内部目录结构,而内部结构本来不一定是公开 API。
pkg v1:
lib/debounce.js
pkg v2:
dist/debounce.mjs
exports: { "./debounce": "./dist/debounce.mjs" }
如果使用方仍然导入 pkg/lib/debounce.js,升级 v2 后就可能直接失败。更稳妥的做法是使用包公开的子路径,例如 pkg/debounce,前提是它在 exports 中声明。
在前端工程里,深层导入还会影响 tree shaking、类型声明和多环境构建。现代包应把可用入口写清楚,应用项目也应尽量避免依赖未声明的内部路径。
七、常见误区与追问
- 误区:Node.js 找模块只看当前目录的 node_modules。 包名解析会从当前文件目录逐级向上查找,直到根目录附近。
- 误区:exports 和 main 作用完全一样。
main主要声明传统入口,exports还能限制公开子路径并支持条件导出。 - 误区:深层导入一定更优化。 深层导入可能依赖内部结构,破坏升级稳定性,也可能绕开包作者设计的副作用和类型入口。
- 追问:为什么有 exports 后某些老路径不能导入了? 因为包显式声明了公开入口,未声明子路径会被封装起来。
- 追问:为什么 ESM 相对路径常要写扩展名? ESM 追求更明确的静态解析,减少 CommonJS 那种隐式补全带来的不确定性。
- 追问:双格式包有什么风险?
import和require可能加载到不同文件,导致单例状态、缓存和类型身份分裂。
八、加强记忆
模块解析可以记成“先分类,再找入口,再守边界”:内置模块直接拿,路径模块按文件找,包模块向上找 node_modules;进入包后看 exports 或传统入口;有 exports 时只认公开边界。面试时把 node_modules 层级、exports 封装、深层导入风险串起来,就是完整答案。