
elpis 这个项目是我在团队里从零搭起来的一个中后台管理系统技术栈是 Vue3 TypeScript Vite从页面布局到接口封装基本都揉在一个仓库里。刚开始只有两三个业务模块跑起来很舒服等模块涨到十几个以后公共工具函数、请求封装、权限判断、甚至几个通用表格组件开始在不同业务目录里出现三四份相近实现。后来实在忍无可忍就做了一次“npm 抽离”把这些公共能力单独拆成一个 npm 包发布出去再让 elpis 和其他几个兄弟项目一起依赖。这篇博客讲的不是某个高深框架而是一套很实用的“把一个项目里的公共代码抽成 npm 包并发布落地”的完整流程。如果你也正在维护一个业务系统或者准备做组件库、工具库的抽离这篇内容应该能帮你少走不少弯路。1. 抽离第一步明确哪些代码值得变成npm包1.1 elpis项目里重复代码到底有多严重我一开始觉得“抽离”就是个技术活把公共代码复制到新仓库、配一下 package.json、发布就完了。真正动手才发现最难的不是发布而是决定“到底哪些代码该被抽出去”。elpis 里每个模块都有自己的 utils 目录你看看就明白了有的页面 copy 了一份formatTime有的页面在自己的 api 文件里重新写了一遍 axios 封装只是把 baseURL 改了一下。还有几个业务组件明明渲染逻辑完全一样只是因为一个在“订单列表”里一个在“用户列表”里就被复制成了两份后续改 bug 时常常只改了一个地方另一个还留着旧逻辑。我统计了一下当时 elpis 里能搜到的formatDate、formatTime、debounce、throttle这类工具函数不算重复的所有文件大概有几十处相似实现。请求封装更乱有的模块用 axios 实例自己设了拦截器有的模块直接axios.get裸调导致后端接口报错时前端提示风格完全不一样。这种状态持续下去会产出一个很典型的隐患新同事入职后不敢随便删代码因为不确定还有没有别的地方在引用改某个公共逻辑时要全局搜索然后挨个文件看是不是同一个实现。所以做抽离的第一步不是打开编辑器就搬代码而是先盘一下 elpis 项目里到底有哪些“隐性公共代码”。我用的办法很笨但很有效用 IDE 的全局搜索把可能重复的函数名、接口封装、组件名分别列出来看使用频率。如果一个函数超过两个业务模块在用或者一个组件在三个页面里出现过就有抽离价值。如果再往后看发现另一个兄弟项目也在做类似功能那就基本可以确定要抽。1.2 抽离候选清单和边界判定按我后来整理的经验一个中后台项目里值得抽成 npm 包的通常有这么几类纯工具函数时间格式化、金额处理、深拷贝、防抖节流、文件下载等不依赖业务数据。请求层封装axios 实例、拦截器、统一错误处理、token 注入逻辑。这部分抽的时候不要写成“elpis 专用”否则其他项目没法用。通用 UI 组件例如带搜索条件的表格、分页器、状态标签、空状态组件。如果组件里和具体接口字段强耦合抽离前要先做一层数据抽象。常量与枚举状态码、字典表、正则表达式、URL 规则等。类型定义接口返回数据的类型、通用 DTO 类型。类型抽出去的好处是前后端联调时多个项目引用同一份避免各写各的。不过不是所有看起来公共的代码都适合抽包。比如全局 store用户信息、菜单权限就不太适合因为要依赖项目里的鉴权流程和路由设计抽出去后难以独立维护。再比如路由配置不同系统的路由结构差异太大抽成通用包反而会增加理解成本。还有工具函数里如果引用了某个业务私有字段比如从 localStorage 里读一个特定业务键名这种其实已经隐含了业务约定要么把键名设计成参数要么干脆留在原项目里。我抽离时有一个判断准则如果一个函数即使放到完全陌生的项目里也能正常工作不需要依赖 elpis 的全局变量或初始化逻辑那它就可以搬进包里。反过来如果你发现“这段代码搬出去后还得把某个 store 也搬出去”那它的边界可能还没切干净。宁可少抽不要硬抽因为一旦发布出去后续改 API 就是给所有使用方添麻烦。1.3 抽离粒度与版本规划确定要抽的代码后还要考虑拆成几个包。很多人的第一反应是“我建一个elpis-utils把工具函数都放进去就好了”。但项目一复杂这种大杂烩包很容易变成一个新的垃圾场。我当时没有直接上一个组件库而是先做了一个elpis/shared只包含纯函数和类型定义。等shared稳定后再把 ui 组件单独抽成elpis/ui。这样做的原因是纯函数包不依赖 Vue任何项目都能用发布和验证成本低UI 组件包要处理样式、插槽、主题复杂度高需要更多时间单独打磨。版本规划也要一开始就定好。还没到 1.0 之前API 随时可能调整所以 0.x 版本号变化默认表示可能有破坏性改动。等抽离的功能基本稳定、elpis 和其他项目接入跑顺后再发 1.0。发布时尽量遵守语义化版本修复 bug 升 patch新增向后兼容的功能升 minor有破坏性改动升 major。很多开发者在 npm 包上栽跟头就是因为“我改了个小函数直接发个 1.2.0”结果使用方升级后才发现函数参数换了运行直接报错。这个在后面版本管理里我会专门展开。2. 工程化准备把要抽离的代码改造成可发布形态2.1 包目录与构建工具选型抽离的代码不能直接把 src 文件发上去最好构建成规范的 npm 产物这样使用方不需要关心源码用什么语法也不需要额外配置 TypeScript 编译。我给elpis/shared规划的目录结构如下elpis-utils/ ├── package.json ├── tsconfig.json ├── tsup.config.ts ├── README.md └── src/ ├── index.ts ├── request/ │ ├── index.ts │ └── types.ts ├── utils/ │ ├── format.ts │ ├── file.ts │ └── debounce.ts └── constants/ └── index.ts构建工具我选了 tsup。这个工具基于 esbuild配置非常简单一条命令就能同时产出 ESM、CJS 和 d.ts 类型声明。我之前用过 webpack 封装组件库配置一堆externals、umd、cssExtract折腾到怀疑人生。后来换 tsup 之后tsup.config.ts只有几十行import { defineConfig } from tsup; export default defineConfig({ entry: [src/index.ts], format: [esm, cjs], dts: true, clean: true, splitting: false, sourcemap: true, external: [axios, vue], outDir: dist, });为什么要把axios、vue放进 external因为如果包里直接打包一份 axios主项目里又安装了一份 axios会出现两个 axios 实例请求拦截器各管各的很容易出问题。同理如果包内用了 Vue 的 API也要让使用方项目统一提供 Vue 实例避免组件注册时报“duplicate Vue”之类的错误。这个设计对应 package.json 里的peerDependencies我后面会详细说。2.2 package.json 关键字段怎么写一个 npm 包能不能被各种工具链正确使用主要看 package.json。我在发布elpis/shared的时候核心字段是这样配的{ name: elpis/shared, version: 0.1.0, description: elpis 项目抽离的公共函数、类型与请求封装, type: module, main: ./dist/index.cjs, module: ./dist/index.js, types: ./dist/index.d.ts, exports: { .: { import: { types: ./dist/index.d.ts, default: ./dist/index.js }, require: { types: ./dist/index.d.cts, default: ./dist/index.cjs } } }, files: [dist], sideEffects: false, scripts: { build: tsup, prepublishOnly: npm run build npm test, test: vitest run }, peerDependencies: { axios: 1.0.0 }, devDependencies: { axios: ^1.6.0, tsup: ^8.0.0, typescript: ^5.4.0, vitest: ^1.6.0 } }逐个解释几个容易忽略的字段type: module告诉 Node 当前包默认是 ESM。如果不设这个字段exports里的import/require条件仍然能用但部分工具会默认按 CJS 处理。main和module一个是老工具读的入口一个是支持 ESM 的打包工具读的入口。新项目推荐直接用exports但main/module还是保留兼容老版本 Node 和 Webpack。exports这是现代 Node 的模块入口映射。它比我之前写的main更精细可以给import和require分别指定不同文件。注意exports里的路径不能乱写如果写了.的映射使用方就不能再import elpis/shared/dist/index.js这种子路径了。files发布到 npm 时只包含dist目录不会把源码、测试、配置文件全部塞进包。这能显著减小下载体积也避免把不该公开的内容发出去。sideEffects: false告诉 Webpack/Vite 可以安全地做 tree-shaking删除未被引用的导出。如果包里存在会被副作用影响的样式文件要在这里声明否则样式会被意外裁掉。prepublishOnlynpm publish之前自动执行构建和测试。这一步能避免仓促发布一个忘掉构建的坏包。还有一个容易踩的坑name用了scope/name这种 scoped 包名默认被 npm 视为私有包。直接npm publish会报错需要加--access public或者设置publishConfig.access。我一开始就卡在这里明明npm login成功了发布就是报 402/404后面才搞清楚。2.3 本地联调验证npm link 的用法和坑在正式发布之前一定要先在 elpis 主项目里本地验证抽离后的包。最常见的验证方式是npm link。在elpis-utils目录里先执行npm link然后在 elpis 主项目目录执行npm link elpis/shared这样elpis/shared会指向本地开发目录改代码后可以立即在 elpis 里看到效果不需要反复发布再安装。不过这套流程在 Vite TypeScript 项目里有两个坑需要注意。第一个坑Vite 默认对依赖做预构建npm link的本地包可能被当成“源码”而不是“node_modules 里的依赖”导致 HMR 或类型识别异常。解决方案是在 vite.config.ts 里把链接包加入optimizeDeps.exclude或者干脆用别名直接把包指向源目录import { defineConfig } from vite; import vue from vitejs/plugin-vue; import path from path; export default defineConfig({ plugins: [vue()], resolve: { alias: { elpis/shared: path.resolve(__dirname, ../elpis-utils/src/index.ts), }, }, });第二种方式看着有点“作弊”但开发调试特别方便改包源码立刻生效不用重新构建。缺点是不经过构建流程可能错过构建期才能暴露的问题所以验证通过后还是要用npm link或者发布前构建产物再测一遍。第二个坑如果主项目用的是 pnpmnpm link会出现一些符号链接不生效的问题。pnpm 对 node_modules 的目录结构管理得很严格直接npm link可能会链接不到或依赖错位。后来我在 pnpm 项目里改用file:依赖{ dependencies: { elpis/shared: file:../elpis-utils } }执行pnpm install后本地包会被复制到 node_modules也能实时开发。但要注意file:方式在 pnpm 下默认不是监听的改完包源码后需要重新pnpm install或者手动拷贝这点我在实际操作中折腾了挺久。2.4 类型声明与产物格式如果你发布的是 TypeScript 写的包类型声明是刚需。没有.d.ts文件使用方在 TS 项目里引入后全是隐式 any编辑器也没办法提示。tsup 开启dts: true后会自动输出index.d.ts但这里有个细节如果包里声明了复杂的泛型或者依赖了第三方类型生成的声明文件不一定完整有时会告诉你“找不到类型声明”。我后来在tsconfig.json里会加上{ compilerOptions: { declaration: true, declarationMap: true, strict: true, module: ESNext, moduleResolution: Bundler, target: ES2020 } }declarationMap会在类型声明和源码之间建立映射这样使用方在编辑器跳转到类型时可以直接跳回源码如果 sourcemap 也已发布。这些体验看起来不起眼但对一个 npm 包的使用者来说细腻得很加分。另外还要注意 ESM 和 CJS 双格式。现在很多新项目已经是纯 ESM但还有老项目还在用 CommonJS。如果只发 ESM老项目用require()引入会直接报错如果只发 CJS新项目虽然能用但少了 tree-shaking 的优势。所以我的exports里做了条件导出import走 ESMrequire走 CJS。发布后可以用一个临时 Node 脚本验证比如同时执行const shared require(elpis/shared);和import { formatDate } from elpis/shared;确认都不报错才算真的稳。3. 发布npm包从命令行到自动化流程3.1 登录与源切换发布 npm 包前先确认终端可以登录 npm 账号。执行npm login输入账号、密码、邮箱。如果开了两步验证会要求输入一次性验证码。登录后可以用下面命令确认状态npm whoami很多国内开发者会在.npmrc里把 registry 配成国内镜像源用来加速安装依赖。这本身没问题但发布时一定要切回官方源。我之前有一段时间npm publish一直失败提示“404”查了半天才发现registry.npmjs.org被镜像源接管了。镜像源主要提供下载缓存不一定支持上传即便支持也往往只上传到镜像节点不会真正发布到官方仓库。推荐两种方式。临时指定 registrynpm publish --registryhttps://registry.npmjs.org或者在项目根目录建.npmrcregistryhttps://registry.npmjs.org/这种方式更稳妥它只影响当前包避免影响机器上其他项目的安装源。我一般会把官方源写在项目的.npmrc里这样团队其他人 publish 时不会走错源。注意不要把账号密码写进.npmrcnpm 有专门的认证文件。3.2 发布前检查清单和npm publish发布是一个“一失足成千古恨”的操作因为 npm 不允许覆盖已发布的版本。我整理了一个发布前自检清单每次照着过一遍npm pack --dry-run看最终包内容确认只有 dist、README、LICENSE 等必要文件。检查package.json的version是否已经更新不能发重复版本。确认main、module、types、exports指向的文件真实存在并且在files指定范围内。看看 README 是否说明了安装方式、最小示例、API 文档链接。至少跑一遍测试防止把坏代码发上去。确认peerDependencies里的依赖版本范围合理。检查 license 信息避免公开包出现合规问题。执行发布的命令很简单npm version patch npm run prepublishOnly npm publish --access publicnpm version patch会自动修改 package.json 的版本号并生成一个 git tag例如v0.1.1。我建议先执行npm version patch再执行npm publish。如果 publish 失败版本号已经改了那也没关系下次发布直接继续用这个版本即可。千万不要在版本号没变的情况下重复npm publishnpm 会直接拒绝。如果你是第一次发布 scoped 包需要加上--access public。如果不加npm 默认把scope/name当成私有包会要求你付费或者无法访问。3.3 版本号管理npm 的版本号严格按照 SemVer 语义化版本规范主版本.次版本.修订号。我把自己的经验总结成几条简单规则修 bugnpm version patch比如0.1.0-0.1.1。新增非破坏性功能npm version minor比如0.1.0-0.2.0。有破坏性改动npm version major比如0.1.0-1.0.0。很多内部项目在 0.x 阶段容易忽略约束觉得反正没到 1.0随便改。但如果你有多个使用方破坏性改动即使发生在 0.x也应该通过 minor 或 major 版本区分。比如 0.1.0 到 0.2.0也意味着 API 可能不一样。最忌讳的是一个包发了十几个补丁版本每个版本都可能改函数签名使用方根本不敢升级。如果发布预览版可以用预发布标签npm version prerelease --preidbeta npm publish --tag beta这样使用者需要npm install elpis/sharedbeta才能安装正常npm install elpis/shared不会拉到 beta 版本。我在抽离前几个月一直通过 beta 标签让兄弟项目试跑等 API 稳定了再发正式版。还有一个经验发布包时最好同步打 git tag。npm 的npm version会自动打 tag但如果你用了独立脚本改版本号记得手动执行git tag -a v0.1.0 -m release v0.1.0。这样后续排查问题可以直接切到对应 tag 看源码也可以通过 CI 监听 tag 自动发布。3.4 用CI脚本实现一键发布如果团队里只有你一个人发布命令行操作没问题。但多人协作时最好把发布流程固化到 CI 里。我在 elpis 的工程仓库里写了一个简易 npm 发布脚本放在.github/workflows/publish.ymlname: publish npm package on: push: tags: - v* jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 registry-url: https://registry.npmjs.org - run: npm ci - run: npm test - run: npm run build - run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}这个脚本的逻辑很简单当仓库推送v*标签时自动构建、测试并发布。需要留意的一点是NPM_TOKEN要在 npm 官网生成自动化 token然后配置到 GitHub 仓库的 Secrets 里不能直接写在 yaml 里。否则等于把账号密码公开了。如果你用的是公司内网私有源比如 Nexus、Verdaccio把registry-url改成内网地址即可。发布到公司自己的源有几个好处不需要担心公共包污染、包名可以不用加 scope、访问权限可控。不过要注意内部源和公共源最好分开配置避免项目里package-lock.json混用两个 registry 导致后续安装异常。我见过团队里有人之前用私有源发布后面切回公共源时发现 package-lock 里还指向旧源CI 环境死活装不上折腾了很久。4. 发布后接入elpis主项目回填替换与上线4.1 安装和替换包发布成功只是第一步更关键的是把 elpis 里原来的重复代码替换成对新包的引用。在 elpis 根目录安装npm install elpis/shared^0.1.0然后删掉原来src/utils/下面那些重复实现。这个过程建议用一个分支专门改不要在主干直接大范围替换。我会先把所有旧文件的引用点统计出来例如全局搜索formatDate看有哪些文件 import 了本地工具函数然后逐个改成import { formatDate, debounce } from elpis/shared;替换完之后第一步不是跑 dev而是先跑vue-tsc --noEmit做类型检查。类型检查能很快速暴露“这个函数我没导出”“参数类型对不上”“默认导出被改成了命名导出”之类的问题。我抽离的时候光是改 import 路径就改了几十个文件但多数错误都被类型检查提前拦住了真正跑到页面才炸的几乎没有。替换的另一个技巧如果不想一次性把所有引用都改完可以在 Vite 的 alias 里把旧路径指向包里的对应文件例如resolve: { alias: { /utils/format: elpis/shared/dist/index.js, }, }这种临时映射可以保证系统继续跑然后再慢慢替换。不过 aliases 多了以后很难维护长期来看还是应该把代码改成统一 import 新包。4.2 处理依赖重复和样式问题当包依赖了 Vue、React 这类框架或者 axios 这种运行时库时一定要用peerDependencies声明让使用方项目自己提供依赖。否则包内部会打包一份 Vue使用方再安装一份 Vue可能出现unmounted警告、组件不渲染、多个全局实例等奇怪问题。我在elpis/shared的peerDependencies里写了axios并且自己也在devDependencies里装一个 axios用于本地测试和构建。如果你抽离的是 UI 组件包样式问题会更明显。我最开始把组件样式直接打包进了 dist/index.css然后在使用方主项目里手动引入。这样做不够“按需加载”后来调整成每个组件输出一个 css 文件通过子路径导出比如elpis/ui/button/style使用方可以用app.use()按需注册组件同时只引入对应样式。对于纯工具包没有样式负担但sideEffects: false依然建议写能让打包器更好地摇树优化。4.3 回滚与版本切换npm 发布不像 Git tag 可以删除理论上你可以npm unpublish elpis/shared0.1.1但 npm 对 unpublish 有严格限制且不推荐。真实场景下的“回滚”往往是降级在主项目里改成安装上一个版本。npm install elpis/shared0.1.0如果新版只是某个函数行为不对也可以先不整体降级而是在主项目里用新包导出的旧函数名做一层适配或者直接用 alias 把某条导入指回旧函数实现从本地代码拷贝一份。但这些都是临时措施最终还是要修新包、发新版本。我的原则是已经发布的版本即便有 bug也不要硬去覆盖而是发一个修复版让使用方自行升级。如果希望快速止损主项目在 package.json 里锁死版本号不用^、~前缀。package.json里^0.1.1表示允许安装0.x.y的最新版在 0.x 阶段这其实很危险因为 minor 版本也可能有破坏性变更。我后来把内部包都写成精准版本或者用~0.1.1只允许 patch 升级等到确实希望统一升级时再手动改。4.4 多项目复用后的反馈elpis 内部替换完成后我又把elpis/shared引入了两个兄弟项目。这一步给我最大的教训是包在源项目里跑得好不等于其他项目也能顺利跑。第一个兄弟项目用的是 Vue2虽然我的包纯函数不依赖 Vue但包内代码用了Object.entries、Array.prototype.at等较新的 APIVue2 项目的编译链里没有自动 polyfill导致在老浏览器上直接挂掉。后来我调整了 tsup 的target改成es2015同时严格避免使用太新的 API才解决这个问题。多项目复用的另一个好处是能逼你把 API 设计得更合理。比如最初request封装里我直接写死了baseURL从 localStorage 读取换项目后就发现不适用。后来改成createRequest(config)工厂函数由每个项目传入自己的 baseURL、超时时间等配置。这个重构让我意识到抽 npm 包不只是把代码搬个家而是要把“业务约定”和“能力复用”分开。5. 实际发布中还踩过的坑和速查表5.1 npm.ps1 无法加载禁止运行脚本Windows 上第一次执行 npm 命令特别容易碰到这个报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。原因很直接Node.js 安装后自带的 npm.ps1 是 PowerShell 脚本而 Windows 默认 PowerShell 执行策略是 Restricted禁止运行任何 .ps1 脚本。解决办法有两种。第一种是用管理员身份打开 PowerShell设置当前用户允许运行本地脚本Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的含义是本地创建的脚本可以运行从网络下载的脚本需要签名。这个策略对日常开发足够安全。第二种方法更简单直接用 CMD 执行 npmCMD 不会受 PowerShell 执行策略限制。我个人比较推荐第一种毕竟现在很多前端脚手架命令都要求 PowerShell 环境。如果你不想改执行策略还有一种临时方式powershell -ExecutionPolicy Bypass -Command npm install但这样每次执行都很累赘。我遇到这个报错的频率太高了新同事入职第一周必来敲我一次后来干脆把这段命令写进团队的初始化文档。5.2 npm不是内部或外部命令环境变量问题比上面的报错更常见的是npm 不是内部或外部命令也不是可运行的程序或批处理文件。这通常意味着 Node.js 安装目录没有加到系统环境变量 PATH 里或者 PATH 被某个安装程序改坏了。解决办法是按 WinR输入sysdm.cpl打开系统属性在“环境变量”里检查 Path。正常安装 Node.js 后Path 里应该有一条类似C:\Program Files\nodejs\如果没有手动加上。加完环境变量后一定要重新打开终端因为环境变量只在新的进程中才会刷新。不要只开一个 CMD 窗口反复试那不会生效。如果确认 Path 没问题但 npm 还是找不到可以用where node和where npm检查实际路径。还有个容易忽略的情形你本机装了 nvm-windows 或 fnm切换 Node 版本后 npm 命令偶尔会失效。这时候可以直接在 nvm 目录下找一下 npm.cmd 是否存在如果不存在可能是当前 Node 版本安装不完整重新在 nvm 里 install/use 一下对应版本就好。5.3 EPERM 权限和 node_modules 锁问题发布和安装过程中经常遇到npm ERR! code EPERM npm ERR! syscall open npm ERR! path ...\node_modules\xxxEPERM权限不允许在 Windows 上满常见的多数原因是某个文件正被编辑器、进程或杀毒软件占用。最常见的就是 IDE 索引或 node 进程还在运行。解决办法先关掉所有编辑器窗口和 Node 相关进程再删除 node_modules 和 lock 文件重新安装。rm -rf node_modules npm cache verify npm install如果你用 Windowsrm -rf在 PowerShell 里不是原生命令可以写Remove-Item -Recurse -Force node_modules或者直接用系统自带的资源管理器删除。对 macOS/Linux 上使用sudo安装全局包越权的问题我也多说一句尽量别用sudo npm install -g这会让全局目录被改造成 root 所有后续操作全都得 sudo。正确做法是给用户目录下的 .npm-global 配置好前缀或者用 nvm 管理 Node避免权限问题。5.4 deprecated 警告和 peerDependencies 冲突npm install 时经常看到npm warn deprecated node-domexception1.0.0: use your platforms native DOMException这种 deprecated 警告代表依赖链里某个库引用了已经不推荐的包。多数情况下不影响功能但也不能完全无视。我看到这类警告会先执行npm ls node-domexception观察它出现在哪条依赖链里。如果是一个老库的间接依赖可以尝试升级那个老库如果当前升级成本太高至少记录到技术债里等后续统一处理。发布自己的 npm 包时如果控制台有 deprecated 警告最好也尽量处理否则使用方会觉得你的包“脏”。peerDependencies 冲突是另一个让人崩溃的问题。npm v7 以后peer 依赖不符合时会直接安装失败不是警告。比如 elpis 用了 Vue 3某个组件包却声明vue: ^2.6.0那安装就会报冲突。解决办法有几个升级组件包到支持 Vue3 的版本如果是远端包把 peer 依赖改成vue: 2.6.0 4再发新版或者临时用overrides字段强制替换。但 overrides 是最后手段它会绕过包的声明可能埋下隐患。5.5 内网开发时 node_modules 里的下划线目录我见过一个很典型的场景开发者在公司内网环境无法直接访问 npm 外网就从同事那儿拷贝了一个 node_modules 压缩包解压后看到大量_前缀目录比如_minimatch3.0.4minimatch然后npm run dev报一堆找不到模块的错误。原因是pnpm这类包管理器在安装依赖时并不是把所有包平铺到 node_modules 根目录而是用符号链接把真实目录放到.pnpm目录下node_modules 顶层只放可执行文件和直接依赖的链接。压缩、拷贝、上传网盘再解压很多符号链接就失效了自然跑不起来。就算你拷贝前用的是 npm如果依赖层级过深Windows 版本也可能生成带_前缀的目录。正确的做法是在内网环境配置一个离线镜像源比如使用 Nexus 代理 npmjs然后把 lock 文件带到内网执行npm ci重新安装。不要直接拷贝 node_modules 来“救急”。另外如果是 pnpm 项目拷贝后想修复也需要清掉 node_modules 后重新pnpm install。所谓“离线包”必须是构建好的 tarball 文件而不是 node_modules 目录。5.6 npm、cnpm、pnpm 的区别与选择在 npm 抽离和发布这个场景里很容易把几个包管理器搞混。我把自己实际使用后的理解整理成一张表包管理器安装方式依赖存储适用场景npmnpm install平铺到 node_modules最通用兼容性最好和 lock 文件配套使用稳定cnpmcnpm install可能生成特殊符号链接/目录国内安装加速但和部分工具链配合偶有问题pnpmpnpm install全局内容寻址存储 符号链接节省磁盘空间安装快对 monorepo 友好cnpm 的核心作用是提供一个访问更快的镜像客户端它并没有改变 npm 的包生态。如果你在维护 npm 包安装依赖速度慢完全可以把 npm 的 registry 指向国内镜像不一定非要换 cnpm。pnpm 在本地安装效率上确实突出但发布 npm 包时还是要靠npm publish或者 CI 里的 npm。从抽离发布的角度我建议团队统一包管理器。你可以在仓库里写死packageManager字段例如{ packageManager: pnpm9.0.0 }这样开发者在项目里跑corepack prepare时会强制使用指定版本。如果团队一半人用 npm一半人用 pnpmpackage-lock.json 和 pnpm-lock.yaml 会互相干扰最终产生一堆奇怪的安装问题。我在 elpis 项目中最终选择了 pnpm但发布这个 npm 包时依然用 npm 官方客户端因为发布和安装本就不是一回事。最后再补一个发布后的细节如果你发布了 scoped 公共包记得在 npm 官网把包主页、README、Git 仓库地址完善好。包是新项目的第一份“门面”README 里应该写清楚安装方式、一行最小示例、API 文档链接和 issue 反馈入口。我当时因为赶进度README 只有三行字结果兄弟项目同事接入时反复来问参数怎么传。后来花了半小时补齐文档长期下来至少帮自己省出了几十次答疑的时间。如果你也准备做类似的 npm 抽离我最想提醒的一句话就是发布流程是流水账真正的难点在于边界划分和文档维护别让技术上的顺利掩盖了使用体验上的粗糙。