业务代码里写 import { useState } from 'react' 时,其实叠了两件事:先选定装哪一版,再选定这一版里用哪个文件。本文只谈后者------入口如何找到具体文件 ,以及安装时实际下载了哪些内容。下文把这套约定称为运行时契约。
核心区分: 依赖能装上,不等于
import一定能跑通。入口写在哪、发布包里有哪些文件,是两件容易混在一起的事。
先收窄和「跑起来」相关的字段
package.json 字段很多,真正影响「导入落到哪、装下来有什么」的只有几组。npm 官方的 package.json 文档 有完整说明,这里按职责压缩成表:
| 字段 | 运行时职责 |
|---|---|
name / version |
包叫什么、是哪一版 |
type |
决定包内 .js 默认按哪种模块格式加载:写成 module 时按 ES 模块(import/export),否则通常按 CommonJS(require) |
exports |
声明对外可用的入口:根入口、子路径,以及不同环境下走哪份文件 |
main |
旧式主入口;没有 exports 时,工具链常靠它找到入口文件 |
files |
发布时把哪些路径打进安装包;常和忽略规则一起用 |
| 三类依赖字段 | dependencies 会跟着被装上;peerDependencies 期望项目自己提供;devDependencies 一般只给开发用,不会进到使用方的安装结果里 |
先记住两枚「主字段」:
exports:管「能不能导、导到哪份文件」files:管「安装包里到底有什么」
exports 的作用可以再压成三句:列出对外入口、按条件挑选文件、挡住未声明的深层路径 。Node.js 在 Packages 文档的 package entry points 里把它当作模块系统的一部分来讲,不只是 npm 的习惯字段。
main 还在,但更多是兼容旧工具。看 npm 上的主流包时,常见两种写法并存:有的几乎只靠 exports;有的同时留着 main。
本节重点: 运行时先抓
exports与files;main多半是兼容层,不是现代包的主故事。
写了包名导入之后,实际怎么找到文件
以 Vite 默认 React 模板里的入口为例(和官方 react-ts 模板同结构):
ts
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
这里的四行 import 不是一类东西:
| 代码 | 属于 |
|---|---|
from 'react' |
第三方包:只写包名 |
from 'react-dom/client' |
第三方包:包名 + 子路径 |
./index.css、./App.tsx |
项目内相对路径 |
后两行带着 ./,直接按项目文件找,不经过 npm 包的 exports。前两行不带 ./、../,一般叫裸包名导入 ,才会去读依赖包的 package.json。
App.tsx 里也常见只写包名的用法,例如:
ts
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Count is {count}</button>
}
export default App
裸包名导入又分两种,差别只在「斜杠后面有没有一段」:
| 写法 | 例子 | 含义 |
|---|---|---|
| 只写包名 | from 'react' |
用这个包的根入口 |
| 包名 + 子路径 | from 'react-dom/client' |
先找到包 react-dom,再用它声明的 ./client 入口 |
易错点:
react-dom/client不是 「react包下面的目录」,而是「另一个包react-dom」+「它对外开放的client入口」。斜杠左边是包名,右边才是子路径。
找到第三方包文件的过程可以想成:
对应到步骤:
- 从导入字符串里拆出包名。
react的包名就是react;react-dom/client的包名是react-dom,子路径是client。 - 从当前文件往上找
node_modules/<包名>,读到这份包的package.json。 - 只写了包名时,看根入口(
exports里的.,或没有exports时的main)。 - 带了子路径时,在这个包 的
exports里找./client这类键。没声明通常直接报错,不会随便落到磁盘上某个文件。 - 同一入口下如果挂了多个条件,再按环境挑选其中一条。
对照 main.tsx:from 'react'
StrictMode、useState 都来自 react。解析器会去读 react 的 package.json。常见形态是这样(节选,字段会随版本变化):
json
{
"main": "index.js",
"exports": {
".": {
"default": "./index.js",
"react-server": "./react.react-server.js"
}
}
}
含义很直接:
from 'react'→ 走根入口.main仍可能留给旧工具链default/react-server:同一根入口在不同环境下可以换成另一份文件
对照 main.tsx:from 'react-dom/client'
Vite 当前模板用的是命名导入:
ts
import { createRoot } from 'react-dom/client'
createRoot(document.getElementById('root')!).render(...)
存量项目和部分脚手架里,也常见默认导入,效果等价于上面的挂载过程:
ts
import ReactDOM from 'react-dom/client'
ReactDOM.createRoot(document.getElementById('root')!).render(...)
重点: 命名导入和默认导入,入口解析结果相同 ------都是
react-dom的./client。差别只在进模块之后怎么取值,不会变成另一个exports键,也不会去读react包。
react-dom 的配置大致是:
json
{
"exports": {
".": {
"default": "./index.js",
"react-server": "./react-dom.react-server.js"
},
"./client": {
"default": "./client.js",
"react-server": "./client.react-server.js"
}
}
}
可以对照着读:
from 'react-dom'→ 根入口.from 'react-dom/client'→./client- 能这样写,是因为作者在
exports里显式声明 了./client;不是「安装目录里刚好有个 client 文件夹」就自动合法
再分清两个都叫 default、但不是一回事的概念:
| 名称 | 指什么 |
|---|---|
exports 里的 "default" |
入口条件的「默认分支」(没匹配到更具体条件时用哪份文件) |
语法上的默认导入 import X from '...' |
从已解析到的那份模块里取默认导出 |
本节重点: 子路径是包对外承诺的入口,不是源码目录自由浏览;默认/命名导入不改变先解析到哪份文件。
补充一句:写 JSX 时,编译器常常还会自动插入对 react/jsx-runtime 的引用。那也是 react 包的子路径,只是业务代码里很少手写,所以本文用大家看得见的 react-dom/client 来说明。
条件字段还可以更细
有些包会在同一入口下同时写 import、require、types:
import/require:管运行时怎么加载types:给类型检查和编辑器提示用
它们可以写在同一段配置里,但用途不同。编辑器如何使用 types,本文不展开。
作为构建工具,vite 自己的包入口也可以几乎只靠 exports 表达:根入口直接写成字符串 ./dist/node/index.js,顶层甚至可以没有 main。这和业务项目里 import ... from 'vite' 时的解析方式是同一套规则。
安装下来的内容,不是 Git 仓库
查依赖出问题时,很容易去对照 GitHub 源码树。
更准确的模型: 安装拿到的是 npm 上的发布压缩包,不是 Git 默认检出的整仓。
里面有什么,取决于发布时的 files、忽略规则,以及一批「永远带上 / 永远不带」的路径。
按 npm 文档的说法,files 用来列出「要打进安装结果」的路径;不写的话,默认会尽量多带。与此同时,package.json、README、LICENSE,以及 main / bin 指向的文件会始终带上;.git、node_modules、锁文件等则不会进包。开发者文档还建议发布前先跑本地的 npm pack:
自检口诀: 看
npm pack --dry-run的清单,比看源码仓库更接近用户实际装到的东西。
可以做一个最小对照:
bash
mkdir demo-pkg && cd demo-pkg
npm init -y
mkdir -p src dist
echo 'export const hi = 1' > src/index.ts
echo 'export const hi = 1' > dist/index.js
# 先不写 files,看试打包会带走什么
npm pack --dry-run
再把 package.json 改成只发布构建结果:
json
{
"name": "demo-pkg",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./dist/index.js"
},
"files": ["dist"]
}
再跑一次 npm pack --dry-run 后,清单里通常没有 src,但仍有 dist 和那些始终保留的元文件。如果线上包体积突然变得很大,优先检查:包含列表是不是太宽,有没有把测试、文档站点或源码草稿一起打进去。
反过来也成立:exports 指向 ./dist/index.js,但 files 把 dist 排除了,使用方就会碰到「解析指向了一个安装结果里不存在的文件」。
本节重点: 入口声明和打包清单必须对得上 ------
exports指到的文件,得真的打进发布包。
两种写错,两种完全不同的症状
| 常见配置失误 | 常见症状 | 优先检查 |
|---|---|---|
exports 漏了子路径,或条件指错文件 |
依赖能装上,但 import 'pkg/sub' 直接报错;或 ES 模块 / CommonJS 加载到不对的文件 |
对照 Node 的入口规则,核对条件名和目标路径 |
files 列得太宽 |
包体积很大,node_modules 里出现测试、文档站点、源码草稿 |
用 npm pack --dry-run 看清单 |
files 列得太窄,或漏了产物目录 |
运行时报找不到模块,或安装结果里几乎只剩 README | 确认 exports / main 指向的文件确实打进了发布包 |
| 只改了源码仓库,没有重新发布构建结果 | 本地源码看起来正常,别人装到的仍是旧行为 | 分清「仓库源码」和「npm 上实际发出去的内容」 |
排查口诀: 导入报错 → 先看入口(
exports);体积异常 / 缺文件 → 先看清单(files)。
公开包里也能对上类似症状,不必当成「某某包做错了」,更适合当排查对照:
- 入口收紧之后,旧子路径失效。
uuid在声明exports之后,像uuid/v1这类深层路径会触发 Node 的ERR_PACKAGE_PATH_NOT_EXPORTED:包能装上,但旧导入写法不再成立。排查时先读对方当前的exports,确认官方还开放哪些子路径,而不是继续沿用仓库里多年前的示例。 - 清单偏宽时,体积先露馅。
moment长期不设狭窄的files白名单,安装结果里常带上大量 locale 等文件,解压后体积可到数百万字节、文件数上百。这更接近「默认能进包的都进来了」,而不是单一字段写错;后来生态转向更轻的日期方案,也和「装依赖不等于只装你用到的那一小块」有关。 - 模块形态变了,加载方式对不上。
chalk高版本、node-fetch的 3.x 走纯 ES 模块后,继续用require(...)会直接失败。这通常是破坏性大版本的选择,症状仍落在入口一侧:先看type与exports条件,再决定用import还是换兼容主版本。
「files 列得太窄」在明星库上反而不常以八卦形式流传,小 CLI 里更常见:exports / bin 已经指向 dist/...,发布物却漏了构建产物。本地仓库看着正常,别人一装就报找不到文件------这时用 npm pack --dry-run 对照入口路径,比猜字段名更快。
观察:写了 exports,不等于入口收得很紧
还有一类值得单独看一眼的写法。以 @modelcontextprotocol/sdk 为例:根入口同样用 types / import / require 区分用途,但 exports 里还有 ./* 这种通配,等于把 dist 下很多路径一并开放出去。
这不等于「不该这样发」。迭代很快的协议类工具包,常常既要支持多入口,也要照顾深层导入。
观察结论: 有
exports≠ 入口收得很紧。还要看它有没有宽通配、边界画在哪------宽通配时更像「目录映射」,而不是「只开放少量稳定入口」。
一些大模型或智能代理相关的工具包也会出现类似情况:用的人很多,但默认仍是 CommonJS,并且同时暴露带后缀和不带后缀的多种子路径。排查时更稳妥的做法,是先打开对方的 package.json,看运行时条件到底指向哪个文件。
运行时契约要点
- 解析: 裸包名导入最终要落到磁盘上的某个文件;现代包优先看
exports,main多用于兼容。 - 安装物:
npm install装到的是发布压缩包,不是 Git 整仓;里面有什么,主要由files和忽略规则决定。 - 排错: 入口配错和清单配错,症状不一样;发布前用
npm pack --dry-run自检,成本最低。
如果在编辑器里跳进依赖后看到大量 .d.ts,或鼠标悬停全是类型信息,那已经换到另一条线:这些内容主要服务类型检查和提示,运行时不一定会加载它们。语言服务如何接上 exports 里的 types,版本号与发行标签如何决定装到哪一份压缩包,以及安装后为何能在脚本里直接跑 vite 这类命令(bin),都可以另文展开。
参考与数据源
- npm CLI:
package.json字段说明(含exports/files/main) - npm CLI:Developers(发布物与
npm pack自检) - Node.js:Packages --- package entry points
uuid子路径与exports的讨论:uuidjs/uuid#613- 文中
react、react-dom、vite、@modelcontextprotocol/sdk、moment、chalk、node-fetch的字段或安装物形态摘自撰稿时的 npm 公开信息;阅读时建议用npm view <包名> exports、npm view <包名> dist对照当前结果