package.json 里常能看到 "dev": "vite",项目并没有全局安装 Vite,终端里直接敲 vite 也可能提示找不到命令,但 npm run dev 却能起来。装依赖不只是为了 import:bin 会在安装后接到项目的 node_modules/.bin,于是脚本和 npx 才能用短命令名找到包里的可执行文件。
核心区分:
exports(或main)回答「代码里 import 落到哪份文件」;bin回答「命令行里敲哪个名字,会执行包里的哪份脚本」。 两边可以同时存在,也可以只有其一。
bin 在声明什么
npm 文档的 package.json 字段说明写到:bin 用来给出命令名到包内文件的映射。安装该包时,包管理器会按这份映射,在消费方项目里接上可执行入口(常见落点是 node_modules/.bin)。
两种常见写法:
json
{
"name": "my-tool",
"bin": "./bin/cli.js"
}
字符串形式表示:命令名默认等于包名(这里是 my-tool),指向包内相对路径。
json
{
"name": "typescript",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
}
}
对象形式可以挂多条命令,且命令名不必等于包名。公开包上也能直接核对(结果随版本变化,以命令输出为准):
bash
npm view vite bin
npm view typescript bin
npm view eslint bin
形态示意:
| 包名 | bin 里常见的命令名 |
指向(示例) |
|---|---|---|
vite |
vite |
bin/vite.js |
typescript |
tsc(以及 tsserver) |
bin/tsc 等 |
eslint |
eslint |
bin/eslint.js |
prettier |
prettier |
bin/prettier.cjs |
所以「装了 TypeScript 包,脚本里却写 tsc」并不奇怪:脚本用的是 bin 声明的命令名,不是包名本身。
本节重点:
bin是命令名到包内脚本的地图。 地图写在包里;真正让短命令名「能被找到」,靠的是安装时的接线,以及运行时谁把接线目录放进查找路径。
核心原理:不是注册给整个操作系统
容易误会成:声明了 bin,操作系统就会像装系统软件一样,永久认识这个命令。本地依赖安装时,并不是往系统全局的可执行目录里登记一条「全机通用」的命令。
更接近真实链路的是两步:
第一步:安装时,npm 读依赖的 bin,在本项目里落盘入口。
执行 npm install(或装上某个带 bin 的依赖)时,npm 会打开该包的 package.json,按 bin 映射,在消费方项目的 node_modules/.bin/ 下为每个命令名创建一个入口------常见是指向包内脚本的符号链接;在 Windows 上则常常是一段小的包装脚本。可以把它看成项目私有的命令目录,不是改系统注册表、也不是改全机 PATH。
bash
ls node_modules/.bin
# 常见会看到 vite、tsc、eslint 等与 bin 命令名对应的入口
第二步:运行时,谁要「认识」这些名字,谁就得把 .bin 放进查找路径。
终端敲一个短命令时,系统只会在当前环境的 PATH 里找同名可执行文件。本地装好 Vite 之后:
- 在项目外随便开一个 shell,直接敲
vite:PATH里通常没有 这个项目的node_modules/.bin,所以会提示找不到命令------依赖其实可能已经装好了。 - 执行
npm run dev且脚本里写着vite:npm 在拉起脚本进程时,会把该项目的node_modules/.bin临时加到这次进程的 PATH 前面,于是短命令名能解析到刚才装好的入口。 - 使用
npx vite/npm exec:由 npm 按本地(或按需拉取的)包去解析命令,本地已装时同样落到这套接线。 - 也可以不靠 PATH:直接跑
./node_modules/.bin/vite,等于手写绝对入口。
所以:bin 是声明;安装是在项目里接线;npm run / npx 是临时(或显式)走这根线。 并不是「声明一次,操作系统从此永远认识」。
和全局安装 要分开看:npm install -g 一类操作会把命令接到 npm 的全局可执行目录;若该目录本来就在你的用户 PATH 里,任意目录敲短命令才可能「整机都能用」。那是另一条路径,解决的是「这台机器默认带哪些工具」,不是「这个项目依赖哪一版」。
原理一句: 本地场景下,npm 扫描的是依赖的
bin,接线目标是项目的node_modules/.bin;系统会不会「认识」,取决于当前进程的 PATH 里有没有这个目录。
三条常见消费路径
假定项目已经 npm install 过,且 node_modules/.bin 里能看到 vite。同一条接线,常见有三种用法------差别在于谁负责打开抽屉、以及你要不要把命令写进仓库脚本。
1. npm run:写进脚本,交给 npm 临时改 PATH
仓库里通常这样写:
json
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}
你在项目根执行的是:
bash
npm run dev
这里发生的事可以拆开看:
- npm 读到脚本字符串是
vite(后面还可以带参数,如vite build)。 - 它拉起一个子进程去执行这条命令,并在这个子进程 的环境里,把
node_modules/.bin插到 PATH 靠前位置。 - 子进程按短命令名查找时,先命中
.bin/vite,再进到包里的真实脚本。
因此:脚本里写的是命令名,不是 ./node_modules/.bin/vite 这种长路径 ;你自己的交互式终端 PATH 不必改,改的是 npm 这次帮忙开的那扇门。同事克隆仓库后 npm install 再 npm run dev,走的是同一套约定。
对照:在同一目录直接敲 vite(前面不加 npm run),往往仍会失败------因为那是当前 shell 自己的 PATH,默认通常不包含项目的 .bin。
2. npx / npm exec:不经 scripts,按命令名让 npm 去解析
有时你不想(或还没)把命令写进 scripts,只想跑一次,例如:
bash
npx vite --version
npx tsc --noEmit
npm exec 与日常习惯的 npx 同族:由 npm 按你给出的命令名 去解析要执行哪份入口。本地已经装过对应依赖时,结果应落到同一套 node_modules/.bin 接线;本地没有时,还可能按规则临时拉取再执行(具体策略随 npm 版本与参数而变,以当前文档为准)。
和 npm run 的分工可以记成:
npm run:命令写在package.json的scripts里,适合项目固定流程(dev / build / lint)。npx:命令写在命令行上,适合一次性或探索性调用;也可以在持续集成里显式写出npx tsc --noEmit这类步骤。
两者都不是「操作系统突然认识了 vite」,而是 npm 作为启动器,帮你走到已接线的入口。
3. 直接跑 .bin 下的文件:把接线结果摊开
第三条路最短、也最「露骨」:
bash
./node_modules/.bin/vite --version
这里不依赖「短命令名 + PATH」:你指明了入口文件(或链接)的路径,shell 按路径执行即可。它适合:
- 排查:
npm run失败时,先看.bin里有没有这个名字;有则再跑这一条,区分「没接线」和「脚本字符串写错」。 - 少数工具链或文档示例需要写死路径时。
日常开发很少把这条写进 scripts------又长又绑目录结构;固定流程仍优先 npm run。
三条怎么选: 固定流程 →
npm run;临时跑一下 →npx;排查接线 → 直接敲.bin。 三条最终都应落到安装时建好的那份入口,而不是三套互不相干的魔法。
到这里,消费侧链路已经闭合:声明 → 装进 .bin → 由 npm run / npx / 路径调用找到它。 后面只回答两件事:发布时别把线剪断;断了时按同一条链排查。
发布时别把线剪断
exports / main 管的是 import 落到哪份文件;bin 管的是命令名落到哪份脚本。两者可以同时存在,也可以只有命令行入口------改一边不会自动修好另一边。
对命令入口来说,发布侧真正要守住的是:bin 指向的相对路径,必须出现在别人 npm install 下来的压缩包里。本地源码目录里文件都在,所以自测总成功;若 files 或忽略规则把 bin/ 漏掉,别人装完后 .bin 即使建了入口,背后也没有可执行文件。
发布前在包根执行:
bash
npm pack --dry-run
核对列表里是否包含 bin 指向的那条路径。再补三件小事即可闭环:映射写对(命令名可以不等于包名)、脚本带解释器行(常见 #!/usr/bin/env node)、装到哪一版仍由版本号与锁文件决定------bin 只描述「这一版里命令叫什么、文件在哪」。
断线时按链路查
对照前面同一条链,症状通常落在其中一环:
| 你看到的 | 先查哪一环 |
|---|---|
npm run 提示找不到命令 |
依赖是否装上;脚本里的名字是否等于 bin 里的命令名(如包名 typescript、命令名 tsc);node_modules/.bin 有没有这个名字 |
装了包,.bin 却是空的或不含预期命令 |
该版是否声明了 bin(npm view <包名> bin);安装是否完整 |
直接敲短命令失败,但 npm run 正常 |
符合预期:当前 shell 的 PATH 未包含项目 .bin |
别人装你的包后 npx / npm run 才坏 |
压缩包是否带上了脚本:回到 npm pack --dry-run |
能 import 却不能当命令跑 |
库入口与 bin 本就不是同一根线 |
口诀: 找不到命令 → 看
.bin有没有名字;有名字却失败 → 看指向的文件还在不在;只有别人装完才坏 → 看发布包有没有带上该文件。
要点
bin是地图,命令名可以不等于包名。- 安装接到项目
.bin,不是给操作系统做全机注册;npm run/npx负责让本次执行找得到它。 - 发布要保住地图指向的文件;库入口字段管不了命令入口。
参考与数据源
- npm CLI:package.json 的 bin 字段
- npm CLI:npm exec(与
npx同族的执行入口) - npm CLI:npm pack
- 文中
vite、typescript、eslint、prettier的bin为形态示意,会随版本变化;请用npm view <包名> bin对照当前结果