ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

npm包开发与私有源发布实战:从目录规范到报错排查

npm包开发与私有源发布实战:从目录规范到报错排查 上个月团队里第三个人跑来问我同一段日期格式化代码放在哪我打开他的项目一看四个仓库里躺着四份几乎一样但细节各有差异的 utils.ts。这事之后我花了两个下午把这类公共逻辑抽成了一个 npm 依赖包从本地调试到打包最后推到公司内部的镜像库。整个过程踩的坑比想象中多npm link 之后模块解析不到、peerDependencies 没声明导致线上出现两份实例、.npmignore 写错把 sourcemap 和测试目录一起传上去、PowerShell 把 npm.ps1 拦住报禁止运行脚本。这篇文章就把这条链路完整走一遍从哪些代码值得抽成包一直讲到怎么发布到自己的私有源以及怎么在别人机器上装得动。适合已经会写 JavaScript 或 TypeScript、但从来没自己发布过包的同学也适合想把内部组件库规范化的团队拿去当参考。我尽量把每个决策背后的原因说清楚而不是只丢一段命令让你抄。1. 从复制粘贴第三次说起npm包的边界怎么划动手写代码之前先想清楚一件事这段逻辑到底该不该做成包。这个判断做错了后面所有工程化的努力都会变成负担。1.1 什么代码值得抽成独立包我的判断标准很朴素满足下面任意两条就值得抽出来跨项目复用且已经复制超过两次。第一次复制是偶然第二次是巧合第三次就是技术债。复制出去的四份代码一旦有一份修了 bug另外三份就是埋着的雷。有独立的领域语义。比如日期时区处理、金额精度计算、统一的请求签名算法。这类东西写错了不会报错只会静默给出错误结果最需要集中维护和测试。需要跨技术栈使用。同一个工具函数既要给内部后台用又要给构建脚本或者命令行工具用那么它的形态最好是零运行时依赖 纯 ESM/CJS 双输出。对外的接口稳定。一个还在天天改签名的模块抽成包只是把改动成本从一次提交变成了改包 发包 升版本 各项目跟进反而更慢。反过来这些情况我不建议抽包只在一个项目里用的业务组件、依赖特定框架版本的工具、以及那种把整个 utils 目录原样搬过来的巨型包。后者最容易变成谁都不敢改、谁都得依赖的怪物。1.2 包名、scope 与一套能长期维护的目录骨架包名有两套体系。发到公共源就用你自己的命名风格注意全小写、不能有空格和大写字母也不能和已存在的包重名。发到公司内部源强烈建议用 scope也就是团队名/包名这种形式。scope 的好处不只是好看它让你可以在.npmrc里把某个前缀的包单独指向私有源其他包照常走公共源这在后面配置多源的时候非常关键。目录骨架我一般这么搭my-date-utils/ ├─ src/ │ ├─ index.ts │ ├─ timezone.ts │ ─ format.ts ├─ test/ │ └─ format.spec.ts ├─ dist/ # 构建产物不提交到仓库 ├─ package.json ├─ tsconfig.json ├─ .npmignore └─ README.mdsrc放源码、dist放构建产物、test放测试这三层分开目的是让发布出去的东西和开发时看的东西解耦。发布包里只有dist和README源码和测试不进包安装体积能小一大截。提示如果你用 TypeScript 写types声明文件一定要和dist一起发布否则用包的人会看到满屏的找不到类型声明。这个问题在 TypeScript 项目里出现频率极高我后面还会再提。2. package.json 里的每个字段都在替你说话package.json不是一个配置文件它是你和所有使用者之间的合同。写得含糊用的人就得靠猜。2.1 npm init 之后必须动手改的字段npm init -y生成的默认内容基本不能用下面这几个字段我每次都会重写字段作用我通常怎么写name包名必须全小写且唯一myteam/date-utilsversion版本号遵循 semver首次发0.1.0description搜索展示和可读性一句话说清用途mainCJS 入口dist/index.cjsmoduleESM 入口打包器识别dist/index.mjstypes类型声明入口dist/index.d.tsfiles白名单决定哪些文件进包[dist, README.md]sideEffects告诉打包器能否摇树falseengines声明 Node 版本要求{ node: 16 }publishConfig指定发布目标源{ registry: http://内网地址:4873/ }license许可证内部包写UNLICENSEDfiles这个字段特别值得单独说。它的语义是白名单——只列进去的才会被打包。很多人习惯用.npmignore做黑名单结果每次新增一个调试目录都得记得补一条早晚会漏。用files白名单漏的反而是少发不会多发敏感文件风险方向完全不同。package.json、README.md、LICENSE是 npm 强制或默认包含的不用写进去。2.2 exports、main、module、types三种模块体系的入口之争现在的 Node 生态处在一个尴尬的过渡期老项目用require新项目用import打包器又各有脾气。只写main已经不够了推荐用exports字段做统一声明{ name: myteam/date-utils, version: 0.1.0, type: module, main: ./dist/index.cjs, module: ./dist/index.mjs, types: ./dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.mjs, require: ./dist/index.cjs }, ./package.json: ./package.json }, files: [dist, README.md] }这里有几个容易翻车的点。第一exports一旦声明它就是唯一的入口规则包内其他文件默认对使用者不可见。有人升级后突然报模块找不到往往就是因为之前写得进pkg/dist/xxx.js这种深层路径加了exports之后被挡住了。第二types条件必须放在最前面。Node 和 TypeScript 解析条件时是从上往下取第一个匹配的把types放到import后面类型就会解析失败。第三./package.json这条导出建议加上很多工具链会去读它。2.3 files 与 .npmignore别把调试残留发上去如果非要用.npmignore记住一条.npmignore存在时files白名单依然生效两者是交集关系。我自己的做法是只用files.npmignore里只留一条*.tsbuildinfo这种构建缓存的排除。真正确认包里有什么要在发布前跑一次npm pack --dry-run它会打印出将要打包的完整文件清单。我见过有人把整个node_modules打进去包体积 40MB安装的时候同事都在骂人原因就是files没写、.npmignore也没写。3. 本地调试三条路npm link、file: 依赖与 workspace包写完了接下来的问题是怎么在真实项目里试。这里的选择直接决定你后面两天是愉快开发还是怀疑人生。3.1 npm link 到底做了什么为什么经常改了没生效npm link的原理是符号链接。在包目录里执行npm linknpm 会把这个包注册到全局的node_modules下建一个指向源码目录的软链接再到使用方项目里执行npm link myteam/date-utils使用方的node_modules里就会出现一个指向全局链接的软链接。# 在包目录 npm link # 在使用方项目 npm link myteam/date-utils好处是改了源码立刻生效不用反复装。但有两个高频坑。第一个坑是指向的是源码目录不是构建产物。如果你的main指向dist/index.cjs而你改的是src里的文件那必须先跑构建。很多人一遍遍刷新页面发现没变化其实只是忘了npm run build。所以我在调试期会开一个tsc --watch或打包器的 watch 模式挂在后台。第二个坑是依赖重复。使用方项目通过软链接引用了包目录包目录自己也有node_modules如果这个包里依赖了某个库而使用方也依赖同一个库很容易出现两份实例。React 项目里这个症状特别典型报invalid hook call或者上下文拿不到。解决办法是把这类必须和使用方共用同一份的依赖写进peerDependencies不要写进dependencies。3.2 file: 协议与 workspace更适合多包项目的两种做法npm link是全局注册容易留下幽灵链接卸载不干净。我现在更常用file:协议{ dependencies: { myteam/date-utils: file:../date-utils } }npm install时 npm 会把这个本地目录当作依赖装进来。它的行为和 link 不太一样——有的是直接复制有的是软链取决于 npm 版本和配置但至少它是写在package.json里的可追溯、可提交、可回滚。一个小技巧是在dependencies里先写file:路径做调试发布完再换成正式版本号提交前用git diff检查一遍别把本地路径带上线。如果是 monorepo 场景直接上 workspace 最省事{ name: my-monorepo, private: true, workspaces: [packages/*] }workspace 会把所有子包的依赖提升到根目录统一安装子包之间互相引用直接写版本号即可npm 自动做软链接。它的缺点是依赖提升会让某些包意外拿到自己没声明过的依赖所以在发布前一定要用npm pack加上在干净目录里装一次来验证。三种方式我做个横向对比方式生效速度可追溯性适合场景npm link快差临时调试单个包file:中好双包联调需要提交记录workspaces快好monorepo 多包统一管理3.3 调试构建产物sourcemap 与 watch 模式调试阶段还有一个容易被忽略的点你调试的到底是源码还是产物。如果使用方项目是通过dist引入的那断点落在产物里可读性极差。开启 sourcemap 并把sourcesContent打进去调试体验会好很多。TypeScript 的配置里打开这几项{ compilerOptions: { declaration: true, declarationMap: true, sourceMap: true, inlineSources: true, outDir: dist } }declarationMap让跳转到定义能直接跳到 TS 源码而不是.d.ts这个在多人协作时非常有用。inlineSources把源码内容嵌进 map 文件这样即使不发布src目录别人也能在调试器里看到原始代码。4. 发布前的自检把事故挡在 npm publish 之前npm publish是一条不可逆的命令。发出去之后再想撤回成本很高尤其是团队内部包别人可能已经装了。所以我的习惯是发布前跑一套固定动作。4.1 版本号不是随便填的semver 与 npm version版本号主.次.补丁的语义必须认真对待补丁位只改 bug不改任何对外行为的签名。使用者可以直接升级。次版本新增功能向后兼容。使用者可以升但要留意。主版本破坏性变更。使用者必须看迁移说明。改版本号不要手改package.json用命令npm version patch -m fix: 修复时区偏移计算错误 npm version minor -m feat: 新增 formatRelative 方法npm version会自动更新版本号、打一个 git tag、生成一次提交。前提是你的工作区是干净的否则它会拒绝执行——这个限制其实是保护你避免把没提交的改动和版本号绑在一起。0.x 版本的阶段有个约定在 0.x 里次版本号承担了主版本号的角色也就是0.1.0到0.2.0可以带破坏性变更。内部工具包长期停在 0.x 完全是正常做法不必为了正式而强行发 1.0.0。4.2 npm pack 预演看看包里到底装了什么npm pack --dry-run输出的内容大概是这样npm notice Tarball Contents npm notice 12.3kB dist/index.mjs npm notice 11.8kB dist/index.cjs npm notice 1.2kB dist/index.d.ts npm notice 3.4kB README.md npm notice package size: 9.1 kB npm notice unpacked size: 28.7 kB重点看三件事有没有意外的文件、体积是否合理、dist里的入口文件名和package.json里声明的是否一致。文件名不一致是最隐蔽的坑——发布不会报错但别人装完import就会失败。4.3 依赖该放哪一类dependencies、peerDependencies 与 devDependencies这三类的区别经常被混淆直接决定使用者装完会不会出问题dependencies包运行必需的依赖使用者会自动装上。注意这个依赖会出现在他们的依赖树里会被安全扫描和体积统计算进去。devDependencies只在开发和构建时用比如测试框架、打包器、类型工具。使用者不会装。peerDependencies声明我要求宿主环境提供某个依赖。适合插件型的包比如一个 React 组件库应该把 React 放进 peerDependencies而不是 dependencies。我在package.json里还会加一段脚本把发布前的检查固化下来{ scripts: { build: tsup src/index.ts --format cjs,esm --dts, test: vitest run, prepublishOnly: npm run test npm run build } }prepublishOnly会在npm publish前自动执行测试不过或者构建失败就直接中断发布。这个钩子值千金我至少有两次靠它拦住了忘了构建就发包。5. 发布到自己的镜像库Verdaccio 与多源管理公共源不是不能用但内部包发到公共源有两个问题一是涉及内部业务逻辑不该对外可见二是网络访问不稳定装包体验差。所以稍微正式一点的团队都会自建源。5.1 为什么很多团队最后都会自建源自建源的价值有几层。最直接的是私有性内部组件只在内网流转。其次是可控性公共源上的某个包被作者删掉、改名或者发了有问题的版本你的构建就会突然挂掉自建源可以在代理层做缓存和固定。第三是速度内网拉取不受外网带宽影响CI 上尤其明显。还有一个经常被忽略的好处自建源可以同时做代理。也就是它既能托管你的私有包又能把对公共包的请求转发到上游源并缓存下来。这样团队里只需要配一个源地址既解决了私有包也顺便加速了公共包。5.2 用 Verdaccio 搭一个最小可用的私有源Verdaccio 是这类需求里上手最快的选择一条命令就能跑起来看效果npx verdaccio它默认监听 4873 端口配置文件在~/.config/verdaccio/config.yaml。要做成团队可用的服务我一般用容器跑配置文件大致长这样storage: /verdaccio/storage auth: htpasswd: file: /verdaccio/conf/htpasswd uplinks: npmjs: url: https://registry.npmmirror.com/ packages: myteam/*: access: $authenticated publish: $authenticated unpublish: $authenticated **: access: $all publish: $authenticated proxy: npmjs logs: - { type: stdout, format: pretty, level: http }几个关键点解释一下。packages里的规则是从上往下匹配第一个命中的生效所以myteam/*必须写在**前面。$authenticated表示需要登录$all表示任何人可读。uplinks里的proxy指向上游源这就是代理缓存功能。启动后创建账号npm adduser --registry http://192.168.1.20:4873/ npm whoami --registry http://192.168.1.20:4873/npm adduser在较新版本里和npm login基本等价。如果账号已经存在用npm login会直接覆盖本地 token一般没问题。注意storage目录一定要挂到持久化卷上。容器一重启、包全没了这种事我见过不止一次恢复起来非常麻烦因为 npm 的版本是不可重复发布的同名同版本无法重新上传。5.3 .npmrc 的写法多源共存与 scope 绑定真正常用的是公共包走公共源、私有包走私有源这种混合模式。项目根目录的.npmrc这样写registryhttps://registry.npmmirror.com/ myteam:registryhttp://192.168.1.20:4873/ //192.168.1.20:4873/:_authToken你的token always-authtrue第一行是默认源第二行把myteam前缀的包指向私有源第三行是认证信息。_authToken可以由npm login自动写入也可以手动填。这里有个必须强调的安全问题.npmrc里带 token 的时候不要提交到公开仓库。做法是把认证信息放在用户级配置~/.npmrc项目里的.npmrc只留源地址然后把这个文件提交上去让团队成员拉下来就能用。认证走各自的用户级配置互不干扰。配置文件的作用范围也要理清楚位置作用范围适合放什么项目.npmrc当前项目源地址、scope 绑定用户~/.npmrc当前用户所有项目认证 token全局.npmrc整台机器代理、缓存目录环境变量当前进程CI 里的临时凭据CI 环境里我倾向用环境变量注入 token比如NPM_TOKEN然后在流水线里写成.npmrc再跑npm publish。这样 token 不会落在任何仓库文件里。5.4 发布、废弃与版本回收的正确姿势发布就是一条命令但通常要显式指定源避免含糊npm publish --registry http://192.168.1.20:4873/ --access restricted--access restricted对 scope 包有效确保它是私有可见的。如果真的发错了版本有两套补救手段npm deprecate把这个版本标记为废弃安装时会打印警告。这是推荐做法因为历史版本还在已经锁死版本的旧项目不会突然构建失败。npm unpublish删除某个版本甚至整个包。公共源上有严格的时间窗限制私有源一般宽松但仍然慎用。删掉一个别人正在依赖的版本等于给别人的构建埋雷。我的经验是只要版本号已经被人装过就只废弃、不删除。真有问题就发一个新的补丁版本修掉这才是对使用者最友好的做法。6. 热搜里反复出现的 npm 报错逐个拆开看这一节是我在实际操作中收集到的高频报错按根因—排查—修复的顺序拆开讲。这些报错的搜索量常年居高不下说明踩的人真的多。6.1 禁止运行脚本npm.ps1 被 PowerShell 拦下完整报错大致是npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。根因是 Windows 上 PowerShell 的执行策略默认是Restricted而 npm 在 Windows 上提供了npm.ps1这个 PowerShell 包装脚本被策略挡住了。这跟 npm 本身没关系也跟 Node 的安装没关系。三种解法我按推荐顺序排# 方案一给当前用户放开本地脚本执行推荐 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned# 方案二改用 npm.cmd绕过 ps1 npm.cmd install# 方案三在 CMD 或 Git Bash 里跑不受 PowerShell 策略影响RemoteSigned的含义是本地脚本可执行从网络下载的脚本需要有签名。这是日常开发里比较平衡的档位比直接设成Unrestricted稳妥。另外注意-Scope CurrentUser只影响你自己不需要管理员权限也不会改到别人机器上。顺带说一句npm 不是内部或外部命令这类报错属于另一类问题是 PATH 里没有 Node 的安装目录。装 nvm-windows 之后这种问题基本不会再出现因为它的目录管理是统一的。6.2 EACCES、EBUSY 与目录权限EACCES是权限不足Linux 和 macOS 上最常见的原因是当初用sudo npm install -g装过东西导致全局目录里混了 root 权限的文件之后普通用户再写就失败。正确做法是改全局目录的所有者或者直接换用版本管理工具# 把全局目录还给当前用户 sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}EBUSY是文件被占用Windows 上尤其常见。典型场景是你开着编辑器或者资源管理器停在node_modules里npm 想删目录删不掉。处理方式很朴素关掉占用的程序或者干脆删掉node_modules和package-lock.json重装。这类问题不需要什么高级技巧但不知道的时候能耗掉半小时。还有一个相关的坑是路径里有空格或中文。某些原生模块在编译时对路径处理不好报错信息又完全看不出是路径问题。我的习惯是项目一律放在纯英文、无空格的短路径下比如D:\work\my-pkg能省掉很多莫名奇妙的故障。6.3 依赖树相关的报错ERESOLVE、optional dependencies 与 node-gyp这几类报错看着吓人但根因都比较清楚。ERESOLVE overriding peer dependency是 npm 7 之后默认严格校验 peer 依赖导致的冲突。常见于老项目里某个库声明的 peer 范围过窄。短期解法是npm install --legacy-peer-deps或者用--force。但这两个都是权宜之计长期方案是在overrides字段里把冲突版本强制统一{ overrides: { some-lib: ^2.1.0 } }cannot find native binding配合 npm has a bug related to optional dependencies 这类报错是 npm 在某些版本上处理可选依赖尤其是各平台的预编译二进制时的已知缺陷。处理顺序是先把 npm 升到较新版本然后删掉node_modules和package-lock.json重新安装。如果还不行检查是否有代理或者镜像源缓存了不完整的元数据。node-gyp相关报错比如找不到 python2是因为这类包需要本地编译。现在 node-gyp 已经要求 Python 3报错信息里提到 python2 通常是某个老依赖的构建脚本还在用旧写法。这类包能不用就不用实在要用Windows 上需要装好构建工具链Linux 上装好build-essential和python3。最后提一个跟前面章节呼应的点如果npm ls 包名查不到你在代码里import的那个包说明它可能是被某个依赖间接带进来、或者被提升到了上层node_modules。真正判断这个依赖有没有被项目实际使用光看package.json不够要看构建工具的产物和依赖分析结果。我习惯在发布前跑一次打包器的分析命令把没用到的依赖从dependencies里清掉——这也是前面说看包里装了什么这件事的延伸。几个排查动作我整理成一张对照表方便直接查报错关键词根因方向第一动作npm.ps1禁止运行脚本PowerShell 执行策略Set-ExecutionPolicy -Scope CurrentUser RemoteSignedEACCES全局目录权限修改 prefix 所有者或换版本管理工具EBUSY文件被占用关闭占用程序删node_modules重装ERESOLVEpeer 依赖冲突先--legacy-peer-deps再用overrides收口cannot find native bindingnpm 可选依赖缺陷升级 npm清 lock 重装node-gyp/ python原生模块编译环境补构建工具链或换掉该依赖EUNSUPPORTEDPROTOCOL workspace:npm 版本过旧升级到支持 workspace 的 npm写到这里我把这套流程在自己项目里跑了三轮最深的体会是发布这件事的难点从来不是那几条命令而是发布之前你有没有真的看清自己发的是什么。npm pack --dry-run、prepublishOnly钩子、file:代替npm link这几条是我踩过坑之后固定下来的动作成本很低但省下的时间是以小时计的。至于私有源早点搭起来比等到三个项目互相复制代码的时候再补要轻松得多。
返回列表